Merge pull request #369 from f3rs3n/fix/borg-ssh-guidance

Clarify Borg SSH account and key setup guidance
This commit is contained in:
MacRimi
2026-09-22 19:23:55 +02:00
committed by GitHub
6 changed files with 386 additions and 7 deletions
@@ -123,6 +123,12 @@ class CommandDescriptionsTests(unittest.TestCase):
for section, key in (("encryption", "pbsHelp"),
("keyfileActions", "pveKeyDescription")):
messages["backup"][section].setdefault(key, english["backup"][section][key])
for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
"sshKeySetupHelp", "sshAuthorizedKeyHelp"):
messages["backup"]["destinations"].setdefault(
key, english["backup"]["destinations"][key])
messages["backup"]["actions"].setdefault(
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
path.write_text(json.dumps(messages, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
+6 -7
View File
@@ -6448,7 +6448,7 @@ function AddDestinationDialog({
<Label htmlFor="borgSshUser">{t("backup.fields.sshUser")}</Label>
<Input id="borgSshUser" value={borgSshUser} onChange={(e) => setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" />
<p className="text-xs text-muted-foreground mt-1">
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
{t("backup.destinations.sshUserHelpMessage")}
</p>
</div>
<div className="grid grid-cols-[1fr_100px] gap-3">
@@ -6477,14 +6477,13 @@ function AddDestinationDialog({
<Label htmlFor="borgKeyPath">{t("backup.fields.sshKeyPath")}</Label>
<Input id="borgKeyPath" value={borgSshKeyPath} onChange={(e) => setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" />
<p className="text-xs text-muted-foreground mt-1">
{t("backup.destinations.sshKeyHelpBefore")}
{" "}{t("backup.destinations.sshKeyHelpMiddle")} <span className="font-medium text-foreground">{t("backup.actions.generateKey")}</span> {t("backup.destinations.sshKeyHelpAfter")}
{t("backup.destinations.sshKeyHelpMessage", { action: t("backup.actions.prepareSshKey") })}
</p>
</div>
<div className="rounded-md border border-border bg-card p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span>
<span className="text-xs font-medium">{t("backup.destinations.sshKeySetupTitle")}</span>
<Button
type="button"
size="sm"
@@ -6494,13 +6493,13 @@ function AddDestinationDialog({
onClick={generateBorgKey}
>
{generatingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
{generatedKey ? t("backup.actions.regenerate") : t("backup.actions.generateKey")}
{t("backup.actions.prepareSshKey")}
</Button>
</div>
{generatedKey ? (
<>
<p className="text-[11px] text-muted-foreground">
{t("backup.destinations.appendAuthorizedKeyBefore")} <code className="font-mono">~{borgSshUser}/.ssh/authorized_keys</code>:
{t("backup.destinations.sshAuthorizedKeyHelp", { user: borgSshUser })}
</p>
<textarea
readOnly
@@ -6511,7 +6510,7 @@ function AddDestinationDialog({
</>
) : (
<p className="text-[11px] text-muted-foreground">
{t("backup.destinations.createsSshKeyBefore")} <code className="font-mono">borg serve</code> {t("backup.destinations.createsSshKeyAfter")}
{t("backup.destinations.sshKeySetupHelp")}
</p>
)}
</div>
+6
View File
@@ -3820,6 +3820,7 @@
"enable": "Enable",
"format": "Format",
"generateKey": "Generate key",
"prepareSshKey": "Prepare key",
"import": "Import",
"importKeyfile": "Import keyfile",
"mount": "Mount",
@@ -3981,11 +3982,16 @@
"saveAnotherPbsTitle": "Save another PBS destination",
"shortIdentifierHelp": "Short name shown in Monitor.",
"single": "Destination",
"sshAuthorizedKeyHelp": "Review the line below before adding it to ~/.ssh/authorized_keys for {user} on the remote host.",
"sshKeyHelpAfter": "and allow it on the remote host.",
"sshKeyHelpBefore": "Monitor stores the private key at",
"sshKeyHelpMiddle": "Copy the public key to",
"sshKeyHelpMessage": "Private key path on this host. Use {action}, then review and add the displayed public-key line to the remote user's ~/.ssh/authorized_keys.",
"sshKeySetupHelp": "Creates a key at the specified local path if none exists; otherwise reads its public key. No key is installed on the remote host.",
"sshKeySetupTitle": "SSH key setup",
"sshUserHelpAfter": "for example root.",
"sshUserHelpBefore": "User on the remote host,",
"sshUserHelpMessage": "Remote account used to access the Borg repository, for example borg.",
"title": "Destinations",
"unmountTitle": "Unmount this destination",
"whereIsBorgRepo": "Where is the Borg repository?"
+70
View File
@@ -0,0 +1,70 @@
# Borg SSH guidance checks
These standalone checks are separate from the Python i18n suite. They do not
contact a Proxmox host or generate SSH keys.
## JSX/provider regression
Prerequisites: Node **22.14+** and installed `AppImage` dependencies (including
React, React DOM and TypeScript). From the repository root:
```bash
node tests/test_borg_ssh_guidance.cjs
```
The test extracts the actual SSH branch from `AddDestinationDialog`, renders it
with React, and executes the actual provider's lookup/interpolation callback.
Basic control wrappers replace shadcn components only in this fast test. It covers
all shipped catalogs, unconditional missing-key English fallback, synthetic
whole-message translations and reordered placeholders, initial/result/loading
states, account escaping, blank/custom paths and literal public-key output.
The optional `account` argument isolates the remote-account instruction.
## Real-component browser fixture
Additional prerequisites: `esbuild`, `playwright`, its Chromium browser and a
successful full Monitor frontend build. The validation runtime used esbuild
0.28.2 and Playwright 1.63.0. If these optional tools are not installed, install
them locally without changing the project manifests/lockfile:
```bash
cd AppImage
npm install --no-save --package-lock=false --legacy-peer-deps esbuild@0.28.2 playwright@1.63.0
node node_modules/playwright/cli.js install chromium
npm run build
cd ..
node tests/test_borg_ssh_browser.cjs /absolute/path/to/borg-browser-evidence
```
The fixture bundles the actual complete `AddDestinationDialog`, real shadcn
components, real API helper and `I18nProvider`, with CSS from `AppImage/out` by default. Set `BORG_SSH_CSS_DIR` to the
`_next/static/css` directory of an archived full build to reuse that build's CSS.
A test-only in-memory export exposes the unexported dialog; production source is
not rewritten. Browser locale fixtures omit the six new keys from Italian and
supply expanded/reordered synthetic messages in German **in memory only**.
They are not proposed translations.
All requests are intercepted **before navigation**. Known static resources and
read endpoints are fulfilled from fixtures; key preparation POSTs receive inert
fresh/existing/error responses. All other requests, including Save, are aborted
and make the run fail. No server is needed. The test checks current request
payloads and characterizes unchanged last-response retention after field edits
and errors; mocked fresh/existing responses are not a backend generation test.
The matrix is desktop/mobile (1440×1000, 390×844), light/dark, English/forced
Italian fallback/synthetic expansion. It writes initial/result screenshots and
`browser-results.json`, and asserts message/button horizontal fit. The modal is
scrolled to show the relevant panel; this is not whole-dashboard acceptance.
## Other gates
```bash
python3 -m unittest discover -s .github/scripts/tests -v
cd AppImage
node node_modules/typescript/bin/tsc --noEmit --incremental false
```
Run typechecking separately: the production build skips it. Compare diagnostics
against the unchanged baseline with the same dependencies; a successful build
is not a clean typecheck. CONTRIBUTING's real-Proxmox deployment smoke test is a
separate integration gate and is not replaced by these fixtures.
+177
View File
@@ -0,0 +1,177 @@
// Isolated real-component browser fixture, not a Proxmox integration test.
// Requires AppImage dependencies, esbuild, playwright + Chromium, and a completed frontend build.
// Run: node tests/test_borg_ssh_browser.cjs /absolute/path/to/evidence-directory
// All navigation/assets/API requests are intercepted BEFORE navigation; nothing reaches a host.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const req = createRequire(path.join(app, 'package.json'));
const { build } = req('esbuild');
const { chromium } = req('playwright');
const out = path.resolve(process.argv[2] || 'borg-browser-evidence');
fs.mkdirSync(out, { recursive: true });
const en = JSON.parse(fs.readFileSync(path.join(app, 'messages/en/common.json')));
const cssDir = process.env.BORG_SSH_CSS_DIR || path.join(app, 'out/_next/static/css');
const css = fs.readdirSync(cssDir).filter(p => p.endsWith('.css')).map(p => fs.readFileSync(path.join(cssDir, p), 'utf8')).join('\n');
const expanded = {
sshUserHelpMessage: 'FIXTURE REMOTE ACCOUNT: the account on the remote repository host, not a local command.',
sshKeyHelpMessage: '{action}: FIXTURE ACTION FIRST. Review the public-key line before adding it to the remote account authorized_keys file. The private key remains on this host.',
sshKeySetupTitle: 'FIXTURE SSH key setup',
sshKeySetupHelp: 'FIXTURE INITIAL: creates a missing local key, otherwise reads its public key. Nothing is installed on the remote host by this action.',
sshAuthorizedKeyHelp: '{user}: FIXTURE USER FIRST. Review the following public-key line before manually adding it to the remote account ~/.ssh/authorized_keys.',
};
const records = [], unexpected = [], errors = [], requests = [];
(async () => {
const bundle = await build({
stdin: { contents: `import React from 'react';
import {createRoot} from 'react-dom/client';
import {I18nProvider} from './lib/i18n/provider';
import {BorgFixtureDialog} from './components/host-backup';
const editing = new URLSearchParams(location.search).has('new') ? null : {kind:'borg',name:'fixture',repository:'ssh://borg@backup.example.invalid/backup/repo',ssh_key_path:'/root/.ssh/proxmenux_borg',encrypt_mode:'none'};
createRoot(document.getElementById('root')).render(<I18nProvider><BorgFixtureDialog type="borg" editing={editing} onClose={()=>{}} onSaved={()=>{throw Error('Save must never be submitted')}} /></I18nProvider>);`,
resolveDir: app, sourcefile: 'borg-fixture.tsx', loader: 'tsx' },
bundle: true, write: false, outfile: 'fixture.js', format: 'iife', platform: 'browser', jsx: 'automatic',
define: { 'process.env.NODE_ENV': '"development"', 'process.env.NEXT_PUBLIC_API_PORT': '"8008"' },
plugins: [{ name: 'test-only-exports-and-synthetic-locale', setup(b) {
b.onLoad({ filter: /host-backup\.tsx$/ }, args => ({ contents: fs.readFileSync(args.path, 'utf8') + '\nexport { AddDestinationDialog as BorgFixtureDialog };', loader: 'tsx' }));
b.onLoad({ filter: /messages\/(de|it)\/common\.json$/ }, args => {
const locale = JSON.parse(fs.readFileSync(args.path));
if (args.path.endsWith('/de/common.json')) {
Object.assign(locale.backup.destinations, expanded);
locale.backup.actions.prepareSshKey = 'Prepare fixture key';
} else {
// Unconditional missing-key fixture survives future automated translations.
for (const key of Object.keys(expanded)) delete locale.backup.destinations[key];
delete locale.backup.actions.prepareSshKey;
}
return { contents: JSON.stringify(locale), loader: 'json' };
});
}}],
});
const javascript = bundle.outputFiles[0].text;
const browser = await chromium.launch({ headless: true });
try {
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
for (const theme of ['light', 'dark']) for (const language of ['en', 'it', 'de']) {
const id = `${viewport.width}-${theme}-${language}`;
const context = await browser.newContext({ viewport, colorScheme: theme, serviceWorkers: 'block' });
const page = await context.newPage();
page.on('pageerror', e => errors.push({ id, message: e.message }));
let release = null, calls = 0;
const fixtureLine = 'command="borg serve --restrict-to-path /backup/repo",restrict ssh-ed25519 AAAA-fixture-only <literal>\n';
// No route.continue() exists: unexpected traffic is aborted and fails the test.
await context.route('**/*', async route => {
const request = route.request(), url = new URL(request.url());
requests.push({ id, method: request.method(), url: request.url(), body: request.postData() });
if (url.origin === 'https://borg.fixture.invalid' && request.method() === 'GET') {
if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: `<!doctype html><html class="${theme === 'dark' ? 'dark' : ''}" data-theme="${theme}"><head><link rel="stylesheet" href="/fixture.css"></head><body><div id="root"></div><script src="/fixture.js"></script></body></html>` });
if (url.pathname === '/fixture.js') return route.fulfill({ contentType: 'application/javascript', body: javascript });
if (url.pathname === '/fixture.css') return route.fulfill({ contentType: 'text/css', body: css });
if (url.pathname === '/api/host-backups/destinations') return route.fulfill({ json: { pbs: [], borg: [], local: { entries: [] } } });
if (url.pathname === '/api/host-backups/usb-drives') return route.fulfill({ json: { drives: [] } });
}
if (url.origin === 'https://borg.fixture.invalid' && url.pathname === '/api/host-backups/ssh-keys/generate' && request.method() === 'POST') {
calls++;
const body = request.postDataJSON();
records.push({ id, fixture: calls === 1 ? 'fresh-key' : calls === 2 ? 'existing-key' : 'error', body });
await new Promise(resolve => { release = resolve; });
release = null;
if (calls === 3) return route.fulfill({ status: 500, json: { error: 'Fixture: public key unavailable' } });
return route.fulfill({ json: { public_key: 'ssh-ed25519 AAAA-fixture-only', authorized_keys_line: fixtureLine } });
}
unexpected.push({ id, method: request.method(), url: request.url() });
return route.abort('blockedbyclient');
});
await context.addInitScript(({ language }) => {
localStorage.setItem('proxmenux-ui-language', language);
}, { language });
await page.goto('https://borg.fixture.invalid/');
await page.waitForFunction(language => document.documentElement.lang === language, language);
assert.equal(await page.evaluate(() => document.documentElement.classList.contains('dark')), theme === 'dark');
await page.locator('#borgKeyPath').waitFor();
const action = language === 'de' ? 'Prepare fixture key' : 'Prepare key';
const button = page.getByRole('button', { name: action, exact: true });
await button.waitFor();
assert.equal(await page.locator('#borgKeyPath').inputValue(), '/root/.ssh/proxmenux_borg');
assert.ok((await page.locator('[role=dialog]').innerText()).includes(language === 'de' ? 'FIXTURE INITIAL' : en.backup.destinations.sshKeySetupHelp));
await button.evaluate(button => button.parentElement.parentElement.scrollIntoView({ block: 'center' }));
assert.equal(await button.evaluate(button => {
const panel = button.parentElement.parentElement;
return [panel, ...panel.querySelectorAll('p, button, span')].every(e => e.scrollWidth <= e.clientWidth + 1 || getComputedStyle(e).display === 'inline');
}), true, `${id}: initial setup guidance fits`);
await page.screenshot({ animations: 'disabled', path: path.join(out, `${id}-initial.png`) });
// Blank input remains disabled; no attempt is sent.
await page.locator('#borgKeyPath').fill('');
assert.equal(await button.isDisabled(), true);
assert.equal(calls, 0);
const longPath = '/fixture/long-local-private-key-directory/'.repeat(6) + 'key';
await page.locator('#borgKeyPath').fill(longPath);
await page.locator('#borgSshUser').fill('root');
await button.click();
await page.waitForFunction(() => document.querySelector('button svg.animate-spin'));
assert.equal(await button.isDisabled(), true);
assert.equal(await button.innerText(), action);
assert.ok(release, 'intercepted mock POST is pending');
release();
await page.locator('textarea[readonly]').waitFor();
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
assert.ok((await page.locator('[role=dialog]').innerText()).includes(language === 'de' ? 'root: FIXTURE USER FIRST' : 'for root on the remote host.'));
// Changing input preserves the last response: characterize, do not change stale-response behavior.
await page.locator('#borgKeyPath').fill('/fixture/already-existing-private-key');
await page.locator('#borgSshUser').fill('<archive-user>');
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
await button.click();
await page.waitForFunction(() => document.querySelector('button svg.animate-spin'));
assert.equal(await button.innerText(), action);
release();
await page.waitForFunction(() => !document.querySelector('button svg.animate-spin'));
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
assert.equal(await page.locator('archive-user').count(), 0);
assert.ok((await page.locator('[role=dialog]').innerText()).includes('<archive-user>'));
await button.evaluate(button => button.parentElement.parentElement.scrollIntoView({ block: 'center' }));
// Measure the changed guidance, its action, and result area, not unrelated whole-dialog layout.
const layout = await button.evaluate(button => {
const panel = button.parentElement.parentElement;
const help = document.querySelector('#borgKeyPath').parentElement.querySelector('p');
const userHelp = document.querySelector('#borgSshUser').parentElement.querySelector('p');
const elements = [panel, button.parentElement, button, help, userHelp, panel.querySelector('p'), panel.querySelector('textarea')];
return elements.map(e => {
const r = e.getBoundingClientRect();
return { tag: e.tagName, text: e.tagName === 'TEXTAREA' ? '[fixture public-key line]' : e.textContent, client: e.clientWidth, scroll: e.scrollWidth, left: r.left, right: r.right, viewport: innerWidth };
});
});
for (const e of layout.filter(e => e.tag !== 'TEXTAREA')) {
assert.ok(e.scroll <= e.client + 1, `${id}: guidance overflow ${JSON.stringify(e)}`);
assert.ok(e.left >= 0 && e.right <= viewport.width + 1, `${id}: guidance outside viewport`);
}
await page.screenshot({ animations: 'disabled', path: path.join(out, `${id}-result.png`) });
records.push({ id, fixture: 'layout', layout });
await button.click();
await page.waitForFunction(() => document.querySelector('button svg.animate-spin'));
release();
await page.getByText('Fixture: public key unavailable', { exact: true }).waitFor();
assert.equal(await button.isEnabled(), true);
assert.equal(await button.innerText(), action);
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
assert.equal(calls, 3);
assert.deepEqual(records.filter(r => r.id === id && r.body).map(r => r.body), [
{ key_path: longPath, remote_path: '/backup/repo' },
{ key_path: '/fixture/already-existing-private-key', remote_path: '/backup/repo' },
{ key_path: '/fixture/already-existing-private-key', remote_path: '/backup/repo' },
], 'real component submits the current path, not a fixed path or the previous response');
await context.close();
}
}
assert.deepEqual(unexpected, []);
assert.deepEqual(errors, []);
const posts = requests.filter(r => r.method === 'POST');
assert.equal(posts.length, 36);
assert.ok(posts.every(p => p.url.endsWith('/ssh-keys/generate')));
console.log('PASS: 12 real-component/provider browser scenarios; desktop/mobile × light/dark × English/Italian fallback/synthetic expanded translation; 36 mocked POSTs, 0 real API calls.');
} finally {
fs.writeFileSync(path.join(out, 'browser-results.json'), JSON.stringify({ records, requests, unexpected, errors }, null, 2));
await browser.close();
}
})().catch(e => { console.error(e); process.exitCode = 1; });
+121
View File
@@ -0,0 +1,121 @@
// Run: node tests/test_borg_ssh_guidance.cjs [account|setup]
// Prerequisites: Node 22.14+, installed AppImage dependencies (React + TypeScript).
// Renders the actual SSH JSX branch and executes the actual provider lookup callback.
// No host-management imports, network, backend, or real SSH key generation.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const req = createRequire(path.join(app, 'package.json'));
const ts = req('typescript');
const React = req('react');
const { renderToStaticMarkup } = req('react-dom/server');
const read = p => fs.readFileSync(path.join(app, p), 'utf8');
const parse = (s, name = 'fixture.tsx') => ts.createSourceFile(name, s, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
function nodes(tree, predicate) {
const result = [];
function visit(n) { if (predicate(n)) result.push(n); ts.forEachChild(n, visit); }
visit(tree); return result;
}
function evaluate(source, bindings) {
const js = ts.transpileModule(source, { compilerOptions: {
jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
}}).outputText;
const module = { exports: {} };
// Only trusted checked-out source is compiled; fixture values are bindings, never code.
new Function('require', 'module', 'exports', ...Object.keys(bindings), js)(req, module, module.exports, ...Object.values(bindings));
return module.exports;
}
const source = read('components/host-backup.tsx');
const tree = parse(source);
const dialog = nodes(tree, n => ts.isFunctionDeclaration(n) && n.name?.text === 'AddDestinationDialog')[0];
assert.ok(dialog, 'actual destination component exists');
const unwrap = n => ts.isParenthesizedExpression(n) ? unwrap(n.expression) : n;
const branch = nodes(dialog, n => ts.isConditionalExpression(n) && n.condition.getText(tree) === 'borgMode === "local"' && ts.isJsxFragment(unwrap(n.whenFalse)));
assert.equal(branch.length, 1, 'unique actual Borg SSH JSX branch');
const providerSource = read('lib/i18n/provider.tsx');
const providerTree = parse(providerSource);
const helpers = nodes(providerTree, n => ts.isFunctionDeclaration(n) && ['getMessage', 'interpolate'].includes(n.name?.text)).map(n => n.getText(providerTree)).join('\n');
const callback = nodes(providerTree, n => ts.isVariableDeclaration(n) && n.name.getText(providerTree) === 't')[0].initializer.arguments[0].getText(providerTree);
const en = JSON.parse(read('messages/en/common.json'));
function translate(locale) {
return evaluate(`${helpers}\nmodule.exports = ${callback}`, { MESSAGE_CATALOG: { en, fixture: locale }, language: 'fixture' });
}
const noop = () => {};
const component = tag => ({ children, ...props }) => React.createElement(tag, props, children);
const literalLine = 'command="borg serve --restrict-to-path /backup/repo",restrict ssh-ed25519 AAAA-fixture-only <not-html>\n';
let renderCount = 0;
function render(locale, { user = 'borg', generated = false, loading = false, keyPath = '/root/.ssh/proxmenux_borg' } = {}) {
renderCount++;
const bindings = {
t: translate(locale), borgSshUser: user, borgSshHost: 'backup.example.invalid', borgSshPort: '22',
borgSshRemotePath: '/backup/repo', borgSshKeyPath: keyPath,
generatedKey: generated ? { authorized_keys_line: literalLine, public_key: 'ssh-ed25519 AAAA-fixture-only' } : null,
generatingKey: loading, generateBorgKey: noop,
setBorgSshUser: noop, setBorgSshHost: noop, setBorgSshPort: noop, setBorgSshRemotePath: noop, setBorgSshKeyPath: noop,
Label: component('label'), Input: component('input'), Button: ({ size, variant, ...p }) => React.createElement('button', p),
Loader2: component('svg'), Plus: component('svg'),
};
const Fixture = evaluate(`module.exports = function Fixture() { return (${branch[0].whenFalse.getText(tree)}) }`, bindings);
return renderToStaticMarkup(React.createElement(Fixture));
}
const escape = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#x27;');
const account = 'Remote account used to access the Borg repository, for example borg.';
const keyHelp = "Private key path on this host. Use Prepare key, then review and add the displayed public-key line to the remote user's ~/.ssh/authorized_keys.";
const setupHelp = 'Creates a key at the specified local path if none exists; otherwise reads its public key. No key is installed on the remote host.';
// Accept future shipped translations, while the always-empty synthetic locale
// below continues to assert the independently specified English contract.
const localText = (locale, group, key, fallback) => typeof locale.backup?.[group]?.[key] === 'string' ? locale.backup[group][key] : fallback;
const keys = ['sshUserHelpMessage', 'sshKeyHelpMessage', 'sshKeySetupTitle', 'sshKeySetupHelp', 'sshAuthorizedKeyHelp'];
const locales = fs.readdirSync(path.join(app, 'messages')).filter(l => fs.existsSync(path.join(app, 'messages', l, 'common.json')));
// The empty synthetic locale is unconditional: fallback stays covered after upstream translations arrive.
for (const locale of [{}, ...locales.map(l => JSON.parse(read(`messages/${l}/common.json`)))]) {
for (const user of ['borg', 'root', 'archive-user', '<img src=x onerror=alert(1)>']) {
const html = render(locale, { user });
assert.ok(html.includes(escape(localText(locale, 'destinations', 'sshUserHelpMessage', account))), 'remote-account guidance must not confuse borg serve with an account or prohibit root');
}
}
const synthetic = { backup: { destinations: { sshUserHelpMessage: 'REMOTE ACCOUNT FIXTURE' } } };
assert.ok(render(synthetic).includes('REMOTE ACCOUNT FIXTURE'), 'actual JSX consumes translated whole account message');
if (process.argv[2] !== 'account') {
for (const locale of [{}, ...locales.map(l => JSON.parse(read(`messages/${l}/common.json`)))]) {
for (const user of ['borg', 'root', 'archive-user', '<img src=x onerror=alert(1)>']) {
for (const generated of [false, true]) for (const loading of [false, true]) {
const html = render(locale, { user, generated, loading, keyPath: '/custom/long-local-path/'.repeat(8) + 'private_key' });
const action = localText(locale, 'actions', 'prepareSshKey', 'Prepare key');
const expectedKeyHelp = localText(locale, 'destinations', 'sshKeyHelpMessage', keyHelp.replace('Prepare key', '{action}')).replaceAll('{action}', action);
assert.ok(html.includes(escape(expectedKeyHelp)), 'complete local-path and manual-install instruction');
assert.ok(html.includes(escape(localText(locale, 'destinations', 'sshKeySetupTitle', 'SSH key setup'))));
assert.ok(html.match(/<button[^>]*>([\s\S]*?)<\/button>/)[1].includes(escape(action)));
assert.ok(!html.includes('Regenerate') && !html.includes('Generate new SSH key') && !html.includes('when you save'));
if (generated) {
const expectedHelp = localText(locale, 'destinations', 'sshAuthorizedKeyHelp', 'Review the line below before adding it to ~/.ssh/authorized_keys for {user} on the remote host.').replaceAll('{user}', user);
assert.ok(html.includes(escape(expectedHelp)));
assert.ok(html.includes(escape(literalLine)), 'authorized_keys_line remains exact, escaped text');
} else assert.ok(html.includes(escape(localText(locale, 'destinations', 'sshKeySetupHelp', setupHelp))));
if (loading) assert.match(html, /<button[^>]*disabled=""/);
}
}
}
const translated = { backup: { actions: { prepareSshKey: 'FIXTURE PREPARE' }, destinations: {
sshUserHelpMessage: 'FIXTURE ACCOUNT', sshKeyHelpMessage: 'At the end use {action}; FIXTURE PATH FIRST.',
sshKeySetupTitle: 'FIXTURE TITLE', sshKeySetupHelp: 'FIXTURE INITIAL HELP',
sshAuthorizedKeyHelp: '{user}: FIXTURE REMOTE FIRST. Review the line.',
} } };
for (const generated of [false, true]) {
const html = render(translated, { user: '<custom>', generated });
for (const text of ['FIXTURE ACCOUNT', 'At the end use FIXTURE PREPARE; FIXTURE PATH FIRST.', 'FIXTURE TITLE', generated ? '&lt;custom&gt;: FIXTURE REMOTE FIRST. Review the line.' : 'FIXTURE INITIAL HELP']) assert.ok(html.includes(text), text);
assert.match(html, /<button[^>]*>.*FIXTURE PREPARE<\/button>/s);
}
// Each new key individually absent, even when all its siblings are translated.
for (const key of [...keys, 'prepareSshKey']) {
const missing = structuredClone(translated);
const group = key === 'prepareSshKey' ? 'actions' : 'destinations';
delete missing.backup[group][key];
assert.equal(translate(missing)(`backup.${group}.${key}`), en.backup[group][key]);
}
assert.match(render({}, { keyPath: '' }), /<button[^>]*disabled=""/);
}
console.log(`PASS ${process.argv[2] || 'all'}: ${renderCount} actual JSX renders; ${locales.length} shipped catalogs + unconditional missing-key fallback; actual provider callback; translated/reordered messages.`);