Files
ProxMenux/tests/test_borg_ssh_browser.cjs
T

178 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; });