212 lines
11 KiB
JavaScript
212 lines
11 KiB
JavaScript
/* 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">&\''), '<script a="1">&'');
|
|
assert.equal(app.highlight('<img src=x onerror=alert(1)>', 'img'), '<<mark>img</mark> src=x onerror=alert(1)>');
|
|
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('<img onerror=x>'));
|
|
});
|
|
|
|
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('<script>bad</script>'));
|
|
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);
|
|
});
|