diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9231f728..97e6736e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,18 @@ A few of these are worth knowing about before they fail: JSDoc, with no build step and nothing emitted. It has to stay clean. A new module needs two entries in `tsconfig.frontend.json`, the plain path and the `?v=*` form, because TypeScript allows one wildcard per pattern. +- **Test code is linted and formatted** along with everything else, with a few + rules relaxed for `api/test`, `api/test-support` and `ui/test` in `biome.json`: + a stub whose signature has to match a real one keeps its unused parameter, a + scanner keeps `while ((m = re.exec(s)))`, and `'use strict'` stays where a test + depends on strict-mode semantics. + + They are **not** typechecked. `checkJs` over `api/test` reports 131 errors, + almost all of them narrowing around Node's `server.address()` in test + harnesses rather than defects. Silencing them means JSDoc casts across roughly + forty files for no behaviour change, so the exclusion is deliberate; revisit it + as its own piece of work, not as a side effect of something else. + - **`npm run lint`** is Biome, from `node_modules`. Run it through npm: a bare `npx biome` resolves to an unrelated package that exits 0 without checking anything. diff --git a/api/test-support/tmp.js b/api/test-support/tmp.js index 7a09765d..afaaa99b 100644 --- a/api/test-support/tmp.js +++ b/api/test-support/tmp.js @@ -36,7 +36,11 @@ function arm() { armed = true; process.on('exit', () => { for (const dir of created) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* best effort */ + } } }); } diff --git a/api/test-support/widget-ctx.js b/api/test-support/widget-ctx.js index 78353321..a1141b4b 100644 --- a/api/test-support/widget-ctx.js +++ b/api/test-support/widget-ctx.js @@ -8,7 +8,9 @@ const { KIND, WidgetError } = require('../src/api-error'); const errorParts = () => ({ - fail: (message, opts) => { throw new WidgetError(message, opts); }, + fail: (message, opts) => { + throw new WidgetError(message, opts); + }, KIND, }); diff --git a/api/test/api-error-integration.test.js b/api/test/api-error-integration.test.js index 7ab3890e..5ce2691b 100644 --- a/api/test/api-error-integration.test.js +++ b/api/test/api-error-integration.test.js @@ -9,7 +9,7 @@ const path = require('node:path'); const fs = require('node:fs'); process.env.ALLOW_PRIVATE_IPS = 'true'; -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const _tmp = tmpDir('apierr'); process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); process.env.WIDGETS_PATH = path.join(_tmp, 'widgets'); @@ -28,7 +28,8 @@ const { KIND } = require('../src/api-error'); const SECRET = 'b'.repeat(64); let server, base, cookie; let upstream, upstreamBase; -let upstreamStatus = 200, upstreamBody = '{"count":3}'; +let upstreamStatus = 200, + upstreamBody = '{"count":3}'; before(async () => { const passwordHash = await hashPassword('correct-horse'); @@ -49,27 +50,47 @@ before(async () => { }); after(async () => { - await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); - await new Promise(r => { upstream.closeAllConnections?.(); upstream.close(r); }); + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); + await new Promise(r => { + upstream.closeAllConnections?.(); + upstream.close(r); + }); }); function post(pathname, body, opts = {}) { const data = JSON.stringify(body); const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: Object.assign({ - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(data), - Origin: base, - Cookie: opts.cookie === null ? '' : (opts.cookie || cookie), - }), - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: Object.assign({ + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + Origin: base, + Cookie: opts.cookie === null ? '' : opts.cookie || cookie, + }), + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); r.on('error', reject); r.end(data); }); @@ -89,11 +110,29 @@ test('a cross-origin write keeps its 403 and gains a kind', async () => { const data = JSON.stringify({ url: upstreamBase }); const u = new URL(base + '/api/ping'); const r = await new Promise((resolve, reject) => { - const q = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: 'http://evil.example', Cookie: cookie }, - }, res => { let b = ''; res.on('data', c => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); }); - q.on('error', reject); q.end(data); + const q = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + Origin: 'http://evil.example', + Cookie: cookie, + }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); + }, + ); + q.on('error', reject); + q.end(data); }); assert.equal(r.status, 403); assert.equal(r.body.kind, KIND.INVALID); @@ -102,7 +141,8 @@ test('a cross-origin write keeps its 403 and gains a kind', async () => { /* ── badge-proxy ──────────────────────────────────────────────────────────── */ test('badge-proxy reports an upstream 401 as a failure, not a bare success', async () => { - upstreamStatus = 401; upstreamBody = '{"detail":"missing token"}'; + upstreamStatus = 401; + upstreamBody = '{"detail":"missing token"}'; const r = await post('/api/badge-proxy', { url: upstreamBase }); assert.equal(r.status, 502); assert.equal(r.body.kind, KIND.UPSTREAM); @@ -110,14 +150,16 @@ test('badge-proxy reports an upstream 401 as a failure, not a bare success', asy }); test('badge-proxy does not leak the upstream response body into detail', async () => { - upstreamStatus = 403; upstreamBody = '{"secret":"hunter2"}'; + upstreamStatus = 403; + upstreamBody = '{"secret":"hunter2"}'; const r = await post('/api/badge-proxy', { url: upstreamBase }); assert.deepEqual(Object.keys(r.body.detail), ['status'], 'detail is server-derived only'); assert.ok(!JSON.stringify(r.body).includes('hunter2')); }); test('badge-proxy still succeeds on a 200', async () => { - upstreamStatus = 200; upstreamBody = '{"count":3}'; + upstreamStatus = 200; + upstreamBody = '{"count":3}'; const r = await post('/api/badge-proxy', { url: upstreamBase }); assert.equal(r.status, 200); assert.ok(!('kind' in r.body), 'a success carries no kind'); @@ -127,7 +169,8 @@ test('badge-proxy still succeeds on a 200', async () => { /* The counterpart to /api/badges no longer sending the body (P4-3): this is the endpoint the admin field picker uses, and it is the one that must keep it. */ test('badge-proxy still returns the upstream body for the field picker', async () => { - upstreamStatus = 200; upstreamBody = '{"count":3,"nested":{"other":9}}'; + upstreamStatus = 200; + upstreamBody = '{"count":3,"nested":{"other":9}}'; const r = await post('/api/badge-proxy', { url: upstreamBase }); assert.deepEqual(r.body.data, { count: 3, nested: { other: 9 } }); assert.ok(r.body.numbers.some(n => n.path === 'count' && n.value === 3)); @@ -136,7 +179,10 @@ test('badge-proxy still returns the upstream body for the field picker', async ( test('badge-proxy tags an unreachable target as a network failure', async () => { const dead = await new Promise(res => { const s = http.createServer(() => {}); - s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); + s.listen(0, '127.0.0.1', () => { + const p = s.address().port; + s.close(() => res(p)); + }); }); const r = await post('/api/badge-proxy', { url: `http://127.0.0.1:${dead}` }); assert.equal(r.status, 502); @@ -155,7 +201,10 @@ test('badge-proxy tags a missing url as invalid', async () => { test('ping keeps its ok:false shape and gains a kind', async () => { const dead = await new Promise(res => { const s = http.createServer(() => {}); - s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); + s.listen(0, '127.0.0.1', () => { + const p = s.address().port; + s.close(() => res(p)); + }); }); const r = await post('/api/ping', { url: `http://127.0.0.1:${dead}` }); assert.equal(r.status, 200); diff --git a/api/test/api-error.test.js b/api/test/api-error.test.js index 7ead52fa..c824c477 100644 --- a/api/test/api-error.test.js +++ b/api/test/api-error.test.js @@ -13,7 +13,6 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { KIND, ApiError, classify, errorBody } = require('../src/api-error'); - /* ── classify ─────────────────────────────────────────────────────────────── */ test('a socket error is classified as network and carries its errno', () => { @@ -46,7 +45,11 @@ test('an SSRF block is classified as blocked, by name not instanceof', () => { test('a malformed request body is classified as invalid', () => { let thrown; - try { JSON.parse('{nope'); } catch (e) { thrown = e; } + try { + JSON.parse('{nope'); + } catch (e) { + thrown = e; + } assert.equal(classify(thrown).kind, KIND.INVALID); }); @@ -59,7 +62,8 @@ test('an unrecognised failure falls back to internal, it does not throw', () => test('an explicit kind on the error wins over inference', () => { const e = Object.assign(new Error('connect ECONNREFUSED 1.2.3.4:80'), { - code: 'ECONNREFUSED', kind: KIND.BLOCKED, + code: 'ECONNREFUSED', + kind: KIND.BLOCKED, }); assert.equal(classify(e).kind, KIND.BLOCKED); }); diff --git a/api/test/auth-hardening.test.js b/api/test/auth-hardening.test.js index 4962398d..6ce1cf58 100644 --- a/api/test/auth-hardening.test.js +++ b/api/test/auth-hardening.test.js @@ -16,7 +16,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const _tmp = tmpDir('auth'); process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); @@ -44,10 +44,10 @@ test('a wrong password does not verify', async () => { test('a malformed stored hash resolves false instead of crashing', async () => { const cases = [ 'somesalt:not-valid-hex', - 'somesalt:' + 'a'.repeat(127), /* odd length, one byte short after decode */ - 'somesalt:' + 'a'.repeat(130), /* too long */ + 'somesalt:' + 'a'.repeat(127) /* odd length, one byte short after decode */, + 'somesalt:' + 'a'.repeat(130) /* too long */, 'somesalt:', - 'zz:' + 'a'.repeat(128), /* non-hex salt */ + 'zz:' + 'a'.repeat(128) /* non-hex salt */, ':' + 'a'.repeat(128), 'nocolon', '', @@ -89,9 +89,16 @@ before(async () => { base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); -beforeEach(() => { saveConfig({ items: [], settings: {} }); }); +beforeEach(() => { + saveConfig({ items: [], settings: {} }); +}); /* Once auth is enabled the config route requires a session, so these tests must send one. Without it the write 401s and the assertions below pass for the @@ -105,19 +112,33 @@ function post(pathname, body, opts = {}) { const data = JSON.stringify(body); const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(data), - Origin: base, - Cookie: opts.cookie || '', + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + Origin: base, + Cookie: opts.cookie || '', + }, }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); r.on('error', reject); r.end(data); }); @@ -138,10 +159,14 @@ test('a config write cannot replace an existing password hash', async () => { cfg.settings.auth = { enabled: true, passwordHash: await hashPassword('correct-horse'), secret: 'real-secret' }; saveConfig(cfg); - const r = await post('/api/config', { - items: [], - settings: { auth: { enabled: false, passwordHash: 'x:zz', secret: 'attacker-chosen' } }, - }, { cookie: sessionCookie() }); + const r = await post( + '/api/config', + { + items: [], + settings: { auth: { enabled: false, passwordHash: 'x:zz', secret: 'attacker-chosen' } }, + }, + { cookie: sessionCookie() }, + ); assert.equal(r.status, 200, 'the write must actually be accepted, or this proves nothing'); const after = loadConfig().settings.auth; @@ -157,16 +182,23 @@ test('a config write cannot add a new field to settings.auth', async () => { cfg.settings.auth = { enabled: true, secret: 'real-secret' }; saveConfig(cfg); - const r = await post('/api/config', { - items: [], - settings: { auth: { enabled: true, secret: 'real-secret', somethingNew: 'injected' } }, - }, { cookie: sessionCookie() }); + const r = await post( + '/api/config', + { + items: [], + settings: { auth: { enabled: true, secret: 'real-secret', somethingNew: 'injected' } }, + }, + { cookie: sessionCookie() }, + ); assert.equal(r.status, 200, 'the write must actually be accepted, or this proves nothing'); assert.ok(!('somethingNew' in loadConfig().settings.auth)); }); test('an ordinary config write is unaffected', async () => { - const r = await post('/api/config', { items: [{ id: 'a1', type: 'app', name: 'App' }], settings: { language: 'en' } }); + const r = await post('/api/config', { + items: [{ id: 'a1', type: 'app', name: 'App' }], + settings: { language: 'en' }, + }); assert.equal(r.status, 200); const cfg = loadConfig(); assert.equal(cfg.settings.language, 'en'); diff --git a/api/test/auth-lockout.test.js b/api/test/auth-lockout.test.js index d951247e..e47d5889 100644 --- a/api/test/auth-lockout.test.js +++ b/api/test/auth-lockout.test.js @@ -15,7 +15,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const _tmp = tmpDir('lockout'); process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); @@ -36,26 +36,47 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); -beforeEach(() => { saveConfig({ items: [], settings: {} }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); +beforeEach(() => { + saveConfig({ items: [], settings: {} }); +}); function req(method, pathname, body, cookie) { const data = body ? JSON.stringify(body) : ''; const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(data), - Origin: base, - Cookie: cookie || '', + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + Origin: base, + Cookie: cookie || '', + }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + ); r.on('error', reject); r.end(data); }); diff --git a/api/test/auth.test.js b/api/test/auth.test.js index a54ad3de..a29c2582 100644 --- a/api/test/auth.test.js +++ b/api/test/auth.test.js @@ -1,13 +1,20 @@ -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpPath } = require('../test-support/tmp'); process.env.CONFIG_PATH = tmpPath('apps.json'); const { test } = require('node:test'); const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const { - makeToken, verifyToken, hashPassword, verifyPassword, - parseCookies, rateLimit, registerLoginAttempt, clearAttempts, - newSessionSecret, newSessionId, + makeToken, + verifyToken, + hashPassword, + verifyPassword, + parseCookies, + rateLimit, + registerLoginAttempt, + clearAttempts, + newSessionSecret, + newSessionId, SESSION_MAX_AGE_MS, } = require('../src/auth'); @@ -130,7 +137,8 @@ test('the two surfaces agree on where the cap falls', () => { }); test('each key is counted on its own', () => { - const a = '203.0.113.21', b = '203.0.113.22'; + const a = '203.0.113.21', + b = '203.0.113.22'; for (let i = 0; i < 3; i++) rateLimit(a, 'k', 3, 60_000); assert.ok(rateLimit(a, 'k', 3, 60_000), 'the exhausted key is refused'); assert.equal(rateLimit(a, 'other', 3, 60_000), null, 'a different route is unaffected'); @@ -139,7 +147,8 @@ test('each key is counted on its own', () => { for (let i = 0; i < 5; i++) registerLoginAttempt(a); assert.ok(registerLoginAttempt(a), 'the locked-out address is refused'); assert.equal(registerLoginAttempt(b), null, 'another address can still log in'); - clearAttempts(a); clearAttempts(b); + clearAttempts(a); + clearAttempts(b); }); /* The window opens on the first hit of a series, and that hit counts. A window @@ -163,8 +172,11 @@ test('a ceiling below one refuses everything', () => { test('the wait is reported in the unit each surface uses', () => { const ip = '203.0.113.25'; for (let i = 0; i < 5; i++) registerLoginAttempt(ip); - assert.match(registerLoginAttempt(ip), /Try again in 15 minutes\./, - 'a fifteen-minute lockout counted in seconds reads as a stopwatch'); + assert.match( + registerLoginAttempt(ip), + /Try again in 15 minutes\./, + 'a fifteen-minute lockout counted in seconds reads as a stopwatch', + ); clearAttempts(ip); for (let i = 0; i < 2; i++) rateLimit(ip, 'unit', 2, 60_000); @@ -186,8 +198,11 @@ test('being refused does not push the window out', async () => { assert.ok(rateLimit(ip, 'slide', 1, WINDOW), 'still inside the window, still refused'); await new Promise(r => setTimeout(r, WINDOW * 0.8)); - assert.equal(rateLimit(ip, 'slide', 1, WINDOW), null, - 'the window is measured from its first hit, not from the last refusal'); + assert.equal( + rateLimit(ip, 'slide', 1, WINDOW), + null, + 'the window is measured from its first hit, not from the last refusal', + ); }); /* One minute left reads as "1 minute", not "1 minutes". */ diff --git a/api/test/backup-data.test.js b/api/test/backup-data.test.js index f1a83332..82448bc7 100644 --- a/api/test/backup-data.test.js +++ b/api/test/backup-data.test.js @@ -13,7 +13,9 @@ function ctxFor(config, endpoint, routes, row = null) { return { calls, ctx: { - config, endpoint, row, + config, + endpoint, + row, /* The same surface widget-data.js hands a data function: a failure is thrown as a WidgetError so its message reaches the browser intact. */ fail: (message, opts) => { @@ -35,10 +37,15 @@ function ctxFor(config, endpoint, routes, row = null) { const LOGIN = { status: 200, data: { AccessToken: 'tok', RefreshNonce: 'nonce' } }; test('duplicati-jobs returns options built from the row URL and password', async () => { - const { ctx, calls } = ctxFor({ slots: [] }, 'duplicati-jobs', { - '/api/v1/auth/login': LOGIN, - '/api/v1/backups': { status: 200, data: [{ Backup: { ID: '3', Name: 'Nightly' } }] }, - }, { provider: 'duplicati', dupUrl: 'duplicati:8200', dupPass: 'pw' }); + const { ctx, calls } = ctxFor( + { slots: [] }, + 'duplicati-jobs', + { + '/api/v1/auth/login': LOGIN, + '/api/v1/backups': { status: 200, data: [{ Backup: { ID: '3', Name: 'Nightly' } }] }, + }, + { provider: 'duplicati', dupUrl: 'duplicati:8200', dupPass: 'pw' }, + ); const out = await dataFn(ctx); assert.deepEqual(out, { options: [{ value: '3', label: 'Nightly' }] }); @@ -58,37 +65,54 @@ test('an options fetch with no resolved row is refused', async () => { test('kopia-sources sends basic auth only when a username is present', async () => { const reply = { status: 200, data: { sources: [{ source: { host: 'h', userName: 'u', path: '/data' } }] } }; - const withUser = ctxFor({}, 'kopia-sources', { '/api/v1/sources': reply }, - { provider: 'kopia', kopiaUrl: 'http://kopia:51515/', kopiaUser: 'u', kopiaPass: 'p' }); + const withUser = ctxFor( + {}, + 'kopia-sources', + { '/api/v1/sources': reply }, + { provider: 'kopia', kopiaUrl: 'http://kopia:51515/', kopiaUser: 'u', kopiaPass: 'p' }, + ); const out = await dataFn(withUser.ctx); assert.equal(out.options.length, 1); assert.equal(out.options[0].label, '/data'); assert.match(withUser.calls[0].opts.headers.Authorization, /^Basic /); assert.equal(withUser.calls[0].url, 'http://kopia:51515/api/v1/sources'); - const anon = ctxFor({}, 'kopia-sources', { '/api/v1/sources': reply }, - { provider: 'kopia', kopiaUrl: 'http://kopia:51515', kopiaPass: 'p' }); + const anon = ctxFor( + {}, + 'kopia-sources', + { '/api/v1/sources': reply }, + { provider: 'kopia', kopiaUrl: 'http://kopia:51515', kopiaPass: 'p' }, + ); await dataFn(anon.ctx); assert.equal(anon.calls[0].opts.headers.Authorization, undefined); }); test('kopia-sources surfaces an auth failure', async () => { - const { ctx } = ctxFor({}, 'kopia-sources', { '/api/v1/sources': { status: 401 } }, - { provider: 'kopia', kopiaUrl: 'http://kopia:51515' }); + const { ctx } = ctxFor( + {}, + 'kopia-sources', + { '/api/v1/sources': { status: 401 } }, + { provider: 'kopia', kopiaUrl: 'http://kopia:51515' }, + ); await assert.rejects(() => dataFn(ctx), /authentication failed/i); }); test('slots returns one entry per slot, null where unconfigured', async () => { - const config = { slots: [ - { provider: 'duplicati', dupUrl: 'http://d:8200', dupPass: 'pw', jobId: '3', customName: 'Nightly' }, - { provider: null }, - { provider: 'kopia', kopiaUrl: 'http://k:51515', jobId: 'h@u:/data' }, - ] }; + const config = { + slots: [ + { provider: 'duplicati', dupUrl: 'http://d:8200', dupPass: 'pw', jobId: '3', customName: 'Nightly' }, + { provider: null }, + { provider: 'kopia', kopiaUrl: 'http://k:51515', jobId: 'h@u:/data' }, + ], + }; const { ctx } = ctxFor(config, 'slots', { '/api/v1/auth/login': LOGIN, '/api/v1/serverstate': { status: 200, data: { ProposedSchedule: [] } }, '/api/v1/backups': { status: 200, data: [{ Backup: { ID: '3', Name: 'Upstream Name' } }] }, - '/api/v1/sources': { status: 200, data: { sources: [{ source: { host: 'h', userName: 'u', path: '/data' }, status: 'IDLE' }] } }, + '/api/v1/sources': { + status: 200, + data: { sources: [{ source: { host: 'h', userName: 'u', path: '/data' }, status: 'IDLE' }] }, + }, }); const out = await dataFn(ctx); @@ -108,23 +132,30 @@ test('slots leaves a slot null when its job is gone upstream', async () => { }); test('slots collapses same-instance slots into one round of upstream calls', async () => { - const config = { slots: [ - { provider: 'duplicati', dupUrl: 'http://d:8200', dupPass: 'pw', jobId: '1' }, - { provider: 'duplicati', dupUrl: 'http://d:8200/', dupPass: 'pw', jobId: '2' }, - ] }; + const config = { + slots: [ + { provider: 'duplicati', dupUrl: 'http://d:8200', dupPass: 'pw', jobId: '1' }, + { provider: 'duplicati', dupUrl: 'http://d:8200/', dupPass: 'pw', jobId: '2' }, + ], + }; const { ctx, calls } = ctxFor(config, 'slots', { '/api/v1/auth/login': LOGIN, '/api/v1/serverstate': { status: 200, data: {} }, '/api/v1/backups': { status: 200, data: [{ Backup: { ID: '1', Name: 'A' } }, { Backup: { ID: '2', Name: 'B' } }] }, }); const out = await dataFn(ctx); - assert.deepEqual(out.map(s => s && s.name), ['A', 'B']); + assert.deepEqual( + out.map(s => s && s.name), + ['A', 'B'], + ); assert.equal(calls.filter(c => c.url.endsWith('/api/v1/serverstate')).length, 1); }); test('an unreachable instance leaves its slots null instead of failing the request', async () => { const { ctx } = ctxFor({ slots: [{ provider: 'duplicati', dupUrl: 'http://d:8200', jobId: '1' }] }, 'slots', { - '/api/v1/auth/login': () => { throw new Error('ECONNREFUSED'); }, + '/api/v1/auth/login': () => { + throw new Error('ECONNREFUSED'); + }, }); assert.deepEqual(await dataFn(ctx), [null]); }); diff --git a/api/test/backup-status.test.js b/api/test/backup-status.test.js index 419849ce..41b8b556 100644 --- a/api/test/backup-status.test.js +++ b/api/test/backup-status.test.js @@ -54,7 +54,10 @@ test('dupDeriveStatus resolves running/error/missed/warning/healthy in priority test('dupDeriveStatus is not missed when the last backup is newer than the scheduled run', () => { const job = { ID: '1', Metadata: { LastBackupFinished: '2025-12-31T00:00:00Z' } }; - assert.equal(b.dupDeriveStatus(job, { ProposedSchedule: [{ Item1: '1', Item2: '2020-06-01T00:00:00Z' }] }), 'healthy'); + assert.equal( + b.dupDeriveStatus(job, { ProposedSchedule: [{ Item1: '1', Item2: '2020-06-01T00:00:00Z' }] }), + 'healthy', + ); }); test('kopiaDeriveStatus resolves its status chain', () => { @@ -66,7 +69,10 @@ test('kopiaDeriveStatus resolves its status chain', () => { const stale = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); assert.equal(b.kopiaDeriveStatus({ status: 'IDLE', lastSnapshot: { endTime: stale } }), 'warning'); const fresh = new Date(Date.now() - 60 * 1000).toISOString(); - assert.equal(b.kopiaDeriveStatus({ status: 'IDLE', lastSnapshot: { endTime: fresh, stats: { errorCount: 0 } } }), 'healthy'); + assert.equal( + b.kopiaDeriveStatus({ status: 'IDLE', lastSnapshot: { endTime: fresh, stats: { errorCount: 0 } } }), + 'healthy', + ); }); test('kopiaSourceId composes host, user and path', () => { diff --git a/api/test/badge-extract.test.js b/api/test/badge-extract.test.js index f93bae2a..3c673839 100644 --- a/api/test/badge-extract.test.js +++ b/api/test/badge-extract.test.js @@ -1,4 +1,3 @@ -const path = require('node:path'); const { test } = require('node:test'); const assert = require('node:assert/strict'); const { collectNumbers, extractPath, computeBadgeValue } = require('../src/badge-extract'); @@ -57,7 +56,8 @@ test('collectNumbers emits an array count and boolean filter counts', () => { test('collectNumbers is null-safe and bounded', () => { assert.deepEqual(collectNumbers(null), []); // deep nesting must terminate rather than blow the stack - let deep = 0; for (let i = 0; i < 50; i++) deep = { d: deep }; + let deep = 0; + for (let i = 0; i < 50; i++) deep = { d: deep }; assert.doesNotThrow(() => collectNumbers(deep)); }); diff --git a/api/test/badge-headers.test.js b/api/test/badge-headers.test.js index 6d493a46..1f14539b 100644 --- a/api/test/badge-headers.test.js +++ b/api/test/badge-headers.test.js @@ -1,9 +1,14 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { - toRows, rowsToObject: _rowsToObject, requestParts: _requestParts, - scrubItemBadgeSecrets, preserveItemBadgeSecrets, migrateItemBadgeHeaders, - droppedRowCount, firstMalformedRow, + toRows, + rowsToObject: _rowsToObject, + requestParts: _requestParts, + scrubItemBadgeSecrets, + preserveItemBadgeSecrets, + migrateItemBadgeHeaders, + droppedRowCount, + firstMalformedRow, } = require('../src/badge-headers'); const { plain } = require('../test-support/plain'); @@ -14,10 +19,10 @@ const rowsToObject = rows => plain(_rowsToObject(rows)); const requestParts = item => plain(_requestParts(item)); test('toRows converts the old object shape to non-secret rows', () => { - assert.deepEqual( - toRows({ 'X-Api-Key': 'abc', Accept: 'application/json' }), - [{ key: 'X-Api-Key', value: 'abc', secret: false }, { key: 'Accept', value: 'application/json', secret: false }], - ); + assert.deepEqual(toRows({ 'X-Api-Key': 'abc', Accept: 'application/json' }), [ + { key: 'X-Api-Key', value: 'abc', secret: false }, + { key: 'Accept', value: 'application/json', secret: false }, + ]); assert.deepEqual(toRows(undefined), []); assert.deepEqual(toRows(null), []); }); @@ -37,10 +42,15 @@ test('rowsToObject skips blank keys and null values', () => { }); test('scrub hides secret values, keeps non-secret ones', () => { - const item = { type: 'app', badge: { headers: [ - { key: 'X-Api-Key', value: 'REAL', secret: true }, - { key: 'Accept', value: 'application/json', secret: false }, - ] } }; + const item = { + type: 'app', + badge: { + headers: [ + { key: 'X-Api-Key', value: 'REAL', secret: true }, + { key: 'Accept', value: 'application/json', secret: false }, + ], + }, + }; scrubItemBadgeSecrets(item); assert.deepEqual(item.badge.headers, [ { key: 'X-Api-Key', secret: true, valueSet: true }, @@ -130,10 +140,15 @@ test('preserve does not leak a stored value into a new unrelated key', () => { }); test('scrub then preserve round-trips without losing the secret', () => { - const stored = { type: 'app', badge: { headers: [ - { key: 'X-Api-Key', value: 'REAL', secret: true }, - { key: 'Accept', value: 'application/json', secret: false }, - ] } }; + const stored = { + type: 'app', + badge: { + headers: [ + { key: 'X-Api-Key', value: 'REAL', secret: true }, + { key: 'Accept', value: 'application/json', secret: false }, + ], + }, + }; const sent = JSON.parse(JSON.stringify(stored)); scrubItemBadgeSecrets(sent); /* browser edits nothing and posts the scrubbed shape back */ @@ -218,12 +233,15 @@ test('droppedRowCount reports how much was skipped', () => { /* ── refused on the way in ────────────────────────────────────────────────── */ test('firstMalformedRow names the field and the position', () => { - assert.deepEqual(firstMalformedRow({ badge: { headers: [{ key: 'A', value: '1' }, null] } }), - { field: 'badge.headers', index: 1 }); - assert.deepEqual(firstMalformedRow({ badge: { params: ['x'] } }), - { field: 'badge.params', index: 0 }); - assert.deepEqual(firstMalformedRow({ monitoring: { activity: { headers: [5] } } }), - { field: 'monitoring.activity.headers', index: 0 }); + assert.deepEqual(firstMalformedRow({ badge: { headers: [{ key: 'A', value: '1' }, null] } }), { + field: 'badge.headers', + index: 1, + }); + assert.deepEqual(firstMalformedRow({ badge: { params: ['x'] } }), { field: 'badge.params', index: 0 }); + assert.deepEqual(firstMalformedRow({ monitoring: { activity: { headers: [5] } } }), { + field: 'monitoring.activity.headers', + index: 0, + }); }); test('a clean item reports nothing', () => { diff --git a/api/test/badges-route.test.js b/api/test/badges-route.test.js index 36260b6b..6abbe0e4 100644 --- a/api/test/badges-route.test.js +++ b/api/test/badges-route.test.js @@ -14,7 +14,7 @@ const path = require('node:path'); process.env.ALLOW_PRIVATE_IPS = 'true'; -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('badges'), 'apps.json'); const { test, before, after } = require('node:test'); @@ -29,7 +29,11 @@ let server, base, upstream, upstreamBase; let upstreamBody = {}; const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); -const close = s => new Promise(r => { s.closeAllConnections?.(); s.close(r); }); +const close = s => + new Promise(r => { + s.closeAllConnections?.(); + s.close(r); + }); before(async () => { upstream = http.createServer((_, res) => { @@ -40,25 +44,37 @@ before(async () => { server = http.createServer(dispatch); base = await listen(server); }); -after(async () => { await close(server); await close(upstream); }); +after(async () => { + await close(server); + await close(upstream); +}); function get(pathname) { const u = new URL(base + pathname); return new Promise((resolve, reject) => { - http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET' }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => resolve(JSON.parse(b))); - }).on('error', reject).end(); + http + .request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET' }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve(JSON.parse(b))); + }) + .on('error', reject) + .end(); }); } function configure(extract) { saveConfig({ - items: [{ - id: 'a1', type: 'app', name: 'App', - badge: { enabled: true, url: `${upstreamBase}/api/counts`, extract }, - }], + items: [ + { + id: 'a1', + type: 'app', + name: 'App', + badge: { enabled: true, url: `${upstreamBase}/api/counts`, extract }, + }, + ], settings: {}, }); } @@ -95,7 +111,9 @@ test('a large upstream body does not enlarge the response', async () => { the dashboard's stale handling depends on. */ test('an unreachable upstream still answers with a value of zero', async () => { saveConfig({ - items: [{ id: 'a1', type: 'app', name: 'App', badge: { enabled: true, url: 'http://127.0.0.1:1/', extract: 'pending' } }], + items: [ + { id: 'a1', type: 'app', name: 'App', badge: { enabled: true, url: 'http://127.0.0.1:1/', extract: 'pending' } }, + ], settings: {}, }); const r = (await get('/api/badges')).a1; diff --git a/api/test/biome-schema.test.js b/api/test/biome-schema.test.js index d2ed7afe..def9a8c1 100644 --- a/api/test/biome-schema.test.js +++ b/api/test/biome-schema.test.js @@ -24,15 +24,20 @@ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) const SCHEMA_PATH = './node_modules/@biomejs/biome/configuration_schema.json'; test('the schema is resolved from the installed package', () => { - assert.equal(biome.$schema, SCHEMA_PATH, - 'point $schema at the installed package so a version bump cannot leave it behind'); + assert.equal( + biome.$schema, + SCHEMA_PATH, + 'point $schema at the installed package so a version bump cannot leave it behind', + ); }); /* The specific regression: a URL carries a version, and a version drifts. */ test('the schema is not a versioned URL', () => { assert.ok(!/^https?:/.test(biome.$schema), '$schema must not be a remote URL'); - assert.ok(!/\d+\.\d+\.\d+/.test(biome.$schema), - `$schema names a version (${biome.$schema}); Dependabot will not update it`); + assert.ok( + !/\d+\.\d+\.\d+/.test(biome.$schema), + `$schema names a version (${biome.$schema}); Dependabot will not update it`, + ); }); /* These two need the package on disk. The suite runs before `npm install` in diff --git a/api/test/blocked-ranges.test.js b/api/test/blocked-ranges.test.js index 760d3fc1..32810d85 100644 --- a/api/test/blocked-ranges.test.js +++ b/api/test/blocked-ranges.test.js @@ -19,25 +19,46 @@ const { isPrivateAddress, isBlockedIPv4, BLOCKED_IPV4 } = require('../src/proxy' /* first and last address of each CIDR, then the address either side of it. */ const CASES = [ - { cidr: '0.0.0.0/8', first: '0.0.0.0', last: '0.255.255.255', after: '1.0.0.0' }, - { cidr: '10.0.0.0/8', first: '10.0.0.0', last: '10.255.255.255', before: '9.255.255.255', after: '11.0.0.0' }, - { cidr: '100.64.0.0/10', first: '100.64.0.0', last: '100.127.255.255', before: '100.63.255.255', after: '100.128.0.0' }, - { cidr: '127.0.0.0/8', first: '127.0.0.0', last: '127.255.255.255', before: '126.255.255.255', after: '128.0.0.0' }, - { cidr: '169.254.0.0/16', first: '169.254.0.0', last: '169.254.255.255', before: '169.253.255.255', after: '169.255.0.0' }, - { cidr: '172.16.0.0/12', first: '172.16.0.0', last: '172.31.255.255', before: '172.15.255.255', after: '172.32.0.0' }, - { cidr: '192.0.0.0/24', first: '192.0.0.0', last: '192.0.0.255', before: '191.255.255.255', after: '192.0.1.0' }, - { cidr: '192.168.0.0/16', first: '192.168.0.0', last: '192.168.255.255', before: '192.167.255.255', after: '192.169.0.0' }, - { cidr: '198.18.0.0/15', first: '198.18.0.0', last: '198.19.255.255', before: '198.17.255.255', after: '198.20.0.0' }, - { cidr: '224.0.0.0/4', first: '224.0.0.0', last: '239.255.255.255', before: '223.255.255.255', after: null }, + { cidr: '0.0.0.0/8', first: '0.0.0.0', last: '0.255.255.255', after: '1.0.0.0' }, + { cidr: '10.0.0.0/8', first: '10.0.0.0', last: '10.255.255.255', before: '9.255.255.255', after: '11.0.0.0' }, + { + cidr: '100.64.0.0/10', + first: '100.64.0.0', + last: '100.127.255.255', + before: '100.63.255.255', + after: '100.128.0.0', + }, + { cidr: '127.0.0.0/8', first: '127.0.0.0', last: '127.255.255.255', before: '126.255.255.255', after: '128.0.0.0' }, + { + cidr: '169.254.0.0/16', + first: '169.254.0.0', + last: '169.254.255.255', + before: '169.253.255.255', + after: '169.255.0.0', + }, + { cidr: '172.16.0.0/12', first: '172.16.0.0', last: '172.31.255.255', before: '172.15.255.255', after: '172.32.0.0' }, + { cidr: '192.0.0.0/24', first: '192.0.0.0', last: '192.0.0.255', before: '191.255.255.255', after: '192.0.1.0' }, + { + cidr: '192.168.0.0/16', + first: '192.168.0.0', + last: '192.168.255.255', + before: '192.167.255.255', + after: '192.169.0.0', + }, + { cidr: '198.18.0.0/15', first: '198.18.0.0', last: '198.19.255.255', before: '198.17.255.255', after: '198.20.0.0' }, + { cidr: '224.0.0.0/4', first: '224.0.0.0', last: '239.255.255.255', before: '223.255.255.255', after: null }, /* 240/4 abuts 224/4 below it and ends the address space above, so it has no allowed neighbour on either side. */ - { cidr: '240.0.0.0/4', first: '240.0.0.0', last: '255.255.255.255', before: null, after: null }, + { cidr: '240.0.0.0/4', first: '240.0.0.0', last: '255.255.255.255', before: null, after: null }, ]; test('every range in the table is covered by a test here', () => { const declared = BLOCKED_IPV4.map(([base, bits]) => `${base}/${bits}`).sort(); - assert.deepEqual(CASES.map(c => c.cidr).sort(), declared, - 'a range was added or removed without updating these tests'); + assert.deepEqual( + CASES.map(c => c.cidr).sort(), + declared, + 'a range was added or removed without updating these tests', + ); }); test('every range blocks its first and last address', () => { @@ -78,8 +99,7 @@ test('IETF protocol assignments and benchmarking ranges are blocked', () => { /* ── IPv6 ─────────────────────────────────────────────────────────────────── */ test('IPv6 multicast is blocked', () => { - for (const a of ['ff02::1', 'ff05::1:3', 'FF02::FB']) - assert.ok(isPrivateAddress(a), `${a} should be blocked`); + for (const a of ['ff02::1', 'ff05::1:3', 'FF02::FB']) assert.ok(isPrivateAddress(a), `${a} should be blocked`); }); /* A group written 'ff' is 0x00ff, not 0xff00, so it is not multicast. 0xff02 can @@ -91,8 +111,7 @@ test('a short first group is not mistaken for multicast', () => { test('the existing IPv6 ranges still work', () => { for (const a of ['::1', '::', 'fd00::1', 'fc00::1', 'fe80::1', 'feb0::1']) assert.ok(isPrivateAddress(a), `${a} should be blocked`); - for (const a of ['2001:db8::1', '2606:4700::1111']) - assert.ok(!isPrivateAddress(a), `${a} should be allowed`); + for (const a of ['2001:db8::1', '2606:4700::1111']) assert.ok(!isPrivateAddress(a), `${a} should be allowed`); }); /* ── the new ranges through the IPv4-in-IPv6 wrappers ─────────────────────── */ diff --git a/api/test/client-ip.test.js b/api/test/client-ip.test.js index 9358509b..fa12a19c 100644 --- a/api/test/client-ip.test.js +++ b/api/test/client-ip.test.js @@ -19,7 +19,7 @@ client-supplied value cannot survive. No header chain is parsed in the app at all: when Stackyard is behind another reverse proxy, nginx resolves the real client itself from TRUSTED_PROXY, so the header is already correct. */ -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const { test } = require('node:test'); const assert = require('node:assert/strict'); @@ -134,10 +134,14 @@ test('the entrypoint refuses to start when nginx rejects the config', () => { fs.writeFileSync(path.join(stubDir, 'nginx'), '#!/bin/sh\necho "bad config" >&2\nexit 1\n'); fs.chmodSync(path.join(stubDir, 'nginx'), 0o755); - assert.throws(() => execFileSync('sh', [path.join(__dirname, '../../docker-entrypoint.sh'), 'true'], { - env: { PATH: `${stubDir}:${process.env.PATH}`, REALIP_CONF: path.join(dir, 'realip.conf') }, - stdio: 'pipe', - }), /bad config|Command failed/); + assert.throws( + () => + execFileSync('sh', [path.join(__dirname, '../../docker-entrypoint.sh'), 'true'], { + env: { PATH: `${stubDir}:${process.env.PATH}`, REALIP_CONF: path.join(dir, 'realip.conf') }, + stdio: 'pipe', + }), + /bad config|Command failed/, + ); }); /* ── the nginx config that consumes it ────────────────────────────────────── */ diff --git a/api/test/config-corrupt.test.js b/api/test/config-corrupt.test.js index 2720f530..5bce6d04 100644 --- a/api/test/config-corrupt.test.js +++ b/api/test/config-corrupt.test.js @@ -1,7 +1,7 @@ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const DIR = tmpDir('config-corrupt'); const CFG = path.join(DIR, 'apps.json'); process.env.CONFIG_PATH = CFG; diff --git a/api/test/config-durability.test.js b/api/test/config-durability.test.js index a99ff69e..bb3ab9ee 100644 --- a/api/test/config-durability.test.js +++ b/api/test/config-durability.test.js @@ -19,7 +19,7 @@ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const dir = tmpDir('durability'); process.env.CONFIG_PATH = path.join(dir, 'apps.json'); @@ -32,7 +32,9 @@ const { saveConfig, loadConfig } = config; const TMP = process.env.CONFIG_PATH + '.tmp'; beforeEach(() => { - try { fs.unlinkSync(TMP); } catch {} + try { + fs.unlinkSync(TMP); + } catch {} saveConfig({ items: [{ id: 'original', type: 'app', name: 'Original' }], settings: {} }); }); @@ -64,8 +66,14 @@ test('the file on disk is complete and parseable', () => { test('the contents are flushed before the rename', () => { const calls = []; const real = { fsync: fs.fsyncSync, rename: fs.renameSync }; - fs.fsyncSync = fd => { calls.push('fsync'); return real.fsync(fd); }; - fs.renameSync = (a, b) => { calls.push('rename'); return real.rename(a, b); }; + fs.fsyncSync = fd => { + calls.push('fsync'); + return real.fsync(fd); + }; + fs.renameSync = (a, b) => { + calls.push('rename'); + return real.rename(a, b); + }; try { saveConfig({ items: [], settings: {} }); } finally { @@ -73,8 +81,10 @@ test('the contents are flushed before the rename', () => { fs.renameSync = real.rename; } assert.ok(calls.includes('fsync'), 'nothing was flushed, so a power cut can lose the contents'); - assert.ok(calls.indexOf('fsync') < calls.indexOf('rename'), - 'flushing after the rename does not help: the rename would already be visible'); + assert.ok( + calls.indexOf('fsync') < calls.indexOf('rename'), + 'flushing after the rename does not help: the rename would already be visible', + ); }); /* A rename changes a directory entry, which needs to reach the disk too, or the @@ -82,16 +92,21 @@ test('the contents are flushed before the rename', () => { test('the directory is flushed after the rename', () => { const calls = []; const real = { fsync: fs.fsyncSync, rename: fs.renameSync }; - fs.fsyncSync = fd => { calls.push('fsync'); return real.fsync(fd); }; - fs.renameSync = (a, b) => { calls.push('rename'); return real.rename(a, b); }; + fs.fsyncSync = fd => { + calls.push('fsync'); + return real.fsync(fd); + }; + fs.renameSync = (a, b) => { + calls.push('rename'); + return real.rename(a, b); + }; try { saveConfig({ items: [], settings: {} }); } finally { fs.fsyncSync = real.fsync; fs.renameSync = real.rename; } - assert.ok(calls.lastIndexOf('fsync') > calls.indexOf('rename'), - 'the directory entry was never flushed'); + assert.ok(calls.lastIndexOf('fsync') > calls.indexOf('rename'), 'the directory entry was never flushed'); }); /* Some filesystems refuse to fsync a directory. The contents are already durable @@ -99,7 +114,10 @@ test('the directory is flushed after the rename', () => { test('a directory that cannot be flushed does not fail the save', () => { const real = fs.fsyncSync; let seen = 0; - fs.fsyncSync = fd => { if (++seen > 1) throw new Error('EINVAL'); return real(fd); }; + fs.fsyncSync = fd => { + if (++seen > 1) throw new Error('EINVAL'); + return real(fd); + }; try { assert.doesNotThrow(() => saveConfig({ items: [{ id: 'still-saved' }], settings: {} })); } finally { @@ -112,8 +130,14 @@ test('a directory that cannot be flushed does not fail the save', () => { function withFailingRename(fn) { const real = fs.renameSync; - fs.renameSync = () => { throw new Error('simulated disk failure'); }; - try { return fn(); } finally { fs.renameSync = real; } + fs.renameSync = () => { + throw new Error('simulated disk failure'); + }; + try { + return fn(); + } finally { + fs.renameSync = real; + } } test('a failed save reports the failure rather than swallowing it', () => { @@ -126,7 +150,9 @@ test('a failed save reports the failure rather than swallowing it', () => { changes that were never saved: the most misleading of the three faults. */ test('a failed save does not leave the cache claiming it succeeded', () => { withFailingRename(() => { - try { saveConfig({ items: [{ id: 'newer' }], settings: {} }); } catch {} + try { + saveConfig({ items: [{ id: 'newer' }], settings: {} }); + } catch {} }); const ids = loadConfig().items.map(i => i.id); assert.ok(ids.includes('original'), `cache reports ${ids.join(', ')}`); @@ -135,7 +161,9 @@ test('a failed save does not leave the cache claiming it succeeded', () => { test('a failed save leaves the file on disk untouched', () => { withFailingRename(() => { - try { saveConfig({ items: [{ id: 'newer' }], settings: {} }); } catch {} + try { + saveConfig({ items: [{ id: 'newer' }], settings: {} }); + } catch {} }); const raw = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); assert.ok(raw.items.some(i => i.id === 'original')); @@ -143,7 +171,9 @@ test('a failed save leaves the file on disk untouched', () => { test('a failed save cleans up its temp file', () => { withFailingRename(() => { - try { saveConfig({ items: [{ id: 'newer' }], settings: {} }); } catch {} + try { + saveConfig({ items: [{ id: 'newer' }], settings: {} }); + } catch {} }); assert.equal(fs.existsSync(TMP), false, 'the temp file would otherwise stay behind for good'); }); diff --git a/api/test/config-kill.test.js b/api/test/config-kill.test.js index 3b8a85b6..2c431fe0 100644 --- a/api/test/config-kill.test.js +++ b/api/test/config-kill.test.js @@ -37,7 +37,7 @@ const assert = require('node:assert/strict'); const { tmpDir } = require('../test-support/tmp'); const LOOP = path.join(__dirname, '..', 'test-support', 'save-loop.js'); -const APPS = 300; /* about 155 KB, a large real dashboard */ +const APPS = 300; /* about 155 KB, a large real dashboard */ const ITERATIONS = 8; /* Spread across the window where a write is in flight. Fixed rather than @@ -48,8 +48,11 @@ const DELAYS_MS = [12, 19, 27, 34, 41, 55, 68, 83]; function runAndKill(configPath, delayMs) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [LOOP, configPath, String(APPS)], { stdio: ['ignore', 'pipe', 'pipe'] }); - let ready = false, stderr = ''; - child.stderr.on('data', c => { stderr += c; }); + let ready = false, + stderr = ''; + child.stderr.on('data', c => { + stderr += c; + }); child.stdout.on('data', c => { if (ready || !String(c).includes('ready')) return; ready = true; @@ -102,8 +105,10 @@ test('a config killed mid-write is never left partial', async t => { if (leftovers.length) interrupted++; for (const f of leftovers) fs.unlinkSync(path.join(dir, f)); - t.diagnostic(`killed after ${delay}ms, generation ${parsed.settings.generation}, ` - + `${fs.statSync(configPath).size} bytes, ${leftovers.length ? 'mid-write' : 'between writes'}`); + t.diagnostic( + `killed after ${delay}ms, generation ${parsed.settings.generation}, ` + + `${fs.statSync(configPath).size} bytes, ${leftovers.length ? 'mid-write' : 'between writes'}`, + ); } t.diagnostic(`${interrupted} of ${ITERATIONS} kills landed inside a write`); }); @@ -119,8 +124,7 @@ test('a temp file left by a killed write does not shadow the config', async () = const leftovers = fs.readdirSync(dir).filter(f => f.endsWith('.tmp')); const { parsed } = readConfig(configPath); - assert.equal(parsed.items.length, APPS, - `the config is not intact, with ${leftovers.length} temp file(s) present`); + assert.equal(parsed.items.length, APPS, `the config is not intact, with ${leftovers.length} temp file(s) present`); /* And loading through the real reader agrees, temp file or not. */ const previous = process.env.CONFIG_PATH; diff --git a/api/test/config-secrets.test.js b/api/test/config-secrets.test.js index c0987211..05df144e 100644 --- a/api/test/config-secrets.test.js +++ b/api/test/config-secrets.test.js @@ -2,7 +2,7 @@ const path = require('node:path'); /* Load the real shipped manifests so this exercises the actual field declarations, not a fixture. */ process.env.WIDGETS_PATH = path.join(__dirname, '../../ui/widgets'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpPath } = require('../test-support/tmp'); process.env.CONFIG_PATH = tmpPath('apps.json'); const { test } = require('node:test'); @@ -11,23 +11,46 @@ const { scrubConfigSecrets, preserveConfigSecrets } = require('../src/widget-sec const { scrubAllSecrets, preserveAllSecrets } = require('../src/config-secrets'); function sampleConfig() { - return { items: [ - { id: 'b1', type: 'widget', widgetType: 'backup', widgetConfig: { slots: [ - { provider: 'duplicati', dupUrl: 'http://d:8200', dupPass: 'DUP' }, - { provider: 'kopia', kopiaUrl: 'http://k:51515', kopiaPass: 'KOP' }, - ] } }, - { id: 's1', type: 'widget', widgetType: 'stats', widgetConfig: { - truenasKey: 'TRUENAS', network: { myspeedPass: 'MYSPEED', enabled: true } } }, - { id: 'c1', type: 'widget', widgetType: 'connections', widgetConfig: { - vpn: { apiKey: 'VPNAPI', token: 'VPNTOK' }, - services: [{ id: 'x', token: 'SVC' }] } }, - ] }; + return { + items: [ + { + id: 'b1', + type: 'widget', + widgetType: 'backup', + widgetConfig: { + slots: [ + { provider: 'duplicati', dupUrl: 'http://d:8200', dupPass: 'DUP' }, + { provider: 'kopia', kopiaUrl: 'http://k:51515', kopiaPass: 'KOP' }, + ], + }, + }, + { + id: 's1', + type: 'widget', + widgetType: 'stats', + widgetConfig: { + truenasKey: 'TRUENAS', + network: { myspeedPass: 'MYSPEED', enabled: true }, + }, + }, + { + id: 'c1', + type: 'widget', + widgetType: 'connections', + widgetConfig: { + vpn: { apiKey: 'VPNAPI', token: 'VPNTOK' }, + services: [{ id: 'x', token: 'SVC' }], + }, + }, + ], + }; } function anySecretLeft(obj) { const found = []; JSON.stringify(obj, (k, v) => { - if (typeof v === 'string' && ['DUP', 'KOP', 'MYSPEED', 'TRUENAS', 'VPNAPI', 'VPNTOK', 'SVC'].includes(v)) found.push(k); + if (typeof v === 'string' && ['DUP', 'KOP', 'MYSPEED', 'TRUENAS', 'VPNAPI', 'VPNTOK', 'SVC'].includes(v)) + found.push(k); return v; }); return found; @@ -75,10 +98,18 @@ test('a newly submitted secret survives instead of being overwritten by the old /* An app item carrying a secret badge header, so the facade tests exercise the badge system alongside the widget system in one pass. */ function badgeApp() { - return { id: 'a1', type: 'app', name: 'A', url: 'http://x', badge: { headers: [ - { key: 'Authorization', value: 'BADGESECRET', secret: true }, - { key: 'X-Env', value: 'prod', secret: false }, - ] } }; + return { + id: 'a1', + type: 'app', + name: 'A', + url: 'http://x', + badge: { + headers: [ + { key: 'Authorization', value: 'BADGESECRET', secret: true }, + { key: 'X-Env', value: 'prod', secret: false }, + ], + }, + }; } test('scrubAllSecrets runs the widget and badge scrubbers in one pass', () => { diff --git a/api/test/config.test.js b/api/test/config.test.js index a0b69639..6d935fa4 100644 --- a/api/test/config.test.js +++ b/api/test/config.test.js @@ -11,7 +11,11 @@ const { test, after } = require('node:test'); const assert = require('node:assert/strict'); const { migrate, saveConfig, loadConfig, SCHEMA_VERSION } = require('../src/config'); -after(() => { try { fs.unlinkSync(TMP); } catch {} }); +after(() => { + try { + fs.unlinkSync(TMP); + } catch {} +}); test('migrate stamps an unversioned config to the current version', () => { const cfg = migrate({ items: [], settings: {} }); @@ -52,7 +56,11 @@ test('migrate tolerates a config with no server settings or a non-string URL', ( test('a config already at the current version is not rewritten again', () => { /* Someone who deliberately re-enters a tcp URL after upgrading keeps it, so the step cannot fight the user on every read. */ - const cfg = { _schemaVersion: SCHEMA_VERSION, items: [], settings: { server: { socketProxyUrl: 'tcp://socket-proxy:2375' } } }; + const cfg = { + _schemaVersion: SCHEMA_VERSION, + items: [], + settings: { server: { socketProxyUrl: 'tcp://socket-proxy:2375' } }, + }; migrate(cfg); assert.equal(cfg.settings.server.socketProxyUrl, 'tcp://socket-proxy:2375'); }); diff --git a/api/test/connections-conduit-data.test.js b/api/test/connections-conduit-data.test.js index a5d385e5..e73421de 100644 --- a/api/test/connections-conduit-data.test.js +++ b/api/test/connections-conduit-data.test.js @@ -14,7 +14,10 @@ test('the conduit map service fetches /metrics raw and parses region data', asyn ].join('\n'); const calls = []; - const fetchJSON = async (url, opts) => { calls.push({ url, opts }); return { status: 200, data: metrics }; }; + const fetchJSON = async (url, opts) => { + calls.push({ url, opts }); + return { status: 200, data: metrics }; + }; const config = { services: [{ type: 'conduit', url: 'http://conduit.local:8080', enabled: true }] }; const r = await dataFn({ endpoint: 'map', config, fetchJSON }); @@ -31,7 +34,9 @@ test('the conduit map service fetches /metrics raw and parses region data', asyn }); test('the map view reads only the services array', async () => { - const fetchJSON = async () => { throw new Error('should not fetch'); }; + const fetchJSON = async () => { + throw new Error('should not fetch'); + }; const r = await dataFn({ endpoint: 'map', config: { conduit: { url: 'http://conduit.local:8080' } }, fetchJSON }); assert.deepEqual(r.services, []); }); diff --git a/api/test/connections-url.test.js b/api/test/connections-url.test.js index a79e5754..618fb60d 100644 --- a/api/test/connections-url.test.js +++ b/api/test/connections-url.test.js @@ -59,7 +59,10 @@ test('the string "undefined" never appears in the result', () => { /* fetchJSON is passed in, so a call can be observed without any network. */ function run(config, endpoint = 'map') { const requested = []; - const fetchJSON = async url => { requested.push(url); return { status: 200, data: {} }; }; + const fetchJSON = async url => { + requested.push(url); + return { status: 200, data: {} }; + }; return data({ config, endpoint, fetchJSON }).then(result => ({ result, requested })); } @@ -79,7 +82,10 @@ test('a service with no address reports the reason', async () => { test('a configured service is still fetched', async () => { const { requested } = await run({ services: [{ id: 's1', type: 'gluetun', url: 'vpn:8000' }] }); assert.ok(requested.length > 0, 'a real address must still be requested'); - assert.ok(requested.every(u => u.startsWith('http://vpn:8000')), requested.join(', ')); + assert.ok( + requested.every(u => u.startsWith('http://vpn:8000')), + requested.join(', '), + ); }); test('the VPN path still refuses a missing address', async () => { diff --git a/api/test/contributor-doc.test.js b/api/test/contributor-doc.test.js index 7e324fd7..60f9387a 100644 --- a/api/test/contributor-doc.test.js +++ b/api/test/contributor-doc.test.js @@ -46,7 +46,7 @@ function ciCommands() { const out = []; for (const step of steps) { if (typeof step?.if === 'string' && step.if.includes("inputs.mode == 'release'")) continue; - if (typeof step?.run !== 'string') continue; /* uses:, not run: */ + if (typeof step?.run !== 'string') continue; /* uses:, not run: */ /* A multi-line run is a script, not a command a contributor types. The test path has none; if one appears, it needs a decision rather than being folded into one line. */ @@ -70,15 +70,19 @@ function docSection() { test('the scan finds the checks and the section', () => { const cmds = ciCommands(); assert.ok(cmds.length >= 7, `only ${cmds.length} CI commands found; the action format may have changed`); - assert.ok(cmds.some(c => /npm run lint/.test(c[0])), 'lint should be among them'); + assert.ok( + cmds.some(c => /npm run lint/.test(c[0])), + 'lint should be among them', + ); assert.ok(docSection().includes('```'), 'the section has no command block'); }); test('every check CI runs is one CONTRIBUTING.md tells you to run', () => { const section = docSection(); - const missing = ciCommands().map(asTyped).filter(c => !section.includes(c)); - assert.deepEqual(missing, [], - `CI runs these and CONTRIBUTING.md does not list them:\n ${missing.join('\n ')}`); + const missing = ciCommands() + .map(asTyped) + .filter(c => !section.includes(c)); + assert.deepEqual(missing, [], `CI runs these and CONTRIBUTING.md does not list them:\n ${missing.join('\n ')}`); }); /* The other direction. A command that no longer exists is worse than a missing @@ -86,18 +90,22 @@ test('every check CI runs is one CONTRIBUTING.md tells you to run', () => { test('CONTRIBUTING.md lists no check CI does not run', () => { const block = /```\n([\s\S]*?)```/.exec(docSection()); assert.ok(block, 'the command block is gone'); - const listed = block[1].split('\n').map(l => l.trim()).filter(Boolean); + const listed = block[1] + .split('\n') + .map(l => l.trim()) + .filter(Boolean); const ci = ciCommands().map(asTyped); const extra = listed.filter(l => !ci.includes(l)); - assert.deepEqual(extra, [], - `Listed in CONTRIBUTING.md but not run by CI:\n ${extra.join('\n ')}`); + assert.deepEqual(extra, [], `Listed in CONTRIBUTING.md but not run by CI:\n ${extra.join('\n ')}`); }); /* The order is what makes the list usable: the cheap checks come first, so a contributor finds out about a lint error before waiting for a docker build. */ test('the documented order is the order CI runs them in', () => { const block = /```\n([\s\S]*?)```/.exec(docSection())[1]; - const positions = ciCommands().map(asTyped).map(c => block.indexOf(c)); + const positions = ciCommands() + .map(asTyped) + .map(c => block.indexOf(c)); const sorted = [...positions].sort((a, b) => a - b); assert.deepEqual(positions, sorted, 'the commands are listed out of order'); }); @@ -110,28 +118,54 @@ test('the step list survives reformatting of the action', () => { tidy-up or a different editor produces. */ const reformatted = yaml.dump(doc, { indent: 6, lineWidth: 40, quotingType: '"' }); const reparsed = yaml.load(reformatted); - const commands = steps => steps - .filter(st => !(typeof st?.if === 'string' && st.if.includes("inputs.mode == 'release'"))) - .filter(st => typeof st?.run === 'string' && !st.run.trim().includes('\n')) - .map(st => [st.run.trim(), st['working-directory'] || null]); - assert.deepEqual(commands(reparsed.runs.steps), ciCommands(), - 'the same action formatted differently produced a different list'); + const commands = steps => + steps + .filter(st => !(typeof st?.if === 'string' && st.if.includes("inputs.mode == 'release'"))) + .filter(st => typeof st?.run === 'string' && !st.run.trim().includes('\n')) + .map(st => [st.run.trim(), st['working-directory'] || null]); + assert.deepEqual( + commands(reparsed.runs.steps), + ciCommands(), + 'the same action formatted differently produced a different list', + ); }); /* CodeQL blocks a merge like any other check, so a contributor should not meet it for the first time on their own PR. */ test('the checks that are not in the composite action are mentioned too', () => { - assert.ok(fs.existsSync(path.join(root, '.github/workflows/codeql.yml')), - 'codeql.yml is gone; the mention in CONTRIBUTING.md should go with it'); + assert.ok( + fs.existsSync(path.join(root, '.github/workflows/codeql.yml')), + 'codeql.yml is gone; the mention in CONTRIBUTING.md should go with it', + ); assert.match(docSection(), /CodeQL/); }); /* The template asked for a fraction of the checks, which is how a contributor learns the wrong set. One list, in one place, and the template points at it. */ test('the PR template points at the list rather than naming its own commands', () => { - assert.match(template, /CONTRIBUTING\.md#before-opening-a-pr/, - 'the template should link to the check list'); + assert.match(template, /CONTRIBUTING\.md#before-opening-a-pr/, 'the template should link to the check list'); const testing = template.slice(template.indexOf('**Testing**'), template.indexOf('**Checklist**')); - assert.doesNotMatch(testing, /```/, - 'the template has its own command block again; it will drift from CI'); + assert.doesNotMatch(testing, /```/, 'the template has its own command block again; it will drift from CI'); +}); + +/* Test code was outside every quality gate: about a megabyte of it, linted by + nothing. It is inside the linter's globs now, and the exclusion that remains, + typechecking, is written down rather than silent. */ +test('the linter covers the test directories', () => { + const cfg = JSON.parse(read('biome.json').replace(/^\s*\/\/.*$/gm, '')); + const globs = cfg.files.includes.join(' '); + for (const dir of ['api/test', 'api/test-support', 'ui/test']) { + assert.ok(globs.includes(dir), `${dir} is not linted`); + } +}); + +test('the typecheck exclusion is documented, not silent', () => { + const cfg = JSON.parse(read('tsconfig.json').replace(/^\s*\/\/.*$/gm, '')); + const included = cfg.include.join(' '); + if (included.includes('api/test')) return; /* someone did the work; nothing to explain */ + assert.match( + contributing, + /not\*\* typechecked|are \*\*not\*\* typechecked/, + 'tests are excluded from typecheck with no reason written down in CONTRIBUTING.md', + ); }); diff --git a/api/test/demo-data.test.js b/api/test/demo-data.test.js index 603fb3b6..6a0d742e 100644 --- a/api/test/demo-data.test.js +++ b/api/test/demo-data.test.js @@ -3,7 +3,8 @@ const assert = require('node:assert/strict'); const { metrics, demoBadges, demoHealth } = require('../src/demo-data'); test('metrics return plausible in-range values', () => { - const cpu = metrics.cpuSample().cpu, ram = metrics.ramPercent(); + const cpu = metrics.cpuSample().cpu, + ram = metrics.ramPercent(); assert.ok(cpu >= 0 && cpu <= 100, `cpu ${cpu}`); assert.ok(ram >= 0 && ram <= 100, `ram ${ram}`); assert.ok(metrics.procCount() > 0); @@ -31,4 +32,3 @@ test('health marks the showcase app unhealthy and covers only healthcheck apps', assert.deepEqual(Object.keys(out), ['app-grafana']); assert.equal(out['app-grafana'].unhealthy, true); }); - diff --git a/api/test/demo.test.js b/api/test/demo.test.js index 1c1cf861..bafa28fb 100644 --- a/api/test/demo.test.js +++ b/api/test/demo.test.js @@ -17,8 +17,9 @@ const demo = JSON.parse(demoRaw); real or private host, fails the build. The check is an allowlist so the repo never has to name the hosts it is trying to keep out. */ function hostAllowed(host) { - return host === 'example.com' || host.endsWith('.example.com') - || host === 'github.com' || host === 'cdn.jsdelivr.net'; + return ( + host === 'example.com' || host.endsWith('.example.com') || host === 'github.com' || host === 'cdn.jsdelivr.net' + ); } const URL_RE = /https?:\/\/([^/"'\s)]+)/gi; @@ -26,9 +27,15 @@ const PRIVATE_IP_RE = /\b(?:10\.\d|127\.\d|169\.254\.|192\.168\.|172\.(?:1[6-9]| const SECRET_KEY_RE = /(token|secret|password|passwd|apikey|pass)$/i; function walk(node, fn, keyPath = '') { - if (Array.isArray(node)) { node.forEach(v => walk(v, fn, keyPath)); return; } + if (Array.isArray(node)) { + node.forEach(v => walk(v, fn, keyPath)); + return; + } if (node && typeof node === 'object') { - for (const [k, v] of Object.entries(node)) { fn(k, v); walk(v, fn, keyPath + '.' + k); } + for (const [k, v] of Object.entries(node)) { + fn(k, v); + walk(v, fn, keyPath + '.' + k); + } } } @@ -54,7 +61,10 @@ test('demo config carries no secret values, only Set flags', () => { }); test('demo config has the expected showcase shape', () => { - const types = demo.items.reduce((a, i) => { a[i.type] = (a[i.type] || 0) + 1; return a; }, {}); + const types = demo.items.reduce((a, i) => { + a[i.type] = (a[i.type] || 0) + 1; + return a; + }, {}); assert.equal(types.widget, 7); assert.equal(types.app, 17); assert.equal(types.folder, 2); @@ -63,7 +73,13 @@ test('demo config has the expected showcase shape', () => { /* Exactly four docked apps. Tile colors are opt-in: only the two icons that are unreadable on a dark tile carry one. */ assert.equal(demo.items.filter(i => i.dock).length, 4); - assert.deepEqual(demo.items.filter(i => i.color).map(i => i.id).sort(), ['app-prowlarr', 'app-vaultwarden']); + assert.deepEqual( + demo.items + .filter(i => i.color) + .map(i => i.id) + .sort(), + ['app-prowlarr', 'app-vaultwarden'], + ); assert.equal(demo.settings.auth.enabled, false); /* Distinct widget types only (no duplicated widget shown twice). */ const wtypes = demo.items.filter(i => i.type === 'widget').map(i => i.widgetType); diff --git a/api/test/entrypoint-config-check.test.js b/api/test/entrypoint-config-check.test.js index 10fb32f2..82370daa 100644 --- a/api/test/entrypoint-config-check.test.js +++ b/api/test/entrypoint-config-check.test.js @@ -85,8 +85,7 @@ test('a config nginx accepts starts normally', () => { test('a config nginx rejects stops the container from starting', () => { const r = run({ nginxExit: 1 }); assert.notEqual(r.status, 0, 'the entrypoint must exit non-zero'); - assert.ok(!r.started, - 'nothing may be started once the config has been rejected'); + assert.ok(!r.started, 'nothing may be started once the config has been rejected'); }); /* Checking before rendering would validate the previous file, or the shipped diff --git a/api/test/environment-doc.test.js b/api/test/environment-doc.test.js index 4a7c4cb4..b9224627 100644 --- a/api/test/environment-doc.test.js +++ b/api/test/environment-doc.test.js @@ -24,7 +24,8 @@ const read = f => fs.readFileSync(path.join(root, f), 'utf8'); /* Variables the code reads that an operator is not meant to set. */ const INTERNAL = { - REALIP_CONF: 'entrypoint only: where the generated proxy config is written, overridable so the rendering can be tested', + REALIP_CONF: + 'entrypoint only: where the generated proxy config is written, overridable so the rendering can be tested', SUPERVISOR_FATAL_MARKER: 'internal handoff between the supervisord listener and the entrypoint', }; @@ -71,8 +72,13 @@ test('the scan finds both sides', () => { }); test('every environment variable the API reads is documented, and no others', () => { - assert.deepEqual(documentedRows().map(r => r[0]).sort(), envInCode(), - 'the environment table in docs/architecture.md is out of date'); + assert.deepEqual( + documentedRows() + .map(r => r[0]) + .sort(), + envInCode(), + 'the environment table in docs/architecture.md is out of date', + ); }); /* Each exemption states why, so the list cannot quietly become a dumping @@ -101,10 +107,8 @@ test('the documented defaults are the ones in the code', () => { test('the documented session and hash defaults match the code', () => { const defaults = Object.fromEntries(documentedRows()); const auth = read('api/src/auth.js'); - assert.equal(defaults.SESSION_MAX_AGE_DAYS, - /_maxAgeDays > 0 \? _maxAgeDays : (\d+)/.exec(auth)[1]); - assert.equal(defaults.PASSWORD_HASH_MEMORY, - /const DEFAULT_PROFILE = '([\w]+)'/.exec(auth)[1]); + assert.equal(defaults.SESSION_MAX_AGE_DAYS, /_maxAgeDays > 0 \? _maxAgeDays : (\d+)/.exec(auth)[1]); + assert.equal(defaults.PASSWORD_HASH_MEMORY, /const DEFAULT_PROFILE = '([\w]+)'/.exec(auth)[1]); }); /* The levels the logger accepts, not the narrower set the Settings screen @@ -125,6 +129,9 @@ test('docker-compose.yml carries every operator variable', () => { const compose = read('docker-compose.yml'); /* Stamped by the release build, so there is nothing for an operator to set. */ const missing = envInCode().filter(n => n !== 'APP_VERSION' && !compose.includes(n)); - assert.deepEqual(missing, [], - `The table says docker-compose.yml lists them all. Add a commented line for:\n ${missing.join('\n ')}`); + assert.deepEqual( + missing, + [], + `The table says docker-compose.yml lists them all. Add a commented line for:\n ${missing.join('\n ')}`, + ); }); diff --git a/api/test/error-disclosure.test.js b/api/test/error-disclosure.test.js index 867a0bf1..59078f89 100644 --- a/api/test/error-disclosure.test.js +++ b/api/test/error-disclosure.test.js @@ -30,10 +30,10 @@ const { errorBody, safeMessage, SAFE_MESSAGES, KIND, KINDS } = require('../src/a /* Things a real failure names that a browser has no business seeing. */ const REVEALING = [ - /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/, /* an IP address */ - /\b[a-z0-9-]+\.(?:lan|local|internal|home)\b/i, /* an internal hostname */ - /\/(?:data|app|etc|home|root|var)\//, /* a server path */ - /\bE[A-Z]{3,}\b/, /* a raw errno in prose */ + /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/ /* an IP address */, + /\b[a-z0-9-]+\.(?:lan|local|internal|home)\b/i /* an internal hostname */, + /\/(?:data|app|etc|home|root|var)\// /* a server path */, + /\bE[A-Z]{3,}\b/ /* a raw errno in prose */, ]; const REAL_ERRORS = [ diff --git a/api/test/health-route.test.js b/api/test/health-route.test.js index fb19ae93..9a82ec55 100644 --- a/api/test/health-route.test.js +++ b/api/test/health-route.test.js @@ -11,7 +11,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('health'), 'apps.json'); const { test, before, after } = require('node:test'); @@ -27,7 +27,11 @@ let containers = []; let targetStatus = 200; const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); -const close = s => new Promise(r => { s.closeAllConnections?.(); s.close(r); }); +const close = s => + new Promise(r => { + s.closeAllConnections?.(); + s.close(r); + }); before(async () => { /* Stands in for the Docker socket proxy. */ @@ -38,22 +42,34 @@ before(async () => { socketBase = await listen(socket); /* Stands in for the app being pinged. */ - target = http.createServer((_, res) => { res.writeHead(targetStatus); res.end(); }); + target = http.createServer((_, res) => { + res.writeHead(targetStatus); + res.end(); + }); targetBase = await listen(target); server = http.createServer(dispatch); base = await listen(server); }); -after(async () => { await close(server); await close(socket); await close(target); }); +after(async () => { + await close(server); + await close(socket); + await close(target); +}); function health() { const u = new URL(base + '/api/health'); return new Promise((resolve, reject) => { - http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET' }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => resolve(JSON.parse(b))); - }).on('error', reject).end(); + http + .request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET' }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve(JSON.parse(b))); + }) + .on('error', reject) + .end(); }); } diff --git a/api/test/http-integration.test.js b/api/test/http-integration.test.js index 1a08b154..b3a77100 100644 --- a/api/test/http-integration.test.js +++ b/api/test/http-integration.test.js @@ -4,7 +4,7 @@ load time. */ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const _tmp = tmpDir('http'); process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); process.env.WIDGETS_PATH = path.join(_tmp, 'widgets'); @@ -14,29 +14,37 @@ process.env.WIDGETS_PATH = path.join(_tmp, 'widgets'); supplied, which is what the SSRF guard has to intercept. */ const _fx = path.join(_tmp, 'widgets', 'fixture'); fs.mkdirSync(_fx, { recursive: true }); -fs.writeFileSync(path.join(_fx, 'widget.json'), JSON.stringify({ - name: 'fixture', label: 'Fixture', sizes: ['medium'], - views: { main: { src: 'index.html' } }, - fields: [{ key: 'url', type: 'text', label: 'URL' }], -})); +fs.writeFileSync( + path.join(_fx, 'widget.json'), + JSON.stringify({ + name: 'fixture', + label: 'Fixture', + sizes: ['medium'], + views: { main: { src: 'index.html' } }, + fields: [{ key: 'url', type: 'text', label: 'URL' }], + }), +); fs.writeFileSync(path.join(_fx, 'index.html'), ''); -fs.writeFileSync(path.join(_fx, 'data.js'), - 'module.exports = async ctx => ctx.fetchJSON(ctx.config.url, {});\n'); +fs.writeFileSync(path.join(_fx, 'data.js'), 'module.exports = async ctx => ctx.fetchJSON(ctx.config.url, {});\n'); const { test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); -require('../src/routes'); // registers auth/config/health/badges/... + OPTIONS -require('../src/widget-data'); // registers /api/widget-data/:id (pulls in widgets) +require('../src/routes'); // registers auth/config/health/badges/... + OPTIONS +require('../src/widget-data'); // registers /api/widget-data/:id (pulls in widgets) const { dispatch, on } = require('../src/router'); const { saveConfig, loadConfig } = require('../src/config'); const { hashPassword, makeToken, clearAttempts } = require('../src/auth'); /* Routes that fail on purpose, to prove the router turns a handler error into a 500 instead of crashing the process. */ -on('GET', '/api/_boom_sync', () => { throw new Error('sync boom'); }); -on('GET', '/api/_boom_async', async () => { throw new Error('async boom'); }); +on('GET', '/api/_boom_sync', () => { + throw new Error('sync boom'); +}); +on('GET', '/api/_boom_async', async () => { + throw new Error('async boom'); +}); const SECRET = 'a'.repeat(64); let server, base, validCookie; @@ -50,31 +58,46 @@ before(async () => { base = `http://127.0.0.1:${server.address().port}`; }); -after(() => new Promise(r => { server.closeAllConnections?.(); server.close(r); })); +after( + () => + new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }), +); function req(method, pathname, opts = {}) { const { cookie, body, origin, host } = opts; const data = body != null ? JSON.stringify(body) : null; const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - method, hostname: u.hostname, port: u.port, path: u.pathname + u.search, - agent: false, - headers: { - ...(cookie ? { Cookie: cookie } : {}), - ...(origin ? { Origin: origin } : {}), - ...(host ? { Host: host } : {}), - ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), + const r = http.request( + { + method, + hostname: u.hostname, + port: u.port, + path: u.pathname + u.search, + agent: false, + headers: { + ...(cookie ? { Cookie: cookie } : {}), + ...(origin ? { Origin: origin } : {}), + ...(host ? { Host: host } : {}), + ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), + }, + }, + res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch {} + resolve({ status: res.statusCode, headers: res.headers, body: json }); + }); }, - }, res => { - const chunks = []; - res.on('data', c => chunks.push(c)); - res.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8'); - let json = null; try { json = text ? JSON.parse(text) : null; } catch {} - resolve({ status: res.statusCode, headers: res.headers, body: json }); - }); - }); + ); r.on('error', reject); if (data) r.write(data); r.end(); @@ -152,8 +175,10 @@ test('path parameters are extracted and routed', async () => { data source" rather than 404. */ test('a widget type naming an inherited member is unknown, not data-less', async () => { const cfg = loadConfig(); - saveConfig({ ...cfg, items: [...(cfg.items || []), - { id: 'proto-w', type: 'widget', widgetType: 'constructor', widgetConfig: {} }] }); + saveConfig({ + ...cfg, + items: [...(cfg.items || []), { id: 'proto-w', type: 'widget', widgetType: 'constructor', widgetConfig: {} }], + }); try { const r = await req('GET', '/api/widget-data/proto-w', { cookie: validCookie }); assert.equal(r.status, 404); @@ -179,7 +204,11 @@ test('OPTIONS without a session is refused like every other method', async () => }); test('no response carries the dead CORS headers', async () => { - for (const [method, path] of [['GET', '/api/config'], ['GET', '/api/health'], ['OPTIONS', '/api/config']]) { + for (const [method, path] of [ + ['GET', '/api/config'], + ['GET', '/api/health'], + ['OPTIONS', '/api/config'], + ]) { const r = await req(method, path, { cookie: validCookie }); assert.equal(r.headers['access-control-allow-methods'], undefined, `${method} ${path}`); assert.equal(r.headers['access-control-allow-headers'], undefined, `${method} ${path}`); @@ -188,8 +217,10 @@ test('no response carries the dead CORS headers', async () => { test('a cross-origin write is rejected by the origin check', async () => { const r = await req('POST', '/api/auth/set-password', { - cookie: validCookie, body: { password: 'longenough123' }, - origin: 'http://evil.example', host: '127.0.0.1:1', + cookie: validCookie, + body: { password: 'longenough123' }, + origin: 'http://evil.example', + host: '127.0.0.1:1', }); assert.equal(r.status, 403); }); @@ -234,9 +265,9 @@ test('POST /api/config drops unknown settings and preserves the stored auth secr // the existing secret was re-merged, so the session still authenticates assert.equal((await req('GET', '/api/config', { cookie: validCookie })).status, 200); const exp = await req('GET', '/api/config/export', { cookie: validCookie }); - assert.equal(exp.body.settings.bogusKey, undefined); // unknown key stripped - assert.equal(exp.body.settings.logLevel, undefined); // invalid level stripped - assert.equal(exp.body.settings.theme, 'dark'); // known key kept + assert.equal(exp.body.settings.bogusKey, undefined); // unknown key stripped + assert.equal(exp.body.settings.logLevel, undefined); // invalid level stripped + assert.equal(exp.body.settings.theme, 'dark'); // known key kept }); test('POST /api/config rejects a stale _rev with 409', async () => { @@ -248,7 +279,10 @@ test('POST /api/config rejects a stale _rev with 409', async () => { assert.equal(ok.status, 200); // same rev again: disk has moved on, so this is the second tab saving over the first - const conflict = await req('POST', '/api/config', { cookie: validCookie, body: { items: [], settings: {}, _rev: stale } }); + const conflict = await req('POST', '/api/config', { + cookie: validCookie, + body: { items: [], settings: {}, _rev: stale }, + }); assert.equal(conflict.status, 409); assert.match(conflict.body.error, /changed somewhere else/); }); @@ -320,7 +354,8 @@ test('POST /api/widget-options rejects a cross-origin write', async () => { const r = await req('POST', '/api/widget-options/x', { cookie: validCookie, body: { widgetType: 'fixture', endpoint: 'any', widgetConfig: { url: 'http://example.com/' } }, - origin: 'http://evil.example', host: '127.0.0.1:1', + origin: 'http://evil.example', + host: '127.0.0.1:1', }); assert.equal(r.status, 403); assert.match(String(r.body?.error), /origin mismatch/); @@ -328,7 +363,8 @@ test('POST /api/widget-options rejects a cross-origin write', async () => { test('POST /api/widget-options refuses an unknown widget type', async () => { const r = await req('POST', '/api/widget-options/x', { - cookie: validCookie, body: { widgetType: 'nope', endpoint: 'any', widgetConfig: {} }, + cookie: validCookie, + body: { widgetType: 'nope', endpoint: 'any', widgetConfig: {} }, }); assert.equal(r.status, 400); }); @@ -353,7 +389,10 @@ test('set-password rejects a too-short password', async () => { }); test('set-password succeeds for an authenticated session and issues a new cookie', async () => { - const r = await req('POST', '/api/auth/set-password', { cookie: validCookie, body: { password: 'a-long-enough-password' } }); + const r = await req('POST', '/api/auth/set-password', { + cookie: validCookie, + body: { password: 'a-long-enough-password' }, + }); assert.equal(r.status, 200); assert.match(String(r.headers['set-cookie'] || ''), /ds=.+/); }); diff --git a/api/test/icon-upload-name.test.js b/api/test/icon-upload-name.test.js index dd6c6436..ec0321b8 100644 --- a/api/test/icon-upload-name.test.js +++ b/api/test/icon-upload-name.test.js @@ -18,7 +18,7 @@ const fs = require('node:fs'); /* Set before anything under src/ is required: ICONS_PATH and CONFIG_PATH are read once when those modules load. */ -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const uploadDir = tmpDir('upload'); process.env.ICONS_PATH = uploadDir; process.env.CONFIG_PATH = path.join(tmpDir('upcfg'), 'apps.json'); @@ -148,24 +148,47 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); function upload(filename, contents) { const boundary = '----sytest'; const body = Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\n` + - `Content-Type: image/svg+xml\r\n\r\n${contents}\r\n--${boundary}--\r\n`, + `Content-Type: image/svg+xml\r\n\r\n${contents}\r\n--${boundary}--\r\n`, ); const u = new URL(base + '/api/icons/upload'); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length, Origin: base }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length, + Origin: base, + }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); r.on('error', reject); r.end(body); }); diff --git a/api/test/image-hardening.test.js b/api/test/image-hardening.test.js index 1cec2236..2fd81037 100644 --- a/api/test/image-hardening.test.js +++ b/api/test/image-hardening.test.js @@ -40,8 +40,7 @@ test('the removal is verified inside the build', () => { }); test('node itself is still checked to work afterwards', () => { - assert.match(dockerfile, /node -e "process\.exit\(0\)"/, - 'removing the package managers must not break the runtime'); + assert.match(dockerfile, /node -e "process\.exit\(0\)"/, 'removing the package managers must not break the runtime'); }); test('nothing in the image invokes a package manager', () => { @@ -54,7 +53,8 @@ test('nothing in the image invokes a package manager', () => { const INVOKED = /(?:^|[\s;&|(])(npm|npx|yarn|corepack)\b/; for (const file of ['Dockerfile', 'docker-entrypoint.sh', 'supervisord.conf']) { const src = fs.readFileSync(path.join(root, file), 'utf8'); - const invocations = src.split('\n') + const invocations = src + .split('\n') .map(l => l.replace(/#.*$/, '').replace(/command -v \w+/g, '')) .filter(l => INVOKED.test(l)); assert.deepEqual(invocations, [], `${file} appears to use a package manager at runtime`); @@ -65,8 +65,11 @@ test('the API still declares no runtime dependencies', () => { /* The removal is only safe while this holds: a dependency would need an install step, and the image has nothing to run one with. */ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'api', 'package.json'), 'utf8')); - assert.deepEqual(pkg.dependencies ?? {}, {}, - 'the API has a runtime dependency, which the image can no longer install'); + assert.deepEqual( + pkg.dependencies ?? {}, + {}, + 'the API has a runtime dependency, which the image can no longer install', + ); }); /* setuptools arrives as an apk dependency of supervisor and is reported against @@ -91,8 +94,11 @@ test('the Dockerfile deletes setuptools', () => { test('setuptools is removed in the layer that installs supervisor', () => { const run = /RUN apk add --no-cache nginx supervisor[\s\S]*?(?=\n(?:#|[A-Z]+ ))/.exec(dockerfile); assert.ok(run, 'the apk install step has been restructured'); - assert.match(run[0], /rm -rf \/usr\/lib\/python3\*\/site-packages\/setuptools/, - 'the deletion moved out of the install layer, so the files survive in it'); + assert.match( + run[0], + /rm -rf \/usr\/lib\/python3\*\/site-packages\/setuptools/, + 'the deletion moved out of the install layer, so the files survive in it', + ); }); /* Deleting the files is only half of it. The build asserts nothing can import diff --git a/api/test/internal-host.test.js b/api/test/internal-host.test.js index 6d7658ef..1dd408d3 100644 --- a/api/test/internal-host.test.js +++ b/api/test/internal-host.test.js @@ -50,8 +50,15 @@ test('localhost and dotted names are not Docker service names', () => { }); test('isInternalHost covers loopback, private addresses and service names', () => { - for (const url of ['https://localhost/', 'https://myservice/', 'https://192.168.1.5/', - 'https://10.0.0.1/', 'https://127.0.0.1/', 'https://[fd00::1]/', 'https://[::1]/']) { + for (const url of [ + 'https://localhost/', + 'https://myservice/', + 'https://192.168.1.5/', + 'https://10.0.0.1/', + 'https://127.0.0.1/', + 'https://[fd00::1]/', + 'https://[::1]/', + ]) { assert.equal(isInternalHost(host(url)), true, url); } }); @@ -87,7 +94,11 @@ test('a public host never skips TLS verification', () => { /* The setting gates everything: with it off, nothing skips verification. */ test('nothing skips TLS verification while the setting is off', () => { - for (const cfg of [{ settings: {} }, { settings: { server: {} } }, { settings: { server: { skipTlsVerify: false } } }]) { + for (const cfg of [ + { settings: {} }, + { settings: { server: {} } }, + { settings: { server: { skipTlsVerify: false } } }, + ]) { assert.equal(shouldSkipTls('myservice', cfg), false); assert.equal(shouldSkipTls('192.168.1.5', cfg), false); } diff --git a/api/test/is-prerelease.test.js b/api/test/is-prerelease.test.js index dcfeb50b..6f68fe2d 100644 --- a/api/test/is-prerelease.test.js +++ b/api/test/is-prerelease.test.js @@ -52,8 +52,7 @@ test('a null or missing tag is a prerelease rather than a crash', () => { }); test('the parse reports the parts it found', () => { - assert.deepEqual(parseTag('v1.5.0-rc.2'), - { major: 1, minor: 5, patch: 0, prerelease: 'rc.2', build: null }); + assert.deepEqual(parseTag('v1.5.0-rc.2'), { major: 1, minor: 5, patch: 0, prerelease: 'rc.2', build: null }); assert.equal(parseTag('nonsense'), null); }); @@ -75,7 +74,9 @@ test('the CLI writes one GITHUB_OUTPUT line and nothing else on stdout', () => { test('the CLI reads GITHUB_REF_NAME when given no argument', () => { const script = path.join(__dirname, '..', '..', 'scripts', 'is-prerelease.js'); - const out = execFileSync(process.execPath, [script], - { encoding: 'utf8', env: { ...process.env, GITHUB_REF_NAME: 'v3.0.0-beta.1' } }); + const out = execFileSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...process.env, GITHUB_REF_NAME: 'v3.0.0-beta.1' }, + }); assert.equal(out, 'prerelease=true\n'); }); diff --git a/api/test/link-url.test.js b/api/test/link-url.test.js index ead66139..16f99752 100644 --- a/api/test/link-url.test.js +++ b/api/test/link-url.test.js @@ -15,7 +15,7 @@ browser's module directly now, so there is one definition. That only works if the file stays free of anything only a browser has, which is what loading it here proves. */ -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const path = require('node:path'); const fs = require('node:fs'); @@ -25,9 +25,7 @@ const assert = require('node:assert/strict'); /* Required, not imported: this is the same file the browser loads, and the point of the test is that the server can load it. */ const shared = require('../../ui/js/link-url.js'); -const { isSafeLinkUrl, firstUnsafeLink, UNSAFE_LINK_SCHEMES, LINK_FIELDS, WIDGET_LINK_FIELDS, sanitizeItemLinks } = shared; - - +const { isSafeLinkUrl, firstUnsafeLink, sanitizeItemLinks } = shared; /* ── what must be refused ─────────────────────────────────────────────────── */ @@ -65,9 +63,17 @@ test('interior whitespace and control characters do not hide a scheme', () => { would break these for no gain: the browser hands them to the OS, not to our origin. */ test('protocol handlers a homelab actually uses are allowed', () => { - for (const v of ['ssh://host', 'vnc://host:5900', 'rdp://host', 'smb://nas/share', - 'sftp://host', 'steam://run/440', 'obsidian://open?vault=x', - 'mailto:me@example.com', 'tel:+15551234']) + for (const v of [ + 'ssh://host', + 'vnc://host:5900', + 'rdp://host', + 'smb://nas/share', + 'sftp://host', + 'steam://run/440', + 'obsidian://open?vault=x', + 'mailto:me@example.com', + 'tel:+15551234', + ]) assert.equal(isSafeLinkUrl(v), true, `${v} should be allowed`); }); @@ -139,7 +145,11 @@ test('sanitizeItemLinks blanks an unsafe link and leaves the rest alone', () => { id: 'a', href: 'javascript:alert(1)' }, { id: 'b', href: 'https://example.com' }, { id: 'c', url: 'data:text/html,x' }, - { id: 'd', type: 'widget', widgetConfig: { linkUrl: 'javascript:x', scrutinyHref: 'https://ok.example', other: 'kept' } }, + { + id: 'd', + type: 'widget', + widgetConfig: { linkUrl: 'javascript:x', scrutinyHref: 'https://ok.example', other: 'kept' }, + }, ]; sanitizeItemLinks(items); assert.equal(items[0].href, ''); @@ -164,7 +174,6 @@ test('sanitizeItemLinks tolerates junk', () => { /* ── the save path ────────────────────────────────────────────────────────── */ test('a config save is rejected when an item carries an unsafe link', async () => { - const fs = require('node:fs'); const http = require('node:http'); process.env.CONFIG_PATH = path.join(tmpDir('link'), 'apps.json'); @@ -177,41 +186,64 @@ test('a config save is rejected when an item carries an unsafe link', async () = await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; - const post = body => new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const u = new URL(base + '/api/config'); - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); + const post = body => + new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const u = new URL(base + '/api/config'); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); + r.on('error', reject); + r.end(data); }); - r.on('error', reject); - r.end(data); - }); try { - const bad = await post({ items: [{ id: 'a1', type: 'app', name: 'X', href: 'javascript:alert(1)' }], settings: {} }); + const bad = await post({ + items: [{ id: 'a1', type: 'app', name: 'X', href: 'javascript:alert(1)' }], + settings: {}, + }); assert.equal(bad.status, 400); assert.match(bad.body.error, /a1/, 'the message should name the item'); assert.match(bad.body.error, /javascript/i, 'and the scheme'); - assert.equal(loadConfig().items.find(i => i.id === 'a1'), undefined, 'nothing may be stored'); + assert.equal( + loadConfig().items.find(i => i.id === 'a1'), + undefined, + 'nothing may be stored', + ); const ok = await post({ items: [{ id: 'a2', type: 'app', name: 'Y', href: 'ssh://host' }], settings: {} }); assert.equal(ok.status, 200, 'a protocol handler must still save'); assert.equal(loadConfig().items.find(i => i.id === 'a2').href, 'ssh://host'); } finally { - await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); } }); /* Damaged rows are refused on save, so they cannot reach stored config. See P4-4 in badge-headers.test.js for why the read path tolerates them instead. */ test('a config save is rejected when a badge row is malformed', async () => { - const os = require('node:os'); - const fs = require('node:fs'); const http = require('node:http'); process.env.CONFIG_PATH = path.join(tmpDir('rows'), 'apps.json'); @@ -224,32 +256,60 @@ test('a config save is rejected when a badge row is malformed', async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; - const post = body => new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const u = new URL(base + '/api/config'); - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); + const post = body => + new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const u = new URL(base + '/api/config'); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); + r.on('error', reject); + r.end(data); }); - r.on('error', reject); - r.end(data); - }); try { - const bad = await post({ items: [{ id: 'a1', type: 'app', badge: { headers: [{ key: 'A', value: '1' }, null] } }], settings: {} }); + const bad = await post({ + items: [{ id: 'a1', type: 'app', badge: { headers: [{ key: 'A', value: '1' }, null] } }], + settings: {}, + }); assert.equal(bad.status, 400); assert.match(bad.body.error, /a1/); assert.match(bad.body.error, /badge\.headers\[1\]/); - assert.equal(loadConfig().items.find(i => i.id === 'a1'), undefined, 'nothing may be stored'); - - const ok = await post({ items: [{ id: 'a2', type: 'app', badge: { headers: [{ key: 'A', value: '1', secret: false }] } }], settings: {} }); + assert.equal( + loadConfig().items.find(i => i.id === 'a1'), + undefined, + 'nothing may be stored', + ); + + const ok = await post({ + items: [{ id: 'a2', type: 'app', badge: { headers: [{ key: 'A', value: '1', secret: false }] } }], + settings: {}, + }); assert.equal(ok.status, 200, 'a clean item must still save'); } finally { - await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); } }); @@ -258,8 +318,6 @@ test('a config save is rejected when a badge row is malformed', async () => { own id. Refused on save rather than repaired: renaming would silently change what folder children point at. See P5-5. */ test('a config save is rejected when two items share an id', async () => { - const os = require('node:os'); - const fs = require('node:fs'); const http = require('node:http'); process.env.CONFIG_PATH = path.join(tmpDir('dup'), 'apps.json'); @@ -272,48 +330,75 @@ test('a config save is rejected when two items share an id', async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; - const post = body => new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const u = new URL(base + '/api/config'); - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); + const post = body => + new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const u = new URL(base + '/api/config'); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); + r.on('error', reject); + r.end(data); }); - r.on('error', reject); - r.end(data); - }); try { - const bad = await post({ items: [ - { id: 'dup', type: 'app', name: 'First' }, - { id: 'dup', type: 'app', name: 'Second' }, - ], settings: {} }); + const bad = await post({ + items: [ + { id: 'dup', type: 'app', name: 'First' }, + { id: 'dup', type: 'app', name: 'Second' }, + ], + settings: {}, + }); assert.equal(bad.status, 400); assert.match(bad.body.error, /duplicate item id: dup/); assert.equal(loadConfig().items.filter(i => i.id === 'dup').length, 0, 'nothing may be stored'); /* Different types, same id, is still a collision: the lookup does not care. */ - const mixed = await post({ items: [ - { id: 'same', type: 'app', name: 'A' }, - { id: 'same', type: 'folder', label: 'B' }, - ], settings: {} }); + const mixed = await post({ + items: [ + { id: 'same', type: 'app', name: 'A' }, + { id: 'same', type: 'folder', label: 'B' }, + ], + settings: {}, + }); assert.equal(mixed.status, 400); - const ok = await post({ items: [ - { id: 'one', type: 'app', name: 'A' }, - { id: 'two', type: 'app', name: 'B' }, - ], settings: {} }); + const ok = await post({ + items: [ + { id: 'one', type: 'app', name: 'A' }, + { id: 'two', type: 'app', name: 'B' }, + ], + settings: {}, + }); assert.equal(ok.status, 200, 'distinct ids must still save'); /* Counted by id, not by type: ensureSystemItems adds a `settings` app. */ const ids = loadConfig().items.map(i => i.id); assert.ok(ids.includes('one') && ids.includes('two')); assert.equal(new Set(ids).size, ids.length, 'the stored config has no duplicates'); } finally { - await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); } }); @@ -338,6 +423,9 @@ test('the declared Node floor supports requiring an ES module', () => { If that stops being true, this test is what says the floor can be revisited. */ test('a src module still requires across the CommonJS/ESM boundary', () => { const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'config.js'), 'utf8'); - assert.match(src, /require\(['"][^'"]*ui\/js\/link-url\.js['"]\)/, - 'routes/config.js no longer requires the browser module; revisit the engines floor'); + assert.match( + src, + /require\(['"][^'"]*ui\/js\/link-url\.js['"]\)/, + 'routes/config.js no longer requires the browser module; revisit the engines floor', + ); }); diff --git a/api/test/log.test.js b/api/test/log.test.js index c3dec4db..7e0405b2 100644 --- a/api/test/log.test.js +++ b/api/test/log.test.js @@ -1,4 +1,4 @@ -const { test, beforeEach, afterEach } = require('node:test'); +const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const log = require('../src/log'); @@ -6,22 +6,28 @@ const log = require('../src/log'); level afterwards since it is module-level state. */ function capture(t) { const lines = []; - t.mock.method(process.stdout, 'write', (s) => { lines.push(s); return true; }); + t.mock.method(process.stdout, 'write', s => { + lines.push(s); + return true; + }); return lines; } afterEach(() => log.setLevel('info')); -test('emitted lines carry the level abbreviation and msg', (t) => { +test('emitted lines carry the level abbreviation and msg', t => { const lines = capture(t); log.info('hello'); assert.match(lines[0], / INF msg=hello/); }); -test('level filtering drops entries below the threshold', (t) => { +test('level filtering drops entries below the threshold', t => { const lines = capture(t); log.setLevel('error'); - log.debug('d'); log.info('i'); log.warn('w'); log.error('e'); + log.debug('d'); + log.info('i'); + log.warn('w'); + log.error('e'); const joined = lines.join(''); assert.doesNotMatch(joined, /msg=d/); assert.doesNotMatch(joined, /msg=i/); @@ -29,21 +35,21 @@ test('level filtering drops entries below the threshold', (t) => { assert.match(joined, /msg=e/); }); -test('audit entries always emit regardless of level', (t) => { +test('audit entries always emit regardless of level', t => { const lines = capture(t); log.setLevel('error'); log.audit('login', { user: 'a' }); assert.match(lines.join(''), / AUD msg=login user=a/); }); -test('scalars print bare and objects print as JSON', (t) => { +test('scalars print bare and objects print as JSON', t => { const lines = capture(t); log.info('m', { count: 9, widgets: ['a', 'b'] }); assert.match(lines[0], /count=9/); assert.match(lines[0], /widgets=\["a","b"\]/); }); -test('Error data is expanded to message and stack, not {}', (t) => { +test('Error data is expanded to message and stack, not {}', t => { const lines = capture(t); log.error('boom', new Error('kaboom')); assert.match(lines[0], /kaboom/); @@ -62,7 +68,7 @@ test('setLevel accepts known names and rejects unknown ones', () => { hostnames, and from upstream error messages. Spaces and '=' broke parsing more quietly, splitting one field into several. */ -test('a newline in a value cannot forge a second log line', (t) => { +test('a newline in a value cannot forge a second log line', t => { const lines = capture(t); log.error('widget-data failed', { widget: 'books\n2026-01-01T00:00:00.000Z AUD msg=config saved by=attacker' }); assert.equal(lines.length, 1, 'one call must produce one write'); @@ -71,7 +77,7 @@ test('a newline in a value cannot forge a second log line', (t) => { assert.ok(lines[0].includes('by=attacker'), 'the value itself is kept, it is only made safe'); }); -test('carriage returns and other control characters are escaped too', (t) => { +test('carriage returns and other control characters are escaped too', t => { const lines = capture(t); log.error('e', { a: 'x\ry', b: 'x\tz', c: 'x\u0007y', d: 'x\u007fy' }); assert.equal(lines[0].split('\n').filter(Boolean).length, 1); @@ -81,32 +87,32 @@ test('carriage returns and other control characters are escaped too', (t) => { assert.match(lines[0], /d="x\\u007fy"/); }); -test('a value containing spaces or = is quoted so fields stay separable', (t) => { +test('a value containing spaces or = is quoted so fields stay separable', t => { const lines = capture(t); log.warn('invalid manifest', { widget: 'name=x other=y', errors: 'a b c' }); assert.match(lines[0], /widget="name=x other=y"/); assert.match(lines[0], /errors="a b c"/); }); -test('a quote inside a value is escaped when the value is quoted', (t) => { +test('a quote inside a value is escaped when the value is quoted', t => { const lines = capture(t); log.error('e', { a: 'say "hi" now' }); assert.match(lines[0], /a="say \\"hi\\" now"/); }); -test('a backslash is escaped so it cannot swallow the closing quote', (t) => { +test('a backslash is escaped so it cannot swallow the closing quote', t => { const lines = capture(t); log.error('e', { a: 'ends with\\', b: 'next' }); assert.match(lines[0], /a="ends with\\\\" b=next/); }); -test('a leading quote is escaped, since a parser reads it as a value opener', (t) => { +test('a leading quote is escaped, since a parser reads it as a value opener', t => { const lines = capture(t); log.error('e', { a: '"leading' }); assert.match(lines[0], /a="\\"leading"/); }); -test('msg is quoted on the same terms as any other value', (t) => { +test('msg is quoted on the same terms as any other value', t => { const lines = capture(t); log.info('two words'); assert.match(lines[0], /msg="two words"/); @@ -114,7 +120,7 @@ test('msg is quoted on the same terms as any other value', (t) => { assert.match(lines[1], /msg=oneword/); }); -test('a value needing no quoting prints exactly as before', (t) => { +test('a value needing no quoting prints exactly as before', t => { const lines = capture(t); log.info('boot', { count: 9, id: 'w1', ok: true, ratio: 1.5 }); assert.match(lines[0], / msg=boot count=9 id=w1 ok=true ratio=1\.5\n$/); @@ -123,13 +129,13 @@ test('a value needing no quoting prints exactly as before', (t) => { /* The file header documents this shape, and a bare token with no whitespace is unambiguous, so an embedded quote does not force the whole value to be wrapped and re-escaped. */ -test('JSON values stay readable rather than being wrapped and escaped', (t) => { +test('JSON values stay readable rather than being wrapped and escaped', t => { const lines = capture(t); log.info('widget registry loaded', { count: 2, widgets: ['backup', 'books'] }); assert.match(lines[0], /widgets=\["backup","books"\]/); }); -test('an Error nested in data is still expanded, and stays on one line', (t) => { +test('an Error nested in data is still expanded, and stays on one line', t => { const lines = capture(t); const e = new Error('boom\nsecond line'); log.error('failed', { error: e }); @@ -137,7 +143,7 @@ test('an Error nested in data is still expanded, and stays on one line', (t) => assert.ok(lines[0].includes('boom')); }); -test('null and undefined values still print empty', (t) => { +test('null and undefined values still print empty', t => { const lines = capture(t); log.info('m', { a: null, b: undefined, c: 'x' }); assert.match(lines[0], / a= b= c=x/); @@ -146,25 +152,41 @@ test('null and undefined values still print empty', (t) => { /* The property behind all of the above: a logfmt reader gets back exactly what was logged. Asserting on the emitted text alone would pass for an encoding that is self-consistent but unreadable, so this decodes instead. */ -test('every value round-trips through a logfmt parser', (t) => { +test('every value round-trips through a logfmt parser', t => { const lines = capture(t); const values = [ - 'plain', 'two words', 'a=b', 'has"quote', '"leading', 'trail\\', - 'line\nbreak', 'tab\there', 'ctrl\u0007char', '', ' ', '===', '\\\\', '{"a":1}', + 'plain', + 'two words', + 'a=b', + 'has"quote', + '"leading', + 'trail\\', + 'line\nbreak', + 'tab\there', + 'ctrl\u0007char', + '', + ' ', + '===', + '\\\\', + '{"a":1}', ]; for (const v of values) log.info('t', { v }); - const unescape = s => s - .replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))) - .replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t') - .replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + const unesc = s => + s + .replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))) + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); values.forEach((expected, i) => { const line = lines[i].trimEnd(); assert.equal(line.split('\n').length, 1, `${JSON.stringify(expected)} spanned lines`); const m = /\sv=(?:"((?:[^"\\]|\\.)*)"|(\S*))/.exec(line); assert.ok(m, `could not find the field in ${line}`); - const got = m[1] !== undefined ? unescape(m[1]) : (m[2] || ''); + const got = m[1] !== undefined ? unesc(m[1]) : m[2] || ''; assert.equal(got, expected, `round trip failed for ${JSON.stringify(expected)}`); }); }); diff --git a/api/test/login-concurrency.test.js b/api/test/login-concurrency.test.js index 63c1a5b4..f3eaf1a9 100644 --- a/api/test/login-concurrency.test.js +++ b/api/test/login-concurrency.test.js @@ -44,7 +44,7 @@ const { saveConfig } = require('../src/config'); const { hashPassword } = require('../src/auth'); const PASSWORD = 'correct-horse-battery-staple'; -const LOGIN_MAX = 5; /* mirrors LOGIN_MAX in api/src/auth.js */ +const LOGIN_MAX = 5; /* mirrors LOGIN_MAX in api/src/auth.js */ let server, base; before(async () => { @@ -52,12 +52,24 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; /* Hashed once: scrypt at the shipped work factor is deliberately slow. */ - saveConfig({ items: [], settings: { auth: { - enabled: true, secret: 'a'.repeat(64), passwordHash: await hashPassword(PASSWORD), - } } }); + saveConfig({ + items: [], + settings: { + auth: { + enabled: true, + secret: 'a'.repeat(64), + passwordHash: await hashPassword(PASSWORD), + }, + }, + }); }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); let _ip = 0; const nextIp = () => `10.9.${Math.floor(_ip / 250)}.${(_ip++ % 250) + 1}`; @@ -66,28 +78,43 @@ function login(password, ip) { const data = JSON.stringify({ password }); const u = new URL(base + '/api/auth/login'); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(data), - Origin: base, - 'X-Real-IP': ip, + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + Origin: base, + 'X-Real-IP': ip, + }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + ); r.on('error', reject); r.end(data); }); } -const countBy = results => results.reduce((acc, r) => { - acc[r.status] = (acc[r.status] || 0) + 1; - return acc; -}, {}); +const countBy = results => + results.reduce((acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, {}); test('a burst of wrong passwords is counted, not waved through', async () => { const ip = nextIp(); @@ -95,10 +122,12 @@ test('a burst of wrong passwords is counted, not waved through', async () => { /* All in flight before any verification can finish. */ const results = await Promise.all(Array.from({ length: burst }, () => login('wrong', ip))); const counts = countBy(results); - assert.equal(counts[401], LOGIN_MAX, - `expected exactly ${LOGIN_MAX} attempts to reach verification, got ${JSON.stringify(counts)}`); - assert.equal(counts[429], burst - LOGIN_MAX, - `expected the rest to be refused, got ${JSON.stringify(counts)}`); + assert.equal( + counts[401], + LOGIN_MAX, + `expected exactly ${LOGIN_MAX} attempts to reach verification, got ${JSON.stringify(counts)}`, + ); + assert.equal(counts[429], burst - LOGIN_MAX, `expected the rest to be refused, got ${JSON.stringify(counts)}`); }); test('the correct password is refused too once the burst has used the attempts', async () => { @@ -141,9 +170,12 @@ test('one client using up its attempts does not lock out another', async () => { }); test('simultaneous bursts from different clients are counted separately', async () => { - const a = nextIp(), b = nextIp(); + const a = nextIp(), + b = nextIp(); const interleaved = []; - for (let i = 0; i < 10; i++) { interleaved.push(login('wrong', a), login('wrong', b)); } + for (let i = 0; i < 10; i++) { + interleaved.push(login('wrong', a), login('wrong', b)); + } const results = await Promise.all(interleaved); /* Ten each, five allowed each: interleaving two clients must not let either spend the other's allowance. */ diff --git a/api/test/metrics-parsing.test.js b/api/test/metrics-parsing.test.js index a990fd11..7e554108 100644 --- a/api/test/metrics-parsing.test.js +++ b/api/test/metrics-parsing.test.js @@ -21,7 +21,10 @@ test('a malformed CPU sample reports zero rather than NaN', () => { test('any NaN in either snapshot is caught', () => { const good = { total: 100, busy: 50, iowait: 5 }; - for (const bad of [{ total: NaN, busy: 1, iowait: 1 }, { total: 200, busy: NaN, iowait: 1 }]) { + for (const bad of [ + { total: NaN, busy: 1, iowait: 1 }, + { total: 200, busy: NaN, iowait: 1 }, + ]) { const r = computeCpu(good, bad); assert.ok(Number.isFinite(r.cpu), `cpu was ${r.cpu}`); assert.ok(Number.isFinite(r.iowait), `iowait was ${r.iowait}`); @@ -29,8 +32,10 @@ test('any NaN in either snapshot is caught', () => { }); test('an ordinary pair of samples still computes correctly', () => { - assert.deepEqual(computeCpu({ total: 100, busy: 50, iowait: 5 }, { total: 200, busy: 120, iowait: 10 }), - { cpu: 70, iowait: 5 }); + assert.deepEqual(computeCpu({ total: 100, busy: 50, iowait: 5 }, { total: 200, busy: 120, iowait: 10 }), { + cpu: 70, + iowait: 5, + }); }); test('two identical samples report zero, not a division by zero', () => { @@ -45,8 +50,9 @@ test('a counter that went backwards does not produce a negative percentage', () /* ── /proc/net/dev: the field shift ───────────────────────────────────────── */ -const HEADER = 'Inter-| Receive | Transmit\n' - + ' face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n'; +const HEADER = + 'Inter-| Receive | Transmit\n' + + ' face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n'; test('an ordinary line reads bytes, not packets', () => { const text = `${HEADER} eth0: 1234567 890 0 0 0 0 0 0 987654 321 0 0 0 0 0 0\n`; diff --git a/api/test/metrics.test.js b/api/test/metrics.test.js index 7212b34e..722a7d89 100644 --- a/api/test/metrics.test.js +++ b/api/test/metrics.test.js @@ -3,48 +3,53 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const metrics = require('../src/metrics'); -test('ramPercent computes used percentage from meminfo', (t) => { - t.mock.method(fs, 'readFileSync', () => - 'MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n'); +test('ramPercent computes used percentage from meminfo', t => { + t.mock.method( + fs, + 'readFileSync', + () => 'MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n', + ); assert.equal(metrics.ramPercent(), 75); }); -test('ramPercent returns 0 when MemTotal is missing', (t) => { +test('ramPercent returns 0 when MemTotal is missing', t => { t.mock.method(fs, 'readFileSync', () => 'MemAvailable: 250 kB\n'); assert.equal(metrics.ramPercent(), 0); }); -test('cpuTemp converts millidegrees to one decimal', (t) => { +test('cpuTemp converts millidegrees to one decimal', t => { t.mock.method(fs, 'readFileSync', () => '45123\n'); assert.equal(metrics.cpuTemp(), 45.1); }); -test('cpuTemp returns null for non-numeric contents', (t) => { +test('cpuTemp returns null for non-numeric contents', t => { t.mock.method(fs, 'readFileSync', () => 'n/a'); assert.equal(metrics.cpuTemp(), null); }); -test('cpuTemp returns null when the sensor read throws', (t) => { - t.mock.method(fs, 'readFileSync', () => { throw new Error('ENOENT'); }); +test('cpuTemp returns null when the sensor read throws', t => { + t.mock.method(fs, 'readFileSync', () => { + throw new Error('ENOENT'); + }); assert.equal(metrics.cpuTemp(), null); }); -test('procCount reads the total from the loadavg process field', (t) => { +test('procCount reads the total from the loadavg process field', t => { t.mock.method(fs, 'readFileSync', () => '0.52 0.48 0.44 3/2566 98765\n'); assert.equal(metrics.procCount(), 2566); }); -test('procCount returns 0 on a malformed loadavg line', (t) => { +test('procCount returns 0 on a malformed loadavg line', t => { t.mock.method(fs, 'readFileSync', () => 'garbage\n'); assert.equal(metrics.procCount(), 0); }); -test('uptimeSeconds floors the first uptime field', (t) => { +test('uptimeSeconds floors the first uptime field', t => { t.mock.method(fs, 'readFileSync', () => '12345.67 88888.88\n'); assert.equal(metrics.uptimeSeconds(), 12345); }); -test('uptimeSeconds returns 0 when the value is not finite', (t) => { +test('uptimeSeconds returns 0 when the value is not finite', t => { t.mock.method(fs, 'readFileSync', () => 'x y\n'); assert.equal(metrics.uptimeSeconds(), 0); }); @@ -53,8 +58,8 @@ test('computeCpu derives busy and iowait percentages from two snapshots', () => const a = { total: 1000, busy: 200, iowait: 50 }; const b = { total: 1200, busy: 300, iowait: 70 }; const r = metrics.computeCpu(a, b); - assert.equal(r.cpu, 50); // (300-200)/(1200-1000) = 50% - assert.equal(r.iowait, 10); // (70-50)/(1200-1000) = 10% + assert.equal(r.cpu, 50); // (300-200)/(1200-1000) = 50% + assert.equal(r.iowait, 10); // (70-50)/(1200-1000) = 10% }); test('computeCpu returns zeros when no time elapsed', () => { diff --git a/api/test/module-system.test.js b/api/test/module-system.test.js index 9f5db343..9b65f8e3 100644 --- a/api/test/module-system.test.js +++ b/api/test/module-system.test.js @@ -30,7 +30,8 @@ function jsFiles(dir) { /* A statement, not the word: `import` appears inside strings and comments, and a dynamic import() is legal in CommonJS. */ -const ESM_STATEMENT = /^\s*(?:import\s+[\w{*][^\n]*from\s+['"]|import\s+['"]|export\s+(?:default|const|let|var|function|class|\{))/m; +const ESM_STATEMENT = + /^\s*(?:import\s+[\w{*][^\n]*from\s+['"]|import\s+['"]|export\s+(?:default|const|let|var|function|class|\{))/m; test('no module under api/src uses ESM syntax', () => { const offenders = jsFiles(path.join(root, 'api', 'src')) @@ -42,10 +43,10 @@ test('no module under api/src uses ESM syntax', () => { test('the scan would notice ESM if it appeared', () => { /* A checker that cannot fail is worse than none. */ assert.match("import { x } from './y.js';", ESM_STATEMENT); - assert.match("export default foo;", ESM_STATEMENT); - assert.match("export function bar() {}", ESM_STATEMENT); + assert.match('export default foo;', ESM_STATEMENT); + assert.match('export function bar() {}', ESM_STATEMENT); assert.doesNotMatch("const x = require('./y');", ESM_STATEMENT); - assert.doesNotMatch("/* import is mentioned here */", ESM_STATEMENT); + assert.doesNotMatch('/* import is mentioned here */', ESM_STATEMENT); assert.doesNotMatch("const mod = await import('./y.js');", ESM_STATEMENT); }); @@ -61,13 +62,17 @@ test('widget data functions are still CommonJS, because the server requires them /* If these ever became ESM the API could not load them, and the lint override above would have to change with them. */ const widgets = path.join(root, 'ui', 'widgets'); - const dataFns = fs.readdirSync(widgets, { withFileTypes: true }) + const dataFns = fs + .readdirSync(widgets, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => path.join(widgets, d.name, 'data.js')) .filter(f => fs.existsSync(f)); assert.ok(dataFns.length, 'no widget data functions found'); for (const f of dataFns) { - assert.doesNotMatch(fs.readFileSync(f, 'utf8'), ESM_STATEMENT, - `${path.relative(root, f)} runs on the server and must stay CommonJS`); + assert.doesNotMatch( + fs.readFileSync(f, 'utf8'), + ESM_STATEMENT, + `${path.relative(root, f)} runs on the server and must stay CommonJS`, + ); } }); diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index d90c49ce..361cfa13 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -28,14 +28,12 @@ const policy = (cspDefault.match(/add_header Content-Security-Policy "([^"]+)"/) test('csp-default.conf declares exactly one Content-Security-Policy header', () => { assert.ok(policy, 'no Content-Security-Policy header found in csp-default.conf'); const directives = cspDefault.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); - assert.equal(directives.length, 1, - 'csp-default.conf must hold the one header and nothing else'); + assert.equal(directives.length, 1, 'csp-default.conf must hold the one header and nothing else'); assert.match(directives[0], /^add_header Content-Security-Policy /); }); test('the default policy is not repeated anywhere in dashboard.conf', () => { - assert.ok(!dashboard.includes(policy), - 'the default policy is inlined somewhere; include csp-default.conf instead'); + assert.ok(!dashboard.includes(policy), 'the default policy is inlined somewhere; include csp-default.conf instead'); }); test('every location that sets a CSP either includes the default or is a known exception', () => { @@ -45,8 +43,11 @@ test('every location that sets a CSP either includes the default or is a known e const EXCEPTIONS = ['^~ /admin', '^~ /widgets/']; const inline = [...dashboard.matchAll(/add_header Content-Security-Policy/g)]; - assert.equal(inline.length, EXCEPTIONS.length, - `expected ${EXCEPTIONS.length} inline CSP headers, found ${inline.length}`); + assert.equal( + inline.length, + EXCEPTIONS.length, + `expected ${EXCEPTIONS.length} inline CSP headers, found ${inline.length}`, + ); for (const name of EXCEPTIONS) { const at = dashboard.indexOf(`location ${name} {`); @@ -71,8 +72,11 @@ test('the include is used, and every use points at the same path', () => { test('the Dockerfile ships every nginx config file', () => { const dockerfile = fs.readFileSync(path.join(__dirname, '../../Dockerfile'), 'utf8'); for (const f of fs.readdirSync(NGINX_DIR).filter(f => f.endsWith('.conf'))) { - assert.match(dockerfile, new RegExp(`COPY nginx/${f.replace('.', '\\.')} `), - `nginx/${f} is not copied into the image, so the include would fail at runtime`); + assert.match( + dockerfile, + new RegExp(`COPY nginx/${f.replace('.', '\\.')} `), + `nginx/${f} is not copied into the image, so the include would fail at runtime`, + ); } }); @@ -106,8 +110,11 @@ test('widget pages restrict who may frame them', () => { test('widgets still clear X-Frame-Options, so frame-ancestors is the only guard', () => { const at = dashboard.indexOf('location ^~ /widgets/ {'); const block = dashboard.slice(at, dashboard.indexOf('\n }', at)); - assert.match(block, /add_header X-Frame-Options "" always;/, - 'if this stops being cleared, the dashboard cannot embed widgets'); + assert.match( + block, + /add_header X-Frame-Options "" always;/, + 'if this stops being cleared, the dashboard cannot embed widgets', + ); }); test('admin refuses framing entirely, matching its X-Frame-Options DENY', () => { @@ -132,8 +139,11 @@ test('every policy in the config states a frame-ancestors', () => { /* ── P16-5 and P16-6: version disclosure and compression ──────────────────── */ test('the nginx version is not advertised', () => { - assert.match(dashboard, /^\s*server_tokens off;$/m, - 'without this, every response carries Server: nginx/x.y.z and error pages print the version'); + assert.match( + dashboard, + /^\s*server_tokens off;$/m, + 'without this, every response carries Server: nginx/x.y.z and error pages print the version', + ); }); /* The API was not compressed at all: gzip_types listed text, CSS, JavaScript and @@ -154,7 +164,10 @@ test('both JavaScript MIME types are listed', () => { /* nginx compresses text/html whenever gzip is on, and warns about a duplicate if it is also listed. */ test('text/html is not listed in gzip_types', () => { - const block = dashboard.slice(dashboard.indexOf('gzip_types'), dashboard.indexOf(';', dashboard.indexOf('gzip_types'))); + const block = dashboard.slice( + dashboard.indexOf('gzip_types'), + dashboard.indexOf(';', dashboard.indexOf('gzip_types')), + ); assert.ok(!/\btext\/html\b/.test(block), 'nginx warns about this as a duplicate'); }); @@ -205,45 +218,57 @@ test('nginx sets a body size limit at all', () => { test('nginx allows at least as much as the API does', () => { const nginxLimit = nginxSize(dashboard, 'client_max_body_size'); - const iconLimit = 2 * 1024 * 1024; /* ICON_MAX_BYTES in routes/icons.js */ + const iconLimit = 2 * 1024 * 1024; /* ICON_MAX_BYTES in routes/icons.js */ - assert.ok(nginxLimit >= BODY_LIMIT, - `nginx allows ${nginxLimit} but the API accepts bodies up to ${BODY_LIMIT}`); - assert.ok(nginxLimit >= iconLimit, - `nginx allows ${nginxLimit} but icon uploads may be ${iconLimit}`); + assert.ok(nginxLimit >= BODY_LIMIT, `nginx allows ${nginxLimit} but the API accepts bodies up to ${BODY_LIMIT}`); + assert.ok(nginxLimit >= iconLimit, `nginx allows ${nginxLimit} but icon uploads may be ${iconLimit}`); }); /* Headroom, not a blank cheque: a body is buffered before it is forwarded, and this runs on hardware with 512 MB or less. */ test('the limits stay within reach of each other', () => { const nginxLimit = nginxSize(dashboard, 'client_max_body_size'); - assert.ok(nginxLimit <= BODY_LIMIT * 4, - `nginx allows ${nginxLimit}, far above the API's ${BODY_LIMIT}; buffering that costs memory`); + assert.ok( + nginxLimit <= BODY_LIMIT * 4, + `nginx allows ${nginxLimit}, far above the API's ${BODY_LIMIT}; buffering that costs memory`, + ); }); /* The measurement the API limit is derived from. If a config item grows enough for this to fail, the limit needs revisiting rather than the test relaxing. */ test('the API limit leaves real configurations far inside it', () => { const app = { - id: 'radarr_m9x2p4', type: 'app', label: 'Radarr', href: 'https://radarr.example.lan:7878', - iconUrl: 'radarr', color: 'dark', dock: false, + id: 'radarr_m9x2p4', + type: 'app', + label: 'Radarr', + href: 'https://radarr.example.lan:7878', + iconUrl: 'radarr', + color: 'dark', + dock: false, monitoring: { healthcheck: { enabled: true }, activity: { - enabled: true, url: 'https://radarr.example.lan:7878/api/v3/queue', interval: 30, - headers: [{ key: 'X-Api-Key', value: '0'.repeat(32), secret: true }], params: [], + enabled: true, + url: 'https://radarr.example.lan:7878/api/v3/queue', + interval: 30, + headers: [{ key: 'X-Api-Key', value: '0'.repeat(32), secret: true }], + params: [], }, }, badge: { enabled: true, url: 'https://radarr.example.lan:7878/api/v3/queue', interval: 30 }, - container: 'radarr', ping: 'https://radarr.example.lan:7878', + container: 'radarr', + ping: 'https://radarr.example.lan:7878', }; const big = JSON.stringify({ items: Array.from({ length: 300 }, (_, i) => ({ ...app, id: `app${i}` })), - settings: { background: {}, server: {} }, _rev: 12, + settings: { background: {}, server: {} }, + _rev: 12, }); - assert.ok(big.length < BODY_LIMIT / 4, - `a 300-app config is ${big.length} bytes against a ${BODY_LIMIT} limit; the headroom has gone`); + assert.ok( + big.length < BODY_LIMIT / 4, + `a 300-app config is ${big.length} bytes against a ${BODY_LIMIT} limit; the headroom has gone`, + ); }); /* The Host nginx forwards decides whether a write is accepted. diff --git a/api/test/nowplaying-data.test.js b/api/test/nowplaying-data.test.js index a27c0928..beba3eb6 100644 --- a/api/test/nowplaying-data.test.js +++ b/api/test/nowplaying-data.test.js @@ -15,80 +15,118 @@ function ctxFor(config, payload) { } test('plex names the player from the device title', async () => { - const r = await dataFn(ctxFor( - { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, - { MediaContainer: { Metadata: [{ - type: 'movie', title: 'Arrival', duration: 6000, viewOffset: 3000, - Player: { state: 'playing', title: 'Living Room TV', product: 'Plex for Apple TV' }, - }] } }, - )); + const r = await dataFn( + ctxFor( + { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, + { + MediaContainer: { + Metadata: [ + { + type: 'movie', + title: 'Arrival', + duration: 6000, + viewOffset: 3000, + Player: { state: 'playing', title: 'Living Room TV', product: 'Plex for Apple TV' }, + }, + ], + }, + }, + ), + ); assert.equal(r.sessions.length, 1); assert.equal(r.sessions[0].player, 'Living Room TV'); assert.equal(r.sessions[0].progress, 0.5); }); test('plex falls back to the product when the device is unnamed', async () => { - const r = await dataFn(ctxFor( - { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, - { MediaContainer: { Metadata: [{ type: 'movie', title: 'Arrival', Player: { product: 'Plex Web' } }] } }, - )); + const r = await dataFn( + ctxFor( + { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, + { MediaContainer: { Metadata: [{ type: 'movie', title: 'Arrival', Player: { product: 'Plex Web' } }] } }, + ), + ); assert.equal(r.sessions[0].player, 'Plex Web'); }); test('plex tolerates a session with no Player at all', async () => { - const r = await dataFn(ctxFor( - { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, - { MediaContainer: { Metadata: [{ type: 'movie', title: 'Arrival' }] } }, - )); + const r = await dataFn( + ctxFor( + { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, + { MediaContainer: { Metadata: [{ type: 'movie', title: 'Arrival' }] } }, + ), + ); assert.equal(r.sessions[0].player, ''); }); test('jellyfin prefers DeviceName over Client', async () => { - const r = await dataFn(ctxFor( - { provider: 'jellyfin', jellyfinUrl: 'https://jf.example.com', jellyfinKey: 'k' }, - [{ - DeviceName: 'Bedroom iPad', Client: 'Jellyfin Web', - NowPlayingItem: { Type: 'Movie', Name: 'Dune', RunTimeTicks: 100 }, - PlayState: { PositionTicks: 25, IsPaused: true }, - }], - )); + const r = await dataFn( + ctxFor({ provider: 'jellyfin', jellyfinUrl: 'https://jf.example.com', jellyfinKey: 'k' }, [ + { + DeviceName: 'Bedroom iPad', + Client: 'Jellyfin Web', + NowPlayingItem: { Type: 'Movie', Name: 'Dune', RunTimeTicks: 100 }, + PlayState: { PositionTicks: 25, IsPaused: true }, + }, + ]), + ); assert.equal(r.sessions[0].player, 'Bedroom iPad'); assert.equal(r.sessions[0].state, 'paused'); assert.equal(r.sessions[0].progress, 0.25); }); test('jellyfin falls back to Client when DeviceName is missing', async () => { - const r = await dataFn(ctxFor( - { provider: 'jellyfin', jellyfinUrl: 'https://jf.example.com', jellyfinKey: 'k' }, - [{ Client: 'Jellyfin Web', NowPlayingItem: { Type: 'Movie', Name: 'Dune' }, PlayState: {} }], - )); + const r = await dataFn( + ctxFor({ provider: 'jellyfin', jellyfinUrl: 'https://jf.example.com', jellyfinKey: 'k' }, [ + { Client: 'Jellyfin Web', NowPlayingItem: { Type: 'Movie', Name: 'Dune' }, PlayState: {} }, + ]), + ); assert.equal(r.sessions[0].player, 'Jellyfin Web'); }); test('navidrome names the player from playerName', async () => { - const r = await dataFn(ctxFor( - { provider: 'navidrome', navidromeUrl: 'https://nd.example.com', navidromeUser: 'u', navidromePassword: 'p' }, - { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [ - { title: 'Time', artist: 'Hans Zimmer', playerName: 'Kitchen Speaker', duration: 100, positionMs: 50000, state: 'playing' }, - ] } } }, - )); + const r = await dataFn( + ctxFor( + { provider: 'navidrome', navidromeUrl: 'https://nd.example.com', navidromeUser: 'u', navidromePassword: 'p' }, + { + 'subsonic-response': { + status: 'ok', + nowPlaying: { + entry: [ + { + title: 'Time', + artist: 'Hans Zimmer', + playerName: 'Kitchen Speaker', + duration: 100, + positionMs: 50000, + state: 'playing', + }, + ], + }, + }, + }, + ), + ); assert.equal(r.sessions[0].player, 'Kitchen Speaker'); assert.equal(r.sessions[0].progress, 0.5); }); test('navidrome leaves player empty when the entry omits it', async () => { - const r = await dataFn(ctxFor( - { provider: 'navidrome', navidromeUrl: 'https://nd.example.com', navidromeUser: 'u', navidromePassword: 'p' }, - { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [{ title: 'Time', artist: 'Hans Zimmer' }] } } }, - )); + const r = await dataFn( + ctxFor( + { provider: 'navidrome', navidromeUrl: 'https://nd.example.com', navidromeUser: 'u', navidromePassword: 'p' }, + { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [{ title: 'Time', artist: 'Hans Zimmer' }] } } }, + ), + ); assert.equal(r.sessions[0].player, ''); assert.equal(r.sessions[0].progress, null); }); test('progress never leaves 0..1 even when the source overruns', async () => { - const r = await dataFn(ctxFor( - { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, - { MediaContainer: { Metadata: [{ type: 'movie', title: 'Arrival', duration: 100, viewOffset: 400 }] } }, - )); + const r = await dataFn( + ctxFor( + { provider: 'plex', plexUrl: 'https://plex.example.com', plexToken: 't' }, + { MediaContainer: { Metadata: [{ type: 'movie', title: 'Arrival', duration: 100, viewOffset: 400 }] } }, + ), + ); assert.equal(r.sessions[0].progress, 1); }); diff --git a/api/test/parse-multipart.test.js b/api/test/parse-multipart.test.js index afbc6399..aea7531f 100644 --- a/api/test/parse-multipart.test.js +++ b/api/test/parse-multipart.test.js @@ -1,4 +1,3 @@ -const path = require('node:path'); const { test } = require('node:test'); const assert = require('node:assert/strict'); const { parseMultipartFile } = require('../src/parse-multipart'); @@ -10,9 +9,10 @@ const B = 'X-BOUNDARY-123'; function build(parts) { const chunks = []; for (const p of parts) { - const disp = p.filename !== undefined - ? `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"` - : `Content-Disposition: form-data; name="${p.name}"`; + const disp = + p.filename !== undefined + ? `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"` + : `Content-Disposition: form-data; name="${p.name}"`; chunks.push(Buffer.from(`--${B}\r\n${disp}\r\n\r\n`, 'latin1')); chunks.push(Buffer.isBuffer(p.body) ? p.body : Buffer.from(p.body, 'utf8')); chunks.push(Buffer.from('\r\n', 'latin1')); @@ -22,36 +22,39 @@ function build(parts) { } test('extracts a single file part with its bytes and a count of one', () => { - const r = parseMultipartFile(build([{ name:'icon', filename:'logo.svg', body:'' }]), B); + const r = parseMultipartFile(build([{ name: 'icon', filename: 'logo.svg', body: '' }]), B); assert.equal(r.filename, 'logo.svg'); assert.equal(r.data.toString('utf8'), ''); assert.equal(r.fileParts, 1); }); test('counts multiple file parts so the route can reject them', () => { - const r = parseMultipartFile(build([ - { name:'a', filename:'one.svg', body:'1' }, - { name:'b', filename:'two.svg', body:'2' }, - ]), B); + const r = parseMultipartFile( + build([ + { name: 'a', filename: 'one.svg', body: '1' }, + { name: 'b', filename: 'two.svg', body: '2' }, + ]), + B, + ); assert.equal(r.fileParts, 2); assert.equal(r.filename, 'two.svg'); }); test('ignores non-file fields: no filename means no file found', () => { - const r = parseMultipartFile(build([{ name:'plain', body:'just a value' }]), B); + const r = parseMultipartFile(build([{ name: 'plain', body: 'just a value' }]), B); assert.equal(r.filename, ''); assert.equal(r.data, null); assert.equal(r.fileParts, 0); }); test('strips path components from the filename', () => { - const r = parseMultipartFile(build([{ name:'icon', filename:'../../etc/passwd', body:'x' }]), B); + const r = parseMultipartFile(build([{ name: 'icon', filename: '../../etc/passwd', body: 'x' }]), B); assert.equal(r.filename, 'passwd'); }); test('preserves binary bodies that contain CRLF up to the next boundary', () => { const body = Buffer.from([0x89, 0x50, 0x0d, 0x0a, 0x4e, 0x47, 0x00, 0xff]); - const r = parseMultipartFile(build([{ name:'icon', filename:'x.png', body }]), B); + const r = parseMultipartFile(build([{ name: 'icon', filename: 'x.png', body }]), B); assert.deepEqual(r.data, body); assert.equal(r.fileParts, 1); }); @@ -64,7 +67,7 @@ test('returns an empty result for junk, empty input and a wrong boundary', () => assert.equal(r.fileParts, 0); assert.equal(r.data, null); } - const r = parseMultipartFile(build([{ name:'icon', filename:'x.svg', body:'' }]), 'OTHER'); + const r = parseMultipartFile(build([{ name: 'icon', filename: 'x.svg', body: '' }]), 'OTHER'); assert.equal(r.fileParts, 0); }); @@ -76,38 +79,49 @@ test('ignores a part whose headers are never terminated', () => { }); test('reads a truncated final part up to the end of the buffer', () => { - const buf = Buffer.from(`--${B}\r\nContent-Disposition: form-data; name="icon"; filename="x.svg"\r\n\r\n`, 'latin1'); + const buf = Buffer.from( + `--${B}\r\nContent-Disposition: form-data; name="icon"; filename="x.svg"\r\n\r\n`, + 'latin1', + ); const r = parseMultipartFile(buf, B); assert.equal(r.fileParts, 1); assert.equal(r.data.toString('utf8'), ''); }); test('accepts an empty file body', () => { - const r = parseMultipartFile(build([{ name:'icon', filename:'empty.svg', body:'' }]), B); + const r = parseMultipartFile(build([{ name: 'icon', filename: 'empty.svg', body: '' }]), B); assert.equal(r.fileParts, 1); assert.equal(r.filename, 'empty.svg'); assert.equal(r.data.length, 0); }); test('treats a part with an empty filename as a non-file field', () => { - const r = parseMultipartFile(build([{ name:'icon', filename:'', body:'x' }]), B); + const r = parseMultipartFile(build([{ name: 'icon', filename: '', body: 'x' }]), B); assert.equal(r.fileParts, 0); assert.equal(r.filename, ''); }); test('matches the filename parameter case-insensitively', () => { - const buf = Buffer.from(`--${B}\r\nContent-Disposition: form-data; name="i"; FileName="up.svg"\r\n\r\nx\r\n--${B}--\r\n`, 'latin1'); + const buf = Buffer.from( + `--${B}\r\nContent-Disposition: form-data; name="i"; FileName="up.svg"\r\n\r\nx\r\n--${B}--\r\n`, + 'latin1', + ); assert.equal(parseMultipartFile(buf, B).filename, 'up.svg'); }); test('strips absolute, backslash and traversal-only filenames', () => { - for (const [given, want] of [['/etc/shadow', 'shadow'], ['a/b/c.svg', 'c.svg'], ['..', '..'], ['./x.svg', 'x.svg']]) { - assert.equal(parseMultipartFile(build([{ name:'i', filename:given, body:'x' }]), B).filename, want, given); + for (const [given, want] of [ + ['/etc/shadow', 'shadow'], + ['a/b/c.svg', 'c.svg'], + ['..', '..'], + ['./x.svg', 'x.svg'], + ]) { + assert.equal(parseMultipartFile(build([{ name: 'i', filename: given, body: 'x' }]), B).filename, want, given); } }); test('does not split a body that merely contains the boundary text mid-line', () => { - const r = parseMultipartFile(build([{ name:'i', filename:'x.svg', body:`prefix--${B}suffix` }]), B); + const r = parseMultipartFile(build([{ name: 'i', filename: 'x.svg', body: `prefix--${B}suffix` }]), B); assert.equal(r.fileParts, 1); assert.equal(r.data.toString('utf8'), `prefix--${B}suffix`); }); @@ -115,7 +129,7 @@ test('does not split a body that merely contains the boundary text mid-line', () test('skips a preamble before the first boundary', () => { const buf = Buffer.concat([ Buffer.from('preamble text\r\n', 'latin1'), - build([{ name:'i', filename:'x.svg', body:'' }]), + build([{ name: 'i', filename: 'x.svg', body: '' }]), ]); const r = parseMultipartFile(buf, B); assert.equal(r.fileParts, 1); @@ -123,14 +137,14 @@ test('skips a preamble before the first boundary', () => { }); test('keeps NUL bytes and high bytes in the filename basename intact', () => { - const r = parseMultipartFile(build([{ name:'i', filename:'логотип.svg', body:'x' }]), B); + const r = parseMultipartFile(build([{ name: 'i', filename: 'логотип.svg', body: 'x' }]), B); assert.equal(r.fileParts, 1); assert.ok(r.filename.endsWith('.svg')); }); test('stops at the closing terminator without consuming trailing bytes', () => { const buf = Buffer.concat([ - build([{ name:'icon', filename:'x.svg', body:'' }]), + build([{ name: 'icon', filename: 'x.svg', body: '' }]), Buffer.from('trailing junk that must not be read', 'latin1'), ]); const r = parseMultipartFile(buf, B); diff --git a/api/test/parse-prometheus.test.js b/api/test/parse-prometheus.test.js index a9cda34b..3dbfb8a6 100644 --- a/api/test/parse-prometheus.test.js +++ b/api/test/parse-prometheus.test.js @@ -19,8 +19,9 @@ test('parsePrometheus reads negative, exponent and fractional values', () => { }); test('parsePrometheus keeps labelled series as distinct keys', () => { - const out = parsePrometheus('http_requests_total{method="get",code="200"} 7\n' - + 'http_requests_total{method="post",code="200"} 9'); + const out = parsePrometheus( + 'http_requests_total{method="get",code="200"} 7\n' + 'http_requests_total{method="post",code="200"} 9', + ); assert.equal(out['http_requests_total{method="get",code="200"}'], 7); assert.equal(out['http_requests_total{method="post",code="200"}'], 9); }); diff --git a/api/test/parse-xml.test.js b/api/test/parse-xml.test.js index 44eaf06b..23356ba3 100644 --- a/api/test/parse-xml.test.js +++ b/api/test/parse-xml.test.js @@ -12,15 +12,17 @@ const parseXml = xml => plain(_parseXml(xml)); /* ── parseXml: general nested shape, matching the JSON shape widgets already read ── */ test('parseXml maps root attributes with lossless numeric coercion', () => { - assert.deepEqual(parseXml(''), - { MediaContainer: { size: 3, title: 'Library' } }); + assert.deepEqual(parseXml(''), { + MediaContainer: { size: 3, title: 'Library' }, + }); }); test('parseXml nests elements and turns repeated tags into arrays', () => { - const xml = '' - + '' - + '' - + ''; + const xml = + '' + + '' + + '' + + ''; const p = parseXml(xml); assert.equal(p.MediaContainer.size, 2); assert.ok(Array.isArray(p.MediaContainer.Metadata)); @@ -37,25 +39,35 @@ test('parseXml keeps a single occurrence as one object, not an array', () => { }); test('parseXml collapses text-only elements to their coerced value', () => { - assert.deepEqual(parseXml('142031876home'), - { stats: { total: 14203, blocked: 1876, name: 'home' } }); + assert.deepEqual(parseXml('142031876home'), { + stats: { total: 14203, blocked: 1876, name: 'home' }, + }); }); test('parseXml leaves IDs, version strings, exponents and huge integers as strings', () => { - assert.deepEqual(parseXml(''), - { r: { id: '007', ver: '1.10', exp: '1e3', big: '9007199254740993' } }); + assert.deepEqual(parseXml(''), { + r: { id: '007', ver: '1.10', exp: '1e3', big: '9007199254740993' }, + }); }); test('parseXml decodes entities, handles both quote styles and CDATA', () => { - assert.deepEqual(parseXml(""), { r: { a: 'x & y', b: '' } }); + assert.deepEqual(parseXml(''), { r: { a: 'x & y', b: '' } }); assert.deepEqual(parseXml('hi & bye]]>'), { note: 'hi & bye' }); }); /* ── Tag names that collide with inherited Object.prototype members ── */ test('parseXml keeps a tag named after an inherited member as a single value', () => { - for (const name of ['toString', 'constructor', 'valueOf', 'hasOwnProperty', - 'isPrototypeOf', 'toLocaleString', 'propertyIsEnumerable', '__defineGetter__']) { + for (const name of [ + 'toString', + 'constructor', + 'valueOf', + 'hasOwnProperty', + 'isPrototypeOf', + 'toLocaleString', + 'propertyIsEnumerable', + '__defineGetter__', + ]) { const p = parseXml(`<${name}>hi`); assert.equal(p.r[name], 'hi', name); assert.ok(!Array.isArray(p.r[name]), name); @@ -64,8 +76,7 @@ test('parseXml keeps a tag named after an inherited member as a single value', ( }); test('parseXml still arrays a repeated tag named after an inherited member', () => { - assert.deepEqual(parseXml('ab'), - { r: { toString: ['a', 'b'] } }); + assert.deepEqual(parseXml('ab'), { r: { toString: ['a', 'b'] } }); }); /* A `__proto__` tag or attribute is kept as an ordinary key. On a plain object @@ -125,8 +136,7 @@ test('parseXml keeps text alongside children under #text', () => { }); test('parseXml decodes numeric character references and leaves unknown entities alone', () => { - assert.deepEqual(parseXml(''), - { r: { a: 'AB', b: ' &unknown;' } }); + assert.deepEqual(parseXml(''), { r: { a: 'AB', b: ' &unknown;' } }); }); test('parseXml leaves an out-of-range numeric reference as written', () => { @@ -233,9 +243,10 @@ test('several attributes each containing > all survive', () => { therefore stops at about 2499 items, sooner than the constant suggests. That is not an off-by-one; exactly 5000 nodes are kept. */ -const _feed = n => '' - + Array.from({ length: n }, (_, i) => `T${i}`).join('') - + ''; +const _feed = n => + '' + + Array.from({ length: n }, (_, i) => `T${i}`).join('') + + ''; test('a document within the caps is not flagged', () => { assert.equal(parseXml('1')['#truncated'], undefined); diff --git a/api/test/parser-fuzz.test.js b/api/test/parser-fuzz.test.js index 025a6f32..94cc41d2 100644 --- a/api/test/parser-fuzz.test.js +++ b/api/test/parser-fuzz.test.js @@ -35,14 +35,67 @@ function rng(seed) { on, so random input reaches real code paths instead of bouncing off the first character. */ const TOKENS = [ - '<', '>', '', '', '', '', '', + '', + '', + '', + '', + '' - + ''; +const VALID_XML = + '' + + ''; const VALID_PROM = '# HELP x help\n# TYPE x gauge\nmetric_a 42\nmetric_b{code="200"} 3.5 1650000000\n'; -const VALID_MULTIPART = '--BOUND\r\nContent-Disposition: form-data; name="i"; filename="x.svg"\r\n\r\n' - + '\r\n--BOUND--\r\n'; +const VALID_MULTIPART = + '--BOUND\r\nContent-Disposition: form-data; name="i"; filename="x.svg"\r\n\r\n' + '\r\n--BOUND--\r\n'; function inputsFor(valid) { const rand = rng(SEED); @@ -93,7 +147,12 @@ test('parseXml never throws and always returns an object', () => { const started = Date.now(); for (const input of inputsFor(VALID_XML)) { let out; - assert.doesNotThrow(() => { out = parseXml(input); }, () => `input: ${JSON.stringify(input)}`); + assert.doesNotThrow( + () => { + out = parseXml(input); + }, + () => `input: ${JSON.stringify(input)}`, + ); assert.equal(typeof out, 'object', `input: ${JSON.stringify(input)}`); assert.notEqual(out, null, `input: ${JSON.stringify(input)}`); } @@ -105,7 +164,12 @@ test('parsePrometheus never throws and always returns finite numeric values', () const started = Date.now(); for (const input of inputsFor(VALID_PROM)) { let out; - assert.doesNotThrow(() => { out = parsePrometheus(input); }, () => `input: ${JSON.stringify(input)}`); + assert.doesNotThrow( + () => { + out = parsePrometheus(input); + }, + () => `input: ${JSON.stringify(input)}`, + ); for (const [key, value] of Object.entries(out)) { assert.equal(typeof value, 'number', `key ${key} from ${JSON.stringify(input)}`); assert.ok(!Number.isNaN(value), `key ${key} from ${JSON.stringify(input)}`); @@ -120,7 +184,12 @@ test('parseMultipartFile never throws and reports a consistent result shape', () for (const input of inputsFor(VALID_MULTIPART)) { const buf = Buffer.from(input, 'latin1'); let out; - assert.doesNotThrow(() => { out = parseMultipartFile(buf, 'BOUND'); }, () => `input: ${JSON.stringify(input)}`); + assert.doesNotThrow( + () => { + out = parseMultipartFile(buf, 'BOUND'); + }, + () => `input: ${JSON.stringify(input)}`, + ); assert.equal(typeof out.filename, 'string', `input: ${JSON.stringify(input)}`); assert.ok(out.fileParts >= 0, `input: ${JSON.stringify(input)}`); /* A file part was seen, so its bytes must have been captured too. */ diff --git a/api/test/password-hash-format.test.js b/api/test/password-hash-format.test.js index c3a52a07..7b2aab7a 100644 --- a/api/test/password-hash-format.test.js +++ b/api/test/password-hash-format.test.js @@ -24,7 +24,7 @@ const path = require('node:path'); const crypto = require('node:crypto'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const _tmp = tmpDir('hash'); process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); @@ -59,7 +59,7 @@ test('the default parameters are the 16 MiB row from OWASP', async () => { number an operator picks by. */ test('every profile label matches the memory it costs', () => { for (const [label, params] of Object.entries(HASH_PROFILES)) { - const mib = (128 * (2 ** params.ln) * params.r) / 1048576; + const mib = (128 * 2 ** params.ln * params.r) / 1048576; assert.equal(`${mib}mib`, label, `${label} actually uses ${mib} MiB`); } }); @@ -69,7 +69,7 @@ test('every profile label matches the memory it costs', () => { pinning, because it is why moving to a heavier row can legitimately trigger a rehash and moving to a lighter one never does. */ test('the profiles are comparable in work without being identical', () => { - const work = ({ ln, r, p }) => (2 ** ln) * r * p; + const work = ({ ln, r, p }) => 2 ** ln * r * p; const all = Object.values(HASH_PROFILES).map(work); const spread = Math.max(...all) / Math.min(...all); assert.ok(spread > 1, 'the rows are not all identical, so ordering matters'); @@ -78,7 +78,7 @@ test('the profiles are comparable in work without being identical', () => { test('every profile is at least as strong as the old implicit parameters', () => { /* The old format was N=2^14, r=8, p=1. No row may be a downgrade on that. */ - const work = ({ ln, r, p }) => (2 ** ln) * r * p; + const work = ({ ln, r, p }) => 2 ** ln * r * p; const before = work({ ln: 14, r: 8, p: 1 }); for (const [label, params] of Object.entries(HASH_PROFILES)) { assert.ok(work(params) > before, `${label} is weaker than what it replaces`); @@ -117,8 +117,11 @@ test('a hash made with different parameters still verifies', async () => { const weaker = await hashPassword('correct-horse'); assert.match(weaker, /ln=13,r=8,p=10/); process.env.PASSWORD_HASH_MEMORY = '32mib'; - assert.equal(await verifyPassword('correct-horse', weaker), true, - 'raising the cost must not invalidate existing hashes'); + assert.equal( + await verifyPassword('correct-horse', weaker), + true, + 'raising the cost must not invalidate existing hashes', + ); } finally { if (prev === undefined) delete process.env.PASSWORD_HASH_MEMORY; else process.env.PASSWORD_HASH_MEMORY = prev; @@ -127,12 +130,20 @@ test('a hash made with different parameters still verifies', async () => { test('a malformed hash fails the login rather than throwing', async () => { const cases = [ - '', 'nocolon', 'salt:', ':key', 'salt:nothex', + '', + 'nocolon', + 'salt:', + ':key', + 'salt:nothex', '$scrypt$ln=14,r=8,p=5$onlyonefield', '$scrypt$ln=14,r=8$salt$key', '$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA', '$scrypt$ln=14,r=8,p=5$$', - null, undefined, 0, {}, [], + null, + undefined, + 0, + {}, + [], ]; for (const h of cases) { assert.equal(await verifyPassword('anything', h), false, `should be false for ${JSON.stringify(h)}`); @@ -142,8 +153,12 @@ test('a malformed hash fails the login rather than throwing', async () => { /* A hand-edited or corrupted hash must not be able to ask for an allocation big enough to take the process down instead of failing a login. */ test('absurd parameters are refused rather than attempted', async () => { - for (const h of ['$scrypt$ln=31,r=8,p=1$c2FsdA$aGFzaA', '$scrypt$ln=14,r=999,p=1$c2FsdA$aGFzaA', - '$scrypt$ln=14,r=8,p=9999$c2FsdA$aGFzaA', '$scrypt$ln=0,r=8,p=1$c2FsdA$aGFzaA']) { + for (const h of [ + '$scrypt$ln=31,r=8,p=1$c2FsdA$aGFzaA', + '$scrypt$ln=14,r=999,p=1$c2FsdA$aGFzaA', + '$scrypt$ln=14,r=8,p=9999$c2FsdA$aGFzaA', + '$scrypt$ln=0,r=8,p=1$c2FsdA$aGFzaA', + ]) { assert.equal(parseHash(h), null, `${h} should not parse`); assert.equal(await verifyPassword('anything', h), false); } @@ -248,7 +263,12 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); const { loadConfig, saveConfig } = require('../src/config'); @@ -258,14 +278,22 @@ function login(password) { const data = JSON.stringify({ password }); const u = new URL(base + '/api/auth/login'); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => resolve({ status: res.statusCode, body: b })); - }); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve({ status: res.statusCode, body: b })); + }, + ); r.on('error', reject); r.end(data); }); diff --git a/api/test/percent-decode.test.js b/api/test/percent-decode.test.js index d8d4e7e3..c200e1c1 100644 --- a/api/test/percent-decode.test.js +++ b/api/test/percent-decode.test.js @@ -19,7 +19,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('decode'), 'apps.json'); const { test, before, after } = require('node:test'); @@ -102,7 +102,7 @@ let server, base; before(async () => { require('../src/routes'); - require('../src/widget-data'); /* registers /api/widget-config/:id */ + require('../src/widget-data'); /* registers /api/widget-config/:id */ const { dispatch } = require('../src/router'); const { saveConfig } = require('../src/config'); saveConfig({ @@ -113,19 +113,38 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); function get(pathname, cookie) { const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET', - headers: cookie ? { Cookie: cookie } : {}, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'GET', + headers: cookie ? { Cookie: cookie } : {}, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); r.on('error', reject); r.end(); }); @@ -176,7 +195,12 @@ test('a valid route parameter still decodes', async () => { test('no malformed URL produces a 500', async () => { const c = session(); - for (const p of ['/api/widget-config/%', '/api/widget-config/%zz', '/api/widget-config/a%', '/api/widget-config/%E0%A4%A']) { + for (const p of [ + '/api/widget-config/%', + '/api/widget-config/%zz', + '/api/widget-config/a%', + '/api/widget-config/%E0%A4%A', + ]) { const r = await get(p, c); assert.notEqual(r.status, 500, `${p} answered 500`); assert.equal(r.status, 400, `${p} should be a bad request`); diff --git a/api/test/ping-log.test.js b/api/test/ping-log.test.js index 354a80f9..bf462adc 100644 --- a/api/test/ping-log.test.js +++ b/api/test/ping-log.test.js @@ -28,7 +28,9 @@ const { pingUrl } = _internals; /* Record what log.warn is given. The runner restores it after each test. */ function warnings(t) { const seen = []; - t.mock.method(log, 'warn', (msg, data) => { seen.push({ msg, data }); }); + t.mock.method(log, 'warn', (msg, data) => { + seen.push({ msg, data }); + }); return seen; } @@ -43,7 +45,7 @@ async function closedPort() { const pingFailure = seen => seen.find(w => w.msg === 'ping failed'); -test('a failed ping logs a usable URL', async (t) => { +test('a failed ping logs a usable URL', async t => { const port = await closedPort(); const seen = warnings(t); await pingUrl(`http://127.0.0.1:${port}/health`, 2000); @@ -55,7 +57,7 @@ test('a failed ping logs a usable URL', async (t) => { }); /* The port is the half that made two services on one host indistinguishable. */ -test('the logged URL names the scheme and the port', async (t) => { +test('the logged URL names the scheme and the port', async t => { const port = await closedPort(); const seen = warnings(t); await pingUrl(`http://127.0.0.1:${port}/`, 2000); @@ -67,7 +69,7 @@ test('the logged URL names the scheme and the port', async (t) => { /* A URL may carry an API key in its query string and credentials in its authority. u.origin excludes both, and the query is deliberately left off. */ -test('the logged URL carries neither credentials nor query string', async (t) => { +test('the logged URL carries neither credentials nor query string', async t => { const port = await closedPort(); const seen = warnings(t); await pingUrl(`http://user:hunter2@127.0.0.1:${port}/api?token=SECRET`, 2000); @@ -79,7 +81,7 @@ test('the logged URL carries neither credentials nor query string', async (t) => }); /* The error text is logged in full, since the response no longer carries it. */ -test('the underlying error message is logged', async (t) => { +test('the underlying error message is logged', async t => { const port = await closedPort(); const seen = warnings(t); await pingUrl(`http://127.0.0.1:${port}/`, 2000); diff --git a/api/test/poll-rate-limits.test.js b/api/test/poll-rate-limits.test.js index 24277f0d..6e99f3e7 100644 --- a/api/test/poll-rate-limits.test.js +++ b/api/test/poll-rate-limits.test.js @@ -15,7 +15,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('polllimit'), 'apps.json'); process.env.ALLOW_PRIVATE_IPS = 'true'; @@ -30,7 +30,10 @@ const { saveConfig } = require('../src/config'); const { _resetRateLimits } = require('../src/auth'); const LIMITS = require('../src/poll-limits'); -let server, base, upstream, upstreamHits = 0; +let server, + base, + upstream, + upstreamHits = 0; before(async () => { upstream = http.createServer((_, res) => { @@ -42,7 +45,9 @@ before(async () => { const upUrl = `http://127.0.0.1:${upstream.address().port}/api`; saveConfig({ - items: [{ id: 'a1', type: 'app', name: 'A', href: 'https://a', badge: { enabled: true, url: upUrl, interval: 30 } }], + items: [ + { id: 'a1', type: 'app', name: 'A', href: 'https://a', badge: { enabled: true, url: upUrl, interval: 30 } }, + ], settings: {}, }); @@ -52,11 +57,20 @@ before(async () => { }); after(async () => { - await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); - await new Promise(r => { upstream.closeAllConnections?.(); upstream.close(r); }); + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); + await new Promise(r => { + upstream.closeAllConnections?.(); + upstream.close(r); + }); }); -beforeEach(() => { upstreamHits = 0; if (_resetRateLimits) _resetRateLimits(); }); +beforeEach(() => { + upstreamHits = 0; + if (_resetRateLimits) _resetRateLimits(); +}); function req(method, pathname) { const u = new URL(base + pathname); @@ -65,9 +79,14 @@ function req(method, pathname) { /* Content-Length only when there is a body: declaring one on a GET leaves the server waiting for bytes that never arrive. */ const headers = { Origin: base }; - if (body) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(body); } - const r = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method, headers }, - res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); + if (body) { + headers['Content-Type'] = 'application/json'; + headers['Content-Length'] = Buffer.byteLength(body); + } + const r = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method, headers }, res => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }); r.on('error', reject); r.end(body ?? undefined); }); @@ -75,7 +94,7 @@ function req(method, pathname) { async function burst(method, pathname, n) { let limited = 0; - for (let i = 0; i < n; i++) if (await req(method, pathname) === 429) limited++; + for (let i = 0; i < n; i++) if ((await req(method, pathname)) === 429) limited++; return limited; } @@ -105,16 +124,18 @@ test('widget-options is limited', async () => { services. */ test('a refused request never reaches the upstream service', async () => { await burst('GET', '/api/badges', LIMITS.BADGES.max + 40); - assert.equal(upstreamHits, LIMITS.BADGES.max, - `${upstreamHits} upstream requests for ${LIMITS.BADGES.max + 40} calls`); + assert.equal( + upstreamHits, + LIMITS.BADGES.max, + `${upstreamHits} upstream requests for ${LIMITS.BADGES.max + 40} calls`, + ); }); /* Counted per widget id, since that is what maps to one upstream service. One busy widget must not silence the others. */ test('widget-data counts each widget separately', async () => { await burst('GET', '/api/widget-data/w1', LIMITS.WIDGET_DATA.max + 5); - assert.notEqual(await req('GET', '/api/widget-data/w2'), 429, - 'a different widget should still be reachable'); + assert.notEqual(await req('GET', '/api/widget-data/w2'), 429, 'a different widget should still be reachable'); }); /* ── the limits fit real use ──────────────────────────────────────────────── */ @@ -132,13 +153,17 @@ test('a minute of normal polling from ten devices stays well inside every limit' }; for (const [name, used] of Object.entries(perMinute)) { const { max } = LIMITS[name]; - assert.ok(used <= max / 2, - `${name}: ten devices use ${used}/min against a ${max}/min limit, leaving no room for a focus refetch`); + assert.ok( + used <= max / 2, + `${name}: ten devices use ${used}/min against a ${max}/min limit, leaving no room for a focus refetch`, + ); } }); test('one open tab uses a small share of the allowance', () => { - const used = 60 / 20; /* badges, the fastest poll */ - assert.ok(used / LIMITS.BADGES.max < 0.1, - `one tab uses ${Math.round((used / LIMITS.BADGES.max) * 100)}% of the badges allowance`); + const used = 60 / 20; /* badges, the fastest poll */ + assert.ok( + used / LIMITS.BADGES.max < 0.1, + `one tab uses ${Math.round((used / LIMITS.BADGES.max) * 100)}% of the badges allowance`, + ); }); diff --git a/api/test/provider-dispatch.test.js b/api/test/provider-dispatch.test.js index f16487e8..f2bd131c 100644 --- a/api/test/provider-dispatch.test.js +++ b/api/test/provider-dispatch.test.js @@ -5,7 +5,7 @@ const { errorParts } = require('../test-support/widget-ctx'); const handlers = { a: async ctx => ({ picked: 'a', cfg: ctx.config }), - b: async ctx => ({ picked: 'b' }), + b: async _ctx => ({ picked: 'b' }), }; test('selects the handler named by the default provider field', async () => { @@ -24,7 +24,10 @@ test('falls back to the default when the field names an unknown provider', async }); test('reads the provider from a custom field', async () => { - const r = await dispatchProvider({ config: { diskProvider: 'b' } }, handlers, { field: 'diskProvider', default: 'a' }); + const r = await dispatchProvider({ config: { diskProvider: 'b' } }, handlers, { + field: 'diskProvider', + default: 'a', + }); assert.equal(r.picked, 'b'); }); @@ -40,16 +43,23 @@ test('fails when no handler matches and no default is given', async () => { }); test('onError wraps a thrown handler error into the widget error shape', async () => { - const throwing = { a: async () => { throw new Error('boom'); } }; - const r = await dispatchProvider({ config: { provider: 'a' } }, throwing, - { default: 'a', onError: e => ({ items: [], error: e.message }) }); + const throwing = { + a: async () => { + throw new Error('boom'); + }, + }; + const r = await dispatchProvider({ config: { provider: 'a' } }, throwing, { + default: 'a', + onError: e => ({ items: [], error: e.message }), + }); assert.deepEqual(r, { items: [], error: 'boom' }); }); test('without onError, a thrown handler error propagates', async () => { - const throwing = { a: async () => { throw new Error('boom'); } }; - await assert.rejects( - () => dispatchProvider({ config: { provider: 'a' } }, throwing, { default: 'a' }), - /boom/, - ); + const throwing = { + a: async () => { + throw new Error('boom'); + }, + }; + await assert.rejects(() => dispatchProvider({ config: { provider: 'a' } }, throwing, { default: 'a' }), /boom/); }); diff --git a/api/test/proxy-boundary.test.js b/api/test/proxy-boundary.test.js index 204a261f..565af93f 100644 --- a/api/test/proxy-boundary.test.js +++ b/api/test/proxy-boundary.test.js @@ -4,8 +4,7 @@ Point config at a nonexistent path so loadConfig falls back to an empty config (no host IP, no port map) and ALLOW_PRIVATE_IPS stays off, so 127.0.0.1 is a private address the guard is expected to block. */ -const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpPath } = require('../test-support/tmp'); process.env.CONFIG_PATH = tmpPath('apps.json'); const { test } = require('node:test'); @@ -13,7 +12,13 @@ const assert = require('node:assert/strict'); const http = require('node:http'); const { fetchChecked, fetchUnchecked, pingChecked, pingUnchecked, SsrfBlockedError } = require('../src/proxy'); -function server(t, handler = (_, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); }) { +function server( + t, + handler = (_, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }, +) { return new Promise(resolve => { const s = http.createServer(handler); s.listen(0, '127.0.0.1', () => { @@ -25,39 +30,39 @@ function server(t, handler = (_, res) => { res.writeHead(200, { 'Content-Type': /* ── fetchChecked: guarded ──────────────────────────────────────────────── */ -test('fetchChecked blocks a private address', async (t) => { +test('fetchChecked blocks a private address', async t => { const port = await server(t); await assert.rejects( () => fetchChecked(`http://127.0.0.1:${port}/x`, { timeout: 3000 }), - (e) => e instanceof SsrfBlockedError && e.status === 403, + e => e instanceof SsrfBlockedError && e.status === 403, ); }); -test('fetchChecked blocks a private address even with a path and query', async (t) => { +test('fetchChecked blocks a private address even with a path and query', async t => { const port = await server(t); await assert.rejects( () => fetchChecked(`http://127.0.0.1:${port}/api/v2.0/pool?a=b`, { timeout: 3000 }), - (e) => e instanceof SsrfBlockedError, + e => e instanceof SsrfBlockedError, ); }); test('SsrfBlockedError carries the status a route should return', async () => { await assert.rejects( () => fetchChecked('http://192.168.1.99:8080/', { timeout: 3000 }), - (e) => e.status === 403 && /private address/.test(e.message), + e => e.status === 403 && /private address/.test(e.message), ); }); test('fetchChecked rejects an unresolvable host rather than connecting', async () => { await assert.rejects( () => fetchChecked('http://nx.invalid/', { timeout: 3000 }), - (e) => e instanceof SsrfBlockedError, + e => e instanceof SsrfBlockedError, ); }); /* ── fetchUnchecked: not guarded, by design ─────────────────────────────── */ -test('fetchUnchecked reaches a private address', async (t) => { +test('fetchUnchecked reaches a private address', async t => { /* The whole point of the split: config-supplied urls are normal homelab targets on private IPs, and must not be blocked. */ const port = await server(t); @@ -68,15 +73,15 @@ test('fetchUnchecked reaches a private address', async (t) => { /* ── ping ───────────────────────────────────────────────────────────────── */ -test('pingChecked blocks a private address', async (t) => { +test('pingChecked blocks a private address', async t => { const port = await server(t); await assert.rejects( () => pingChecked(`http://127.0.0.1:${port}/`, 3000, false), - (e) => e instanceof SsrfBlockedError && e.status === 403, + e => e instanceof SsrfBlockedError && e.status === 403, ); }); -test('pingUnchecked reaches a private address', async (t) => { +test('pingUnchecked reaches a private address', async t => { const port = await server(t); const r = await pingUnchecked(`http://127.0.0.1:${port}/`, 3000, false); assert.equal(r.ok, true); @@ -85,14 +90,18 @@ test('pingUnchecked reaches a private address', async (t) => { /* ── the invariant the refactor exists to protect ──────────────────────── */ -test('the guard sees the url that is actually connected to', async (t) => { +test('the guard sees the url that is actually connected to', async t => { /* fetchChecked resolves and pins internally, so a caller cannot hand it one url to check and a different one to fetch. If a redirect-style host swap or a new rewrite step is ever introduced upstream of the connection, it must sit above the guard. This test fails loudly if the connection lands somewhere the guard would have rejected. */ let hit = false; - const port = await server(t, (_, res) => { hit = true; res.writeHead(200); res.end('{}'); }); + const port = await server(t, (_, res) => { + hit = true; + res.writeHead(200); + res.end('{}'); + }); await assert.rejects(() => fetchChecked(`http://127.0.0.1:${port}/`, { timeout: 3000 })); assert.equal(hit, false, 'blocked request must never reach the socket'); }); diff --git a/api/test/proxy-host.test.js b/api/test/proxy-host.test.js index d928a4dc..8eb2a158 100644 --- a/api/test/proxy-host.test.js +++ b/api/test/proxy-host.test.js @@ -2,10 +2,13 @@ file with a host IP set. proxy.test.js covers the no-config fallback. */ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const dir = tmpDir('proxy'); process.env.CONFIG_PATH = path.join(dir, 'apps.json'); -fs.writeFileSync(process.env.CONFIG_PATH, JSON.stringify({ items: [], settings: { server: { hostIp: '192.168.1.50' } } })); +fs.writeFileSync( + process.env.CONFIG_PATH, + JSON.stringify({ items: [], settings: { server: { hostIp: '192.168.1.50' } } }), +); const { test } = require('node:test'); const assert = require('node:assert/strict'); diff --git a/api/test/proxy-ping-rewrite.test.js b/api/test/proxy-ping-rewrite.test.js index 2d1c9fe8..8a51ec62 100644 --- a/api/test/proxy-ping-rewrite.test.js +++ b/api/test/proxy-ping-rewrite.test.js @@ -15,19 +15,24 @@ test looking for it belongs. */ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const dir = tmpDir('ping'); process.env.CONFIG_PATH = path.join(dir, 'apps.json'); -fs.writeFileSync(process.env.CONFIG_PATH, JSON.stringify({ - items: [], - settings: { server: { - hostIp: '192.168.1.50', - portMap: { - '8096': { host: 'stackyard-test-nx-host', port: '8096' }, - '7000': { host: '10.0.0.9', port: '80' }, +fs.writeFileSync( + process.env.CONFIG_PATH, + JSON.stringify({ + items: [], + settings: { + server: { + hostIp: '192.168.1.50', + portMap: { + 8096: { host: 'stackyard-test-nx-host', port: '8096' }, + 7000: { host: '10.0.0.9', port: '80' }, + }, + }, }, - } }, -})); + }), +); const { test } = require('node:test'); const assert = require('node:assert/strict'); @@ -38,8 +43,14 @@ const { fetchChecked, pingChecked, pingUnchecked, SsrfBlockedError } = require(' async function targetOf(fn) { const real = log.warn; const seen = []; - log.warn = (msg, fields) => { seen.push(fields || {}); }; - try { await fn(); } finally { log.warn = real; } + log.warn = (msg, fields) => { + seen.push(fields || {}); + }; + try { + await fn(); + } finally { + log.warn = real; + } return seen.map(f => String(f.url || '')).join(' '); } @@ -48,7 +59,9 @@ const MS = 4000; test('pingChecked follows portMap to the mapped container', async () => { let r; - const target = await targetOf(async () => { r = await pingChecked(MAPPED, MS, false); }); + const target = await targetOf(async () => { + r = await pingChecked(MAPPED, MS, false); + }); assert.equal(r.ok, false); assert.match(target, /stackyard-test-nx-host/, 'ping must target the rewritten host'); assert.doesNotMatch(r.error, /stackyard-test-nx-host/, 'and must not tell the browser the host'); @@ -57,7 +70,9 @@ test('pingChecked follows portMap to the mapped container', async () => { test('pingUnchecked follows portMap to the mapped container', async () => { /* Health checks ping config-supplied urls, and diverged the same way. */ let r; - const target = await targetOf(async () => { r = await pingUnchecked(MAPPED, MS, false); }); + const target = await targetOf(async () => { + r = await pingUnchecked(MAPPED, MS, false); + }); assert.equal(r.ok, false); assert.match(target, /stackyard-test-nx-host/); }); @@ -70,8 +85,13 @@ test('ping and fetch resolve the same url to the same target', async () => { detail; only the response body is sanitised, and errorBody is what does that. So the two are read from their respective internals rather than from what a browser would see. */ - const pingTarget = await targetOf(async () => { await pingChecked(MAPPED, MS, false); }); - const fetchErr = await fetchChecked(MAPPED, { timeout: MS }).then(() => null, e => e.message); + const pingTarget = await targetOf(async () => { + await pingChecked(MAPPED, MS, false); + }); + const fetchErr = await fetchChecked(MAPPED, { timeout: MS }).then( + () => null, + e => e.message, + ); assert.match(pingTarget, /stackyard-test-nx-host/); assert.match(fetchErr, /stackyard-test-nx-host/, 'both must resolve to the same host'); }); @@ -81,7 +101,7 @@ test('pingChecked guards the rewritten target, not the url as typed', async () = Blocking proves the guard sees the mapped private target instead. */ await assert.rejects( () => pingChecked('http://192.168.1.50:7000/', MS, false), - (e) => e instanceof SsrfBlockedError && /10\.0\.0\.9/.test(e.message), + e => e instanceof SsrfBlockedError && /10\.0\.0\.9/.test(e.message), ); }); diff --git a/api/test/proxy-rewrite.test.js b/api/test/proxy-rewrite.test.js index 189cdc6c..dd1e1ca0 100644 --- a/api/test/proxy-rewrite.test.js +++ b/api/test/proxy-rewrite.test.js @@ -9,16 +9,21 @@ these fails, the behaviour changed and that is the thing to look at. */ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const dir = tmpDir('rewrite'); process.env.CONFIG_PATH = path.join(dir, 'apps.json'); -fs.writeFileSync(process.env.CONFIG_PATH, JSON.stringify({ - items: [], - settings: { server: { - hostIp: '192.168.1.50', - portMap: { '8096': { host: 'jellyfin', port: '8096' }, '9000': { host: 'portainer', port: '9443' } }, - } }, -})); +fs.writeFileSync( + process.env.CONFIG_PATH, + JSON.stringify({ + items: [], + settings: { + server: { + hostIp: '192.168.1.50', + portMap: { 8096: { host: 'jellyfin', port: '8096' }, 9000: { host: 'portainer', port: '9443' } }, + }, + }, + }), +); const { test } = require('node:test'); const assert = require('node:assert/strict'); diff --git a/api/test/proxy.test.js b/api/test/proxy.test.js index 6409903d..d87270bc 100644 --- a/api/test/proxy.test.js +++ b/api/test/proxy.test.js @@ -1,7 +1,6 @@ /* Point config at a nonexistent path so loadConfig falls back to an empty config (no host IP, no port map), which keeps these tests hermetic. */ -const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpPath } = require('../test-support/tmp'); process.env.CONFIG_PATH = tmpPath('apps.json'); const { test } = require('node:test'); @@ -36,7 +35,15 @@ test('embeddedIPv4 extracts the IPv4 from IPv4-in-IPv6 wrappers', () => { }); test('isPrivateAddress blocks hex-form IPv4-compatible, IPv4-mapped and NAT64 private targets', () => { - for (const a of ['::7f00:1', '::127.0.0.1', '::ffff:7f00:1', '::ffff:a00:1', '::ffff:a9fe:a9fe', '64:ff9b::7f00:1', '::ffff:127.0.0.1']) + for (const a of [ + '::7f00:1', + '::127.0.0.1', + '::ffff:7f00:1', + '::ffff:a00:1', + '::ffff:a9fe:a9fe', + '64:ff9b::7f00:1', + '::ffff:127.0.0.1', + ]) assert.ok(isPrivateAddress(a), `${a} should be private`); }); @@ -56,7 +63,14 @@ test('isPrivateAddress agrees with PRIVATE_IP_RE on plain addresses', () => { }); test('guardSsrf blocks IPv4-mapped and NAT64 loopback/metadata literals', async () => { - for (const u of ['http://[::7f00:1]/', 'http://[::127.0.0.1]/', 'http://[::ffff:7f00:1]/', 'http://[::ffff:a9fe:a9fe]/', 'http://[64:ff9b::7f00:1]/', 'http://[::1]/']) { + for (const u of [ + 'http://[::7f00:1]/', + 'http://[::127.0.0.1]/', + 'http://[::ffff:7f00:1]/', + 'http://[::ffff:a9fe:a9fe]/', + 'http://[64:ff9b::7f00:1]/', + 'http://[::1]/', + ]) { const r = await guardSsrf(u); assert.match(r.error || '', /private address/, `${u} should be blocked`); } @@ -119,34 +133,40 @@ test('fetchJSON pins the IP and preserves the Host header', async () => { looks public (which a stale re-resolution could otherwise exploit). ── */ const dns = require('node:dns').promises; -test('guardSsrf resolves a public hostname and returns the IP to pin', async (t) => { +test('guardSsrf resolves a public hostname and returns the IP to pin', async t => { t.mock.method(dns, 'lookup', async () => ({ address: '93.184.216.34', family: 4 })); assert.deepEqual(await guardSsrf('http://example.com/path'), { error: null, ip: '93.184.216.34' }); }); -test('guardSsrf blocks a public-looking name that resolves to a private IP', async (t) => { +test('guardSsrf blocks a public-looking name that resolves to a private IP', async t => { t.mock.method(dns, 'lookup', async () => ({ address: '192.168.1.50', family: 4 })); const r = await guardSsrf('http://sneaky.example.com/'); assert.equal(r.ip, null); assert.match(r.error, /private IP/); }); -test('guardSsrf blocks a name that resolves to the link-local metadata IP', async (t) => { +test('guardSsrf blocks a name that resolves to the link-local metadata IP', async t => { t.mock.method(dns, 'lookup', async () => ({ address: '169.254.169.254', family: 4 })); const r = await guardSsrf('http://rebind.example.com/'); assert.equal(r.ip, null); assert.match(r.error, /private IP/); }); -test('guardSsrf blocks when the hostname cannot be resolved', async (t) => { - t.mock.method(dns, 'lookup', async () => { throw new Error('ENOTFOUND'); }); +test('guardSsrf blocks when the hostname cannot be resolved', async t => { + t.mock.method(dns, 'lookup', async () => { + throw new Error('ENOTFOUND'); + }); const r = await guardSsrf('http://nxdomain.example.com/'); assert.equal(r.ip, null); assert.match(r.error, /could not be resolved/); }); test('shouldSkipTls returns false unless skipTlsVerify is explicitly true', () => { - for (const cfg of [{ settings: {} }, { settings: { server: {} } }, { settings: { server: { skipTlsVerify: 'true' } } }]) + for (const cfg of [ + { settings: {} }, + { settings: { server: {} } }, + { settings: { server: { skipTlsVerify: 'true' } } }, + ]) assert.equal(shouldSkipTls('nas', cfg), false); }); @@ -174,7 +194,7 @@ test('fetchJSON returns the untouched body with opts.raw, and auto-parses withou const parsed = await fetchJSON(`http://127.0.0.1:${port}/metrics`, { timeout: 3000 }); assert.notEqual(typeof parsed.data, 'string'); // auto-parsed into an object const raw = await fetchJSON(`http://127.0.0.1:${port}/metrics`, { raw: true, timeout: 3000 }); - assert.equal(raw.data, metrics); // returned untouched + assert.equal(raw.data, metrics); // returned untouched } finally { server.close(); } diff --git a/api/test/release-workflow.test.js b/api/test/release-workflow.test.js index 97eae79e..3677350b 100644 --- a/api/test/release-workflow.test.js +++ b/api/test/release-workflow.test.js @@ -59,12 +59,21 @@ test('the scan gates the push', () => { assert.equal(built.with.push, false, 'the gate must not publish what it is gating'); assert.equal(built.with.load, true, 'trivy reads it from the local daemon'); assert.equal(built.with.platforms, 'linux/amd64', 'only one platform can be loaded on the runner'); - assert.equal(built.with.tags, byName('Scan the image').with['image-ref'], - 'the scan must read the image this step just built'); - assert.match(String(built.with['build-args']), /APP_VERSION=\$\{\{ steps\.meta\.outputs\.version \}\}/, - 'a different build-arg would build different layers from the ones pushed'); - assert.match(String(byName('Build and push').with['cache-from']), /type=gha/, - 'without the shared cache the amd64 image is built twice over'); + assert.equal( + built.with.tags, + byName('Scan the image').with['image-ref'], + 'the scan must read the image this step just built', + ); + assert.match( + String(built.with['build-args']), + /APP_VERSION=\$\{\{ steps\.meta\.outputs\.version \}\}/, + 'a different build-arg would build different layers from the ones pushed', + ); + assert.match( + String(byName('Build and push').with['cache-from']), + /type=gha/, + 'without the shared cache the amd64 image is built twice over', + ); }); test('the scan fails the job on a high or critical finding', () => { @@ -86,7 +95,9 @@ test('the image is addressed by digest everywhere after the build', () => { test('latest is decided by the semver check, not by looking for a hyphen', () => { const meta = byName('Extract metadata'); - const latest = String(meta.with.tags).split('\n').find(l => l.includes('value=latest')); + const latest = String(meta.with.tags) + .split('\n') + .find(l => l.includes('value=latest')); assert.ok(latest, 'the latest tag rule is gone'); assert.match(latest, /steps\.tag\.outputs\.prerelease == 'false'/); assert.doesNotMatch(latest, /contains\(github\.ref_name/, 'the hyphen heuristic is back'); @@ -125,10 +136,12 @@ test('the checks still run before anything is published', () => { reaching a registry if it slips through anyway. */ test('the release waits for the end-to-end suite', () => { - assert.equal(workflow.jobs.e2e?.uses, './.github/workflows/e2e.yml', - 'the release should call the same e2e workflow, not a copy of it'); - assert.deepEqual([].concat(job.needs || []), ['e2e'], - 'publishing must depend on the browser tests'); + assert.equal( + workflow.jobs.e2e?.uses, + './.github/workflows/e2e.yml', + 'the release should call the same e2e workflow, not a copy of it', + ); + assert.deepEqual([].concat(job.needs || []), ['e2e'], 'publishing must depend on the browser tests'); }); test('the e2e workflow can be called, and still runs on its own', () => { @@ -138,6 +151,8 @@ test('the e2e workflow can be called, and still runs on its own', () => { for (const t of ['workflow_call', 'pull_request', 'push', 'workflow_dispatch']) { assert.ok(t in triggers, `the e2e workflow lost its ${t} trigger`); } - assert.ok(triggers.pull_request.paths?.length, - 'the pull request run should be filtered by path, or every docs change pays for it'); + assert.ok( + triggers.pull_request.paths?.length, + 'the pull request run should be filtered by path, or every docs change pays for it', + ); }); diff --git a/api/test/request-deadline.test.js b/api/test/request-deadline.test.js index dd409e31..5fc07f8f 100644 --- a/api/test/request-deadline.test.js +++ b/api/test/request-deadline.test.js @@ -28,9 +28,19 @@ const BUDGET = 1000; const TOLERANCE = 900; const servers = []; -after(() => { for (const s of servers) { try { s.close(); } catch {} } }); +after(() => { + for (const s of servers) { + try { + s.close(); + } catch {} + } +}); -const listen = s => new Promise(r => { servers.push(s); s.listen(0, '127.0.0.1', () => r(s.address().port)); }); +const listen = s => + new Promise(r => { + servers.push(s); + s.listen(0, '127.0.0.1', () => r(s.address().port)); + }); /* Accepts the connection and then says nothing at all. Asked for over https, the TLS handshake never completes, which is the phase the socket timer does @@ -66,15 +76,20 @@ test('both honour the same budget', async () => { const port = await stalledPort(); const ping = await timed(() => pingUnchecked(`https://127.0.0.1:${port}/`, BUDGET)); const fetch = await timed(() => fetchUnchecked(`https://127.0.0.1:${port}/`, { timeout: BUDGET })); - assert.ok(Math.abs(ping.ms - fetch.ms) < TOLERANCE, - `ping took ${ping.ms}ms and fetch took ${fetch.ms}ms for the same budget`); + assert.ok( + Math.abs(ping.ms - fetch.ms) < TOLERANCE, + `ping took ${ping.ms}ms and fetch took ${fetch.ms}ms for the same budget`, + ); }); /* A HEAD answered with 405 is retried as GET. A per-request timer would let a stalled host spend the budget twice. */ test('a HEAD that is refused does not buy a second budget', async () => { const srv = http.createServer((req, res) => { - if (req.method === 'HEAD') { res.writeHead(405); res.end(); } + if (req.method === 'HEAD') { + res.writeHead(405); + res.end(); + } /* GET: never answer. */ }); const port = await listen(srv); @@ -86,7 +101,12 @@ test('a HEAD that is refused does not buy a second budget', async () => { /* ── the paths that must still work ───────────────────────────────────────── */ test('a healthy server answers immediately', async () => { - const port = await listen(http.createServer((_, res) => { res.writeHead(200); res.end(); })); + const port = await listen( + http.createServer((_, res) => { + res.writeHead(200); + res.end(); + }), + ); const { result, ms } = await timed(() => pingUnchecked(`http://127.0.0.1:${port}/`, BUDGET)); assert.deepEqual({ ok: result.ok, status: result.status }, { ok: true, status: 200 }); assert.ok(ms < BUDGET, 'a healthy ping must not wait for the deadline'); @@ -94,8 +114,13 @@ test('a healthy server answers immediately', async () => { test('a server that refuses HEAD is still reached with GET', async () => { const srv = http.createServer((req, res) => { - if (req.method === 'HEAD') { res.writeHead(405); res.end(); return; } - res.writeHead(200); res.end(); + if (req.method === 'HEAD') { + res.writeHead(405); + res.end(); + return; + } + res.writeHead(200); + res.end(); }); const port = await listen(srv); const { result } = await timed(() => pingUnchecked(`http://127.0.0.1:${port}/`, BUDGET)); @@ -103,7 +128,12 @@ test('a server that refuses HEAD is still reached with GET', async () => { }); test('a server error is reported rather than treated as healthy', async () => { - const port = await listen(http.createServer((_, res) => { res.writeHead(503); res.end(); })); + const port = await listen( + http.createServer((_, res) => { + res.writeHead(503); + res.end(); + }), + ); const { result } = await timed(() => pingUnchecked(`http://127.0.0.1:${port}/`, BUDGET)); assert.equal(result.ok, false); assert.equal(result.status, 503); @@ -120,7 +150,12 @@ test('a closed port fails fast rather than waiting out the budget', async () => test('only one result is delivered even when the deadline races a response', async () => { /* Answers right at the budget, so the timer and the response may both fire. */ const srv = http.createServer((_, res) => { - setTimeout(() => { try { res.writeHead(200); res.end(); } catch {} }, BUDGET); + setTimeout(() => { + try { + res.writeHead(200); + res.end(); + } catch {} + }, BUDGET); }); const port = await listen(srv); const { result } = await timed(() => pingUnchecked(`http://127.0.0.1:${port}/`, BUDGET)); diff --git a/api/test/secret-scope-integration.test.js b/api/test/secret-scope-integration.test.js index 3e9e11e5..7f274822 100644 --- a/api/test/secret-scope-integration.test.js +++ b/api/test/secret-scope-integration.test.js @@ -11,7 +11,7 @@ const path = require('node:path'); process.env.ALLOW_PRIVATE_IPS = 'true'; process.env.WIDGETS_PATH = path.join(__dirname, '../../ui/widgets'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const _tmp = tmpDir('scope'); process.env.CONFIG_PATH = path.join(_tmp, 'apps.json'); @@ -38,24 +38,35 @@ function stub(sink) { }); } const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); -const close = s => new Promise(r => { s.closeAllConnections?.(); s.close(r); }); +const close = s => + new Promise(r => { + s.closeAllConnections?.(); + s.close(r); + }); before(async () => { - realSrv = stub(realSeen); realBase = await listen(realSrv); - evilSrv = stub(evilSeen); evilBase = await listen(evilSrv); + realSrv = stub(realSeen); + realBase = await listen(realSrv); + evilSrv = stub(evilSeen); + evilBase = await listen(evilSrv); saveConfig({ items: [ { - id: 'app1', type: 'app', name: 'App', + id: 'app1', + type: 'app', + name: 'App', badge: { - enabled: true, url: `${realBase}/api`, + enabled: true, + url: `${realBase}/api`, headers: [{ key: 'X-Api-Key', value: SECRET_VALUE, secret: true }], params: [{ key: 'mode', value: 'full', secret: false }], }, }, { - id: 'w1', type: 'widget', widgetType: 'books', + id: 'w1', + type: 'widget', + widgetType: 'books', widgetConfig: { provider: 'audiobookshelf', absUrl: realBase, absKey: SECRET_VALUE }, }, ], @@ -66,20 +77,38 @@ before(async () => { base = await listen(server); }); -after(async () => { await close(server); await close(realSrv); await close(evilSrv); }); +after(async () => { + await close(server); + await close(realSrv); + await close(evilSrv); +}); function post(pathname, body) { const data = JSON.stringify(body); const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); }); - }); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); r.on('error', reject); r.end(data); }); @@ -92,7 +121,8 @@ const sawSecret = seen => seen.some(r => JSON.stringify(r).includes(SECRET_VALUE test('badge-proxy sends the stored credential to the saved destination', async () => { realSeen.length = 0; const r = await post('/api/badge-proxy', { - itemId: 'app1', url: `${realBase}/api`, + itemId: 'app1', + url: `${realBase}/api`, headers: [{ key: 'X-Api-Key', secret: true }], params: [{ key: 'mode', value: 'full', secret: false }], }); @@ -103,7 +133,8 @@ test('badge-proxy sends the stored credential to the saved destination', async ( test('badge-proxy does not send the stored credential to a caller-chosen host', async () => { evilSeen.length = 0; await post('/api/badge-proxy', { - itemId: 'app1', url: `${evilBase}/collect`, + itemId: 'app1', + url: `${evilBase}/collect`, headers: [{ key: 'X-Api-Key', secret: true }], params: [{ key: 'mode', value: 'full', secret: false }], }); @@ -112,9 +143,11 @@ test('badge-proxy does not send the stored credential to a caller-chosen host', }); test('badge-proxy does not leak via a changed non-secret param either', async () => { - evilSeen.length = 0; realSeen.length = 0; + evilSeen.length = 0; + realSeen.length = 0; await post('/api/badge-proxy', { - itemId: 'app1', url: `${realBase}/api`, + itemId: 'app1', + url: `${realBase}/api`, headers: [{ key: 'X-Api-Key', secret: true }], params: [{ key: 'mode', value: 'CHANGED', secret: false }], }); @@ -126,7 +159,8 @@ test('badge-proxy does not leak via a changed non-secret param either', async () test('widget-options sends the stored credential to the saved destination', async () => { realSeen.length = 0; await post('/api/widget-options/w1', { - widgetType: 'books', endpoint: 'lists', + widgetType: 'books', + endpoint: 'lists', widgetConfig: { provider: 'audiobookshelf', absUrl: realBase }, }); assert.ok(sawSecret(realSeen), 'a normal fetch must still use the stored credential'); @@ -135,7 +169,8 @@ test('widget-options sends the stored credential to the saved destination', asyn test('widget-options does not send the stored credential to a caller-chosen host', async () => { evilSeen.length = 0; await post('/api/widget-options/w1', { - widgetType: 'books', endpoint: 'lists', + widgetType: 'books', + endpoint: 'lists', widgetConfig: { provider: 'audiobookshelf', absUrl: evilBase }, }); assert.ok(!sawSecret(evilSeen), `the stored credential leaked: ${JSON.stringify(evilSeen)}`); @@ -144,7 +179,8 @@ test('widget-options does not send the stored credential to a caller-chosen host test('widget-options ignores an id that is not saved', async () => { evilSeen.length = 0; await post('/api/widget-options/__preview__', { - widgetType: 'books', endpoint: 'lists', + widgetType: 'books', + endpoint: 'lists', widgetConfig: { provider: 'audiobookshelf', absUrl: evilBase }, }); assert.ok(!sawSecret(evilSeen)); diff --git a/api/test/secret-scope.test.js b/api/test/secret-scope.test.js index ccb656be..6bd07110 100644 --- a/api/test/secret-scope.test.js +++ b/api/test/secret-scope.test.js @@ -16,8 +16,11 @@ const path = require('node:path'); process.env.WIDGETS_PATH = path.join(__dirname, '../../ui/widgets'); const { - stableEqual, stripWidgetSecrets, widgetConfigMatchesSaved, - rowsMatch, badgeRequestMatchesSaved, + stableEqual, + stripWidgetSecrets, + widgetConfigMatchesSaved, + rowsMatch, + badgeRequestMatchesSaved, } = require('../src/secret-scope'); const { getRegistry } = require('../src/widgets'); @@ -136,7 +139,10 @@ test('a changed non-secret param value does not restore', () => { }); test('rowsMatch treats a reorder as a change', () => { - const a = [{ key: 'a', value: '1', secret: false }, { key: 'b', value: '2', secret: false }]; + const a = [ + { key: 'a', value: '1', secret: false }, + { key: 'b', value: '2', secret: false }, + ]; assert.equal(rowsMatch([a[1], a[0]], a), false); assert.equal(rowsMatch(a, a), true); }); diff --git a/api/test/security-doc.test.js b/api/test/security-doc.test.js index d2df4f32..090f88dc 100644 --- a/api/test/security-doc.test.js +++ b/api/test/security-doc.test.js @@ -43,10 +43,13 @@ test('the scan reads the page', () => { test('the page only warns about headers the code actually reads', () => { const readsXff = /x-forwarded-for/i.test(auth + router); const warnsXff = /`X-Forwarded-For`(?!\s+is not used)/.test(doc); - assert.equal(warnsXff, readsXff, + assert.equal( + warnsXff, + readsXff, readsXff ? 'the code reads X-Forwarded-For; docs/security.md should say so' - : 'nothing reads X-Forwarded-For; docs/security.md must not warn about it'); + : 'nothing reads X-Forwarded-For; docs/security.md must not warn about it', + ); }); /* ── the login limit ──────────────────────────────────────────────────────── */ @@ -57,8 +60,11 @@ test('the documented login limit is the one in the code', () => { const m = /LOGIN_MAX\s*=\s*(\d+)[\s,]+LOGIN_WINDOW_MS\s*=\s*(\d+)\s*\*\s*60\s*\*\s*1000/.exec(auth); assert.ok(m, 'LOGIN_MAX / LOGIN_WINDOW_MS not found in auth.js'); const [, max, minutes] = m; - assert.match(doc, new RegExp(`${max} attempts per IP per ${minutes} minutes`), - `the code allows ${max} attempts per ${minutes} minutes`); + assert.match( + doc, + new RegExp(`${max} attempts per IP per ${minutes} minutes`), + `the code allows ${max} attempts per ${minutes} minutes`, + ); }); test('the documented session lifetime is the one in the code', () => { @@ -73,15 +79,13 @@ test('the documented session lifetime is the one in the code', () => { /* The reason matters as much as the fact. nginx overwriting the header is true of the shipped container only; the loopback check is what holds regardless. */ test('the page explains the client address by the check that enforces it', () => { - assert.match(router, /LOOPBACK\.has\(peer\)/, - 'getIp no longer gates X-Real-IP on a loopback peer'); + assert.match(router, /LOOPBACK\.has\(peer\)/, 'getIp no longer gates X-Real-IP on a loopback peer'); const at = doc.indexOf('`X-Real-IP`'); assert.notEqual(at, -1, 'the page does not mention X-Real-IP'); /* Within the paragraph that makes the claim, not anywhere on the page: the blocked-range table names loopback too. */ const para = doc.slice(at, doc.indexOf('\n\n', at)); - assert.match(para, /loopback/i, - 'the page must say the header is only believed over loopback'); + assert.match(para, /loopback/i, 'the page must say the header is only believed over loopback'); }); /* ── the password hashing profiles ────────────────────────────────────────── */ @@ -93,12 +97,10 @@ test('every hash profile the code offers is documented, and no others', () => { const section = doc.slice(doc.indexOf('PASSWORD_HASH_MEMORY')); const documented = [...section.matchAll(/`(\d+mib)`/g)].map(m => m[1]); - assert.deepEqual([...new Set(documented)].sort(), inCode, - 'the profile list in docs/security.md is out of date'); + assert.deepEqual([...new Set(documented)].sort(), inCode, 'the profile list in docs/security.md is out of date'); const dflt = /const DEFAULT_PROFILE = '([\w]+)'/.exec(auth)[1]; - assert.match(section, new RegExp(`\`${dflt}\` \\(default\\)`), - `${dflt} is the default in the code`); + assert.match(section, new RegExp(`\`${dflt}\` \\(default\\)`), `${dflt} is the default in the code`); }); /* ── the container ────────────────────────────────────────────────────────── */ @@ -113,19 +115,16 @@ test('the Compose hardening the page claims is in the Compose file', () => { /* The Compose file sets no `user:`. Saying it runs the API as a non-root user points a reader at the wrong file when they go to verify it. */ test('the page credits the right layer for the process users', () => { - assert.ok(!/^\s*user:/m.test(compose), - 'the Compose file now sets a user; the page should say so'); + assert.ok(!/^\s*user:/m.test(compose), 'the Compose file now sets a user; the page should say so'); assert.match(supervisord, /^user=node$/m, 'the API should run as node'); - assert.match(doc, /supervisord runs as root/, - 'the page must say which processes run as whom, and where that is set'); + assert.match(doc, /supervisord runs as root/, 'the page must say which processes run as whom, and where that is set'); assert.match(doc, /`node` user/); }); /* ── behaviour a reader has to know before deploying ──────────────────────── */ test('the page documents that auth with no password is treated as off', () => { - assert.match(auth, /auth\?\.enabled && auth\?\.passwordHash/, - 'authActive no longer requires a stored password'); + assert.match(auth, /auth\?\.enabled && auth\?\.passwordHash/, 'authActive no longer requires a stored password'); assert.match(doc, /Authentication is only in force when a password is stored/); }); diff --git a/api/test/semver.test.js b/api/test/semver.test.js index ba4a68a3..76a48e3b 100644 --- a/api/test/semver.test.js +++ b/api/test/semver.test.js @@ -69,8 +69,14 @@ test('missing segments count as zero', () => { and more convincing than inventing cases. */ test('the ordering from the specification holds', () => { const chain = [ - '1.0.0-alpha', '1.0.0-alpha.1', '1.0.0-alpha.beta', '1.0.0-beta', - '1.0.0-beta.2', '1.0.0-beta.11', '1.0.0-rc.1', '1.0.0', + '1.0.0-alpha', + '1.0.0-alpha.1', + '1.0.0-alpha.beta', + '1.0.0-beta', + '1.0.0-beta.2', + '1.0.0-beta.11', + '1.0.0-rc.1', + '1.0.0', ]; for (let i = 1; i < chain.length; i++) { assert.equal(compareVersions(chain[i], chain[i - 1]), 1, `${chain[i]} should follow ${chain[i - 1]}`); diff --git a/api/test/session-revocation.test.js b/api/test/session-revocation.test.js index 7758ad39..4a368eb1 100644 --- a/api/test/session-revocation.test.js +++ b/api/test/session-revocation.test.js @@ -17,7 +17,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('revoke'), 'apps.json'); const { test, before, after, beforeEach } = require('node:test'); @@ -36,7 +36,12 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); async function enableAuth() { saveConfig({ @@ -49,18 +54,28 @@ beforeEach(() => saveConfig({ items: [], settings: {} })); function req(method, pathname, cookie) { const u = new URL(base + pathname); return new Promise((resolve, reject) => { - const r = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method, - headers: { 'Content-Type': 'application/json', 'Content-Length': 2, Origin: base, Cookie: cookie || '' }, - }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => { - let j = null; - try { j = JSON.parse(b); } catch {} - resolve({ status: res.statusCode, body: j, setCookie: res.headers['set-cookie'] || [] }); - }); - }); + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method, + headers: { 'Content-Type': 'application/json', 'Content-Length': 2, Origin: base, Cookie: cookie || '' }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j, setCookie: res.headers['set-cookie'] || [] }); + }); + }, + ); r.on('error', reject); r.end('{}'); }); @@ -109,8 +124,11 @@ test('the caller is handed a working replacement session', async () => { const r = await req('POST', '/api/auth/revoke-sessions', cookieFor(secret())); const fresh = tokenFrom(r.setCookie); assert.ok(fresh, 'the response must set a new session cookie'); - assert.equal((await req('GET', '/api/config', 'ds=' + fresh)).status, 200, - 'the person who pressed the button must stay signed in'); + assert.equal( + (await req('GET', '/api/config', 'ds=' + fresh)).status, + 200, + 'the person who pressed the button must stay signed in', + ); }); test('the replacement session is a different one, not the old token reissued', async () => { @@ -159,10 +177,24 @@ test('a cross-origin request cannot revoke', async () => { const before = secret(); const u = new URL(base + '/api/auth/revoke-sessions'); const r = await new Promise((resolve, reject) => { - const q = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': 2, Origin: 'http://evil.example', Cookie: cookieFor(before) }, - }, res => { let b = ''; res.on('data', c => { b += c; }); res.on('end', () => resolve({ status: res.statusCode })); }); + const q = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': 2, + Origin: 'http://evil.example', + Cookie: cookieFor(before), + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve({ status: res.statusCode })); + }, + ); q.on('error', reject); q.end('{}'); }); @@ -195,10 +227,24 @@ test('changing the password still signs other devices out', async () => { const u = new URL(base + '/api/auth/set-password'); const data = JSON.stringify({ password: 'a-new-password' }); await new Promise((resolve, reject) => { - const q = http.request({ - hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base, Cookie: old }, - }, res => { res.resume(); res.on('end', resolve); }); + const q = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + Origin: base, + Cookie: old, + }, + }, + res => { + res.resume(); + res.on('end', resolve); + }, + ); q.on('error', reject); q.end(data); }); diff --git a/api/test/session-secret.test.js b/api/test/session-secret.test.js index 477b3201..e11b57ff 100644 --- a/api/test/session-secret.test.js +++ b/api/test/session-secret.test.js @@ -13,7 +13,7 @@ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('secret'), 'apps.json'); const { test } = require('node:test'); diff --git a/api/test/stats-speed-data.test.js b/api/test/stats-speed-data.test.js index 26ac801a..73d99b37 100644 --- a/api/test/stats-speed-data.test.js +++ b/api/test/stats-speed-data.test.js @@ -15,7 +15,10 @@ function ctxFor(network, response) { endpoint: 'speed', config: { network }, normalizeBase, - fetchJSON: async (url, opts) => { calls.push({ url, opts }); return response; }, + fetchJSON: async (url, opts) => { + calls.push({ url, opts }); + return response; + }, ...errorParts(), }, calls, @@ -49,25 +52,22 @@ test('myspeed sends the x-password header only when a password is set', async () }); test('myspeed surfaces a 401 as a credentials error', async () => { - const { ctx } = ctxFor( - { enabled: true, url: 'http://ms:5216', provider: 'myspeed' }, - { status: 401, data: null }, - ); + const { ctx } = ctxFor({ enabled: true, url: 'http://ms:5216', provider: 'myspeed' }, { status: 401, data: null }); await assert.rejects(dataFn(ctx), /MySpeed returned 401, check password/); }); test('myspeed reports an empty result', async () => { - const { ctx } = ctxFor( - { enabled: true, url: 'http://ms:5216', provider: 'myspeed' }, - { status: 200, data: [] }, - ); + const { ctx } = ctxFor({ enabled: true, url: 'http://ms:5216', provider: 'myspeed' }, { status: 200, data: [] }); await assert.rejects(dataFn(ctx), /No result from MySpeed/); }); test('speedtest-tracker returns the latest row shaped for the widget', async () => { const { ctx } = ctxFor( { enabled: true, url: 'http://stt', provider: 'speedtest-tracker' }, - { status: 200, data: { data: { id: 7, download: 500, upload: 50, ping: 9, failed: false, created_at: '2026-02-02T00:00:00Z' } } }, + { + status: 200, + data: { data: { id: 7, download: 500, upload: 50, ping: 9, failed: false, created_at: '2026-02-02T00:00:00Z' } }, + }, ); const r = await dataFn(ctx); assert.deepEqual(r, { download: 500, upload: 50, ping: 9, failed: false, ts: '2026-02-02T00:00:00Z' }); @@ -96,7 +96,9 @@ test('a thrown fetch propagates rather than becoming a successful result', async endpoint: 'speed', config: { network: { enabled: true, url: 'http://ms', provider: 'myspeed' } }, normalizeBase, - fetchJSON: async () => { throw new Error('connect ECONNREFUSED'); }, + fetchJSON: async () => { + throw new Error('connect ECONNREFUSED'); + }, }; await assert.rejects(dataFn(ctx), /connect ECONNREFUSED/); }); diff --git a/api/test/supervisord-fatal.test.js b/api/test/supervisord-fatal.test.js index b7152b40..f80e63bb 100644 --- a/api/test/supervisord-fatal.test.js +++ b/api/test/supervisord-fatal.test.js @@ -34,8 +34,11 @@ const listener = read('scripts/exit-on-fatal.py'); test('an event listener is registered for FATAL', () => { assert.match(supervisord, /^\[eventlistener:exit-on-fatal\]$/m); - assert.match(supervisord, /^events=PROCESS_STATE_FATAL$/m, - 'without this event the listener never hears about a program giving up'); + assert.match( + supervisord, + /^events=PROCESS_STATE_FATAL$/m, + 'without this event the listener never hears about a program giving up', + ); }); test('the listener starts, and restarts if it dies', () => { @@ -67,8 +70,7 @@ test('the listener writes a marker and the entrypoint reads the same one', () => const inEntrypoint = /SUPERVISOR_FATAL_MARKER:-([^}]+)\}/.exec(entrypoint); assert.ok(inListener, 'the listener does not define a marker path'); assert.ok(inEntrypoint, 'the entrypoint does not read one'); - assert.equal(inListener[1], inEntrypoint[1].trim(), - 'the two must agree or the failure never reaches the exit code'); + assert.equal(inListener[1], inEntrypoint[1].trim(), 'the two must agree or the failure never reaches the exit code'); }); test('the entrypoint exits non-zero when the marker is present', () => { @@ -78,8 +80,10 @@ test('the entrypoint exits non-zero when the marker is present', () => { test('the marker is cleared at startup and after being read', () => { /* A marker left behind would fail every subsequent start. */ - assert.ok((entrypoint.match(/rm -f "\$MARKER"/g) || []).length >= 2, - 'the marker must be cleared before running and after reading it'); + assert.ok( + (entrypoint.match(/rm -f "\$MARKER"/g) || []).length >= 2, + 'the marker must be cleared before running and after reading it', + ); }); /* ── shutdown still works ─────────────────────────────────────────────────── */ @@ -89,14 +93,16 @@ test('the marker is cleared at startup and after being read', () => { without forwarding, a `docker stop` would kill the shell and leave supervisord running until the kill timeout, with nothing shut down cleanly. */ test('signals are forwarded to supervisord', () => { - assert.match(entrypoint, /trap '[^']*kill -TERM "\$child"[^']*' TERM INT/, - 'docker stop would otherwise not reach supervisord'); + assert.match( + entrypoint, + /trap '[^']*kill -TERM "\$child"[^']*' TERM INT/, + 'docker stop would otherwise not reach supervisord', + ); assert.match(entrypoint, /wait "\$child"/); }); test('the entrypoint reports the real exit code when nothing failed', () => { - assert.match(entrypoint, /exit "\$status"/, - 'an ordinary shutdown must not be reported as a failure'); + assert.match(entrypoint, /exit "\$status"/, 'an ordinary shutdown must not be reported as a failure'); }); /* ── the image ships what the config names ────────────────────────────────── */ @@ -106,8 +112,10 @@ test('the listener script is copied into the image at the path the config uses', assert.ok(m, 'the listener has no command'); const [, interpreter, script] = m; assert.ok(dockerfile.includes(`${script}`), `${script} is not copied into the image`); - assert.ok(dockerfile.includes(interpreter), - `${interpreter} is never asserted to exist, so the listener could fail to start silently`); + assert.ok( + dockerfile.includes(interpreter), + `${interpreter} is never asserted to exist, so the listener could fail to start silently`, + ); }); test('the script is valid Python, checked at build time', () => { @@ -131,17 +139,20 @@ test('the listener only acts on FATAL', () => { test('the listener is not excluded from the build context', () => { const ignore = read('.dockerignore'); - assert.match(ignore, /^!scripts\/exit-on-fatal\.py$/m, - 'without this the COPY fails: the file is not in the build context'); - assert.match(ignore, /^scripts\/\*\*$/m, - 'a bare `scripts` prunes the directory and the re-include is ignored'); - assert.doesNotMatch(ignore, /^scripts$/m, - 'the bare form would silently defeat the exception above it'); + assert.match( + ignore, + /^!scripts\/exit-on-fatal\.py$/m, + 'without this the COPY fails: the file is not in the build context', + ); + assert.match(ignore, /^scripts\/\*\*$/m, 'a bare `scripts` prunes the directory and the re-include is ignored'); + assert.doesNotMatch(ignore, /^scripts$/m, 'the bare form would silently defeat the exception above it'); }); test('the exception comes after the exclusion it overrides', () => { /* Docker applies every pattern in order and the last match wins. */ - const lines = read('.dockerignore').split('\n').map(l => l.trim()); + const lines = read('.dockerignore') + .split('\n') + .map(l => l.trim()); const excluded = lines.indexOf('scripts/**'); const included = lines.indexOf('!scripts/exit-on-fatal.py'); assert.ok(excluded !== -1 && included !== -1); @@ -151,6 +162,9 @@ test('the exception comes after the exclusion it overrides', () => { test('the rest of scripts/ is still kept out of the image', () => { const ignore = read('.dockerignore'); const exceptions = ignore.split('\n').filter(l => l.trim().startsWith('!scripts/')); - assert.deepEqual(exceptions.map(l => l.trim()), ['!scripts/exit-on-fatal.py'], - 'build tooling has no place in the image'); + assert.deepEqual( + exceptions.map(l => l.trim()), + ['!scripts/exit-on-fatal.py'], + 'build tooling has no place in the image', + ); }); diff --git a/api/test/svg-sanitize-fuzz.test.js b/api/test/svg-sanitize-fuzz.test.js index 65165ee1..980e3387 100644 --- a/api/test/svg-sanitize-fuzz.test.js +++ b/api/test/svg-sanitize-fuzz.test.js @@ -1,4 +1,3 @@ -const path = require('node:path'); const { test } = require('node:test'); const assert = require('node:assert/strict'); const { sanitizeSvg } = require('../src/svg-sanitize'); @@ -37,13 +36,50 @@ function mulberry32(a) { /* Fragments chosen to collide: tag openers with no closer, separators, quote characters, and the payloads the sanitizer exists to remove. */ const TOKENS = [ - '', '', '', '', - '/', '>', '=', ' ', '\t', '\n', - 'onload=', 'onerror=', 'xlink:href=', 'href=', 'src=', 'style=', 'd=', 'fill=', - '"alert(1)"', '"M0 0"', '"x:javascript:y"', "'", '"', - '', '', '', - 'url(javascript:x)', '@import url(evil)', 'expression(alert(1))', - '&', '<', '<', '<', '#', 'a', + '', + '', + '', + '', + '/', + '>', + '=', + ' ', + '\t', + '\n', + 'onload=', + 'onerror=', + 'xlink:href=', + 'href=', + 'src=', + 'style=', + 'd=', + 'fill=', + '"alert(1)"', + '"M0 0"', + '"x:javascript:y"', + "'", + '"', + '', + '', + '', + 'url(javascript:x)', + '@import url(evil)', + 'expression(alert(1))', + '&', + '<', + '<', + '<', + '#', + 'a', ]; const STYLE_BODY = /' - + '' - + ''; + const svg = + '' + + '' + + '' + + ''; const noise = ['<', '>', '/', '"', "'", '=', ' ', 'onload=x', ' const input = svg.slice(0, at) + insert + svg.slice(at); const out = sanitizeSvg(input); const why = unsafeReason(out); - assert.equal(why, null, `${why} survived after inserting ${JSON.stringify(insert)} at ${at}\n out: ${JSON.stringify(out)}`); + assert.equal( + why, + null, + `${why} survived after inserting ${JSON.stringify(insert)} at ${at}\n out: ${JSON.stringify(out)}`, + ); } }); diff --git a/api/test/svg-sanitize.test.js b/api/test/svg-sanitize.test.js index c4c39de2..143fbc2d 100644 --- a/api/test/svg-sanitize.test.js +++ b/api/test/svg-sanitize.test.js @@ -1,4 +1,3 @@ -const path = require('node:path'); const { test } = require('node:test'); const assert = require('node:assert/strict'); const { sanitizeSvg } = require('../src/svg-sanitize'); @@ -40,7 +39,9 @@ test('safe elements and attributes are preserved', () => { }); test('dangerous CSS in '); + const out = sanitizeSvg( + '', + ); assert.doesNotMatch(out, /@import/i); assert.doesNotMatch(out, /javascript:/i); }); @@ -118,7 +119,9 @@ test('legacy script protocols are scrubbed from CSS', () => { test('legitimate url(#id) references are preserved', () => { const attr = sanitizeSvg(''); assert.match(attr, /url\(#grad\)/); - const grad = sanitizeSvg(''); + const grad = sanitizeSvg( + '', + ); assert.match(grad, /fill="url\(#g\)"/); assert.match(grad, / { /* Truncating a known-good document at every offset should never produce something dangerous. Cheap to run and covers cut points nobody would pick. */ test('no truncation of a realistic icon produces a handler or a script', () => { - const svg = '' - + '' - + ''; + const svg = + '' + + '' + + ''; for (let i = 0; i <= svg.length; i++) { const out = sanitizeSvg(svg.slice(0, i)); assert.doesNotMatch(out, /\son\w+\s*=/i, `handler at cut ${i}: ${out}`); diff --git a/api/test/tmp-hygiene.test.js b/api/test/tmp-hygiene.test.js index bc076066..39a95eaa 100644 --- a/api/test/tmp-hygiene.test.js +++ b/api/test/tmp-hygiene.test.js @@ -38,23 +38,32 @@ test('the scan sees the suite', () => { test('no test creates a temporary directory of its own', () => { const offenders = files.filter(f => /mkdtemp/.test(read(f))); - assert.deepEqual(offenders, [], - `Use tmpDir() from test-support/tmp.js, which removes the directory on exit:\n ${offenders.join('\n ')}`); + assert.deepEqual( + offenders, + [], + `Use tmpDir() from test-support/tmp.js, which removes the directory on exit:\n ${offenders.join('\n ')}`, + ); }); /* The half that actually caused a failure. A fixed path is shared between runs and between developers, and is only safe while nothing writes to it. */ test('no test names a fixed path under /tmp', () => { const offenders = files.filter(f => /['"]\/tmp\//.test(read(f))); - assert.deepEqual(offenders, [], - `A fixed /tmp path persists between runs. Use tmpPath() from test-support/tmp.js:\n ${offenders.join('\n ')}`); + assert.deepEqual( + offenders, + [], + `A fixed /tmp path persists between runs. Use tmpPath() from test-support/tmp.js:\n ${offenders.join('\n ')}`, + ); }); /* os.tmpdir() joined by hand is the same hazard wearing a portable hat. */ test('no test builds its own path from os.tmpdir()', () => { const offenders = files.filter(f => /os\.tmpdir\(\)/.test(read(f))); - assert.deepEqual(offenders, [], - `Use tmpDir() or tmpPath() rather than composing a path from os.tmpdir():\n ${offenders.join('\n ')}`); + assert.deepEqual( + offenders, + [], + `Use tmpDir() or tmpPath() rather than composing a path from os.tmpdir():\n ${offenders.join('\n ')}`, + ); }); /* The guarantee rests entirely on the helper removing what it made. */ @@ -70,6 +79,8 @@ test('the helper registers cleanup for every directory it creates', () => { the module that reads it. A hook would run far too late. */ test('cleanup does not depend on a test hook', () => { const src = fs.readFileSync(path.join(testDir, HELPER), 'utf8'); - assert.ok(!/require\('node:test'\)/.test(src), - 'the helper must not need the test runner; it is used at module scope'); + assert.ok( + !/require\('node:test'\)/.test(src), + 'the helper must not need the test runner; it is used at module scope', + ); }); diff --git a/api/test/unknown-widget-config.test.js b/api/test/unknown-widget-config.test.js index 9cb5db64..9fccb947 100644 --- a/api/test/unknown-widget-config.test.js +++ b/api/test/unknown-widget-config.test.js @@ -32,8 +32,18 @@ const SECRET = 'ORPHAN-SECRET'; function storedConfig() { return { items: [ - { id: 'w1', type: 'widget', widgetType: 'books', widgetConfig: { absUrl: 'https://real.example', absKey: 'KNOWN-SECRET' } }, - { id: 'w2', type: 'widget', widgetType: 'no-such-widget', widgetConfig: { url: 'https://real.example', apiKey: SECRET, nested: { token: 'DEEP-SECRET' } } }, + { + id: 'w1', + type: 'widget', + widgetType: 'books', + widgetConfig: { absUrl: 'https://real.example', absKey: 'KNOWN-SECRET' }, + }, + { + id: 'w2', + type: 'widget', + widgetType: 'no-such-widget', + widgetConfig: { url: 'https://real.example', apiKey: SECRET, nested: { token: 'DEEP-SECRET' } }, + }, { id: 'a1', type: 'app', name: 'App' }, ], }; @@ -92,9 +102,18 @@ test('a supplied config for an unrecognised widget is discarded', () => { test('a new widget of an unrecognised type keeps what was sent', () => { const stored = storedConfig(); const incoming = copy(stored); - incoming.items.push({ id: 'w3', type: 'widget', widgetType: 'no-such-widget', widgetConfig: { url: 'https://new.example' } }); + incoming.items.push({ + id: 'w3', + type: 'widget', + widgetType: 'no-such-widget', + widgetConfig: { url: 'https://new.example' }, + }); preserveAllSecrets(incoming, stored); - assert.deepEqual(find(incoming, 'w3').widgetConfig, { url: 'https://new.example' }, 'nothing stored, so nothing to protect'); + assert.deepEqual( + find(incoming, 'w3').widgetConfig, + { url: 'https://new.example' }, + 'nothing stored, so nothing to protect', + ); }); test('the withheld flag is never written to stored config', () => { diff --git a/api/test/unknown-widget-empty-registry.test.js b/api/test/unknown-widget-empty-registry.test.js index 41ea2fd5..bd68642a 100644 --- a/api/test/unknown-widget-empty-registry.test.js +++ b/api/test/unknown-widget-empty-registry.test.js @@ -8,7 +8,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.WIDGETS_PATH = path.join(tmpDir('nowidgets'), 'missing'); const { test } = require('node:test'); @@ -19,7 +19,12 @@ const { scrubAllSecrets, preserveAllSecrets } = require('../src/config-secrets') const stored = () => ({ items: [ - { id: 'w1', type: 'widget', widgetType: 'books', widgetConfig: { absUrl: 'https://real.example', absKey: 'KNOWN-SECRET' } }, + { + id: 'w1', + type: 'widget', + widgetType: 'books', + widgetConfig: { absUrl: 'https://real.example', absKey: 'KNOWN-SECRET' }, + }, { id: 'w2', type: 'widget', widgetType: 'stats', widgetConfig: { token: 'ANOTHER-SECRET' } }, ], }); diff --git a/api/test/url-scheme-policy.test.js b/api/test/url-scheme-policy.test.js index 8df61de5..59bf9fc4 100644 --- a/api/test/url-scheme-policy.test.js +++ b/api/test/url-scheme-policy.test.js @@ -16,7 +16,6 @@ Checked in two places on purpose. fetchUnchecked and pingUnchecked skip the guard by design and carry URLs from saved config, which can arrive by import, so the rule also sits where the connection is opened. */ -const path = require('node:path'); const { test } = require('node:test'); const assert = require('node:assert/strict'); @@ -113,8 +112,14 @@ test('a rejected URL opens no connection', async () => { const attempts = []; const realHttp = http.request; const realHttps = https.request; - http.request = (...args) => { attempts.push(args[0]); return realHttp.apply(http, args); }; - https.request = (...args) => { attempts.push(args[0]); return realHttps.apply(https, args); }; + http.request = (...args) => { + attempts.push(args[0]); + return realHttp.apply(http, args); + }; + https.request = (...args) => { + attempts.push(args[0]); + return realHttps.apply(https, args); + }; try { await fetchUnchecked('file:///etc/passwd', { timeout: 500 }).catch(() => {}); await pingUnchecked('file:///etc/passwd', 500).catch(() => {}); @@ -127,13 +132,19 @@ test('a rejected URL opens no connection', async () => { }); test('an ordinary http request still works', async () => { - const srv = http.createServer((_, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); }); + const srv = http.createServer((_, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); await new Promise(r => srv.listen(0, '127.0.0.1', r)); try { const r = await fetchUnchecked(`http://127.0.0.1:${srv.address().port}/x`, { timeout: 2000 }); assert.equal(r.status, 200); assert.deepEqual(r.data, { ok: true }); } finally { - await new Promise(r => { srv.closeAllConnections?.(); srv.close(r); }); + await new Promise(r => { + srv.closeAllConnections?.(); + srv.close(r); + }); } }); diff --git a/api/test/version-route.test.js b/api/test/version-route.test.js index baec3241..baf2028c 100644 --- a/api/test/version-route.test.js +++ b/api/test/version-route.test.js @@ -14,7 +14,7 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('ver'), 'apps.json'); const { test, before, after } = require('node:test'); @@ -41,8 +41,11 @@ test('a recent success is not re-fetched', () => { the guard asked whether a version had been found, so this returned true and every request went back out. */ test('a recent failure is not re-fetched either', () => { - assert.equal(shouldFetch({ at: NOW - 1000, checked: true, latest: null }, NOW), false, - 'a failed lookup must be cached like a successful one'); + assert.equal( + shouldFetch({ at: NOW - 1000, checked: true, latest: null }, NOW), + false, + 'a failed lookup must be cached like a successful one', + ); }); test('an expired entry is re-fetched, success or failure', () => { @@ -63,16 +66,26 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; }); -after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); }); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); function version() { const u = new URL(base + '/api/version'); return new Promise((resolve, reject) => { - http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET' }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => resolve(JSON.parse(b))); - }).on('error', reject).end(); + http + .request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET' }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve(JSON.parse(b))); + }) + .on('error', reject) + .end(); }); } diff --git a/api/test/weather-data.test.js b/api/test/weather-data.test.js index c06571c6..1fb06044 100644 --- a/api/test/weather-data.test.js +++ b/api/test/weather-data.test.js @@ -12,16 +12,24 @@ function ctxFor(config, endpoint, reply) { ctx: { config, endpoint, - fetchJSON: async (url) => { calls.push(url); return reply; }, + fetchJSON: async url => { + calls.push(url); + return reply; + }, ...errorParts(), }, }; } -const GEO = { status: 200, data: { results: [ - { name: 'Ottawa', admin1: 'Ontario', country: 'Canada', latitude: 45.42, longitude: -75.7 }, - { name: 'Ottawa', admin1: 'Illinois', country: 'United States', latitude: 41.35, longitude: -88.84 }, -] } }; +const GEO = { + status: 200, + data: { + results: [ + { name: 'Ottawa', admin1: 'Ontario', country: 'Canada', latitude: 45.42, longitude: -75.7 }, + { name: 'Ottawa', admin1: 'Illinois', country: 'United States', latitude: 41.35, longitude: -88.84 }, + ], + }, +}; test('geocode returns one option per match, each carrying lat and lon', async () => { const { ctx, calls } = ctxFor({ cityQuery: 'Ottawa' }, 'geocode', GEO); diff --git a/api/test/widget-catalogs.test.js b/api/test/widget-catalogs.test.js index bcd2e683..d39d1137 100644 --- a/api/test/widget-catalogs.test.js +++ b/api/test/widget-catalogs.test.js @@ -1,4 +1,4 @@ -const fs = require('node:fs'); +const fs = require('node:fs'); const path = require('node:path'); const { test } = require('node:test'); const assert = require('node:assert/strict'); @@ -18,13 +18,13 @@ const WIDGETS_DIR = path.join(__dirname, '..', '..', 'ui', 'widgets'); cannot select would never be read. */ const LANGUAGES = ['en', 'fa', 'zh-Hans', 'es', 'de', 'fr']; -const widgets = fs.readdirSync(WIDGETS_DIR, { withFileTypes: true }) +const widgets = fs + .readdirSync(WIDGETS_DIR, { withFileTypes: true }) .filter(d => d.isDirectory() && fs.existsSync(path.join(WIDGETS_DIR, d.name, 'widget.json'))) .map(d => d.name); const hasCatalog = w => fs.existsSync(path.join(WIDGETS_DIR, w, 'i18n', 'en.json')); -const readCatalog = (w, lang) => - JSON.parse(fs.readFileSync(path.join(WIDGETS_DIR, w, 'i18n', lang + '.json'), 'utf8')); +const readCatalog = (w, lang) => JSON.parse(fs.readFileSync(path.join(WIDGETS_DIR, w, 'i18n', lang + '.json'), 'utf8')); /* Every translatable string in a manifest, as it is written there. */ function manifestStrings(widget) { @@ -52,8 +52,11 @@ for (const widget of widgets.filter(hasCatalog)) { for (const lang of LANGUAGES.filter(l => l !== 'en')) { const file = path.join(WIDGETS_DIR, widget, 'i18n', lang + '.json'); assert.ok(fs.existsSync(file), `${widget} is missing ${lang}.json`); - assert.deepEqual(Object.keys(readCatalog(widget, lang)).sort(), en, - `${widget}/${lang}.json does not have the same keys as en.json`); + assert.deepEqual( + Object.keys(readCatalog(widget, lang)).sort(), + en, + `${widget}/${lang}.json does not have the same keys as en.json`, + ); } }); @@ -81,7 +84,7 @@ for (const widget of widgets.filter(hasCatalog)) { const other = readCatalog(widget, lang); for (const [k, v] of Object.entries(en)) { const want = (v.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); - const got = ((other[k] || '').match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); + const got = ((other[k] || '').match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); assert.deepEqual(got, want, `${widget}/${lang}.json: ${k} does not carry the same placeholders`); } } diff --git a/api/test/widget-data.test.js b/api/test/widget-data.test.js index 960cbf2f..8e9c1caa 100644 --- a/api/test/widget-data.test.js +++ b/api/test/widget-data.test.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('wd'), 'apps.json'); const { test } = require('node:test'); @@ -61,7 +61,9 @@ test('resolveRow ignores a row entry that is not a plain object', () => { test('dataFnContext exposes the resolved row and null without one', () => { const wc = { slots: [{ url: 'a' }, { url: 'b' }] }; - assert.deepEqual(dataFnContext(wc, 'jobs', new URLSearchParams(), async () => {}, { key: 'slots', index: 0 }).row, { url: 'a' }); + assert.deepEqual(dataFnContext(wc, 'jobs', new URLSearchParams(), async () => {}, { key: 'slots', index: 0 }).row, { + url: 'a', + }); assert.equal(dataFnContext(wc, '', new URLSearchParams(), async () => {}).row, null); }); diff --git a/api/test/widget-demo.test.js b/api/test/widget-demo.test.js index b5b81e17..d570909a 100644 --- a/api/test/widget-demo.test.js +++ b/api/test/widget-demo.test.js @@ -41,7 +41,10 @@ test('books body matches the widget contract', () => { assert.equal(typeof bk.finished, 'boolean'); assert.ok(bk.progress === null || (bk.progress >= 0 && bk.progress <= 1)); } - assert.ok(b.books.some(bk => bk.finished), 'at least one finished book'); + assert.ok( + b.books.some(bk => bk.finished), + 'at least one finished book', + ); }); test('weather body matches the widget contract', () => { @@ -53,7 +56,8 @@ test('weather body matches the widget contract', () => { test('github calendar is 53 weeks of 7 days and is stable across calls', () => { const fn = demoFn('github'); - const a = fn(ctx()), b = fn(ctx()); + const a = fn(ctx()), + b = fn(ctx()); assert.equal(a.weeks.length, 53); assert.ok(a.weeks.every(w => w.contributionDays.length === 7)); assert.equal(a.totalContributions, b.totalContributions); diff --git a/api/test/widget-error-route.test.js b/api/test/widget-error-route.test.js index c70d8838..795a6afd 100644 --- a/api/test/widget-error-route.test.js +++ b/api/test/widget-error-route.test.js @@ -11,7 +11,7 @@ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const root = tmpDir('werr-route'); const widgetsDir = path.join(root, 'widgets'); @@ -19,8 +19,7 @@ const widgetsDir = path.join(root, 'widgets'); function writeWidget(name, dataSrc) { const dir = path.join(widgetsDir, name); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'widget.json'), - JSON.stringify({ name, label: name, sizes: ['small'] })); + fs.writeFileSync(path.join(dir, 'widget.json'), JSON.stringify({ name, label: name, sizes: ['small'] })); fs.writeFileSync(path.join(dir, 'data.js'), dataSrc); } writeWidget('vouched', "module.exports = ctx => ctx.fail('Set a Pi-hole password', { kind: ctx.KIND.AUTH });\n"); @@ -53,15 +52,25 @@ before(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); port = server.address().port; }); -after(() => new Promise(r => { server.closeAllConnections?.(); server.close(r); })); +after( + () => + new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }), +); function get(id) { return new Promise((resolve, reject) => { - http.get({ hostname: '127.0.0.1', port, path: `/api/widget-data/${id}` }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); - }).on('error', reject); + http + .get({ hostname: '127.0.0.1', port, path: `/api/widget-data/${id}` }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); + }) + .on('error', reject); }); } diff --git a/api/test/widget-error.test.js b/api/test/widget-error.test.js index 54f8008d..6794464c 100644 --- a/api/test/widget-error.test.js +++ b/api/test/widget-error.test.js @@ -14,7 +14,7 @@ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); process.env.CONFIG_PATH = path.join(tmpDir('werr'), 'apps.json'); const { test } = require('node:test'); @@ -74,8 +74,11 @@ test('ctx.fail throws rather than returning', () => { test('what ctx.fail throws carries the message through to the response', () => { let thrown; - try { ctx().fail('TrueNAS auth failed, check API key', { kind: KIND.AUTH }); } - catch (e) { thrown = e; } + try { + ctx().fail('TrueNAS auth failed, check API key', { kind: KIND.AUTH }); + } catch (e) { + thrown = e; + } assert.ok(thrown); const body = errorBody(thrown); assert.equal(body.error, 'TrueNAS auth failed, check API key'); @@ -91,7 +94,8 @@ test('ctx exposes the kinds so a widget can classify without importing', () => { /* ── every bundled widget reports the same way ───────────────────────────── */ const WIDGETS = path.join(__dirname, '..', '..', 'ui', 'widgets'); -const dataFiles = fs.readdirSync(WIDGETS) +const dataFiles = fs + .readdirSync(WIDGETS) .map(n => [n, path.join(WIDGETS, n, 'data.js')]) .filter(([, p]) => fs.existsSync(p)); @@ -116,8 +120,11 @@ test('no widget returns an error instead of throwing', () => { offenders.push(`${name}: ${m[0].slice(0, 60)}`); } } - assert.deepEqual(offenders, [], - `Whole-response error returned instead of thrown. Use ctx.fail(message) so the failure reaches the poll lifecycle:\n${offenders.join('\n')}`); + assert.deepEqual( + offenders, + [], + `Whole-response error returned instead of thrown. Use ctx.fail(message) so the failure reaches the poll lifecycle:\n${offenders.join('\n')}`, + ); }); /* A caught exception re-reported as a returned field skipped sanitisation @@ -141,11 +148,15 @@ test('no widget forwards a raw caught message to the browser', () => { Scrutiny URL first." and the browser still show "HTTP 503", because a hand-rolled fetch throws on the status and never reads the body. fetchData reads it; a hand-rolled fetch must read `.error` itself. */ -const frontendFiles = fs.readdirSync(WIDGETS, { withFileTypes: true }) +const frontendFiles = fs + .readdirSync(WIDGETS, { withFileTypes: true }) .filter(d => d.isDirectory()) - .flatMap(d => fs.readdirSync(path.join(WIDGETS, d.name)) - .filter(n => n.endsWith('.html')) - .map(n => [`${d.name}/${n}`, path.join(WIDGETS, d.name, n)])); + .flatMap(d => + fs + .readdirSync(path.join(WIDGETS, d.name)) + .filter(n => n.endsWith('.html')) + .map(n => [`${d.name}/${n}`, path.join(WIDGETS, d.name, n)]), + ); test('every widget frontend shows the message the server vouched for', () => { const offenders = []; @@ -156,8 +167,11 @@ test('every widget frontend shows the message the server vouched for', () => { const readsError = /\.error\s*\|\||\berror:\s*\w+\.error\b/.test(src); if (!usesToolbox && !readsError) offenders.push(name); } - assert.deepEqual(offenders, [], - `Widget-data failure reported without the server's message. Use fetchData from widget-toolbox, or read .error off the response body:\n${offenders.join('\n')}`); + assert.deepEqual( + offenders, + [], + `Widget-data failure reported without the server's message. Use fetchData from widget-toolbox, or read .error off the response body:\n${offenders.join('\n')}`, + ); }); /* The other half of the convention. A plain Error is sanitised to "Something @@ -185,6 +199,9 @@ test('no widget reports a user-facing failure with a plain Error', () => { offenders.push(`${name}: throw new Error(${m[1].slice(0, 50)})`); } } - assert.deepEqual(offenders, [], - `Use ctx.fail(message, { kind }) so the message reaches the browser:\n${offenders.join('\n')}`); + assert.deepEqual( + offenders, + [], + `Use ctx.fail(message, { kind }) so the message reaches the browser:\n${offenders.join('\n')}`, + ); }); diff --git a/api/test/widget-i18n.test.js b/api/test/widget-i18n.test.js index 2020a052..d6da8e92 100644 --- a/api/test/widget-i18n.test.js +++ b/api/test/widget-i18n.test.js @@ -1,4 +1,4 @@ -const fs = require('node:fs'); +const fs = require('node:fs'); const path = require('node:path'); const { test, after, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); @@ -18,14 +18,17 @@ function widget(name, catalogs) { const dir = path.join(ROOT, name); fs.mkdirSync(path.join(dir, 'i18n'), { recursive: true }); for (const [lang, body] of Object.entries(catalogs)) { - fs.writeFileSync(path.join(dir, 'i18n', lang + '.json'), - typeof body === 'string' ? body : JSON.stringify(body)); + fs.writeFileSync(path.join(dir, 'i18n', lang + '.json'), typeof body === 'string' ? body : JSON.stringify(body)); } return dir; } beforeEach(() => _resetCache()); -after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} }); +after(() => { + try { + fs.rmSync(ROOT, { recursive: true, force: true }); + } catch {} +}); test('a key resolves from the active language', () => { const dir = widget('a', { en: { 'x.label': 'Server URL' }, fa: { 'x.label': 'نشانی سرور' } }); @@ -60,33 +63,57 @@ test('English is served from the English catalog, not the raw key', () => { test('every human-facing key is translated and nothing else is', () => { const dir = widget('e', { en: { - 'f.label': 'Label', 'f.placeholder': 'Placeholder', 'f.hint': 'Hint', - 'f.rowLabel': 'Row', 'f.fetchLabel': 'Fetch', 'provider': 'TRANSLATED', + 'f.label': 'Label', + 'f.placeholder': 'Placeholder', + 'f.hint': 'Hint', + 'f.rowLabel': 'Row', + 'f.fetchLabel': 'Fetch', + provider: 'TRANSLATED', }, }); const entry = { - fields: [{ - key: 'provider', type: 'provider', variant: 'provider', optionsFrom: 'provider', - label: 'f.label', placeholder: 'f.placeholder', hint: 'f.hint', - rowLabel: 'f.rowLabel', fetchLabel: 'f.fetchLabel', - showIf: { field: 'provider', equals: 'provider' }, - }], + fields: [ + { + key: 'provider', + type: 'provider', + variant: 'provider', + optionsFrom: 'provider', + label: 'f.label', + placeholder: 'f.placeholder', + hint: 'f.hint', + rowLabel: 'f.rowLabel', + fetchLabel: 'f.fetchLabel', + showIf: { field: 'provider', equals: 'provider' }, + }, + ], }; const f = translateEntry(entry, dir, 'e', 'en').fields[0]; assert.deepEqual( { label: f.label, placeholder: f.placeholder, hint: f.hint, rowLabel: f.rowLabel, fetchLabel: f.fetchLabel }, - { label: 'Label', placeholder: 'Placeholder', hint: 'Hint', rowLabel: 'Row', fetchLabel: 'Fetch' }); + { label: 'Label', placeholder: 'Placeholder', hint: 'Hint', rowLabel: 'Row', fetchLabel: 'Fetch' }, + ); /* Identifiers, types and comparison values share the word "provider" and must survive untouched, or the lookups they take part in break. */ assert.deepEqual( { key: f.key, type: f.type, variant: f.variant, optionsFrom: f.optionsFrom, showIf: f.showIf }, - { key: 'provider', type: 'provider', variant: 'provider', optionsFrom: 'provider', - showIf: { field: 'provider', equals: 'provider' } }); + { + key: 'provider', + type: 'provider', + variant: 'provider', + optionsFrom: 'provider', + showIf: { field: 'provider', equals: 'provider' }, + }, + ); }); test('option labels nested in arrays are translated', () => { const dir = widget('f', { en: { 'p.opt.adguard': 'AdGuard Home' }, de: { 'p.opt.adguard': 'AdGuard Home (DE)' } }); - const out = translateEntry({ fields: [{ key: 'p', options: [{ value: 'adguard', label: 'p.opt.adguard' }] }] }, dir, 'f', 'de'); + const out = translateEntry( + { fields: [{ key: 'p', options: [{ value: 'adguard', label: 'p.opt.adguard' }] }] }, + dir, + 'f', + 'de', + ); assert.equal(out.fields[0].options[0].label, 'AdGuard Home (DE)'); assert.equal(out.fields[0].options[0].value, 'adguard'); }); diff --git a/api/test/widget-manifests.test.js b/api/test/widget-manifests.test.js index f6d26b39..8fb751ef 100644 --- a/api/test/widget-manifests.test.js +++ b/api/test/widget-manifests.test.js @@ -18,8 +18,15 @@ const WIDGETS_DIR = path.join(__dirname, '..', '..', 'ui', 'widgets'); the manifests inside the release image; it must never appear in a committed one, which the separate test below enforces. */ const KNOWN_KEYS = new Set([ - 'name', 'label', 'sizes', 'fields', - 'card', 'views', 'viewField', 'defaultView', 'entryVersions', + 'name', + 'label', + 'sizes', + 'fields', + 'card', + 'views', + 'viewField', + 'defaultView', + 'entryVersions', ]); function unknownKeys(manifest) { @@ -27,13 +34,15 @@ function unknownKeys(manifest) { } function widgetFolders() { - return fs.readdirSync(WIDGETS_DIR, { withFileTypes: true }) + return fs + .readdirSync(WIDGETS_DIR, { withFileTypes: true }) .filter(e => e.isDirectory() && fs.existsSync(path.join(WIDGETS_DIR, e.name, 'widget.json'))) .map(e => e.name); } test('every widget folder ships a widget.json', () => { - const missing = fs.readdirSync(WIDGETS_DIR, { withFileTypes: true }) + const missing = fs + .readdirSync(WIDGETS_DIR, { withFileTypes: true }) .filter(e => e.isDirectory() && !fs.existsSync(path.join(WIDGETS_DIR, e.name, 'widget.json'))) .map(e => e.name); assert.deepEqual(missing, [], `widget folders without a manifest: ${missing.join(', ')}`); @@ -43,7 +52,9 @@ for (const name of widgetFolders()) { test(`${name}: widget.json is valid`, () => { const raw = fs.readFileSync(path.join(WIDGETS_DIR, name, 'widget.json'), 'utf8'); let manifest; - assert.doesNotThrow(() => { manifest = JSON.parse(raw); }, `${name}/widget.json is not valid JSON`); + assert.doesNotThrow(() => { + manifest = JSON.parse(raw); + }, `${name}/widget.json is not valid JSON`); const { errors } = validateManifest(name, manifest); assert.deepEqual(errors, [], `${name}/widget.json: ${errors.join('; ')}`); }); @@ -65,8 +76,11 @@ for (const name of widgetFolders()) { test(`${name}: widget.json carries no entryVersions`, () => { const manifest = JSON.parse(fs.readFileSync(path.join(WIDGETS_DIR, name, 'widget.json'), 'utf8')); - assert.equal('entryVersions' in manifest, false, - `${name}/widget.json: entryVersions is written into the image at release time and must not be committed`); + assert.equal( + 'entryVersions' in manifest, + false, + `${name}/widget.json: entryVersions is written into the image at release time and must not be committed`, + ); }); } diff --git a/api/test/widget-rejections.test.js b/api/test/widget-rejections.test.js index 826a24d6..48821c1e 100644 --- a/api/test/widget-rejections.test.js +++ b/api/test/widget-rejections.test.js @@ -16,22 +16,26 @@ const path = require('node:path'); const fs = require('node:fs'); -const { tmpDir, tmpPath } = require('../test-support/tmp'); +const { tmpDir } = require('../test-support/tmp'); const root = tmpDir('reject'); const widgetsDir = path.join(root, 'widgets'); function writeWidget(name, manifest) { const dir = path.join(widgetsDir, name); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'widget.json'), - typeof manifest === 'string' ? manifest : JSON.stringify(manifest)); + fs.writeFileSync(path.join(dir, 'widget.json'), typeof manifest === 'string' ? manifest : JSON.stringify(manifest)); return dir; } writeWidget('fine', { name: 'fine', label: 'Fine', sizes: ['small'] }); writeWidget('badjson', '{ this is not json'); -writeWidget('badmanifest', { name: 'badmanifest', label: 'B', sizes: ['small'], - viewField: 'veiw', views: { a: { src: 'a.html' } } }); +writeWidget('badmanifest', { + name: 'badmanifest', + label: 'B', + sizes: ['small'], + viewField: 'veiw', + views: { a: { src: 'a.html' } }, +}); writeWidget('wrongname', { name: 'somethingelse', label: 'W', sizes: ['small'] }); /* A directory with no widget.json is not a folder-style widget and is not a rejection: the legacy flat-file widgets sit beside these. */ @@ -86,8 +90,10 @@ test('unreadable JSON is reported as such', () => { /* The parser names the syntax problem and an offset, never file content, so it is safe to put in front of an operator. */ test('the JSON error does not quote the file back', () => { - assert.ok(!byName.badjson[0].includes('this is not json'), - `the message must not carry file content: ${byName.badjson[0]}`); + assert.ok( + !byName.badjson[0].includes('this is not json'), + `the message must not carry file content: ${byName.badjson[0]}`, + ); }); test('every reported error is a non-empty string', () => { @@ -120,19 +126,28 @@ test('GET /api/widgets carries the rejections', async () => { const { dispatch } = require('../src/router'); /* Put a broken widget back so there is something to report. */ - writeWidget('badmanifest', { name: 'badmanifest', label: 'B', sizes: ['small'], - viewField: 'veiw', views: { a: { src: 'a.html' } } }); + writeWidget('badmanifest', { + name: 'badmanifest', + label: 'B', + sizes: ['small'], + viewField: 'veiw', + views: { a: { src: 'a.html' } }, + }); widgets.loadRegistry(); const server = http.createServer(dispatch); await new Promise(r => server.listen(0, '127.0.0.1', r)); const { port } = server.address(); const body = await new Promise((resolve, reject) => { - http.get({ hostname: '127.0.0.1', port, path: '/api/widgets' }, res => { - let b = ''; - res.on('data', c => { b += c; }); - res.on('end', () => resolve(JSON.parse(b))); - }).on('error', reject); + http + .get({ hostname: '127.0.0.1', port, path: '/api/widgets' }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve(JSON.parse(b))); + }) + .on('error', reject); }); server.closeAllConnections?.(); await new Promise(r => server.close(r)); @@ -144,6 +159,5 @@ test('GET /api/widgets carries the rejections', async () => { assert.match(bad.errors.join(' '), /not a declared field/); /* Additive: a frontend that does not know about the field is unaffected. */ - assert.ok(!body.widgets.some(w => w.name === 'badmanifest'), - 'a rejected widget must not appear in the widget list'); + assert.ok(!body.widgets.some(w => w.name === 'badmanifest'), 'a rejected widget must not appear in the widget list'); }); diff --git a/api/test/widget-secrets.test.js b/api/test/widget-secrets.test.js index 62bf41ee..a91c7edd 100644 --- a/api/test/widget-secrets.test.js +++ b/api/test/widget-secrets.test.js @@ -8,10 +8,15 @@ const ENTRY = { fields: [ { key: 'apiKey', type: 'secret', label: 'API key' }, { key: 'host', type: 'text', label: 'Host' }, - { key: 'accounts', type: 'group', label: 'Accounts', fields: [ - { key: 'token', type: 'secret', label: 'Token' }, - { key: 'label', type: 'text', label: 'Label' }, - ] }, + { + key: 'accounts', + type: 'group', + label: 'Accounts', + fields: [ + { key: 'token', type: 'secret', label: 'Token' }, + { key: 'label', type: 'text', label: 'Label' }, + ], + }, ], }, }; @@ -31,10 +36,15 @@ test('scrubWidgetSecrets replaces a top-level secret with a Set flag', () => { }); test('scrubWidgetSecrets scrubs secrets inside group rows', () => { - const item = { widgetType: 'x', widgetConfig: { accounts: [ - { token: 'tok-1', label: 'Primary' }, - { token: 'tok-2', label: 'Backup' }, - ] } }; + const item = { + widgetType: 'x', + widgetConfig: { + accounts: [ + { token: 'tok-1', label: 'Primary' }, + { token: 'tok-2', label: 'Backup' }, + ], + }, + }; scrubWidgetSecrets(item, ENTRY); for (const row of item.widgetConfig.accounts) { assert.equal(row.token, undefined); @@ -66,14 +76,17 @@ test('preserveWidgetSecrets keeps a newly submitted secret instead of the old on }); test('preserveWidgetSecrets matches group rows by position', () => { - const oldItem = { widgetConfig: { accounts: [ - { token: 'tok-1', label: 'Primary' }, - { token: 'tok-2', label: 'Backup' }, - ] } }; - const newItem = { widgetConfig: { accounts: [ - { label: 'Primary renamed' }, - { token: 'tok-2-new', label: 'Backup' }, - ] } }; + const oldItem = { + widgetConfig: { + accounts: [ + { token: 'tok-1', label: 'Primary' }, + { token: 'tok-2', label: 'Backup' }, + ], + }, + }; + const newItem = { + widgetConfig: { accounts: [{ label: 'Primary renamed' }, { token: 'tok-2-new', label: 'Backup' }] }, + }; preserveWidgetSecrets(newItem, oldItem, ENTRY); assert.equal(newItem.widgetConfig.accounts[0].token, 'tok-1'); assert.equal(newItem.widgetConfig.accounts[0].tokenSet, true); @@ -92,12 +105,13 @@ test('preserveWidgetSecrets is a no-op for items without widgetConfig', () => { const ENTRY2 = { manifest: { fields: [ - { key: 'network', type: 'object', label: 'Network', fields: [ - { key: 'pass', type: 'secret', label: 'Password' }, - ] }, - { key: 'services', type: 'group', label: 'Services', fields: [ - { key: 'token', type: 'secret', label: 'Token' }, - ] }, + { + key: 'network', + type: 'object', + label: 'Network', + fields: [{ key: 'pass', type: 'secret', label: 'Password' }], + }, + { key: 'services', type: 'group', label: 'Services', fields: [{ key: 'token', type: 'secret', label: 'Token' }] }, ], }, }; @@ -126,16 +140,17 @@ test('preserveWidgetSecrets restores an object secret omitted by the browser', ( }); test('preserveWidgetSecrets matches group rows by id regardless of order', () => { - const oldItem = { widgetConfig: { services: [ - { id: 'a', token: 'tok-a' }, - { id: 'b', token: 'tok-b' }, - ] } }; + const oldItem = { + widgetConfig: { + services: [ + { id: 'a', token: 'tok-a' }, + { id: 'b', token: 'tok-b' }, + ], + }, + }; /* Rows reordered and one omits its token; id matching must still restore the right secret to each row rather than lining them up by position. */ - const newItem = { widgetConfig: { services: [ - { id: 'b', token: 'tok-b-new' }, - { id: 'a' }, - ] } }; + const newItem = { widgetConfig: { services: [{ id: 'b', token: 'tok-b-new' }, { id: 'a' }] } }; preserveWidgetSecrets(newItem, oldItem, ENTRY2); assert.equal(newItem.widgetConfig.services[0].token, 'tok-b-new'); assert.equal(newItem.widgetConfig.services[1].token, 'tok-a'); @@ -152,9 +167,12 @@ const INHERITED_ENTRY = { manifest: { fields: [ { key: 'toString', type: 'secret', label: 'Token' }, - { key: 'accounts', type: 'group', label: 'Accounts', fields: [ - { key: 'constructor', type: 'secret', label: 'Key' }, - ] }, + { + key: 'accounts', + type: 'group', + label: 'Accounts', + fields: [{ key: 'constructor', type: 'secret', label: 'Key' }], + }, ], }, }; diff --git a/api/test/widget-settings.test.js b/api/test/widget-settings.test.js index f41c4b55..22bf5d36 100644 --- a/api/test/widget-settings.test.js +++ b/api/test/widget-settings.test.js @@ -15,7 +15,12 @@ const { widgetSettings, SHARED_KEYS } = require('../src/widget-settings'); const FULL = () => ({ auth: { enabled: true, secret: 'SIGNING-KEY', passwordHash: 'scrypt$1$2$3$salt$key' }, - server: { hostIp: '192.168.1.10', socketProxyUrl: 'http://socket:2375', skipTlsVerify: true, portMap: { 8080: 'app' } }, + server: { + hostIp: '192.168.1.10', + socketProxyUrl: 'http://socket:2375', + skipTlsVerify: true, + portMap: { 8080: 'app' }, + }, background: { type: 'url', url: '/wall.jpg' }, stats: { diskMount: '/mnt/media', networkInterface: 'eth1' }, logLevel: 'info', @@ -74,7 +79,9 @@ test('nested objects are frozen too, not just the top level', () => { test('a strict-mode write throws', () => { 'use strict'; const out = widgetSettings(FULL()); - assert.throws(() => { out.stats.diskMount = '/etc'; }, TypeError); + assert.throws(() => { + out.stats.diskMount = '/etc'; + }, TypeError); }); test('a missing, empty or malformed settings object yields an empty result', () => { diff --git a/api/test/widget-views.test.js b/api/test/widget-views.test.js index 8576f44a..be7d01e8 100644 --- a/api/test/widget-views.test.js +++ b/api/test/widget-views.test.js @@ -11,7 +11,9 @@ test('a bare single-view manifest (no views) is valid', () => { test('a views block with viewField and defaultView is valid', () => { const { errors } = validateManifest('w', { - ...base, viewField: 'mode', defaultView: 'a', + ...base, + viewField: 'mode', + defaultView: 'a', views: { a: { src: 'a.html' }, b: { label: 'B', src: 'b.html' } }, /* viewField has to name a field the form renders, offering the view keys. */ fields: [{ key: 'mode', type: 'select', label: 'Mode', options: ['a', 'b'] }], @@ -21,25 +23,40 @@ test('a views block with viewField and defaultView is valid', () => { test('a view without a src is rejected', () => { const { errors } = validateManifest('w', { ...base, views: { a: { label: 'A' } } }); - assert.ok(errors.some(e => /src/.test(e)), errors.join('; ')); + assert.ok( + errors.some(e => /src/.test(e)), + errors.join('; '), + ); }); test('an empty views block is rejected', () => { const { errors } = validateManifest('w', { ...base, views: {} }); - assert.ok(errors.some(e => /non-empty/.test(e)), errors.join('; ')); + assert.ok( + errors.some(e => /non-empty/.test(e)), + errors.join('; '), + ); }); test('a views array (wrong shape) is rejected', () => { const { errors } = validateManifest('w', { ...base, views: [{ src: 'a.html' }] }); - assert.ok(errors.some(e => /non-empty object/.test(e)), errors.join('; ')); + assert.ok( + errors.some(e => /non-empty object/.test(e)), + errors.join('; '), + ); }); test('defaultView must name a declared view', () => { const { errors } = validateManifest('w', { ...base, defaultView: 'z', views: { a: { src: 'a.html' } } }); - assert.ok(errors.some(e => /defaultView/.test(e)), errors.join('; ')); + assert.ok( + errors.some(e => /defaultView/.test(e)), + errors.join('; '), + ); }); test('viewField without a views block is rejected', () => { const { errors } = validateManifest('w', { ...base, viewField: 'mode' }); - assert.ok(errors.some(e => /views/.test(e)), errors.join('; ')); + assert.ok( + errors.some(e => /views/.test(e)), + errors.join('; '), + ); }); diff --git a/api/test/widgets.test.js b/api/test/widgets.test.js index 6dddf11f..14e7e0ea 100644 --- a/api/test/widgets.test.js +++ b/api/test/widgets.test.js @@ -4,29 +4,36 @@ const assert = require('node:assert/strict'); const widgets = require('../src/widgets'); const { plain } = require('../test-support/plain'); -function dir(name) { return { name, isDirectory: () => true }; } +function dir(name) { + return { name, isDirectory: () => true }; +} /* Drive loadRegistry against a mocked filesystem: a valid folder widget, one with an invalid manifest, and a plain folder with no widget.json. */ function mountFs(t, manifests) { t.mock.method(process.stdout, 'write', () => true); // silence registry logs t.mock.method(fs, 'readdirSync', () => - Object.keys(manifests).map(dir).concat([{ name: 'plain', isDirectory: () => true }, - { name: 'afile', isDirectory: () => false }])); - t.mock.method(fs, 'existsSync', (p) => { + Object.keys(manifests) + .map(dir) + .concat([ + { name: 'plain', isDirectory: () => true }, + { name: 'afile', isDirectory: () => false }, + ]), + ); + t.mock.method(fs, 'existsSync', p => { if (p.endsWith('plain/widget.json') || p.endsWith('afile/widget.json')) return false; if (p.endsWith('widget.json')) return true; if (p.endsWith('good/data.js')) return true; return false; }); - t.mock.method(fs, 'readFileSync', (p) => { + t.mock.method(fs, 'readFileSync', p => { for (const name of Object.keys(manifests)) if (p.includes(`/${name}/`) || p.includes(`${name}/widget.json`)) return manifests[name]; throw new Error('unexpected read ' + p); }); } -test('loadRegistry includes a valid manifest and records its data function', (t) => { +test('loadRegistry includes a valid manifest and records its data function', t => { mountFs(t, { good: JSON.stringify({ name: 'good', label: 'Good', sizes: ['small', 'medium'] }), }); @@ -36,24 +43,24 @@ test('loadRegistry includes a valid manifest and records its data function', (t) assert.deepEqual(reg.good.manifest.sizes, ['small', 'medium']); }); -test('loadRegistry skips a manifest that fails validation', (t) => { +test('loadRegistry skips a manifest that fails validation', t => { mountFs(t, { good: JSON.stringify({ name: 'good', label: 'Good', sizes: ['small'] }), - bad: JSON.stringify({ name: 'bad', label: 'Bad', sizes: ['enormous'] }), + bad: JSON.stringify({ name: 'bad', label: 'Bad', sizes: ['enormous'] }), }); const reg = widgets.loadRegistry(); assert.ok(reg.good); assert.equal(reg.bad, undefined, 'unknown size should disqualify the widget'); }); -test('loadRegistry skips a widget whose name does not match its folder', (t) => { +test('loadRegistry skips a widget whose name does not match its folder', t => { mountFs(t, { good: JSON.stringify({ name: 'mismatch', label: 'X', sizes: ['small'] }), }); assert.equal(widgets.loadRegistry().good, undefined); }); -test('loadRegistry survives invalid JSON without throwing', (t) => { +test('loadRegistry survives invalid JSON without throwing', t => { mountFs(t, { good: '{ not valid json' }); assert.deepEqual(plain(widgets.loadRegistry()), {}); }); @@ -66,19 +73,28 @@ test('loadRegistry survives invalid JSON without throwing', (t) => { declares no data source" instead of 404, and widget-secrets treated the item as recognised rather than withholding its config. */ -test('the registry does not answer a lookup for an inherited member', (t) => { +test('the registry does not answer a lookup for an inherited member', t => { mountFs(t, { good: JSON.stringify({ name: 'good', label: 'Good', sizes: ['small'] }) }); const reg = widgets.loadRegistry(); - for (const name of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', - 'isPrototypeOf', 'propertyIsEnumerable', '__proto__']) { + for (const name of [ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + 'isPrototypeOf', + 'propertyIsEnumerable', + '__proto__', + ]) { assert.equal(reg[name], undefined, name); } assert.ok(reg.good, 'and a real widget still resolves'); }); -test('loadRegistry returns an empty registry when the directory is unreadable', (t) => { +test('loadRegistry returns an empty registry when the directory is unreadable', t => { t.mock.method(process.stdout, 'write', () => true); - t.mock.method(fs, 'readdirSync', () => { throw new Error('ENOENT'); }); + t.mock.method(fs, 'readdirSync', () => { + throw new Error('ENOENT'); + }); assert.deepEqual(plain(widgets.loadRegistry()), {}); }); @@ -92,9 +108,10 @@ test('validateManifest accepts a color field', () => { }); test('validateManifest accepts a color field inside a group', () => { - assert.deepEqual(errsFor([ - { key: 'slots', type: 'group', label: 'Slots', fields: [{ key: 'tint', type: 'color', label: 'Tint' }] }, - ]), []); + assert.deepEqual( + errsFor([{ key: 'slots', type: 'group', label: 'Slots', fields: [{ key: 'tint', type: 'color', label: 'Tint' }] }]), + [], + ); }); test('validateManifest still rejects an unknown field type', () => { @@ -108,14 +125,25 @@ test('validateManifest still rejects an unknown field type', () => { const cond = v => ({ field: 'type', equals: v }); /* The field cond() points at. A showIf must name a sibling, so every fixture using cond() has to declare it. */ -const typeField = { key: 'type', type: 'select', label: 'Service', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }; +const typeField = { + key: 'type', + type: 'select', + label: 'Service', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], +}; test('validateManifest accepts a repeated key when every declaration is conditional', () => { - assert.deepEqual(errsFor([ - { key: 'type', type: 'select', label: 'Service', options: [{ value: 'a', label: 'A' }] }, - { key: 'url', type: 'text', label: 'Metrics URL', showIf: cond('a') }, - { key: 'url', type: 'text', label: 'Management API URL', showIf: cond('b') }, - ]), []); + assert.deepEqual( + errsFor([ + { key: 'type', type: 'select', label: 'Service', options: [{ value: 'a', label: 'A' }] }, + { key: 'url', type: 'text', label: 'Metrics URL', showIf: cond('a') }, + { key: 'url', type: 'text', label: 'Management API URL', showIf: cond('b') }, + ]), + [], + ); }); test('validateManifest rejects a repeated key when one declaration is unconditional', () => { @@ -138,27 +166,42 @@ test('validateManifest reports a repeated key once, not once per declaration', ( }); test('validateManifest applies the rule inside a group and inside an object', () => { - const bad = [{ key: 'url', type: 'text', label: 'A' }, { key: 'url', type: 'text', label: 'B' }]; - const good = [typeField, { key: 'url', type: 'text', label: 'A', showIf: cond('a') }, { key: 'url', type: 'text', label: 'B', showIf: cond('b') }]; + const bad = [ + { key: 'url', type: 'text', label: 'A' }, + { key: 'url', type: 'text', label: 'B' }, + ]; + const good = [ + typeField, + { key: 'url', type: 'text', label: 'A', showIf: cond('a') }, + { key: 'url', type: 'text', label: 'B', showIf: cond('b') }, + ]; assert.match(errsFor([{ key: 'svcs', type: 'group', label: 'Services', fields: bad }])[0], /svcs: key "url"/); assert.match(errsFor([{ key: 'vpn', type: 'object', label: 'VPN', fields: bad }])[0], /vpn: key "url"/); assert.deepEqual(errsFor([{ key: 'svcs', type: 'group', label: 'Services', fields: good }]), []); }); test('a key repeated across different levels is not a conflict', () => { - assert.deepEqual(errsFor([ - { key: 'url', type: 'text', label: 'URL' }, - { key: 'vpn', type: 'object', label: 'VPN', fields: [{ key: 'url', type: 'text', label: 'URL' }] }, - ]), []); + assert.deepEqual( + errsFor([ + { key: 'url', type: 'text', label: 'URL' }, + { key: 'vpn', type: 'object', label: 'VPN', fields: [{ key: 'url', type: 'text', label: 'URL' }] }, + ]), + [], + ); }); /* ── Per-view sizes ─────────────────────────────────────────────────────── */ -const withViews = views => widgets.validateManifest('w', { - name: 'w', label: 'W', sizes: ['small', 'medium'], viewField: 'view', views, - /* viewField has to name a real field offering exactly the view keys. */ - fields: [{ key: 'view', type: 'select', label: 'View', options: Object.keys(views) }], -}).errors; +const withViews = views => + widgets.validateManifest('w', { + name: 'w', + label: 'W', + sizes: ['small', 'medium'], + viewField: 'view', + views, + /* viewField has to name a real field offering exactly the view keys. */ + fields: [{ key: 'view', type: 'select', label: 'View', options: Object.keys(views) }], + }).errors; test('validateManifest accepts a view that narrows the size list', () => { assert.deepEqual(withViews({ a: { src: 'a.html', sizes: ['medium'] }, b: { src: 'b.html' } }), []); @@ -182,7 +225,12 @@ test('validateManifest accepts a picklist with a fixed count', () => { }); test('validateManifest accepts a picklist counted by size', () => { - assert.deepEqual(errsFor([{ key: 'bays', type: 'picklist', label: 'Bay', optionsFrom: 'devices', countBySize: { small: 4, medium: 10 } }]), []); + assert.deepEqual( + errsFor([ + { key: 'bays', type: 'picklist', label: 'Bay', optionsFrom: 'devices', countBySize: { small: 4, medium: 10 } }, + ]), + [], + ); }); test('validateManifest rejects a picklist with no count', () => { @@ -225,25 +273,37 @@ test('validateManifest rejects a showIf naming a field that does not exist', () test('validateManifest rejects a showIf that is not an object', () => { for (const bad of [true, 'provider', 42, ['provider'], null]) { const errs = errsFor([{ key: 'a', type: 'text', label: 'A', showIf: bad }]); - assert.ok(errs.some(e => /"showIf" that is not an object/.test(e)), JSON.stringify(bad)); + assert.ok( + errs.some(e => /"showIf" that is not an object/.test(e)), + JSON.stringify(bad), + ); } }); test('validateManifest rejects a showIf with no condition', () => { const errs = errsFor([typeField, { key: 'u', type: 'text', label: 'U', showIf: { field: 'type' } }]); - assert.ok(errs.some(e => /"showIf" needs "equals" or "in"/.test(e)), errs.join('; ')); + assert.ok( + errs.some(e => /"showIf" needs "equals" or "in"/.test(e)), + errs.join('; '), + ); }); test('validateManifest rejects an empty or non-array showIf.in', () => { for (const bad of [[], 'a', {}]) { const errs = errsFor([typeField, { key: 'u', type: 'text', label: 'U', showIf: { field: 'type', in: bad } }]); - assert.ok(errs.some(e => /"showIf\.in" must be a non-empty array/.test(e)), JSON.stringify(bad)); + assert.ok( + errs.some(e => /"showIf\.in" must be a non-empty array/.test(e)), + JSON.stringify(bad), + ); } }); test('validateManifest rejects a showIf on the field itself', () => { const errs = errsFor([{ key: 'u', type: 'text', label: 'U', showIf: { field: 'u', equals: 'x' } }]); - assert.ok(errs.some(e => /"showIf" on itself/.test(e)), errs.join('; ')); + assert.ok( + errs.some(e => /"showIf" on itself/.test(e)), + errs.join('; '), + ); }); /* Conditions resolve within a sibling set, so reaching out of a group is the @@ -251,20 +311,29 @@ test('validateManifest rejects a showIf on the field itself', () => { test('validateManifest rejects a group sub-field conditioned on a top-level field', () => { const errs = errsFor([ typeField, - { key: 'svcs', type: 'group', label: 'Services', fields: [ - { key: 'url', type: 'text', label: 'U', showIf: { field: 'type', equals: 'a' } }, - ] }, + { + key: 'svcs', + type: 'group', + label: 'Services', + fields: [{ key: 'url', type: 'text', label: 'U', showIf: { field: 'type', equals: 'a' } }], + }, ]); - assert.ok(errs.some(e => /svcs: field "url" has a "showIf" on "type"/.test(e)), errs.join('; ')); + assert.ok( + errs.some(e => /svcs: field "url" has a "showIf" on "type"/.test(e)), + errs.join('; '), + ); }); test('validateManifest accepts every showIf shape the shipped widgets use', () => { - assert.deepEqual(errsFor([ - typeField, - { key: 'one', type: 'text', label: 'One', showIf: { field: 'type', equals: 'a' } }, - { key: 'many', type: 'text', label: 'Many', showIf: { field: 'type', in: ['a', 'b'] } }, - { key: 'off', type: 'text', label: 'Off', showIf: { field: 'type', equals: false } }, - ]), []); + assert.deepEqual( + errsFor([ + typeField, + { key: 'one', type: 'text', label: 'One', showIf: { field: 'type', equals: 'a' } }, + { key: 'many', type: 'text', label: 'Many', showIf: { field: 'type', in: ['a', 'b'] } }, + { key: 'off', type: 'text', label: 'Off', showIf: { field: 'type', equals: false } }, + ]), + [], + ); }); /* ── P5-10: viewField was checked for type but not for reference ───────────── @@ -273,9 +342,15 @@ test('validateManifest accepts every showIf shape the shipped widgets use', () = selector does nothing. An option with no matching view selects a view that does not exist. */ -const withViewField = (viewField, fields, views) => widgets.validateManifest('w', { - name: 'w', label: 'W', sizes: ['small'], viewField, views, fields, -}).errors; +const withViewField = (viewField, fields, views) => + widgets.validateManifest('w', { + name: 'w', + label: 'W', + sizes: ['small'], + viewField, + views, + fields, + }).errors; const TWO_VIEWS = { vpn: { src: 'v.html' }, map: { src: 'm.html' } }; @@ -286,21 +361,53 @@ test('validateManifest rejects a viewField naming no declared field', () => { }); test('validateManifest rejects a view no option can select', () => { - const errs = withViewField('view', [{ key: 'view', type: 'select', label: 'V', options: ['vpn', 'graph'] }], TWO_VIEWS); - assert.ok(errs.some(e => /view "map" cannot be selected/.test(e)), errs.join('; ')); - assert.ok(errs.some(e => /offers "graph", which is not a declared view/.test(e)), errs.join('; ')); + const errs = withViewField( + 'view', + [{ key: 'view', type: 'select', label: 'V', options: ['vpn', 'graph'] }], + TWO_VIEWS, + ); + assert.ok( + errs.some(e => /view "map" cannot be selected/.test(e)), + errs.join('; '), + ); + assert.ok( + errs.some(e => /offers "graph", which is not a declared view/.test(e)), + errs.join('; '), + ); }); test('validateManifest accepts a viewField whose options are exactly the views', () => { - assert.deepEqual(withViewField('view', [{ key: 'view', type: 'select', label: 'V', options: ['vpn', 'map'] }], TWO_VIEWS), []); + assert.deepEqual( + withViewField('view', [{ key: 'view', type: 'select', label: 'V', options: ['vpn', 'map'] }], TWO_VIEWS), + [], + ); }); test('validateManifest accepts the { value, label } option shape', () => { - assert.deepEqual(withViewField('view', [{ key: 'view', type: 'select', label: 'V', - options: [{ value: 'vpn', label: 'VPN' }, { value: 'map', label: 'Map' }] }], TWO_VIEWS), []); + assert.deepEqual( + withViewField( + 'view', + [ + { + key: 'view', + type: 'select', + label: 'V', + options: [ + { value: 'vpn', label: 'VPN' }, + { value: 'map', label: 'Map' }, + ], + }, + ], + TWO_VIEWS, + ), + [], + ); }); /* A runtime-populated selector has nothing to compare against here. */ test('validateManifest does not check the views against an optionsFrom field', () => { - assert.deepEqual(withViewField('view', [{ key: 'view', type: 'select', label: 'V', optionsFrom: 'modes' }], TWO_VIEWS), []); + assert.deepEqual( + withViewField('view', [{ key: 'view', type: 'select', label: 'V', optionsFrom: 'modes' }], TWO_VIEWS), + [], + ); }); diff --git a/api/test/workflow-hardening.test.js b/api/test/workflow-hardening.test.js index cc0e30ee..c10b9288 100644 --- a/api/test/workflow-hardening.test.js +++ b/api/test/workflow-hardening.test.js @@ -28,7 +28,9 @@ const root = path.join(__dirname, '..', '..'); const wfDir = path.join(root, '.github', 'workflows'); const actionsDir = path.join(root, '.github', 'actions'); -const workflows = fs.readdirSync(wfDir).filter(f => f.endsWith('.yml')) +const workflows = fs + .readdirSync(wfDir) + .filter(f => f.endsWith('.yml')) .map(f => [`workflows/${f}`, fs.readFileSync(path.join(wfDir, f), 'utf8')]); function compositeActions() { @@ -52,18 +54,17 @@ test('the scan finds the workflows', () => { /* A workflow with no permissions block inherits the repository default, which here is write. */ test('every workflow declares its permissions', () => { - const missing = workflows.filter(([, s]) => !/^permissions:/m.test(s) && !/^\s+permissions:/m.test(s)) + const missing = workflows + .filter(([, s]) => !/^permissions:/m.test(s) && !/^\s+permissions:/m.test(s)) .map(([f]) => f); - assert.deepEqual(missing, [], - `These inherit the repository default, which is write:\n ${missing.join('\n ')}`); + assert.deepEqual(missing, [], `These inherit the repository default, which is write:\n ${missing.join('\n ')}`); }); test('the test workflow can only read', () => { const [, src] = workflows.find(([f]) => f.endsWith('test.yml')); const block = src.slice(src.indexOf('permissions:')); assert.match(block, /contents: read/); - assert.ok(!/write/.test(block.split('jobs:')[0]), - 'the test workflow must not grant any write scope'); + assert.ok(!/write/.test(block.split('jobs:')[0]), 'the test workflow must not grant any write scope'); }); /* Only the release publishes, and only to the package registry. */ @@ -98,8 +99,11 @@ test('every third-party action is pinned to a commit', () => { if (!/^[0-9a-f]{40}$/.test(rev)) unpinned.push(`${f}: ${ref}`); } } - assert.deepEqual(unpinned, [], - `Pin to a full commit SHA with the version in a trailing comment:\n ${unpinned.join('\n ')}`); + assert.deepEqual( + unpinned, + [], + `Pin to a full commit SHA with the version in a trailing comment:\n ${unpinned.join('\n ')}`, + ); }); /* A bare SHA is unreadable, and a reviewer cannot tell v4 from v7. */ @@ -133,8 +137,7 @@ test('checkout does not leave its credentials behind', () => { whatever was current the day they were written. */ test('dependabot watches the actions', () => { const cfg = fs.readFileSync(path.join(root, '.github', 'dependabot.yml'), 'utf8'); - assert.match(cfg, /package-ecosystem:\s*github-actions/, - 'pinned SHAs need Dependabot to update them'); + assert.match(cfg, /package-ecosystem:\s*github-actions/, 'pinned SHAs need Dependabot to update them'); }); /* Dependency code runs in these jobs, and the release build publishes what they @@ -144,8 +147,10 @@ test('dependabot watches the actions', () => { test('the lockfile is committed and CI installs from it', () => { const lock = path.join(root, 'package-lock.json'); assert.ok(fs.existsSync(lock), 'package-lock.json is missing'); - assert.ok(!/^\s*package-lock\.json\s*$/m.test(fs.readFileSync(path.join(root, '.gitignore'), 'utf8')), - '.gitignore excludes the lockfile, so npm ci has nothing to install from'); + assert.ok( + !/^\s*package-lock\.json\s*$/m.test(fs.readFileSync(path.join(root, '.gitignore'), 'utf8')), + '.gitignore excludes the lockfile, so npm ci has nothing to install from', + ); const loose = []; for (const [f, src] of all) { diff --git a/biome.json b/biome.json index 1dbcc648..685a84a3 100644 --- a/biome.json +++ b/biome.json @@ -8,7 +8,10 @@ "scripts/**/*.js", "docs/widget-template/**/*.js", "e2e/**/*.js", - "playwright.config.js" + "playwright.config.js", + "api/test/**/*.js", + "api/test-support/**/*.js", + "ui/test/**/*.mjs" ] }, "formatter": { @@ -48,6 +51,27 @@ } } } + }, + { + "includes": ["api/test/**/*.js", "api/test-support/**/*.js", "ui/test/**/*.mjs"], + "linter": { + "rules": { + "suspicious": { + "noAssignInExpressions": "off", + "noRedundantUseStrict": "off", + "noTemplateCurlyInString": "off" + }, + "security": { + "noGlobalEval": "off" + }, + "complexity": { + "noCommaOperator": "off" + }, + "correctness": { + "noUnusedFunctionParameters": "off" + } + } + } } ], "javascript": { diff --git a/ui/test/admin-drag-logic.test.mjs b/ui/test/admin-drag-logic.test.mjs index 27bc68f0..82d0451a 100644 --- a/ui/test/admin-drag-logic.test.mjs +++ b/ui/test/admin-drag-logic.test.mjs @@ -39,31 +39,79 @@ test('an app dropped on a top-level non-folder row reorders', () => { test('reorder moves a top-level item below the target', () => { const items = model(); - assert.equal(applyDrop(items, { srcId: 'a', srcFolderId: null, targetId: 'b', targetFolderId: null, targetIsFolder: false, indent: false, childIdx: null, dropAbove: false }), true); + assert.equal( + applyDrop(items, { + srcId: 'a', + srcFolderId: null, + targetId: 'b', + targetFolderId: null, + targetIsFolder: false, + indent: false, + childIdx: null, + dropAbove: false, + }), + true, + ); assert.deepEqual(order(items), ['b', 'a', 'f', 'c', 'w']); }); test('reorder respects dropAbove', () => { const items = model(); - applyDrop(items, { srcId: 'w', srcFolderId: null, targetId: 'a', targetFolderId: null, targetIsFolder: false, indent: false, childIdx: null, dropAbove: true }); + applyDrop(items, { + srcId: 'w', + srcFolderId: null, + targetId: 'a', + targetFolderId: null, + targetIsFolder: false, + indent: false, + childIdx: null, + dropAbove: true, + }); assert.deepEqual(order(items), ['w', 'a', 'b', 'f', 'c']); }); test('an app dropped on a folder row joins that folder', () => { const items = model(); - applyDrop(items, { srcId: 'a', srcFolderId: null, targetId: 'f', targetFolderId: null, targetIsFolder: true, indent: false, childIdx: null, dropAbove: false }); + applyDrop(items, { + srcId: 'a', + srcFolderId: null, + targetId: 'f', + targetFolderId: null, + targetIsFolder: true, + indent: false, + childIdx: null, + dropAbove: false, + }); assert.deepEqual(folder(items, 'f'), ['c', 'a']); }); test('an app dropped on a child row inserts at that index', () => { const items = model(); - applyDrop(items, { srcId: 'a', srcFolderId: null, targetId: 'c', targetFolderId: 'f', targetIsFolder: false, indent: true, childIdx: 0, dropAbove: false }); + applyDrop(items, { + srcId: 'a', + srcFolderId: null, + targetId: 'c', + targetFolderId: 'f', + targetIsFolder: false, + indent: true, + childIdx: 0, + dropAbove: false, + }); assert.deepEqual(folder(items, 'f'), ['a', 'c']); }); test('a child dragged onto a top-level row leaves its folder', () => { const items = model(); - applyDrop(items, { srcId: 'c', srcFolderId: 'f', targetId: 'b', targetFolderId: null, targetIsFolder: false, indent: false, childIdx: null, dropAbove: false }); + applyDrop(items, { + srcId: 'c', + srcFolderId: 'f', + targetId: 'b', + targetFolderId: null, + targetIsFolder: false, + indent: false, + childIdx: null, + dropAbove: false, + }); assert.deepEqual(folder(items, 'f'), []); assert.deepEqual(order(items), ['a', 'b', 'c', 'f', 'w']); }); @@ -76,20 +124,50 @@ test('a child moves from one folder to another', () => { { id: 'g', type: 'folder', children: ['d'] }, { id: 'd', type: 'app' }, ]; - applyDrop(items, { srcId: 'c', srcFolderId: 'f', targetId: 'g', targetFolderId: null, targetIsFolder: true, indent: false, childIdx: null, dropAbove: false }); + applyDrop(items, { + srcId: 'c', + srcFolderId: 'f', + targetId: 'g', + targetFolderId: null, + targetIsFolder: true, + indent: false, + childIdx: null, + dropAbove: false, + }); assert.deepEqual(folder(items, 'f'), []); assert.deepEqual(folder(items, 'g'), ['d', 'c']); }); test('a widget dropped on a folder row reorders, it does not join', () => { const items = model(); - applyDrop(items, { srcId: 'w', srcFolderId: null, targetId: 'f', targetFolderId: null, targetIsFolder: true, indent: false, childIdx: null, dropAbove: false }); + applyDrop(items, { + srcId: 'w', + srcFolderId: null, + targetId: 'f', + targetFolderId: null, + targetIsFolder: true, + indent: false, + childIdx: null, + dropAbove: false, + }); assert.deepEqual(folder(items, 'f'), ['c']); assert.deepEqual(order(items), ['a', 'b', 'f', 'w', 'c']); }); test('dropping an item on itself changes nothing', () => { const items = model(); - assert.equal(applyDrop(items, { srcId: 'a', srcFolderId: null, targetId: 'a', targetFolderId: null, targetIsFolder: false, indent: false, childIdx: null, dropAbove: false }), false); + assert.equal( + applyDrop(items, { + srcId: 'a', + srcFolderId: null, + targetId: 'a', + targetFolderId: null, + targetIsFolder: false, + indent: false, + childIdx: null, + dropAbove: false, + }), + false, + ); assert.deepEqual(order(items), ['a', 'b', 'f', 'c', 'w']); }); diff --git a/ui/test/admin-error.test.mjs b/ui/test/admin-error.test.mjs index c50079cb..63c29019 100644 --- a/ui/test/admin-error.test.mjs +++ b/ui/test/admin-error.test.mjs @@ -87,7 +87,8 @@ test('an unknown future kind degrades instead of crashing an older frontend', () test('the retype instruction is shown on its own, without a failure prefix', async () => { const { optionsErrorText } = await import('../js/admin-error.js'); - const msg = 'This configuration has changed since it was saved, so the stored credential was not used. Enter the credential to test these settings.'; + const msg = + 'This configuration has changed since it was saved, so the stored credential was not used. Enter the credential to test these settings.'; assert.equal(optionsErrorText({ kind: KIND.INVALID, message: msg }), msg); }); diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index bb792d3c..b712b0f5 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -1,20 +1,45 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { reorderItems, isDockBlocked, nextActiveIndex, groupBounds, visibleFieldKeys, clearsStoredSecret, authEnableBlocked, widgetConfigMode, resolveAdminSection, rejectionLines, refusedNoticeKey } from '../js/admin-logic.js'; +import { + reorderItems, + isDockBlocked, + nextActiveIndex, + groupBounds, + visibleFieldKeys, + clearsStoredSecret, + authEnableBlocked, + widgetConfigMode, + resolveAdminSection, + rejectionLines, + refusedNoticeKey, +} from '../js/admin-logic.js'; test('reorderItems swaps top-level rows and reports whether it moved', () => { - const items = [{ id: 'a', type: 'app' }, { id: 'b', type: 'app' }, { id: 'c', type: 'app' }]; + const items = [ + { id: 'a', type: 'app' }, + { id: 'b', type: 'app' }, + { id: 'c', type: 'app' }, + ]; assert.equal(reorderItems(items, items[1], -1), true); - assert.deepEqual(items.map(i => i.id), ['b', 'a', 'c']); + assert.deepEqual( + items.map(i => i.id), + ['b', 'a', 'c'], + ); assert.equal(reorderItems(items, items[0], -1), false); // already at the top - assert.deepEqual(items.map(i => i.id), ['b', 'a', 'c']); + assert.deepEqual( + items.map(i => i.id), + ['b', 'a', 'c'], + ); }); test('reorderItems skips items nested inside folders when ordering the top level', () => { const folder = { id: 'f', type: 'folder', children: ['x'] }; const items = [folder, { id: 'x', type: 'app' }, { id: 'b', type: 'app' }]; assert.equal(reorderItems(items, folder, 1), true); // folder moves past nested x to b's slot - assert.deepEqual(items.map(i => i.id), ['b', 'x', 'f']); + assert.deepEqual( + items.map(i => i.id), + ['b', 'x', 'f'], + ); }); test('reorderItems reorders a child within its folder', () => { @@ -313,22 +338,24 @@ test('every reason becomes a line, named for the picker', () => { /* The editor is already showing one widget, so repeating its name reads as a stutter. */ test('the name is left off when the caller is showing one widget', () => { - assert.deepEqual(rejectionLines([REFUSALS[0]], { withName: false }), - ['viewField "veiw" is not a declared field']); + assert.deepEqual(rejectionLines([REFUSALS[0]], { withName: false }), ['viewField "veiw" is not a declared field']); }); /* The list is JSON off the API. A malformed entry must not render as "undefined: undefined" in front of someone already debugging a manifest. */ test('an entry that is not a named widget with reasons is dropped', () => { - assert.deepEqual(rejectionLines([ - null, - { errors: ['no name'] }, - { name: '', errors: ['empty name'] }, - { name: 'x' }, - { name: 'y', errors: 'not an array' }, - { name: 'z', errors: [] }, - { name: 'ok', errors: ['', ' ', 42, 'a real reason'] }, - ]), ['ok: a real reason']); + assert.deepEqual( + rejectionLines([ + null, + { errors: ['no name'] }, + { name: '', errors: ['empty name'] }, + { name: 'x' }, + { name: 'y', errors: 'not an array' }, + { name: 'z', errors: [] }, + { name: 'ok', errors: ['', ' ', 42, 'a real reason'] }, + ]), + ['ok: a real reason'], + ); }); test('a missing or non-array list is no lines rather than a throw', () => { diff --git a/ui/test/admin-nav.test.mjs b/ui/test/admin-nav.test.mjs index b8c3b704..6d536eb4 100644 --- a/ui/test/admin-nav.test.mjs +++ b/ui/test/admin-nav.test.mjs @@ -16,10 +16,13 @@ import { resolveAdminSection } from '../js/admin-logic.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const html = fs.readFileSync(path.join(root, 'admin/index.html'), 'utf8'); -const sections = [...new Set( - [...html.matchAll(/id="sec-([a-z-]+)"[^>]*class="[^"]*\bsec\b/g)].map(m => m[1]) - .concat([...html.matchAll(/class="[^"]*\bsec\b[^"]*"[^>]*id="sec-([a-z-]+)"/g)].map(m => m[1])), -)]; +const sections = [ + ...new Set( + [...html.matchAll(/id="sec-([a-z-]+)"[^>]*class="[^"]*\bsec\b/g)] + .map(m => m[1]) + .concat([...html.matchAll(/class="[^"]*\bsec\b[^"]*"[^>]*id="sec-([a-z-]+)"/g)].map(m => m[1])), + ), +]; const links = [...new Set([...html.matchAll(/data-sec="([a-z-]+)"/g)].map(m => m[1]))]; test('the markup still has sections and nav links', () => { diff --git a/ui/test/admin-save-logic.test.mjs b/ui/test/admin-save-logic.test.mjs index 8fe9c520..57e31f6d 100644 --- a/ui/test/admin-save-logic.test.mjs +++ b/ui/test/admin-save-logic.test.mjs @@ -1,6 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { cleanId, buildAppItem, newItemId, upsertItem, claimFolderChildren, randomSuffix } from '../js/admin-save-logic.js'; +import { + cleanId, + buildAppItem, + newItemId, + upsertItem, + claimFolderChildren, + randomSuffix, +} from '../js/admin-save-logic.js'; test('cleanId keeps alphanumerics, collapses the rest, and trims', () => { assert.equal(cleanId('My App!'), 'My_App'); @@ -20,7 +27,10 @@ test('buildAppItem validates name and url', () => { }); test('buildAppItem builds a minimal app with disabled monitoring', () => { - const { item } = buildAppItem({ label: 'My App', href: 'http://x', hcEn: false, actEn: false, scol: 'dark', spaths: [] }, null); + const { item } = buildAppItem( + { label: 'My App', href: 'http://x', hcEn: false, actEn: false, scol: 'dark', spaths: [] }, + null, + ); assert.equal(item.type, 'app'); assert.equal(item.label, 'My App'); assert.equal(item.color, 'dark'); @@ -38,11 +48,21 @@ test('buildAppItem preserves an existing id and defaults color to dark', () => { }); test('buildAppItem enables healthcheck and activity from their fields', () => { - const { item } = buildAppItem({ - label: 'A', href: 'http://x', hcEn: true, hcCon: 'nginx', - actEn: true, actUrl: 'http://api', actInt: 45, - actParams: [{ key: 'a', value: '1', secret: false }], actHeaders: [], spaths: ['stats.total'], - }, null); + const { item } = buildAppItem( + { + label: 'A', + href: 'http://x', + hcEn: true, + hcCon: 'nginx', + actEn: true, + actUrl: 'http://api', + actInt: 45, + actParams: [{ key: 'a', value: '1', secret: false }], + actHeaders: [], + spaths: ['stats.total'], + }, + null, + ); assert.equal(item.monitoring.healthcheck.enabled, true); assert.equal(item.monitoring.healthcheck.container, 'nginx'); assert.equal(item.monitoring.activity.enabled, true); @@ -60,9 +80,15 @@ test('buildAppItem maps multiple extract paths to objects', () => { test('buildAppItem builds custom and static badge objects only when meaningful', () => { const none = buildAppItem({ label: 'A', href: 'http://x', actColor: '#0289ff', custUnit: '', spaths: [] }, null).item; assert.equal(none.monitoring.activity.custom, undefined); - const custom = buildAppItem({ label: 'A', href: 'http://x', actColor: '#ff0000', custUnit: 'GB', spaths: [] }, null).item; + const custom = buildAppItem( + { label: 'A', href: 'http://x', actColor: '#ff0000', custUnit: 'GB', spaths: [] }, + null, + ).item; assert.deepEqual(custom.monitoring.activity.custom, { color: '#ff0000', unit: 'GB' }); - const stat = buildAppItem({ label: 'A', href: 'http://x', staticEn: true, staticLabel: 'VeryLongLabelHere', staticColor: 'red', spaths: [] }, null).item; + const stat = buildAppItem( + { label: 'A', href: 'http://x', staticEn: true, staticLabel: 'VeryLongLabelHere', staticColor: 'red', spaths: [] }, + null, + ).item; assert.deepEqual(stat.monitoring.staticBadge, { enabled: true, label: 'VeryLongLa', color: 'red' }); }); @@ -154,7 +180,11 @@ test('an existing item is replaced in place', () => { const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; const r = upsertItem(items, 'b', { id: 'b', label: 'edited' }); assert.equal(r.replaced, true); - assert.deepEqual(items.map(i => i.id), ['a', 'b', 'c'], 'order is preserved'); + assert.deepEqual( + items.map(i => i.id), + ['a', 'b', 'c'], + 'order is preserved', + ); assert.equal(items[1].label, 'edited'); }); @@ -170,7 +200,10 @@ test('no holes are ever created', () => { const items = [{ id: 'a' }]; upsertItem(items, 'gone', { id: 'new' }); assert.equal(items.length, 2); - assert.ok(items.every(i => i != null), `holes present: ${JSON.stringify(items)}`); + assert.ok( + items.every(i => i != null), + `holes present: ${JSON.stringify(items)}`, + ); assert.ok(!JSON.stringify({ items }).includes('null')); }); @@ -178,13 +211,19 @@ test('an id that no longer exists appends rather than losing the edit', () => { const items = [{ id: 'a' }]; const r = upsertItem(items, 'deleted-elsewhere', { id: 'new', label: 'my work' }); assert.equal(r.replaced, false, 'so the toast says Added, not Updated'); - assert.deepEqual(items.map(i => i.id), ['a', 'new']); + assert.deepEqual( + items.map(i => i.id), + ['a', 'new'], + ); }); test('adding a new item appends', () => { const items = [{ id: 'a' }]; assert.equal(upsertItem(items, null, { id: 'b' }).replaced, false); - assert.deepEqual(items.map(i => i.id), ['a', 'b']); + assert.deepEqual( + items.map(i => i.id), + ['a', 'b'], + ); }); test('upsertItem tolerates a missing list', () => { @@ -224,7 +263,10 @@ test('the folder doing the claiming is left alone', () => { }); test('apps are not touched, only folders', () => { - const items = [{ id: 'a1', type: 'app', children: ['app1'] }, { id: 'f1', type: 'folder', children: ['app1'] }]; + const items = [ + { id: 'a1', type: 'app', children: ['app1'] }, + { id: 'f1', type: 'folder', children: ['app1'] }, + ]; claimFolderChildren(items, 'f2', ['app1']); assert.deepEqual(items[0].children, ['app1'], 'an app is not a folder'); assert.deepEqual(items[1].children, []); diff --git a/ui/test/admin-state-ratchet.test.mjs b/ui/test/admin-state-ratchet.test.mjs index 69ad8fe5..fb684f9d 100644 --- a/ui/test/admin-state-ratchet.test.mjs +++ b/ui/test/admin-state-ratchet.test.mjs @@ -34,7 +34,8 @@ function stateFields(src) { const fields = stateFields(stateSrc); -const others = fs.readdirSync(jsDir) +const others = fs + .readdirSync(jsDir) .filter(f => f.endsWith('.js') && f !== STATE_FILE) .map(f => fs.readFileSync(path.join(jsDir, f), 'utf8')) .join('\n'); @@ -52,8 +53,11 @@ test('the state object declares the fields this test expects to find', () => { test('no field is declared and never used', () => { const dead = fields.filter(f => !used(f) && !(f in ALLOWED)); - assert.deepEqual(dead, [], - `Declared in ${STATE_FILE} but referenced nowhere else. Remove them, or add to ALLOWED with a reason: ${dead.join(', ')}`); + assert.deepEqual( + dead, + [], + `Declared in ${STATE_FILE} but referenced nowhere else. Remove them, or add to ALLOWED with a reason: ${dead.join(', ')}`, + ); }); /* The guard above is a grep, so it is only sound while access stays static. @@ -61,8 +65,11 @@ test('no field is declared and never used', () => { this test would wrongly demand its removal. */ test('state is not accessed dynamically, which would defeat the check above', () => { const dynamic = [...others.matchAll(/\bst(?:ate)?\[[^\]]+\]/g)].map(m => m[0]); - assert.deepEqual(dynamic, [], - `Dynamic state access found, so the dead-field check is no longer reliable: ${dynamic.join(', ')}`); + assert.deepEqual( + dynamic, + [], + `Dynamic state access found, so the dead-field check is no longer reliable: ${dynamic.join(', ')}`, + ); }); /* The two fields the finding named, so a revert is caught by name and not only diff --git a/ui/test/badge-logic.test.mjs b/ui/test/badge-logic.test.mjs index 8f70031f..bdbfb987 100644 --- a/ui/test/badge-logic.test.mjs +++ b/ui/test/badge-logic.test.mjs @@ -3,14 +3,25 @@ import assert from 'node:assert/strict'; import { computeBadgeVisual, needsDark, resolveColor, NAMED, healthReason } from '../js/badge-logic.js'; test('unhealthy takes priority over everything else', () => { - const v = computeBadgeVisual({ health: true, activity: 5, staticBdg: { enabled: true, label: 'x' }, hasHC: true, hideHealthy: false }); + const v = computeBadgeVisual({ + health: true, + activity: 5, + staticBdg: { enabled: true, label: 'x' }, + hasHC: true, + hideHealthy: false, + }); assert.equal(v.cls, 'badge on red'); assert.equal(v.txt, '!'); assert.equal(v.aria, 'Status: needs attention'); }); test('activity takes priority over static label and healthy dot', () => { - const v = computeBadgeVisual({ activity: 3, staticBdg: { enabled: true, label: 'x' }, hasHC: true, hideHealthy: false }); + const v = computeBadgeVisual({ + activity: 3, + staticBdg: { enabled: true, label: 'x' }, + hasHC: true, + hideHealthy: false, + }); assert.equal(v.cls, 'badge on blue'); assert.equal(v.txt, '3'); assert.equal(v.aria, '3 pending'); @@ -152,13 +163,24 @@ test('healthReason tolerates junk', () => { /* ── the reason reaches the badge ─────────────────────────────────────────── */ test('an unhealthy badge carries the reason as hover text', () => { - const v = computeBadgeVisual({ health: 1, activity: 0, hasHC: true, healthDetail: { state: 'exited', status: 'Exited (1)' } }); + const v = computeBadgeVisual({ + health: 1, + activity: 0, + hasHC: true, + healthDetail: { state: 'exited', status: 'Exited (1)' }, + }); assert.equal(v.title, 'Exited (1)'); assert.match(v.aria, /needs attention: Exited \(1\)/, 'and is not sight-only'); }); test('a healthy badge carries no hover text', () => { - const v = computeBadgeVisual({ health: 0, activity: 0, hasHC: true, hideHealthy: false, healthDetail: { state: 'running', status: 'Up 3 days' } }); + const v = computeBadgeVisual({ + health: 0, + activity: 0, + hasHC: true, + hideHealthy: false, + healthDetail: { state: 'running', status: 'Up 3 days' }, + }); assert.equal(v.title, ''); }); diff --git a/ui/test/cache-busting.test.mjs b/ui/test/cache-busting.test.mjs index 89c68f12..31540d16 100644 --- a/ui/test/cache-busting.test.mjs +++ b/ui/test/cache-busting.test.mjs @@ -29,8 +29,9 @@ const REF = /(["'])(\/(?:css|js)\/[a-zA-Z0-9_.-]+\.(?:css|js))(\?v=[0-9a-zA-Z]+) function sources(dir, out = []) { for (const e of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) { const rel = path.join(dir, e.name); - if (e.isDirectory()) { if (e.name !== 'test' && e.name !== 'node_modules') sources(rel, out); } - else if (/\.(js|html)$/.test(e.name)) out.push(rel); + if (e.isDirectory()) { + if (e.name !== 'test' && e.name !== 'node_modules') sources(rel, out); + } else if (/\.(js|html)$/.test(e.name)) out.push(rel); } return out; } @@ -55,9 +56,14 @@ test('the scan finds the references it should', () => { /* The finding. */ test('every asset reference carries a version stamp', () => { - const missing = references().filter(r => !r.stamp).map(r => `${r.file} -> ${r.asset}`); - assert.deepEqual(missing, [], - `these will never cache-bust:\n${missing.join('\n')}\nAdd ?v=1; the build keeps it current.`); + const missing = references() + .filter(r => !r.stamp) + .map(r => `${r.file} -> ${r.asset}`); + assert.deepEqual( + missing, + [], + `these will never cache-bust:\n${missing.join('\n')}\nAdd ?v=1; the build keeps it current.`, + ); }); test('every referenced asset exists', () => { @@ -89,8 +95,7 @@ test('the build script can see an unstamped reference', () => { in test.yml, which now just calls it. */ test('the check is wired into CI', () => { const action = fs.readFileSync(path.resolve(root, '../.github/actions/checks/action.yml'), 'utf8'); - assert.match(action, /bump-cache-busting\.js --check/, - 'without this the check only runs when someone remembers to'); + assert.match(action, /bump-cache-busting\.js --check/, 'without this the check only runs when someone remembers to'); }); test('the workflows call the shared checks rather than listing their own', () => { diff --git a/ui/test/color-control.test.mjs b/ui/test/color-control.test.mjs index 409f1f32..4251e886 100644 --- a/ui/test/color-control.test.mjs +++ b/ui/test/color-control.test.mjs @@ -30,12 +30,17 @@ function stubContext() { /* Anything else leaves the previous value, which is what a browser does: an invalid assignment to fillStyle is ignored. */ }, - get fillStyle() { return value; }, + get fillStyle() { + return value; + }, }; } globalThis.document = { - createElement: () => { canvasCount++; return { getContext: () => stubContext() }; }, + createElement: () => { + canvasCount++; + return { getContext: () => stubContext() }; + }, }; const { _internals } = await import('../js/admin-color-control.js'); @@ -45,8 +50,7 @@ const { cssToHex, hsvToRgb, hexToHsv } = _internals; test('a value already in #rrggbb form is not parsed through a canvas', () => { canvasCount = 0; - for (const hex of ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', - '#ffcd00', '#35c759', '#0289ff', '#cb30df']) { + for (const hex of ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']) { assert.equal(cssToHex(hex), hex); } assert.equal(canvasCount, 0, 'the eight presets should need no parsing at all'); @@ -112,7 +116,10 @@ test('every hue yields channels in range', () => { for (const s of [0, 50, 100]) { for (const v of [0, 50, 100]) { const c = hsvToRgb(h, s, v); - assert.ok(c.every(n => Number.isInteger(n) && n >= 0 && n <= 255), `h=${h} s=${s} v=${v} -> ${c}`); + assert.ok( + c.every(n => Number.isInteger(n) && n >= 0 && n <= 255), + `h=${h} s=${s} v=${v} -> ${c}`, + ); } } } diff --git a/ui/test/config-card-visibility.test.mjs b/ui/test/config-card-visibility.test.mjs index d76427b8..d520f92e 100644 --- a/ui/test/config-card-visibility.test.mjs +++ b/ui/test/config-card-visibility.test.mjs @@ -30,7 +30,8 @@ register('./js-root-hooks.mjs', import.meta.url); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const { visibleFieldKeys } = await import('../js/admin-logic.js'); -const widgets = fs.readdirSync(path.join(root, 'widgets'), { withFileTypes: true }) +const widgets = fs + .readdirSync(path.join(root, 'widgets'), { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .filter(n => fs.existsSync(path.join(root, 'widgets', n, 'widget.json'))) @@ -63,9 +64,12 @@ test('every card has at least one field that always shows', () => { } } } - assert.deepEqual(empty, [], + assert.deepEqual( + empty, + [], `these cards can render empty:\n${empty.join('\n')}\n` + - 'Give each card a field with no showIf, usually the selector the others depend on.'); + 'Give each card a field with no showIf, usually the selector the others depend on.', + ); }); /* Stronger than counting: with nothing filled in, which is how a form opens, diff --git a/ui/test/contrast.test.mjs b/ui/test/contrast.test.mjs index 36d1e318..953d76c9 100644 --- a/ui/test/contrast.test.mjs +++ b/ui/test/contrast.test.mjs @@ -45,10 +45,7 @@ const raisedAt = s => s.indexOf('@media (prefers-contrast: more)'); /* Two resolvers: the default theme, and the one someone gets after asking their system for more contrast. The raised block is layered on top of the base. */ function resolver({ raised }) { - const base = new Map([ - ...declarations(tokens, 0, raisedAt(tokens)), - ...declarations(admin, 0, raisedAt(admin)), - ]); + const base = new Map([...declarations(tokens, 0, raisedAt(tokens)), ...declarations(admin, 0, raisedAt(admin))]); if (raised) { for (const [k, v] of declarations(tokens, raisedAt(tokens))) base.set(k, v); for (const [k, v] of declarations(admin, raisedAt(admin))) base.set(k, v); @@ -142,8 +139,7 @@ test('increased contrast improves the pairs that are near their threshold', () = if (h <= b) worse.push(`${fg} on ${bg}: ${h.toFixed(2)} raised, ${b.toFixed(2)} default`); } } - assert.deepEqual(worse, [], - `Increased contrast has to help where it matters:\n ${worse.join('\n ')}`); + assert.deepEqual(worse, [], `Increased contrast has to help where it matters:\n ${worse.join('\n ')}`); }); /* The two greys that are not Apple's exist because Apple's neighbours do not @@ -182,7 +178,11 @@ function toastRules() { const re = /#toast\.(ok|err)\{background:color-mix\(in srgb,\s*var\((--[\w-]+)\)\s+(\d+)%,\s*var\((--[\w-]+)\)\);border-color:var\((--[\w-]+)\)\}/g; const rules = [...src.matchAll(re)].map(m => ({ - cls: m[1], accent: m[2], percent: Number(m[3]), page: m[4], border: m[5], + cls: m[1], + accent: m[2], + percent: Number(m[3]), + page: m[4], + border: m[5], })); assert.equal(rules.length, 2, 'expected an .ok and an .err toast rule in the form the test reads'); return rules; @@ -190,7 +190,10 @@ function toastRules() { test('the toast reads its own colours from the stylesheet', () => { const rules = toastRules(); - assert.deepEqual(rules.map(r => r.cls), ['ok', 'err']); + assert.deepEqual( + rules.map(r => r.cls), + ['ok', 'err'], + ); for (const r of rules) assert.ok(r.percent > 0 && r.percent < 100, `odd mix percentage: ${r.percent}`); }); diff --git a/ui/test/css-tokens.test.mjs b/ui/test/css-tokens.test.mjs index dd8469e0..247bbc58 100644 --- a/ui/test/css-tokens.test.mjs +++ b/ui/test/css-tokens.test.mjs @@ -30,10 +30,31 @@ const all = files.map(f => [f, read(f)]); per-element sizing onto inline styles. A var() naming one of these is correct even though no stylesheet defines it. */ const RUNTIME = new Set([ - '--bg-image', '--bg-brightness', '--bg-color', - '--sc', '--sz', '--iw', '--rh', '--cw', '--lfs', '--lw', '--br', '--gap', - '--pad', '--gw', '--gh', '--fiw', '--dsz', '--tfs', '--left', '--bw', '--bh', - '--top', '--pt', '--ph', '--pb', + '--bg-image', + '--bg-brightness', + '--bg-color', + '--sc', + '--sz', + '--iw', + '--rh', + '--cw', + '--lfs', + '--lw', + '--br', + '--gap', + '--pad', + '--gw', + '--gh', + '--fiw', + '--dsz', + '--tfs', + '--left', + '--bw', + '--bh', + '--top', + '--pt', + '--ph', + '--pb', ]); const defined = new Set(); @@ -56,8 +77,11 @@ test('every var() names a token that exists', () => { missing.push(`${name} in ${f}`); } } - assert.deepEqual(missing, [], - `Undefined token with no fallback. The declaration is dropped at computed-value time and the property silently reverts:\n${missing.join('\n')}`); + assert.deepEqual( + missing, + [], + `Undefined token with no fallback. The declaration is dropped at computed-value time and the property silently reverts:\n${missing.join('\n')}`, + ); }); test('the accent is teal, through the role and not a literal', () => { @@ -107,16 +131,22 @@ test('no theme colour literal outside tokens.css', () => { } } } - assert.deepEqual(offenders, [], - `Theme colour written as a literal. Use a role from tokens.css, or declare a page-scoped token in that file's :root:\n${offenders.join('\n')}`); + assert.deepEqual( + offenders, + [], + `Theme colour written as a literal. Use a role from tokens.css, or declare a page-scoped token in that file's :root:\n${offenders.join('\n')}`, + ); }); /* The rules both pages need live in one file. They were written out in full in each, so the wallpaper layer was ten identical declarations in two places. */ test('the shared root rules are defined once, in tokens.css', () => { for (const sel of ['html::before', '*, *::before, *::after']) { - const owners = all.filter(([, src]) => src.includes(sel.replace(/, /g, ',')) - || src.includes(sel)).map(([f]) => f); - assert.deepEqual(owners, ['tokens.css'], `${sel} should be defined only in tokens.css, found in ${owners.join(', ')}`); + const owners = all.filter(([, src]) => src.includes(sel.replace(/, /g, ',')) || src.includes(sel)).map(([f]) => f); + assert.deepEqual( + owners, + ['tokens.css'], + `${sel} should be defined only in tokens.css, found in ${owners.join(', ')}`, + ); } }); diff --git a/ui/test/dashboard-localisation.test.mjs b/ui/test/dashboard-localisation.test.mjs index 84dd88aa..16b10b8d 100644 --- a/ui/test/dashboard-localisation.test.mjs +++ b/ui/test/dashboard-localisation.test.mjs @@ -41,8 +41,17 @@ const translatorFor = file => { /* ── the strings are translated ───────────────────────────────────────────── */ test('every locale carries the status strings', () => { - const needed = ['needsAttention', 'healthy', 'pending', 'stale', - 'containerNotFound', 'containerState', 'pingFailed', 'pingReturned', 'diskHealth']; + const needed = [ + 'needsAttention', + 'healthy', + 'pending', + 'stale', + 'containerNotFound', + 'containerState', + 'pingFailed', + 'pingReturned', + 'diskHealth', + ]; for (const file of locales()) { const cat = JSON.parse(read(`i18n/${file}`)); for (const key of needed) assert.ok(cat.status?.[key], `${file} is missing status.${key}`); @@ -69,8 +78,12 @@ test('every locale carries the API-unreachable message', () => { /* A translation that loses a placeholder produces a message with a hole in it. */ test('no translation drops a placeholder', () => { - const withVars = { 'status.pending': ['count'], 'status.containerState': ['state'], - 'status.pingFailed': ['error'], 'status.pingReturned': ['status'] }; + const withVars = { + 'status.pending': ['count'], + 'status.containerState': ['state'], + 'status.pingFailed': ['error'], + 'status.pingReturned': ['status'], + }; for (const file of locales()) { const cat = JSON.parse(read(`i18n/${file}`)); for (const [key, vars] of Object.entries(withVars)) { @@ -87,8 +100,7 @@ test('the translations are not all copies of the English', () => { const en = JSON.parse(read('i18n/en.json')); for (const file of locales().filter(f => f !== 'en.json')) { const cat = JSON.parse(read(`i18n/${file}`)); - const same = ['needsAttention', 'healthy', 'containerNotFound'] - .filter(k => cat.status[k] === en.status[k]); + const same = ['needsAttention', 'healthy', 'containerNotFound'].filter(k => cat.status[k] === en.status[k]); assert.equal(same.length, 0, `${file} left ${same.join(', ')} in English`); } }); @@ -150,7 +162,7 @@ test('the fallback matches the English locale exactly', () => { const src = read('js/badge-logic.js'); const table = src.slice(src.indexOf('const EN = {'), src.indexOf('};', src.indexOf('const EN = {'))); for (const [key, value] of Object.entries(en.status)) { - if (key === 'diskHealth') continue; /* not used by this module */ + if (key === 'diskHealth') continue; /* not used by this module */ assert.ok(table.includes(`'${value}'`), `the fallback disagrees with en.json for status.${key}`); } }); @@ -171,8 +183,12 @@ test('the dashboard hands the translator in', () => { test('the first-run prompt and API error are translated', () => { const src = read('js/dashboard.js'); - for (const gone of ['Set a dashboard password?', 'Could not connect to dashboard API', - 'Make sure the API container is running', 'Could not set password.']) { + for (const gone of [ + 'Set a dashboard password?', + 'Could not connect to dashboard API', + 'Make sure the API container is running', + 'Could not set password.', + ]) { assert.ok(!src.includes(`>${gone}<`) && !src.includes(`'${gone}'`), `"${gone}" is still hardcoded`); } for (const key of ['setup.title', 'setup.sub', 'home.apiDownTitle', 'home.retry']) { diff --git a/ui/test/dashboard-logic.test.mjs b/ui/test/dashboard-logic.test.mjs index 3152e449..08ac8dba 100644 --- a/ui/test/dashboard-logic.test.mjs +++ b/ui/test/dashboard-logic.test.mjs @@ -41,13 +41,27 @@ test('a bumped revision reloads', () => { Admin that other open dashboards used to ignore entirely. */ test('every kind of edit is noticed, not just name and link', () => { const edits = { - 'icon': c => { c.items[0].iconUrl = '/i/new.png'; }, - 'colour': c => { c.items[0].color = 'blue'; }, - 'dock pin': c => { c.items[0].dock = true; }, - 'hidden flag': c => { c.items[0].hidden = true; }, - 'badge settings': c => { c.items[0].badge = { enabled: true, url: 'https://r/api' }; }, - 'name': c => { c.items[0].label = 'Radarr 4K'; }, - 'link': c => { c.items[0].href = 'https://new'; }, + icon: c => { + c.items[0].iconUrl = '/i/new.png'; + }, + colour: c => { + c.items[0].color = 'blue'; + }, + 'dock pin': c => { + c.items[0].dock = true; + }, + 'hidden flag': c => { + c.items[0].hidden = true; + }, + 'badge settings': c => { + c.items[0].badge = { enabled: true, url: 'https://r/api' }; + }, + name: c => { + c.items[0].label = 'Radarr 4K'; + }, + link: c => { + c.items[0].href = 'https://new'; + }, }; for (const [what, edit] of Object.entries(edits)) { assert.equal(configChanged(loaded, withRev(5, edit)), true, `a changed ${what} should reload`); diff --git a/ui/test/dashboard-switch-model.test.mjs b/ui/test/dashboard-switch-model.test.mjs index 605ce38b..0a384c34 100644 --- a/ui/test/dashboard-switch-model.test.mjs +++ b/ui/test/dashboard-switch-model.test.mjs @@ -1,9 +1,15 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - PALETTE, KEY_STYLES, EXTRA_STYLES, - hashString, normalizeUrl, sameDashboard, - deriveComposition, deriveColor, buildModel, + PALETTE, + KEY_STYLES, + EXTRA_STYLES, + hashString, + normalizeUrl, + sameDashboard, + deriveComposition, + deriveColor, + buildModel, } from '../js/dashboard-switch-model.js'; /* ── normalizeUrl ── */ @@ -129,21 +135,31 @@ test('buildModel caps slots at 2 for small and 5 for medium', () => { }); test('buildModel drops rows without a usable URL', () => { - const m = buildModel({ keychains: [ - { name: 'ok', url: 'http://a.lan' }, - { name: 'blank', url: '' }, - { name: 'bad', url: 'javascript:alert(1)' }, - { name: 'missing' }, - ] }, { size: 'medium' }); + const m = buildModel( + { + keychains: [ + { name: 'ok', url: 'http://a.lan' }, + { name: 'blank', url: '' }, + { name: 'bad', url: 'javascript:alert(1)' }, + { name: 'missing' }, + ], + }, + { size: 'medium' }, + ); assert.equal(m.slots.length, 1); assert.equal(m.slots[0].name, 'ok'); }); test('buildModel de-duplicates keychains that canonicalize to the same URL', () => { - const m = buildModel({ keychains: [ - { name: 'first', url: 'http://dash.lan' }, - { name: 'dup', url: 'HTTP://dash.lan:80/' }, - ] }, { size: 'medium' }); + const m = buildModel( + { + keychains: [ + { name: 'first', url: 'http://dash.lan' }, + { name: 'dup', url: 'HTTP://dash.lan:80/' }, + ], + }, + { size: 'medium' }, + ); assert.equal(m.slots.length, 1); assert.equal(m.slots[0].name, 'first'); }); @@ -164,10 +180,15 @@ test('buildModel honours a picked colour and derives one when unset or invalid', }); test('buildModel flags the current dashboard by origin', () => { - const m = buildModel({ keychains: [ - { name: 'A', url: 'http://a.lan' }, - { name: 'B', url: 'http://b.lan' }, - ] }, { size: 'medium', currentHref: 'http://b.lan/admin/index.html' }); + const m = buildModel( + { + keychains: [ + { name: 'A', url: 'http://a.lan' }, + { name: 'B', url: 'http://b.lan' }, + ], + }, + { size: 'medium', currentHref: 'http://b.lan/admin/index.html' }, + ); assert.equal(m.slots.find(s => s.name === 'A').isCurrent, false); assert.equal(m.slots.find(s => s.name === 'B').isCurrent, true); }); diff --git a/ui/test/dialog.test.mjs b/ui/test/dialog.test.mjs index 389679f2..b1e4cd9d 100644 --- a/ui/test/dialog.test.mjs +++ b/ui/test/dialog.test.mjs @@ -49,14 +49,26 @@ function makeDom() { disabled: opts.disabled || false, attrs: new Map(Object.entries(opts.attrs || {})), offsetParent: opts.hidden ? null : {}, - focus() { active = this; this.focused = (this.focused || 0) + 1; }, - getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }, - setAttribute(k, v) { this.attrs.set(k, v); }, + focus() { + active = this; + this.focused = (this.focused || 0) + 1; + }, + getAttribute(k) { + return this.attrs.has(k) ? this.attrs.get(k) : null; + }, + setAttribute(k, v) { + this.attrs.set(k, v); + }, contains(other) { if (other === this) return true; return this.children.some(c => c.contains && c.contains(other)); }, - append(...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } }, + append(...kids) { + for (const k of kids) { + k.parent = this; + this.children.push(k); + } + }, querySelectorAll(sel) { /* Only the shapes the module asks for. */ const wanted = sel.split(',').map(s => s.trim()); @@ -69,7 +81,8 @@ function makeDom() { if (w.startsWith('input')) return c.tagName === 'INPUT' && !c.disabled; if (w.startsWith('select')) return c.tagName === 'SELECT' && !c.disabled; if (w.startsWith('textarea')) return c.tagName === 'TEXTAREA' && !c.disabled; - if (w.startsWith('[tabindex]')) return c.getAttribute('tabindex') != null && c.getAttribute('tabindex') !== '-1'; + if (w.startsWith('[tabindex]')) + return c.getAttribute('tabindex') != null && c.getAttribute('tabindex') !== '-1'; return false; }); if (matches) out.push(c); @@ -91,30 +104,47 @@ function makeDom() { _fire(type, event) { for (const l of (listeners.get(this) || []).filter(x => x.type === type)) l.fn(event); }, - _listenerCount() { return (listeners.get(this) || []).length; }, + _listenerCount() { + return (listeners.get(this) || []).length; + }, }; return node; }; globalThis.document = { - get activeElement() { return active; }, + get activeElement() { + return active; + }, createElement: t => el(t), }; globalThis.getComputedStyle = () => ({ visibility: 'visible', display: 'block', position: 'static' }); - return { el, setActive: n => { active = n; }, getActive: () => active }; + return { + el, + setActive: n => { + active = n; + }, + getActive: () => active, + }; } function keyEvent(key, shiftKey = false) { const e = { key, shiftKey, defaultPrevented: false }; - e.preventDefault = () => { e.defaultPrevented = true; }; + e.preventDefault = () => { + e.defaultPrevented = true; + }; return e; } async function withDom(fn) { const dom = makeDom(); const mod = await import('../js/dialog.js'); - try { await fn(mod, dom); } finally { delete globalThis.document; delete globalThis.getComputedStyle; } + try { + await fn(mod, dom); + } finally { + delete globalThis.document; + delete globalThis.getComputedStyle; + } } /* A dialog with three focusable controls and one that is not. */ @@ -273,7 +303,11 @@ test('Escape closes by default', async () => { await withDom(({ trapFocus }, dom) => { const { ov } = dialogOf(dom); let closed = 0; - trapFocus(ov, { onClose: () => { closed++; } }); + trapFocus(ov, { + onClose: () => { + closed++; + }, + }); ov._fire('keydown', keyEvent('Escape')); assert.equal(closed, 1); }); @@ -285,7 +319,12 @@ test('Escape can be turned off', async () => { await withDom(({ trapFocus }, dom) => { const { ov } = dialogOf(dom); let closed = 0; - trapFocus(ov, { closeOnEscape: false, onClose: () => { closed++; } }); + trapFocus(ov, { + closeOnEscape: false, + onClose: () => { + closed++; + }, + }); ov._fire('keydown', keyEvent('Escape')); assert.equal(closed, 0); }); @@ -308,7 +347,10 @@ test('releasing twice is harmless', async () => { await withDom(({ trapFocus }, dom) => { const { ov } = dialogOf(dom); const release = trapFocus(ov); - assert.doesNotThrow(() => { release(); release(); }); + assert.doesNotThrow(() => { + release(); + release(); + }); }); }); @@ -348,7 +390,10 @@ test('nothing keeps its own copy of the trap', () => { /* The search overlay's inline version is what left the others with nothing to reuse. */ for (const file of ['js/spotlight.js', 'js/ui.js', 'js/dashboard.js']) { - assert.doesNotMatch(read(file), /e\.shiftKey && document\.activeElement === first/, - `${file} still carries its own trap`); + assert.doesNotMatch( + read(file), + /e\.shiftKey && document\.activeElement === first/, + `${file} still carries its own trap`, + ); } }); diff --git a/ui/test/frontend-typecheck.test.mjs b/ui/test/frontend-typecheck.test.mjs index 4c4604a4..5a4acbf4 100644 --- a/ui/test/frontend-typecheck.test.mjs +++ b/ui/test/frontend-typecheck.test.mjs @@ -27,8 +27,7 @@ const raw = fs.readFileSync(cfgPath, 'utf8'); const cfg = JSON.parse(raw.replace(/^\s*\/\*[\s\S]*?\*\/\s*/, '')); test('the frontend project is actually a typecheck', () => { - assert.equal(cfg.compilerOptions.checkJs, true, - 'checkJs off makes this a path resolver, not a typecheck'); + assert.equal(cfg.compilerOptions.checkJs, true, 'checkJs off makes this a path resolver, not a typecheck'); assert.equal(cfg.compilerOptions.allowJs, true); assert.equal(cfg.compilerOptions.noEmit, true); }); @@ -51,8 +50,11 @@ test('every module under ui/js has both path entries', () => { else if (paths[key][0] !== `./ui/js/${m}`) missing.push(`${key} points at ${paths[key][0]}`); } } - assert.deepEqual(missing, [], - `Add both the plain and the ?v=* form; TypeScript allows one wildcard per pattern:\n ${missing.join('\n ')}`); + assert.deepEqual( + missing, + [], + `Add both the plain and the ?v=* form; TypeScript allows one wildcard per pattern:\n ${missing.join('\n ')}`, + ); }); test('the project covers ui/js', () => { diff --git a/ui/test/html.test.mjs b/ui/test/html.test.mjs index a0a15cac..a5d8ee5c 100644 --- a/ui/test/html.test.mjs +++ b/ui/test/html.test.mjs @@ -11,10 +11,8 @@ test('interpolated values are escaped without being asked', () => { }); test('escaping covers the attribute-breakout characters', () => { - assert.equal(s(html`
`), - '
'); - assert.equal(s(html`
`), - "
"); + assert.equal(s(html`
`), '
'); + assert.equal(s(html`
`), "
"); }); test('static markup in the template is left alone', () => { @@ -32,8 +30,7 @@ test('nested html results are not double-escaped', () => { test('arrays are joined with no separator and each item escaped', () => { const items = ['a&b', '']; - assert.equal(s(html`
    ${items.map(i => html`
  • ${i}
  • `)}
`), - '
  • a&b
  • <c>
'); + assert.equal(s(html`
    ${items.map(i => html`
  • ${i}
  • `)}
`), '
  • a&b
  • <c>
'); }); test('nested arrays flatten', () => { diff --git a/ui/test/i18n-coverage.test.mjs b/ui/test/i18n-coverage.test.mjs index b34f3752..1c1f45a2 100644 --- a/ui/test/i18n-coverage.test.mjs +++ b/ui/test/i18n-coverage.test.mjs @@ -18,20 +18,56 @@ import { fileURLToPath } from 'node:url'; const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'i18n'); const read = f => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')); -const flat = (o, p = '') => Object.entries(o) - .flatMap(([k, v]) => (v && typeof v === 'object' ? flat(v, `${p}${k}.`) : [[p + k, v]])); +const flat = (o, p = '') => + Object.entries(o).flatMap(([k, v]) => (v && typeof v === 'object' ? flat(v, `${p}${k}.`) : [[p + k, v]])); /* Keys whose English value is correct in that language too. */ const SAME_ON_PURPOSE = { - 'de.json': new Set(['nav.dashboard', 'about.support', 'dashboard.filterApps', 'dashboard.filterWidgets', - 'type.app', 'type.widget', 'app.name', 'app.url', 'app.badge', 'app.container', 'app.ping', - 'app.dockPill', 'app.badgePill', 'home.dock', 'status.containerState', 'widgetCfg.name', - 'general.importExport']), - 'es.json': new Set(['nav.general', 'common.color', 'appearance.color', 'dashboard.filterWidgets', - 'type.widget', 'app.url', 'app.ping', 'app.dockPill', 'home.dock']), - 'fr.json': new Set(['general.description', 'appearance.source', 'about.documentation', - 'dashboard.filterWidgets', 'type.widget', 'app.url', 'app.type', 'app.ping', 'app.badge', - 'app.secret', 'app.dockPill', 'app.badgePill', 'home.dock']), + 'de.json': new Set([ + 'nav.dashboard', + 'about.support', + 'dashboard.filterApps', + 'dashboard.filterWidgets', + 'type.app', + 'type.widget', + 'app.name', + 'app.url', + 'app.badge', + 'app.container', + 'app.ping', + 'app.dockPill', + 'app.badgePill', + 'home.dock', + 'status.containerState', + 'widgetCfg.name', + 'general.importExport', + ]), + 'es.json': new Set([ + 'nav.general', + 'common.color', + 'appearance.color', + 'dashboard.filterWidgets', + 'type.widget', + 'app.url', + 'app.ping', + 'app.dockPill', + 'home.dock', + ]), + 'fr.json': new Set([ + 'general.description', + 'appearance.source', + 'about.documentation', + 'dashboard.filterWidgets', + 'type.widget', + 'app.url', + 'app.type', + 'app.ping', + 'app.badge', + 'app.secret', + 'app.dockPill', + 'app.badgePill', + 'home.dock', + ]), 'fa.json': new Set([]), 'zh-Hans.json': new Set(['app.ping']), }; @@ -41,11 +77,14 @@ const en = Object.fromEntries(flat(read('en.json'))); for (const [file, allowed] of Object.entries(SAME_ON_PURPOSE)) { test(`${file} translates every value that is not deliberately shared`, () => { const other = Object.fromEntries(flat(read(file))); - const untranslated = Object.keys(en).filter(k => - !k.startsWith('_meta.') && !/Ph$/.test(k) && /* placeholders are examples */ - /[A-Za-z]{3}/.test(String(en[k])) && /* something translatable */ - other[k] === en[k] && !allowed.has(k)); - assert.deepEqual(untranslated, [], - `${file}: still English. Translate, or add to SAME_ON_PURPOSE with a reason.`); + const untranslated = Object.keys(en).filter( + k => + !k.startsWith('_meta.') && + !/Ph$/.test(k) /* placeholders are examples */ && + /[A-Za-z]{3}/.test(String(en[k])) /* something translatable */ && + other[k] === en[k] && + !allowed.has(k), + ); + assert.deepEqual(untranslated, [], `${file}: still English. Translate, or add to SAME_ON_PURPOSE with a reason.`); }); } diff --git a/ui/test/i18n-markup.test.mjs b/ui/test/i18n-markup.test.mjs index 5af970b4..19d9df1a 100644 --- a/ui/test/i18n-markup.test.mjs +++ b/ui/test/i18n-markup.test.mjs @@ -68,8 +68,12 @@ test('every locale keeps its markup too', () => { for (const key of htmlKeys()) { const [a, b] = key.split('.'); const value = cat[a]?.[b]; - if (!value) continue; /* a locale may not translate every key yet */ - assert.deepEqual(tagsOf(sanitizeI18nMarkup(value)), tagsOf(value), `${file}: ${key} uses markup outside the subset`); + if (!value) continue; /* a locale may not translate every key yet */ + assert.deepEqual( + tagsOf(sanitizeI18nMarkup(value)), + tagsOf(value), + `${file}: ${key} uses markup outside the subset`, + ); } } }); @@ -110,7 +114,7 @@ test('a tag outside the subset is shown, not dropped', () => { test('ordinary text is escaped as usual', () => { assert.equal(sanitizeI18nMarkup('plain & '), 'plain & <text>'); - assert.equal(sanitizeI18nMarkup("it's \"quoted\""), 'it's "quoted"'); + assert.equal(sanitizeI18nMarkup('it\'s "quoted"'), 'it's "quoted"'); }); /* ── malformed markup cannot unbalance the output ─────────────────────────── */ @@ -139,8 +143,17 @@ test('a self-closing non-void tag is not treated as an element', () => { }); test('output has balanced tags for arbitrary junk', () => { - const junk = ['<>', '', '<>', '', '', - 'x', '


', 'x']; + const junk = [ + '<>', + '', + '<>', + '', + '', + 'x', + '


', + 'x', + ]; for (const v of junk) { const out = sanitizeI18nMarkup(v); const opens = (out.match(/<(strong|em|code)>/g) || []).length; diff --git a/ui/test/i18n-reachability.test.mjs b/ui/test/i18n-reachability.test.mjs index c0bdbfac..a11da57f 100644 --- a/ui/test/i18n-reachability.test.mjs +++ b/ui/test/i18n-reachability.test.mjs @@ -91,8 +91,11 @@ test('the scan finds the catalog and the source', () => { belonged to was deleted and the string left behind. */ test('every catalog key is referenced by name', () => { const unreferenced = keys.filter(k => !referenced.has(k) && !(k in DYNAMIC)).sort(); - assert.deepEqual(unreferenced, [], - `Defined in en.json but named nowhere. Remove them, or add to DYNAMIC with a reason:\n ${unreferenced.join('\n ')}`); + assert.deepEqual( + unreferenced, + [], + `Defined in en.json but named nowhere. Remove them, or add to DYNAMIC with a reason:\n ${unreferenced.join('\n ')}`, + ); }); /* Direction two: this is the one a user sees. t('hom.retry') renders the string @@ -104,8 +107,11 @@ test('every referenced key exists in the catalog', () => { const known = new Set(keys); const missing = [...named].filter(k => !known.has(k)).sort(); - assert.deepEqual(missing, [], - `Referenced but not in en.json; t() renders the key itself to the user:\n ${missing.join('\n ')}`); + assert.deepEqual( + missing, + [], + `Referenced but not in en.json; t() renders the key itself to the user:\n ${missing.join('\n ')}`, + ); }); /* The mechanism this replaced. Its absence is what makes the two tests above @@ -113,8 +119,10 @@ test('every referenced key exists in the catalog', () => { test('translation by matching English text is gone', () => { const i18n = fs.readFileSync(path.join(uiDir, 'js/i18n.js'), 'utf8'); for (const gone of ['revMap', 'translateText', 'TEXT_SELECTORS']) { - assert.ok(!i18n.includes(gone), - `${gone} is back. Translating by matching rendered English makes reachability undecidable and breaks silently when the English changes.`); + assert.ok( + !i18n.includes(gone), + `${gone} is back. Translating by matching rendered English makes reachability undecidable and breaks silently when the English changes.`, + ); } }); diff --git a/ui/test/i18n.test.mjs b/ui/test/i18n.test.mjs index c754b3ff..365d8a8e 100644 --- a/ui/test/i18n.test.mjs +++ b/ui/test/i18n.test.mjs @@ -52,8 +52,15 @@ test('t falls back to the key itself when nothing is loaded', () => { "constructor", "toString" and the rest. t() returned the inherited function instead of the key, and that value was written into the DOM. */ test('t returns the key itself for a key named after an inherited member', () => { - for (const key of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', - 'isPrototypeOf', 'propertyIsEnumerable', '__proto__']) { + for (const key of [ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + 'isPrototypeOf', + 'propertyIsEnumerable', + '__proto__', + ]) { assert.equal(t(key), key, key); } }); diff --git a/ui/test/icons.test.mjs b/ui/test/icons.test.mjs index e59a446e..06e71aeb 100644 --- a/ui/test/icons.test.mjs +++ b/ui/test/icons.test.mjs @@ -165,10 +165,11 @@ test('a name already in catalogue form is unchanged', () => { test('a mixed-case name reaches the right CDN URL', async () => { await withLocalIcons([], () => { - assert.equal(iconChain('MySpeed')[0], - 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/myspeed.svg'); - assert.equal(iconChain('Home Assistant')[0], - 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg'); + assert.equal(iconChain('MySpeed')[0], 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/myspeed.svg'); + assert.equal( + iconChain('Home Assistant')[0], + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg', + ); }); }); diff --git a/ui/test/inline-handlers.test.mjs b/ui/test/inline-handlers.test.mjs index 09a33952..ccb0c654 100644 --- a/ui/test/inline-handlers.test.mjs +++ b/ui/test/inline-handlers.test.mjs @@ -22,8 +22,9 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); function sources(dir, out = []) { for (const e of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) { const rel = path.join(dir, e.name); - if (e.isDirectory()) { if (e.name !== 'test' && e.name !== 'node_modules') sources(rel, out); } - else if (/\.(js|mjs|html)$/.test(e.name)) out.push(rel); + if (e.isDirectory()) { + if (e.name !== 'test' && e.name !== 'node_modules') sources(rel, out); + } else if (/\.(js|mjs|html)$/.test(e.name)) out.push(rel); } return out; } @@ -120,20 +121,31 @@ test('an unterminated comment does not hide or reveal a handler', () => { const closed = '