first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-28 09:16:54 +01:00
commit 32a7ba8e8f
168 files changed
+123856

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
/* Run with node --test web/tests/core.test.cjs. No packages required. */
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function application(options = {}) {
const storage = () => ({ getItem: () => null, setItem() {}, removeItem() {} });
const context = {
window: {}, document: { querySelector: selector => selector === 'meta[name="allow-registration"]' && options.registration !== undefined ? { content: options.registration } : null }, sessionStorage: storage(), localStorage: storage(),
AbortController, URLSearchParams, Blob, URL, Response, fetch, setTimeout, clearTimeout,
requestAnimationFrame: fn => fn(), performance, console
};
context.window.window = context.window;
vm.createContext(context);
for (const name of ['i18n.js', 'core.js', 'views.js']) vm.runInContext(fs.readFileSync(path.join(__dirname, '../js', name), 'utf8'), context);
return context.window.MBS;
}
test('all four languages include every statically used translation key', () => {
const app = application();
const code = ['core.js', 'views.js', 'app.js'].map(file => fs.readFileSync(path.join(__dirname, '../js', file), 'utf8')).join('\n');
const keys = [...code.matchAll(/'((?:app|auth|common|confirm|dashboard|diffConfigs|files|footer|logs|nav|routers|settings|sidebar|toast|topbar|ui)\.[A-Za-z]+)'/g)].map(match => match[1]);
for (const lang of ['pl', 'en', 'es', 'no']) {
for (const key of keys.filter(key => key.includes('.'))) {
// Dynamic weekday suffix is validated by the schedule editor test.
if (key === 'settings.weekday') continue;
const value = key.split('.').reduce((node, part) => node && node[part], app.translations[lang]);
assert.equal(typeof value, 'string', `${lang}: ${key}`);
}
}
});
test('untrusted text and diff search results are HTML escaped', () => {
const app = application();
assert.equal(app.escape('<script a="1">&\''), '&lt;script a=&quot;1&quot;&gt;&amp;&#39;');
assert.equal(app.highlight('<img src=x onerror=alert(1)>', 'img'), '&lt;<mark>img</mark> src=x onerror=alert(1)&gt;');
app.state.diff = {
left_file_name: '<script>alert(1)</script>', right_file_name: '<img src=x>', diff_text: '<script>x</script>',
lines: [{ type: 'modified', left_number: 1, right_number: 1, left_text: '<img onerror=x>', right_text: '&password' }]
};
const html = app.diffResult();
assert.ok(!html.includes('<script>'));
assert.ok(!html.includes('<img'));
assert.ok(html.includes('&lt;img onerror=x&gt;'));
});
test('settings and device forms never prefill secret values returned by a legacy server', () => {
const app = application();
app.state.settingsTab = 'credentials';
const secret = 'do-not-render-secret-654321';
const settings = app.settingsForm({ global_ssh_key: secret, default_switchos_password: secret, smtp_password: secret, pushover_token: secret, pushover_userkey: secret });
const device = app.deviceForm({ id: 1, name: 'R1', device_type: 'routeros', host: '127.0.0.1', ssh_password: secret, ssh_key: secret });
assert.ok(!settings.includes(secret));
assert.ok(!device.includes(secret));
assert.ok(settings.includes('clear_global_ssh_key'));
assert.ok(device.includes('clear_ssh_password'));
});
test('registration is hidden unless the public page explicitly enables it', () => {
for (const registration of [undefined, 'false', 'TRUE', '1']) {
const app = application({ registration });
assert.equal(app.config.allowRegistration, false);
assert.ok(!app.authView(false).includes('href="/register"'));
assert.ok(!app.authView(true).includes('id="register-form"'));
}
const app = application({ registration: 'true' });
assert.equal(app.config.allowRegistration, true);
assert.ok(app.authView(false).includes('href="/register"'));
assert.ok(app.authView(true).includes('id="register-form"'));
});
test('login has no header or decorative SVGs and retains language and theme controls', () => {
const html = application().authView(false);
assert.ok(!html.includes('<header'));
assert.ok(!html.includes('<svg'));
assert.ok(html.includes('data-language'));
assert.ok(html.includes('data-action="theme"'));
});
test('account settings contain the existing password form outside the system settings form', async () => {
const app = application();
app.state.settingsTab = 'account';
app.api = async endpoint => endpoint.endsWith('scheduler-status') ? { jobs: [] } : {};
const html = await app.views.settings(false);
assert.ok(html.includes('data-settings-tab="account" role="tab" aria-selected="true"'));
assert.ok(html.includes('<section data-settings-pane="account">'));
assert.ok(html.includes('id="password-form"'));
assert.ok(html.indexOf('</form>') < html.indexOf('id="password-form"'));
assert.ok(html.includes('data-settings-system hidden'));
});
test('binary and SwitchOS backups never offer comparison actions or export tabs', () => {
const app = application();
const routeros = { id: 1, device_type: 'routeros', name: 'R1' };
const switchos = { id: 2, device_type: 'switchos', name: 'SW1', supports_export: false };
for (const file of [
{ id: 3, router_id: 1, backup_type: 'binary', device_type: 'routeros', file_name: 'test.backup' },
{ id: 4, router_id: 2, backup_type: 'binary', device_type: 'switchos', file_name: 'test.swb' },
{ id: 5, router_id: 2, backup_type: 'export', device_type: 'switchos', file_name: 'legacy.rsc' }
]) assert.ok(!app.backupTable([file]).includes('data-action="diff-latest"'));
app.state.cache.device = routeros;
app.state.detailTab = 'binary';
assert.ok(!app.detailBody().includes('data-action="compare-selected"'));
app.state.cache.device = switchos;
app.state.detailTab = 'overview';
assert.ok(!app.detailTabs().includes('data-detail-tab="export"'));
assert.ok(!app.detailBody().includes('data-action="device-export"'));
app.state.detailTab = 'policy';
assert.ok(!app.detailBody().includes('name="disable_export_backups"'));
app.state.cache.routers = [routeros, switchos];
app.state.cache.diffFiles = [
{ id: 1, backup_type: 'export', file_name: 'allowed.rsc', device_type: 'routeros' },
{ id: 2, backup_type: 'binary', file_name: 'forbidden.backup' },
{ id: 3, backup_type: 'export', file_name: 'forbidden.swb', device_type: 'switchos' }
];
const diff = app.diffControls();
assert.ok(diff.includes('allowed.rsc'));
assert.ok(!diff.includes('forbidden.'));
assert.ok(!diff.includes('SW1'));
});
test('SwitchOS credential forms preserve legacy choice and never prefill passwords', () => {
const app = application();
const base = { id: 2, device_type: 'switchos', name: 'SW1', ssh_user: 'own-admin', ssh_password: 'secret-not-to-render', has_own_password: true };
for (const mode of [null, true, false]) {
const html = app.deviceForm({ ...base, use_global_switchos_credentials: mode });
assert.ok(html.includes(`value="${mode === null ? '' : mode}" selected`));
assert.ok(!html.includes('secret-not-to-render'));
assert.equal(html.includes('id="device-own-credentials" hidden'), mode === true);
}
assert.equal(app.deviceUsername({ ssh_user: 'stored-own-admin', effective_username: null, use_global_switchos_credentials: true }), '—');
});
test('diff form errors never create an inline error element', () => {
const app = application();
let removed = false;
const form = { id: 'diff-form', querySelector: () => ({ remove() { removed = true; } }) };
app.formError(form, 'Choose two exports');
assert.equal(removed, true);
});
test('automation editors and active settings form contain no nested cards', () => {
const app = application();
app.state.settingsTab = 'automation';
const html = app.settingsForm({ export_cron: '0 2 * * mon', enable_auto_export: true });
assert.ok(html.includes('data-settings-pane="automation"'));
assert.ok(html.includes('class="automation-row"'));
assert.ok(!html.includes('class="card"'));
assert.ok(!html.includes('class="card-body"'));
assert.ok(!html.includes('schedule-editor'));
const status = app.schedulerTable({ jobs: [], timezone: 'Europe/Warsaw', running: true });
assert.ok(!status.includes('class="card'));
});
test('translated UI branding does not expose implementation terminology', () => {
const app = application();
for (const lang of ['pl', 'en', 'es', 'no']) assert.ok(!/rust|monolit/i.test(app.translations[lang].ui.about));
});
test('legacy naive UTC timestamps are parsed as UTC', () => {
const app = application();
assert.equal(app.parseDate('2026-01-02 03:04:05').toISOString(), '2026-01-02T03:04:05.000Z');
assert.equal(app.parseDate('2026-01-02T04:04:05+01:00').toISOString(), '2026-01-02T03:04:05.000Z');
assert.equal(app.parseDate('not a date'), null);
});
test('weekly schedules preserve actual APScheduler weekday meaning', () => {
const app = application();
const monday = app.scheduleEditor('export', 'settings.exportCron', '0 2 * * 0');
const tuesday = app.scheduleEditor('export', 'settings.exportCron', '0 2 * * 1');
const sunday = app.scheduleEditor('export', 'settings.exportCron', '0 2 * * sun');
assert.ok(monday.includes('value="mon" selected'));
assert.ok(tuesday.includes('value="tue" selected'));
assert.ok(sunday.includes('value="sun" selected'));
const custom = app.scheduleEditor('export', 'settings.exportCron', '*/15 * * * mon-fri');
assert.ok(custom.includes('value="custom" selected'));
});
test('dashboard renders only real input counts and escapes stored labels/logs', async () => {
const app = application();
app.api = async endpoint => endpoint === '/dashboard'
? { routers_count: 1, export_count: 0, binary_count: 0, total_backups: 0, storage: { total: 100, used: 30, free: 70, folder_used: 0 }, recent_logs: [{ message: '<script>bad</script>', timestamp: '2026-01-01' }] }
: endpoint === '/routers' ? [{ id: 1, name: 'one', last_connection_status: null }] : [];
const html = await app.views.dashboard();
assert.ok(html.includes('&lt;script&gt;bad&lt;/script&gt;'));
assert.ok(!html.includes('<script>bad'));
assert.ok(html.includes('stroke-dasharray="326.73 326.73"'));
const cards = html.split('<div class="cards-grid">')[1].split('<div class="grid-2">')[0];
assert.equal((cards.match(/class="metric"/g) || []).length, 4);
assert.ok(!cards.includes('<svg'));
assert.ok(!cards.includes('metric-icon'));
});
test('query parameters use URL encoding and skip empty values', () => {
const app = application();
const params = new URLSearchParams(app.backupQuery({ search: 'r1&router_id=2', router_id: '', order: 'asc' }));
assert.equal(params.get('search'), 'r1&router_id=2');
assert.equal(params.has('router_id'), false);
});
test('a late device response cannot overwrite the state after navigation', async () => {
const app = application(), pending = [];
app.api = endpoint => new Promise(resolve => pending.push({ endpoint, resolve }));
const load = app.views.device(1);
app.state.routeId += 1;
pending.forEach(({ endpoint, resolve }) => resolve(endpoint.startsWith('/routers') ? { id: 1, name: 'stale' } : []));
assert.equal(await load, '');
assert.equal(app.state.cache.device, undefined);
});
+88
View File
@@ -0,0 +1,88 @@
/* Read-only browser smoke test of a running Rust server.
* Set MBS_BASE_URL, MBS_TEST_USERNAME and MBS_TEST_PASSWORD explicitly.
* Optional MBS_CHROMIUM points to an existing browser executable.
*/
const { chromium } = require('playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const base = process.env.MBS_BASE_URL;
if (!base || !process.env.MBS_TEST_USERNAME || !process.env.MBS_TEST_PASSWORD) throw new Error('Set MBS_BASE_URL, MBS_TEST_USERNAME and MBS_TEST_PASSWORD');
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'], ...(process.env.MBS_CHROMIUM ? { executablePath: process.env.MBS_CHROMIUM } : {}) });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = [], failed = [], security = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' && /Content Security|Refused to|CSP/i.test(message.text())) security.push(message.text()); });
page.on('response', response => { if (response.url().includes('/api/') && response.status() >= 400) failed.push(`${response.status()} ${new URL(response.url()).pathname}`); });
try {
await page.goto(base);
await page.locator('#login-form').waitFor();
assert.equal(await page.locator('.auth-layout header, .auth-layout svg').count(), 0);
if (!await page.evaluate(() => window.MBS.config.allowRegistration)) assert.equal(await page.locator('a[href="/register"]').count(), 0);
await page.fill('#login-form [name=username]', process.env.MBS_TEST_USERNAME);
await page.fill('#login-form [name=password]', process.env.MBS_TEST_PASSWORD);
await page.click('#login-form button[type=submit]');
await page.locator('.cards-grid').waitFor();
assert.equal(await page.locator('.metric svg, .topbar .brand svg, .topbar .brand-sub, .nav a[href="/change-password"]').count(), 0);
assert.equal(await page.locator('.nav a[href="/schedule"]').count(), 0);
assert.equal(await page.locator('.api-docs-link').getAttribute('href'), '/docs');
for (const route of ['/devices', '/files', '/diff-configs', '/logs', '/settings', '/schedule', '/change-password', '/']) {
await page.evaluate(route => window.MBS.navigate(route), route);
await page.locator('.loading').waitFor({ state: 'detached' });
assert.equal(await page.locator('.error-panel').count(), 0, `Failed route ${route}`);
assert.equal(await page.locator('main h1').count(), 1, `Missing heading ${route}`);
assert.ok(!/\b(?:routers|settings|dashboard|files|auth|ui)\.[a-zA-Z]+\b/.test(await page.locator('main').textContent()), `Untranslated key on ${route}`);
if (route === '/change-password') {
assert.equal(new URL(page.url()).pathname, '/settings');
assert.equal(new URL(page.url()).searchParams.get('tab'), 'account');
assert.equal(await page.locator('#password-form').isVisible(), true);
}
if (route === '/schedule') {
assert.equal(new URL(page.url()).pathname, '/settings');
assert.equal(new URL(page.url()).searchParams.get('tab'), 'automation');
assert.equal(await page.locator('.settings-layout .card:visible').count(), 0);
}
console.log(`PASS live route ${route}`);
}
const routers = await page.evaluate(() => window.MBS.state.cache.routers || []);
if (routers.length) {
await page.evaluate(id => window.MBS.navigate(`/devices/${id}`), routers[0].id);
await page.locator('#device-detail-body').waitFor();
const supported = routers[0].device_type !== 'switchos' && routers[0].supports_export !== false;
assert.equal(await page.locator('.device-heading .metric-icon').count(), 0);
if (!supported) assert.equal(await page.locator('[data-detail-tab=export], [data-action=device-export]').count(), 0);
for (const tab of [...(supported ? ['export'] : []), 'binary', 'policy', 'overview']) await page.click(`[data-detail-tab=${tab}]`);
console.log('PASS live device detail and tabs');
}
const exports = await page.evaluate(() => (window.MBS.state.cache.backups || []).filter(file => file.backup_type === 'export'));
if (exports.length >= 2) {
await page.evaluate(ids => window.MBS.navigate(`/diff-configs?left=${ids[0]}&right=${ids[1]}`), exports.slice(0, 2).map(file => file.id));
await page.locator('.diff-split').waitFor();
assert.equal(await page.locator('.diff-pane').count(), 2);
await page.click('[data-diff-mode=unified]'); await page.locator('.diff-unified').waitFor();
await page.click('[data-diff-mode=split]');
if (process.env.MBS_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-diff.png'), fullPage: true, animations: 'disabled' });
}
console.log('PASS live RSC diff split and unified');
}
await page.evaluate(() => window.MBS.navigate('/'));
if (process.env.MBS_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-dashboard.png'), fullPage: true, animations: 'disabled' });
await page.evaluate(() => window.MBS.navigate('/settings'));
await page.click('[data-settings-tab=automation]');
await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-settings.png'), fullPage: true, animations: 'disabled' });
}
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => window.MBS.navigate('/'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'Mobile horizontal overflow');
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-mobile.png'), fullPage: true, animations: 'disabled' });
assert.deepEqual(errors, []); assert.deepEqual(failed, []); assert.deepEqual(security, []);
await page.click('[data-action=logout]'); await page.locator('#login-form').waitFor();
console.log('PASS live responsive layout, CSP, no API errors, no JS errors and logout');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+358
View File
@@ -0,0 +1,358 @@
/* Browser contract smoke test against isolated local HTTP fixtures.
* Requires Playwright: node web/tests/smoke.cjs
* It never contacts a MikroTik or sends an email/push notification.
*/
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const web = path.resolve(__dirname, '..');
const assets = fs.readFileSync(path.join(web, 'assets.list'), 'utf8').trim().split(/\r?\n/).filter(line => line && !line.startsWith('#'));
const script = assets.filter(file => file.endsWith('.js')).map(file => fs.readFileSync(path.join(web, file), 'utf8')).join('\n');
const css = assets.filter(file => file.endsWith('.css')).map(file => fs.readFileSync(path.join(web, file), 'utf8')).join('\n');
const now = new Date().toISOString();
const state = {
allowRegistration: false,
failNextDiff: false,
calls: [], user: { id: 1, username: 'qa_user', preferred_language: 'en', preferred_font: 'default' },
routers: [{ id: 1, owner_id: 1, name: 'R1-main', device_type: 'routeros', host: '127.0.0.1', port: 22, ssh_user: 'admin', effective_username: 'admin', has_effective_password: true, has_effective_ssh_key: false, supports_export: true, supports_restore_upload: true, disable_all_backups: false, disable_export_backups: false, disable_binary_backups: false, disable_ping: false, last_connection_status: true, last_connection_model: 'RB5009', last_connection_version: '7.20', last_connection_tested_at: now, last_connection_uptime: '1d2h', created_at: now }],
files: [1, 2, 3].map(id => ({ id, router_id: 1, router_name: 'R1-main', device_type: 'routeros', file_name: `R1-${id}.${id === 3 ? 'backup' : 'rsc'}`, backup_type: id === 3 ? 'binary' : 'export', file_size: 1024 * id, checksum: 'a'.repeat(64), created_at: new Date(Date.now() - (4 - id) * 60000).toISOString() })),
settings: { id: 1, backup_retention_days: 7, log_retention_days: 7, export_cron: '0 2 * * 1', binary_cron: '', retention_cron: '', enable_auto_export: true, connection_test_interval_minutes: 0, global_ssh_key: null, has_global_ssh_key: true, default_switchos_username: 'admin', default_switchos_password: null, has_default_switchos_credentials: true, has_default_switchos_password: true, pushover_token: null, pushover_userkey: null, has_pushover_token: true, has_pushover_userkey: true, notify_failures_only: true, smtp_host: 'localhost', smtp_port: 587, smtp_login: '', smtp_password: null, has_smtp_password: true, smtp_notifications_enabled: false, recipient_email: 'qa@example.invalid' }
};
const publicRouter = router => {
const { _ownPassword, _ownKey, ssh_password, ssh_key, ...result } = router;
const switchos = router.device_type === 'switchos';
const source = router.use_global_switchos_credentials;
const ownPassword = !!_ownPassword || !!router.has_own_password || !switchos && !!router.has_effective_password;
const global = switchos && source === true;
const legacy = switchos && source == null;
return { ...result, ssh_password: null, ssh_key: null, has_own_password: ownPassword,
use_global_switchos_credentials: source ?? null, supports_export: !switchos,
effective_username: global ? state.settings.default_switchos_username || null : router.ssh_user || (legacy ? state.settings.default_switchos_username : null),
uses_global_switchos_credentials: global || legacy && (!router.ssh_user || !ownPassword),
has_effective_password: global ? !!state.settings.has_default_switchos_password : ownPassword || legacy && !!state.settings.has_default_switchos_password
};
};
const json = (response, status, data) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(data)); };
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost'), pathname = url.pathname;
if (pathname === '/assets/app.js') { res.writeHead(200, { 'Content-Type': 'text/javascript' }); res.end(script); return; }
if (pathname === '/assets/app.css') { res.writeHead(200, { 'Content-Type': 'text/css' }); res.end(css); return; }
if (!pathname.startsWith('/api/')) { res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'" }); res.end(fs.readFileSync(path.join(web, 'index.html'), 'utf8').replace('<meta name="allow-registration" content="false">', `<meta name="allow-registration" content="${state.allowRegistration}">`)); return; }
let raw = ''; for await (const chunk of req) raw += chunk;
const body = raw ? JSON.parse(raw) : {};
state.calls.push({ method: req.method, path: pathname, body, query: Object.fromEntries(url.searchParams) });
if (pathname === '/api/health') return json(res, 200, { status: 'ok' });
if (pathname === '/api/auth/register') return state.allowRegistration ? json(res, 200, { id: 2, username: body.username, preferred_language: 'en', preferred_font: 'default' }) : json(res, 403, { detail: 'Registration is disabled' });
if (pathname === '/api/auth/login') return body.username === 'qa_user' && body.password === 'test-password' ? json(res, 200, { access_token: 'fixture-only-token', token_type: 'bearer', user: state.user }) : json(res, 401, { detail: 'Invalid credentials' });
if (!req.headers.authorization) return json(res, 401, { detail: 'Unauthorized' });
if (pathname === '/api/auth/me') return json(res, 200, state.user);
if (pathname === '/api/auth/preferences') { Object.assign(state.user, body); return json(res, 200, state.user); }
if (pathname === '/api/auth/change-password') return json(res, 200, { message: 'Password changed' });
if (pathname === '/api/dashboard') return json(res, 200, { routers_count: state.routers.length, export_count: state.files.filter(f => f.backup_type === 'export').length, binary_count: state.files.filter(f => f.backup_type === 'binary').length, total_backups: state.files.length, storage: { total: 1000000, used: 600000, free: 400000, folder_used: 10240 }, recent_logs: [{ id: 1, message: '<img src=x onerror=alert(1)> safely escaped', timestamp: now }] });
if (pathname === '/api/routers/ping-statuses') return json(res, 200, { items: state.routers.map(r => ({ router_id: r.id, reachable: true, latency_ms: 0.5, disabled: r.disable_ping })) });
if (pathname === '/api/routers') {
if (req.method === 'POST') { const r = { id: Math.max(0, ...state.routers.map(router => router.id)) + 1, owner_id: 1, created_at: now, has_effective_password: false, ...body, _ownPassword: body.ssh_password }; state.routers.push(r); return json(res, 200, publicRouter(r)); }
return json(res, 200, state.routers.map(publicRouter));
}
let match;
if ((match = pathname.match(/^\/api\/routers\/(\d+)\/test-connection$/))) return json(res, 200, { success: true, tested_at: now, hostname: 'R1-main', model: 'RB5009', uptime: '1d2h' });
if ((match = pathname.match(/^\/api\/routers\/(\d+)$/))) {
const id = Number(match[1]), router = state.routers.find(r => r.id === id);
if (req.method === 'PUT') { Object.assign(router, body); if (body.ssh_password) router._ownPassword = body.ssh_password; if (body.clear_ssh_password) { delete router._ownPassword; router.has_own_password = false; } }
if (req.method === 'DELETE') state.routers = state.routers.filter(r => r.id !== id);
return json(res, 200, router ? publicRouter(router) : { message: 'Deleted' });
}
if (pathname === '/api/settings') { if (req.method === 'PUT') Object.assign(state.settings, body); return json(res, 200, state.settings); }
if (pathname === '/api/settings/scheduler-status') return json(res, 200, { timezone: 'Europe/Warsaw', running: true, jobs: [{ key: 'auto_export', label: 'settings.schedulerAutoExportLabel', enabled: true, valid: true, cron: state.settings.export_cron, description: 'settings.schedulerCronDescription', description_params: { description: state.settings.export_cron }, next_runs: [now] }] });
if (pathname === '/api/settings/reveal-ssh-key') return body.password === 'test-password' ? json(res, 200, { global_ssh_key: 'FAKE-TEST-KEY-NOT-REAL' }) : json(res, 400, { detail: 'Invalid password' });
if (pathname === '/api/settings/test-email' || pathname === '/api/settings/test-pushover') return json(res, 200, { message: 'Test notification accepted by fixture' });
if (pathname === '/api/logs') return json(res, 200, [{ id: 1, message: 'Backup completed', timestamp: now }, { id: 2, message: '<script>window.hacked=true</script>', timestamp: now }]);
if (pathname.startsWith('/api/logs/older-than/')) return json(res, 200, { message: 'Deleted' });
if (pathname === '/api/backups') return json(res, 200, state.files.filter(f => (!url.searchParams.get('backup_type') || f.backup_type === url.searchParams.get('backup_type')) && (!url.searchParams.get('router_id') || f.router_id === Number(url.searchParams.get('router_id')))));
if ((match = pathname.match(/^\/api\/backups\/router\/(\d+)$/))) return json(res, 200, state.files.filter(f => f.router_id === Number(match[1])));
if ((match = pathname.match(/^\/api\/backups\/router\/(\d+)\/(export|binary)$/))) { const id = state.files.length + 1; const file = { id, router_id: Number(match[1]), router_name: 'R1-main', device_type: 'routeros', file_name: `new-${id}.rsc`, backup_type: match[2], checksum: 'b'.repeat(64), file_size: 100, created_at: now }; state.files.push(file); return json(res, 200, file); }
if (/^\/api\/backups\/router\/\d+\/upload\/\d+$/.test(pathname)) return json(res, 200, { message: 'Uploaded to isolated fixture only' });
if (pathname === '/api/backups/routers/export-all' || pathname === '/api/backups/routers/binary-all') return json(res, 200, [{ router_id: 1, router_name: 'R1-main', status: 'ok' }, { router_id: 2, router_name: 'R2', status: 'error', error: 'Fixture test of partial failure' }]);
if ((match = pathname.match(/^\/api\/backups\/(\d+)\/view$/))) return json(res, 200, { content: '/system identity\nset name="<img src=x onerror=alert(1)>"\n', backup: state.files.find(f => f.id === Number(match[1])) });
if ((match = pathname.match(/^\/api\/backups\/(\d+)\/diff\/(\d+)(\/html)?$/))) {
if (state.failNextDiff) { state.failNextDiff = false; return json(res, 502, { detail: 'Fixture diff failure' }); }
if (match[3]) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<pre>escaped fixture</pre>'); return; }
return json(res, 200, { left_backup_id: Number(match[1]), right_backup_id: Number(match[2]), left_file_name: 'old.rsc', right_file_name: 'new.rsc', diff_text: '--- old\n+++ new\n-old\n+new', stats: { added: 1, removed: 0, modified: 1, context: 1 }, lines: [{ type: 'context', left_number: 1, right_number: 1, left_text: '/system identity', right_text: '/system identity' }, { type: 'modified', left_number: 2, right_number: 2, left_text: 'set name="old"', right_text: 'set name="new"' }, { type: 'added', left_number: null, right_number: 3, left_text: '', right_text: '<img src=x>' }] });
}
if (pathname.endsWith('/download') || pathname === '/api/backups/bulk' && body.action === 'download') { res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="fixture-backup.rsc"' }); res.end('fixture backup'); return; }
if (pathname === '/api/backups/bulk' && body.action === 'delete') { state.files = state.files.filter(f => !body.backup_ids.includes(f.id)); return json(res, 200, { message: 'Deleted' }); }
if ((match = pathname.match(/^\/api\/backups\/(\d+)$/)) && req.method === 'DELETE') { state.files = state.files.filter(f => f.id !== Number(match[1])); return json(res, 200, { message: 'Deleted' }); }
return json(res, 404, { detail: `Unknown fixture endpoint ${req.method} ${pathname}` });
});
(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'], ...(process.env.MBS_CHROMIUM ? { executablePath: process.env.MBS_CHROMIUM } : {}) });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
const page = await context.newPage();
const errors = []; page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => { errors.push(`Unexpected native dialog: ${dialog.message()}`); dialog.dismiss(); });
const go = async route => { await page.evaluate(route => window.MBS.navigate(route), route); await page.locator('.loading').waitFor({ state: 'detached' }); };
try {
await page.goto(`${origin}/register`);
await page.locator('#login-form').waitFor();
assert.equal(new URL(page.url()).pathname, '/login');
assert.equal(await page.evaluate(() => window.MBS.config.allowRegistration), false);
assert.equal(await page.locator('a[href="/register"]').count(), 0);
assert.equal(await page.locator('.auth-layout header, .auth-layout svg').count(), 0);
const assertFlatLogin = async () => {
const gradients = await page.evaluate(() => [document.body, ...document.querySelectorAll('.auth-layout, .auth-layout *')].filter(el => getComputedStyle(el).backgroundImage.includes('gradient')).map(el => el.className));
assert.deepEqual(gradients, [], 'Login must use solid backgrounds, including the submit button');
assert.equal(await page.locator('.auth-layout header, .auth-layout svg').count(), 0);
};
await assertFlatLogin();
if (process.env.MBS_SCREENSHOT_DIR) { fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'login.png'), fullPage: true, animations: 'disabled' }); }
await page.click('[data-action=theme]'); assert.equal(await page.getAttribute('html', 'data-theme'), 'light'); await assertFlatLogin();
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'login-light.png'), fullPage: true, animations: 'disabled' });
await page.click('[data-action=theme]');
await page.selectOption('[data-language]', 'en'); assert.equal(await page.getAttribute('html', 'lang'), 'en'); await page.selectOption('[data-language]', 'pl');
await page.setViewportSize({ width: 390, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'login-mobile.png'), fullPage: true, animations: 'disabled' });
await page.setViewportSize({ width: 1440, height: 1000 });
assert.equal(state.calls.filter(call => call.path === '/api/auth/register').length, 0);
state.allowRegistration = true;
await page.goto(`${origin}/login`); await page.locator('#login-form').waitFor();
assert.equal(await page.evaluate(() => window.MBS.config.allowRegistration), true);
await page.click('a[href="/register"]'); await page.locator('#register-form').waitFor();
await page.fill('#f-username', 'qa_registered'); await page.fill('#f-password', 'register-test'); await page.fill('#f-confirm_password', 'mismatch');
await page.click('#register-form button[type=submit]'); await page.locator('#register-form .form-error').waitFor();
assert.equal(state.calls.filter(call => call.path === '/api/auth/register').length, 0);
await page.fill('#f-confirm_password', 'register-test'); await page.click('#register-form button[type=submit]'); await page.locator('#login-form').waitFor();
assert.equal(state.calls.filter(call => call.path === '/api/auth/register').length, 1);
await page.fill('#f-username', 'qa_user'); await page.fill('#f-password', 'incorrect'); await page.click('#login-form button[type=submit]');
await page.locator('#login-form .form-error').waitFor(); assert.match(await page.locator('#login-form .form-error').textContent(), /Invalid credentials/);
await page.fill('#f-password', 'test-password'); await page.click('#login-form button[type=submit]');
await page.locator('h1').filter({ hasText: 'Dashboard' }).waitFor();
assert.equal(await page.locator('.metric').count(), 4);
assert.equal(await page.locator('.metric svg, .metric-icon').count(), 0);
assert.equal(await page.locator('.topbar .brand svg, .topbar .brand-sub').count(), 0);
assert.equal(await page.locator('.topbar .brand').textContent(), 'MikroTik Backup System');
assert.equal(await page.locator('.nav a[href="/change-password"]').count(), 0);
assert.equal(await page.locator('.nav a[href="/schedule"]').count(), 0);
assert.ok(await page.locator('.nav svg').count() > 0, 'Icons outside the four metric cards stay available');
assert.equal(await page.locator('.api-docs-link').getAttribute('href'), '/docs');
assert.equal(await page.locator('.api-docs-link').getAttribute('data-nav'), null);
assert.ok(!/monolit|\brust\b/i.test(await page.locator('.sidebar-footer').textContent()));
const languageColors = await page.evaluate(() => {
const select = document.querySelector('.topbar .language-select'), option = select.options[0];
return { select: getComputedStyle(select).backgroundColor, option: getComputedStyle(option).backgroundColor, bar: getComputedStyle(document.querySelector('.topbar')).backgroundColor, scheme: getComputedStyle(select).colorScheme };
});
assert.equal(languageColors.select, languageColors.bar);
assert.equal(languageColors.option, languageColors.bar);
assert.match(languageColors.scheme, /dark/);
assert.equal(await page.locator('main img').count(), 0);
assert.match(await page.locator('.activity-list').textContent(), /<img src=x/);
console.log('PASS registration disabled/enabled, flat login, login errors, session, minimal brand/cards and stored XSS escaping');
await go('/devices'); await page.click('[data-action="new-device"]');
await page.fill('dialog #f-name', 'R2-office'); await page.fill('dialog #f-host', '127.0.0.2');
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
await page.getByRole('link', { name: 'R2-office', exact: true }).waitFor();
await page.click('[data-action="edit-device"][data-id="2"]'); await page.fill('dialog #f-name', 'R2-renamed');
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
const update = state.calls.find(c => c.method === 'PUT' && c.path === '/api/routers/2');
assert.equal(Object.hasOwn(update.body, 'ssh_password'), false);
assert.equal(Object.hasOwn(update.body, 'ssh_key'), false);
await go('/devices/1');
assert.equal(await page.locator('.device-heading svg, .device-heading .metric-icon').count(), 0);
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'device-detail.png'), fullPage: true, animations: 'disabled' });
await page.click('[data-detail-tab=binary]');
assert.equal(await page.locator('[data-action=compare-selected], [data-action=diff-latest]').count(), 0);
await page.click('[data-detail-tab="policy"]');
await page.check('[name=disable_all_backups]'); await page.click('#device-policy-form button[type=submit]');
await page.waitForFunction(() => window.MBS.state.cache.device.disable_all_backups === true);
assert.equal(state.routers[0].disable_binary_backups, true);
await page.uncheck('[name=disable_all_backups]'); await page.uncheck('[name=disable_export_backups]'); await page.uncheck('[name=disable_binary_backups]');
await page.click('#device-policy-form button[type=submit]');
await page.waitForFunction(() => window.MBS.state.cache.device.disable_all_backups === false);
console.log('PASS device creation, edit, secret preservation and policy');
await go('/files');
assert.equal(await page.locator('[data-action=diff-latest][data-id="3"]').count(), 0);
await page.check('[data-select-backup="3"]'); assert.equal(await page.locator('[data-action=compare-selected]').isVisible(), false);
await page.check('[data-select-backup="1"]'); assert.equal(await page.locator('[data-action=compare-selected]').isVisible(), false);
await page.uncheck('[data-select-backup="3"]'); await page.uncheck('[data-select-backup="1"]');
await page.click('[data-action=preview][data-id="1"]');
await page.locator('dialog pre').waitFor(); assert.match(await page.locator('dialog pre').textContent(), /<img src=x/);
assert.equal(await page.locator('dialog img').count(), 0); await page.click('dialog [data-action=close-dialog]');
await page.click('[data-action=diff-latest][data-id="1"]'); await page.locator('.diff-split').waitFor(); await go('/files');
await page.check('[data-select-backup="1"]'); await page.check('[data-select-backup="2"]'); await page.click('[data-action=compare-selected]');
await page.locator('.diff-line').first().waitFor();
await page.fill('#diff-search', 'new'); await page.locator('.diff-line mark').waitFor();
await page.click('[data-diff-mode=unified]'); await page.locator('.diff-unified').waitFor();
assert.match(await page.locator('.diff-unified').textContent(), /\+new/);
await page.click('[data-diff-mode=split]'); assert.equal(await page.locator('#diff-result img').count(), 0);
const invalidPairMessage = await page.evaluate(() => window.MBS.t('ui.selectPair'));
await page.selectOption('#diff-form [name=left]', '2'); await page.selectOption('#diff-form [name=right]', '2');
await page.click('#diff-form button[type=submit]'); await page.locator('#toast-region').getByText(invalidPairMessage, { exact: true }).waitFor();
assert.equal(await page.locator('#diff-form .form-error').count(), 0);
await page.selectOption('#diff-form [name=left]', '1');
state.failNextDiff = true;
await page.click('#diff-form button[type=submit]'); await page.locator('#toast-region').getByText('Fixture diff failure', { exact: true }).waitFor();
assert.equal(await page.locator('#diff-form .form-error').count(), 0);
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'diff-toast.png'), fullPage: true, animations: 'disabled' });
console.log('PASS RSC-only actions, binary bulk/detail exclusions, diff search/views and toast-only validation/API errors');
await go('/files'); const download = page.waitForEvent('download'); await page.click('[data-action=download][data-id="1"]'); assert.equal((await download).suggestedFilename(), 'fixture-backup.rsc');
await page.check('[data-select-backup="1"]'); await page.check('[data-select-backup="2"]');
await page.click('[data-action=bulk-delete]'); await page.locator('#accept-confirm').waitFor();
assert.equal(state.files.length, 3); await page.click('#accept-confirm');
await page.waitForFunction(() => window.MBS.state.cache.files.length === 1);
await page.click('[data-action=upload][data-id="3"]');
const uploadResponse = page.waitForResponse(response => response.url().endsWith('/api/backups/router/1/upload/3'));
await page.click('#accept-confirm'); await uploadResponse;
assert.ok(state.calls.some(c => c.path === '/api/backups/router/1/upload/3'));
await page.click('[data-action=new-backup]'); await page.locator('#backup-form').waitFor();
await page.selectOption('#backup-form [name=router_id]', '1'); await page.selectOption('#backup-form [name=backup_type]', 'export');
await page.click('#backup-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
await page.waitForFunction(() => window.MBS.state.cache.files.length === 2);
console.log('PASS authenticated download, confirmed bulk deletion and binary upload');
await go('/devices'); await page.click('[data-action=new-device]');
await page.fill('#device-form [name=name]', 'SW1-global'); await page.fill('#device-form [name=host]', '192.0.2.10');
await page.selectOption('#device-form [name=device_type]', 'switchos');
assert.equal(await page.inputValue('[name=use_global_switchos_credentials]'), 'true');
assert.equal(await page.locator('#device-own-credentials').isVisible(), false);
assert.equal(await page.locator('#device-form [name=ssh_password]').isDisabled(), true);
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'switchos-global-form.png'), fullPage: true, animations: 'disabled' });
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
const switchCreate = state.calls.filter(call => call.method === 'POST' && call.path === '/api/routers').at(-1).body;
assert.equal(switchCreate.use_global_switchos_credentials, true);
for (const field of ['ssh_user', 'ssh_password', 'ssh_key', 'clear_ssh_password', 'clear_ssh_key']) assert.equal(Object.hasOwn(switchCreate, field), false);
const sw = state.routers.find(router => router.name === 'SW1-global');
state.files.push({ id: 40, router_id: sw.id, router_name: sw.name, device_type: 'switchos', file_name: 'SW1.swb', backup_type: 'binary', file_size: 88, created_at: now });
await go(`/devices/${sw.id}`);
assert.equal(await page.locator('[data-detail-tab=export], [data-action=device-export]').count(), 0);
await page.click('[data-detail-tab=policy]'); assert.equal(await page.locator('[name=disable_export_backups]').count(), 0);
await page.click('[data-detail-tab=binary]');
assert.equal(await page.locator('[data-action=compare-selected], [data-action=diff-latest], [data-action=upload]').count(), 0);
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'switchos-binary.png'), fullPage: true, animations: 'disabled' });
await page.click(`[data-action=edit-device][data-id="${sw.id}"]`);
await page.selectOption('[name=use_global_switchos_credentials]', 'false');
await page.fill('#device-form [name=ssh_user]', 'own-admin'); await page.fill('#device-form [name=ssh_password]', 'fixture-own-password');
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
let switchUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${sw.id}`).at(-1).body;
assert.equal(switchUpdate.use_global_switchos_credentials, false); assert.equal(switchUpdate.ssh_password, 'fixture-own-password');
await page.click(`[data-action=edit-device][data-id="${sw.id}"]`);
assert.equal(await page.inputValue('#device-form [name=ssh_password]'), '');
await page.selectOption('[name=use_global_switchos_credentials]', 'true');
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
switchUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${sw.id}`).at(-1).body;
assert.equal(switchUpdate.use_global_switchos_credentials, true);
for (const field of ['ssh_user', 'ssh_password', 'clear_ssh_password', 'clear_ssh_key']) assert.equal(Object.hasOwn(switchUpdate, field), false);
await page.click(`[data-action=edit-device][data-id="${sw.id}"]`); await page.selectOption('[name=use_global_switchos_credentials]', 'false');
assert.equal(await page.inputValue('#device-form [name=ssh_user]'), 'own-admin');
assert.equal(await page.inputValue('#device-form [name=ssh_password]'), '');
assert.equal(await page.getAttribute('#device-form [name=ssh_password]', 'placeholder'), await page.evaluate(() => window.MBS.t('ui.kept')));
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
switchUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${sw.id}`).at(-1).body;
assert.equal(switchUpdate.use_global_switchos_credentials, false); assert.equal(Object.hasOwn(switchUpdate, 'ssh_password'), false);
assert.equal(state.routers.find(router => router.id === sw.id)._ownPassword, 'fixture-own-password');
const legacyId = Math.max(...state.routers.map(router => router.id)) + 1;
state.routers.push({ id: legacyId, name: 'SW-legacy', device_type: 'switchos', host: '192.0.2.11', port: 80, ssh_user: 'legacy-admin', _ownPassword: 'fixture-legacy-password', use_global_switchos_credentials: null, created_at: now });
await go(`/devices/${legacyId}`); await page.click(`[data-action=edit-device][data-id="${legacyId}"]`);
assert.equal(await page.inputValue('[name=use_global_switchos_credentials]'), '');
await page.fill('#device-form [name=name]', 'SW-legacy-renamed'); await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
const legacyUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${legacyId}`).at(-1).body;
assert.equal(Object.hasOwn(legacyUpdate, 'use_global_switchos_credentials'), false);
assert.equal(Object.hasOwn(legacyUpdate, 'ssh_password'), false);
await go('/files'); await page.click('[data-action=new-backup]'); await page.selectOption('#backup-form [name=router_id]', String(sw.id));
assert.equal(await page.locator('#backup-form [name=backup_type] option[value=export]').count(), 0);
assert.equal(await page.inputValue('#backup-form [name=backup_type]'), 'binary');
await page.click('dialog [data-action=close-dialog]');
await go('/diff-configs');
assert.equal(await page.locator(`#diff-form [name=router_id] option[value="${sw.id}"]`).count(), 0);
assert.equal(await page.locator('#diff-form [name=left] option[value="40"], #diff-form [name=right] option[value="40"]').count(), 0);
console.log('PASS SwitchOS global/own/legacy credential payloads, preserved own secrets and hidden unsupported actions');
await go('/schedule');
assert.equal(new URL(page.url()).pathname, '/settings');
assert.equal(new URL(page.url()).searchParams.get('tab'), 'automation');
assert.equal(await page.locator('.settings-layout .card:visible').count(), 0);
assert.equal(await page.locator('.scheduler-status .card').count(), 0);
assert.equal(await page.locator('[data-schedule]').count(), 3);
assert.ok(await page.evaluate(() => [...document.querySelectorAll('[data-schedule]')].every(el => {
const css = getComputedStyle(el); return css.backgroundColor === 'rgba(0, 0, 0, 0)' && css.borderLeftWidth === '0px' && css.borderRightWidth === '0px' && css.borderTopWidth === '0px';
})));
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'automation.png'), fullPage: true, animations: 'disabled' });
await page.setViewportSize({ width: 390, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'automation-mobile.png'), fullPage: true, animations: 'disabled' });
await page.setViewportSize({ width: 1440, height: 1000 });
assert.equal(await page.inputValue('[name=export_weekday]'), 'tue');
await page.selectOption('[name=export_weekday]', 'mon'); await page.fill('[name=export_time]', '04:30');
await page.selectOption('[name=binary_mode]', 'daily'); await page.fill('[name=binary_time]', '05:15');
await page.selectOption('[name=retention_mode]', 'custom'); await page.fill('[name=retention_custom]', '17 */6 * * mon-fri');
await page.click('#settings-form button[type=submit]');
await page.waitForFunction(() => window.MBS.state.cache.settings.export_cron === '30 4 * * mon');
const saved = state.calls.filter(c => c.path === '/api/settings' && c.method === 'PUT').at(-1).body;
assert.equal(Object.hasOwn(saved, 'smtp_password'), false); assert.equal(Object.hasOwn(saved, 'global_ssh_key'), false);
assert.equal(saved.binary_cron, '15 5 * * *'); assert.equal(saved.retention_cron, '17 */6 * * mon-fri');
await go('/settings?tab=schedule'); assert.equal(new URL(page.url()).searchParams.get('tab'), 'automation');
assert.equal(await page.inputValue('[name=binary_mode]'), 'daily'); assert.equal(await page.inputValue('[name=retention_mode]'), 'custom');
await page.click('[data-settings-tab=smtp]'); await page.check('[name=clear_smtp_password]'); await page.click('#settings-form button[type=submit]');
await page.waitForFunction(() => window.MBS.state.cache.settings.clear_smtp_password === true);
assert.equal(state.calls.filter(call => call.path === '/api/settings' && call.method === 'PUT').at(-1).body.clear_smtp_password, true);
const emailResponse = page.waitForResponse(response => response.url().endsWith('/api/settings/test-email'));
await page.click('[data-action=test-email]'); await emailResponse;
await page.click('[data-settings-tab=pushover]');
const pushResponse = page.waitForResponse(response => response.url().endsWith('/api/settings/test-pushover'));
await page.click('[data-action=test-pushover]'); await pushResponse;
await page.click('[data-settings-tab=credentials]'); await page.click('[data-action=reveal-ssh]');
await page.fill('dialog [name=password]', 'test-password'); await page.click('#reveal-form button[type=submit]');
await page.locator('.secret-preview').waitFor(); assert.equal(await page.locator('.secret-preview').textContent(), 'FAKE-TEST-KEY-NOT-REAL');
await page.click('dialog [data-action=close-dialog]'); await page.locator('dialog').waitFor({ state: 'detached' }); assert.equal(await page.locator('body').textContent().then(text => text.includes('FAKE-TEST-KEY-NOT-REAL')), false);
await page.selectOption('#preferences-form [name=preferred_language]', 'en'); await page.selectOption('#preferences-form [name=preferred_font]', 'hack'); await page.click('#preferences-form button[type=submit]');
await page.waitForFunction(() => document.documentElement.lang === 'en'); assert.equal(await page.getAttribute('html', 'data-font'), 'hack');
console.log('PASS schedule weekdays, masked secrets, reveal lifecycle, language and font preferences');
await go('/logs'); assert.equal(await page.evaluate(() => window.hacked), undefined);
const deleteBox = await page.locator('#logs-cleanup button').boundingBox(), daysBox = await page.locator('#logs-cleanup [name=days]').boundingBox(), searchBox = await page.locator('#logs-search').boundingBox();
assert.ok(Math.abs(deleteBox.y - daysBox.y) <= 1 && Math.abs(deleteBox.y - searchBox.y) <= 1, 'Delete must align with the filter inputs');
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'logs.png'), fullPage: true, animations: 'disabled' });
await page.fill('#logs-search', 'Backup'); await page.waitForTimeout(250); assert.equal(await page.locator('#logs-table tbody tr').count(), 1);
await go('/settings'); await page.click('[data-settings-tab=account]'); await page.locator('#password-form').waitFor({ state: 'visible' });
assert.equal(new URL(page.url()).searchParams.get('tab'), 'account');
assert.equal(await page.locator('#settings-form').isVisible(), false);
assert.equal(await page.locator('#preferences-form').isVisible(), true);
await go('/change-password');
assert.equal(new URL(page.url()).pathname, '/settings');
assert.equal(new URL(page.url()).searchParams.get('tab'), 'account');
assert.equal(await page.locator('[data-settings-tab=account]').getAttribute('aria-selected'), 'true');
await page.reload(); await page.locator('#password-form').waitFor({ state: 'visible' });
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'settings-account.png'), fullPage: true, animations: 'disabled' });
await page.fill('[name=current_password]', 'test-password'); await page.fill('[name=new_password]', 'new-pass-123'); await page.fill('[name=confirm_password]', 'wrong');
await page.click('#password-form button[type=submit]'); await page.locator('#password-form .form-error').waitFor();
assert.equal(state.calls.filter(c => c.path === '/api/auth/change-password').length, 0);
await page.fill('[name=confirm_password]', 'new-pass-123'); await page.click('#password-form button[type=submit]');
await page.waitForFunction(() => document.querySelector('[name=new_password]').value === '');
assert.equal(state.calls.filter(c => c.path === '/api/auth/change-password').length, 1);
await page.click('[data-settings-tab=general]'); assert.equal(await page.locator('#password-form').isVisible(), false); assert.equal(await page.locator('#settings-form').isVisible(), true);
console.log('PASS log XSS/search, account tab, legacy route, refresh and password matching/submission');
await go('/'); await page.click('[data-action=batch-export]'); await page.click('#accept-confirm');
await page.locator('dialog .notice.warning').waitFor(); assert.match(await page.locator('dialog').textContent(), /Fixture test of partial failure/); await page.click('dialog [data-action=close-dialog]');
await page.setViewportSize({ width: 390, height: 844 });
await page.click('[data-action=sidebar]'); assert.equal(await page.locator('.layout.mobile-open').count(), 1); await page.click('[data-action=sidebar-close]');
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1));
const logoutBox = await page.locator('[data-action=logout]').boundingBox();
assert.ok(logoutBox.y >= 0 && logoutBox.y + logoutBox.height <= 54, 'Mobile header controls must stay on one row');
await page.click('[data-action=theme]'); assert.equal(await page.getAttribute('html', 'data-theme'), 'light');
if (process.env.MBS_SCREENSHOT_DIR) { fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'mobile.png'), fullPage: true, animations: 'disabled' }); await page.setViewportSize({ width: 1440, height: 1000 }); await page.click('[data-action=theme]'); await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'dashboard.png'), fullPage: true, animations: 'disabled' }); }
await page.click('[data-action=logout]'); await page.locator('#login-form').waitFor();
assert.equal(await page.evaluate(() => sessionStorage.getItem('mbs_token')), null);
assert.deepEqual(errors, []);
console.log('PASS partial failure report, responsive layout, themes and logout');
console.log(`PASS browser smoke: ${state.calls.length} local API fixture requests; no page errors`);
} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }
})().catch(error => { console.error(error); process.exitCode = 1; server.close(); });