mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +00:00
Merge pull request #369 from f3rs3n/fix/borg-ssh-guidance
Clarify Borg SSH account and key setup guidance
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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.
|
||||
@@ -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; });
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
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 ? '<custom>: 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.`);
|
||||
Reference in New Issue
Block a user