// Generated, do not edit. sha256:b010e85d240007ea64c04ad8392259f493d1479d73de4754cba27fd27e215811 import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { createRequestListener } from '../../server/app.mjs'; import { passwordMatches, parseCookies, sessionCookie, clearedSessionCookie, sessionTokenOf, resolveBinding, } from '../../server/auth.mjs'; import { createSessionStore, createAttemptLimiter } from '../../server/sessions.mjs'; import { CONFIG } from '../../shared/config.js'; const GELDIG = { password: 'geheim' }; function serverMet(opties) { const ctx = {}; before(async () => { ctx.server = createServer(createRequestListener(opties)); await new Promise(klaar => ctx.server.listen(0, '127.0.0.1', klaar)); ctx.basis = `http://127.0.0.1:${ctx.server.address().port}`; }); after(() => new Promise(klaar => ctx.server.close(klaar))); ctx.haal = (pad, init = {}) => fetch(`${ctx.basis}${pad}`, { redirect: 'manual', ...init }); ctx.login = (wachtwoord) => ctx.haal('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: wachtwoord }), }); return ctx; } const cookieUit = (res) => (res.headers.get('set-cookie') || '').split(';')[0]; describe('passwordMatches', () => { it('accepteert het juiste wachtwoord en weigert een verkeerd', () => { assert.equal(passwordMatches('geheim', GELDIG), true); assert.equal(passwordMatches('fout', GELDIG), false); }); it('weigert alles zodra er niets is ingesteld of het ingestelde leeg is', () => { assert.equal(passwordMatches('', { password: '' }), false); assert.equal(passwordMatches('watdanook', { password: '' }), false); assert.equal(passwordMatches('watdanook', null), false); assert.equal(passwordMatches(null, GELDIG), false); assert.equal(passwordMatches(undefined, GELDIG), false); }); it('accepteert geen niet-string die toevallig gelijk oogt', () => { assert.equal(passwordMatches({ toString: () => 'geheim' }, GELDIG), false); assert.equal(passwordMatches(['geheim'], GELDIG), false); }); it('behandelt niet-ASCII en dubbele punten gewoon als tekens', () => { assert.equal(passwordMatches('a:b:c', { password: 'a:b:c' }), true); assert.equal(passwordMatches('wachtwoord€', { password: 'wachtwoord€' }), true); }); }); describe('parseCookies', () => { it('leest een enkel cookie en meerdere naast elkaar', () => { assert.deepEqual(parseCookies('a=1'), { a: '1' }); assert.deepEqual(parseCookies('a=1; b=2'), { a: '1', b: '2' }); }); it('slaat brokken zonder = over in plaats van om te vallen', () => { assert.deepEqual(parseCookies('rommel; a=1'), { a: '1' }); assert.deepEqual(parseCookies(''), {}); assert.deepEqual(parseCookies(undefined), {}); }); it('laat de EERSTE waarde winnen bij een dubbele naam', () => { assert.equal(parseCookies('s=echt; s=vals').s, 'echt'); }); it('laat een waarde met een = erin heel', () => { assert.equal(parseCookies('s=a=b').s, 'a=b'); }); }); describe('het sessiecookie', () => { it('draagt HttpOnly, SameSite en een Max-Age die bij de sessie past', () => { const c = sessionCookie('abc'); assert.match(c, /^assetapp_session=abc;/); assert.match(c, /HttpOnly/); assert.match(c, /SameSite=Lax/); assert.match(c, new RegExp(`Max-Age=${Math.floor(CONFIG.AUTH.SESSION_TTL_MS / 1000)}\\b`)); }); it('krijgt Secure alleen mét TLS', () => { assert.doesNotMatch(sessionCookie('abc', { secure: false }), /Secure/); assert.match(sessionCookie('abc', { secure: true }), /Secure/); }); it('haalt zichzelf weg met dezelfde vlaggen als waarmee hij gezet is', () => { const gezet = sessionCookie('abc', { secure: true }); const weg = clearedSessionCookie({ secure: true }); const vlaggenVan = (c) => c.split(';').slice(1).map(d => d.trim()).filter(d => !d.startsWith('Max-Age')); assert.deepEqual(vlaggenVan(weg), vlaggenVan(gezet)); assert.match(weg, /Max-Age=0/); }); it('leest het token terug uit een verzoek, en geeft null zonder cookie', () => { assert.equal(sessionTokenOf({ headers: { cookie: 'assetapp_session=xyz' } }), 'xyz'); assert.equal(sessionTokenOf({ headers: { cookie: 'iets=anders' } }), null); assert.equal(sessionTokenOf({ headers: {} }), null); assert.equal(sessionTokenOf(undefined), null); }); }); describe('createSessionStore', () => { it('geeft een vers token dat geldig is, en twee keer niet hetzelfde', () => { const s = createSessionStore(); const a = s.create(); const b = s.create(); assert.notEqual(a, b); assert.equal(s.isValid(a), true); assert.equal(s.isValid(b), true); }); it('weigert een token dat hij nooit heeft uitgegeven', () => { const s = createSessionStore(); s.create(); assert.equal(s.isValid('a'.repeat(64)), false); assert.equal(s.isValid(''), false); assert.equal(s.isValid(undefined), false); assert.equal(s.isValid(null), false); assert.equal(s.isValid(123), false); }); it('laat een token vervallen zodra de tijd om is', () => { let nu = 1000; const s = createSessionStore({ ttlMs: 100, now: () => nu }); const token = s.create(); nu = 1099; assert.equal(s.isValid(token), true, 'net vóór de grens telt hij nog'); nu = 1100; assert.equal(s.isValid(token), false, 'op de grens is hij voorbij'); }); it('verlengt een sessie NIET door hem te gebruiken', () => { let nu = 0; const s = createSessionStore({ ttlMs: 100, now: () => nu }); const token = s.create(); for (nu = 10; nu < 100; nu += 10) assert.equal(s.isValid(token), true); nu = 100; assert.equal(s.isValid(token), false); }); it('ruimt een verlopen token op in plaats van hem te laten liggen', () => { let nu = 0; const s = createSessionStore({ ttlMs: 100, now: () => nu }); s.create(); nu = 200; s.create(); assert.equal(s.size(), 1, 'de verlopen sessie hoort bij het aanmaken opgeruimd te zijn'); }); it('beëindigt een sessie met drop, en een onbekend token is geen fout', () => { const s = createSessionStore(); const token = s.create(); assert.equal(s.drop(token), true); assert.equal(s.isValid(token), false); assert.equal(s.drop('bestaat-niet'), false); }); it('deelt niets tussen twee opslagen', () => { const a = createSessionStore(); const b = createSessionStore(); assert.equal(b.isValid(a.create()), false); }); }); describe('createAttemptLimiter', () => { it('laat de eerste pogingen vrij en gaat daarna oplopend wachten', () => { let nu = 0; const l = createAttemptLimiter({ free: 3, baseLockMs: 100, maxLockMs: 10000, now: () => nu }); const adres = '127.0.0.1'; for (let i = 0; i < 3; i++) { l.fail(adres); assert.equal(l.retryAfterMs(adres), 0); } assert.equal(l.fail(adres), 100, 'de vierde misser kost de basiswachttijd'); assert.equal(l.fail(adres), 200, 'en daarna verdubbelt het'); assert.equal(l.fail(adres), 400); }); it('laat de wachttijd aflopen met de tijd', () => { let nu = 0; const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu }); l.fail('a'); assert.equal(l.retryAfterMs('a'), 100); nu = 60; assert.equal(l.retryAfterMs('a'), 40); nu = 100; assert.equal(l.retryAfterMs('a'), 0); }); it('zet de teller NIET terug door de wachttijd uit te zitten', () => { let nu = 0; const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu }); l.fail('a'); nu = 100; assert.equal(l.retryAfterMs('a'), 0); assert.equal(l.fail('a'), 200, 'de volgende misser bouwt voort op de vorige'); }); it('gaat niet boven het maximum uit', () => { let nu = 0; const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu }); for (let i = 0; i < 20; i++) l.fail('a'); assert.equal(l.retryAfterMs('a'), 250); }); it('telt per adres en niet over adressen heen', () => { let nu = 0; const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu }); l.fail('a'); l.fail('a'); l.fail('a'); assert.equal(l.retryAfterMs('b'), 0); }); it('vergeet een adres na een geslaagde poging', () => { let nu = 0; const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu }); l.fail('a'); l.succeed('a'); assert.equal(l.retryAfterMs('a'), 0); assert.equal(l.size(), 0); }); it('vergeet een adres dat lang genoeg stil is, zodat de lijst niet eindeloos groeit', () => { let nu = 0; const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu }); l.fail('a'); nu = 100; assert.equal(l.size(), 1, 'net uitgezeten is nog niet vergeten'); nu = 100000; l.retryAfterMs('a'); assert.equal(l.size(), 0); }); }); describe('resolveBinding', () => { it('bindt op alle interfaces zodra TLS én inloggegevens er zijn', () => { assert.deepEqual(resolveBinding({ tls: true, credentials: GELDIG }), { host: '0.0.0.0', waarschuwing: null }); }); it('bindt op loopback zolang een van de twee ontbreekt', () => { assert.equal(resolveBinding({ tls: false, credentials: GELDIG }).host, '127.0.0.1'); assert.equal(resolveBinding({ tls: true, credentials: null }).host, '127.0.0.1'); assert.equal(resolveBinding({}).host, '127.0.0.1'); }); it('volgt een expliciete host, maar waarschuwt als die onbeschermd buiten loopback ligt', () => { const uit = resolveBinding({ tls: false, credentials: null, host: '0.0.0.0' }); assert.equal(uit.host, '0.0.0.0'); assert.match(uit.waarschuwing, /TLS en inloggegevens/); }); it('noemt in de waarschuwing alleen wat er werkelijk ontbreekt', () => { const uit = resolveBinding({ tls: true, credentials: null, host: '192.168.1.50' }); assert.match(uit.waarschuwing, /inloggegevens ontbreekt/); assert.doesNotMatch(uit.waarschuwing, /TLS/); }); it('waarschuwt niet bij een expliciete loopback-host', () => { for (const host of ['127.0.0.1', '::1', 'localhost']) { assert.equal(resolveBinding({ tls: false, credentials: null, host }).waarschuwing, null); } }); }); describe('createRequestListener: alles zit achter de sessiecontrole', () => { const ctx = serverMet({ credentials: GELDIG }); it('weigert /api/health zonder cookie, met een kale 401', async () => { const r = await ctx.haal('/api/health'); assert.equal(r.status, 401); }); it('stuurt op een DOCUMENTverzoek om naar het inlogscherm', async () => { const r = await ctx.haal('/'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), CONFIG.AUTH.LOGIN_PATH); }); it('stuurt op een API-verzoek NIET om, want fetch zou die omleiding volgen', async () => { const r = await ctx.haal('/api/data/assets/listAssets', { method: 'POST' }); assert.equal(r.status, 401); assert.equal(r.headers.get('location'), null); }); it('weigert een onbekend /api-pad ook vóór de router, dus zonder 404 te verklappen', async () => { const r = await ctx.haal('/api/bestaat-niet'); assert.equal(r.status, 401); }); it('draagt NERGENS een WWW-Authenticate-header', async () => { for (const pad of ['/', '/api/health', '/api/bestaat-niet', '/css/styles.css']) { const r = await ctx.haal(pad); assert.equal(r.headers.get('www-authenticate'), null, `${pad} draagt de header nog`); } }); }); describe('createRequestListener: inloggen en uitloggen', () => { const ctx = serverMet({ credentials: GELDIG }); it('weigert een verkeerd wachtwoord zonder cookie te zetten', async () => { const r = await ctx.login('fout'); assert.equal(r.status, 401); assert.equal(r.headers.get('set-cookie'), null); }); it('zet bij het juiste wachtwoord een HttpOnly-sessiecookie', async () => { const r = await ctx.login('geheim'); assert.equal(r.status, 200); const cookie = r.headers.get('set-cookie'); assert.match(cookie, /^assetapp_session=[0-9a-f]{64};/); assert.match(cookie, /HttpOnly/); }); it('opent daarmee de app én de API', async () => { const cookie = cookieUit(await ctx.login('geheim')); const app = await ctx.haal('/', { headers: { cookie } }); assert.equal(app.status, 200); const api = await ctx.haal('/api/health', { headers: { cookie } }); assert.equal(api.status, 200); assert.deepEqual(await api.json(), { ok: true }); }); it('weigert een verzonnen cookie', async () => { const r = await ctx.haal('/api/health', { headers: { cookie: `assetapp_session=${'a'.repeat(64)}` } }); assert.equal(r.status, 401); }); it('maakt het cookie ongeldig bij uitloggen, aan beide kanten', async () => { const cookie = cookieUit(await ctx.login('geheim')); const uit = await ctx.haal('/api/logout', { method: 'POST', headers: { cookie } }); assert.equal(uit.status, 200); assert.match(uit.headers.get('set-cookie'), /Max-Age=0/, 'de browser hoort het cookie weg te gooien'); const na = await ctx.haal('/api/health', { headers: { cookie } }); assert.equal(na.status, 401); }); it('stuurt een ingelogde bezoeker van het inlogscherm door naar de app', async () => { const cookie = cookieUit(await ctx.login('geheim')); const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH, { headers: { cookie } }); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), '/'); }); it('weigert GET op de inlog- en uitlogroute', async () => { assert.equal((await ctx.haal('/api/login')).status, 405); assert.equal((await ctx.haal('/api/logout')).status, 405); }); it('laat het formulier ook zonder JavaScript geen wachtwoord in de URL zetten', async () => { const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8'); const form = /]*id="login-form"[^>]*>/.exec(html); assert.ok(form, 'geen formulier met id login-form gevonden'); assert.match(form[0], /method="post"/i); assert.match(form[0], /action="\/api\/login"/); }); it('weigert een body die geen JSON is, zonder de teller te verstoren', async () => { const r = await ctx.haal('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'geen json', }); assert.equal(r.status, 400); assert.equal((await ctx.login('geheim')).status, 200); }); }); describe('createRequestListener: de uitzondering voor het inlogscherm', () => { const ctx = serverMet({ credentials: GELDIG }); it('laat het inlogscherm zelf zien', async () => { const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH); assert.equal(r.status, 200); assert.match(r.headers.get('content-type'), /text\/html/); assert.match(await r.text(), /login-form/); }); it('laat ELK bestand door dat login.html noemt', async () => { const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8'); const verwijzingen = [...html.matchAll(/(?:src|href)\s*=\s*"([^"]+)"/g)].map(m => m[1]) .filter(pad => !/^(https?:|data:|#|\/\/)/.test(pad)); assert.ok(verwijzingen.length >= 3, `verwacht minstens drie verwijzingen, gevonden ${verwijzingen.length}`); for (const pad of verwijzingen) { assert.equal((await ctx.haal(`/${pad}`)).status, 200, `/${pad} staat in login.html maar is dicht`); } }); it('laat de lettertypebestanden door waar die stylesheet naar wijst', async () => { const css = readFileSync(new URL('../../client/vendor/inter/inter.css', import.meta.url), 'utf8'); const eerste = /url\(\.\/(files\/[^)]+)\)/.exec(css); assert.ok(eerste, 'geen enkel woff2-bestand gevonden in inter.css'); assert.equal((await ctx.haal(`/vendor/inter/${eerste[1]}`)).status, 200); }); it('houdt de app-schil dicht, en dat is de reden dat dit een eigen pagina is', async () => { for (const pad of ['/index.html', '/app.js', '/boot-gate.js', '/shared/config.js', '/portfolio.js', '/i18n/nl.js', '/data/_rpc.js']) { const r = await ctx.haal(pad); assert.equal(r.status, 302, `${pad} is bereikbaar zonder in te loggen`); } }); it('laat geen ander bestand uit de font-map van de app door dan de fonts zelf', async () => { assert.equal((await ctx.haal('/vendor/crypto/noble-scure.js')).status, 302); }); it('laat de uitzondering niet door een gecodeerd pad misbruiken', async () => { for (const pad of ['/css/%2e%2e/app.js', '/vendor/inter/files/%2e%2e/%2e%2e/%2e%2e/app.js']) { const r = await ctx.haal(pad); assert.equal(r.status, 302, `${pad} kwam langs de uitzondering`); } }); }); describe('createRequestListener: de pogingenlimiet', () => { const ctx = serverMet({ credentials: GELDIG }); it('gaat na een reeks missers wachten en meldt hoe lang', async () => { let laatste; for (let i = 0; i < CONFIG.AUTH.LOGIN_ATTEMPTS.FREE + 1; i++) laatste = await ctx.login('fout'); const json = await laatste.json(); assert.equal(laatste.status, 401); assert.ok(json.retryAfterMs > 0, 'de wachttijd hoort in het antwoord te staan'); const geblokkeerd = await ctx.login('geheim'); assert.equal(geblokkeerd.status, 429); assert.ok(Number(geblokkeerd.headers.get('retry-after')) >= 1); }); }); describe('createRequestListener zonder inloggegevens: bewust open', () => { const ctx = serverMet({}); it('serveert zonder cookie, want dit is de modus die resolveBinding op loopback houdt', async () => { assert.equal((await ctx.haal('/api/health')).status, 200); }); it('toont geen inlogscherm, want er valt niets te ontsluiten', async () => { const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), '/'); }); });