mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
fix(monitor): clarify BORG key guidance
This commit is contained in:
@@ -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; });
|
||||
Reference in New Issue
Block a user