// ═══════════════════════════════════════════════════════════════════════════════ // Toetst het toegangsbeleid van de relay: tools/evolu-relay/src/policy.js // // Draaien: node tests/test_limiter.mjs // // Geen testrunner en geen afhankelijkheden, net als de Python-tests in deze map. // Geeft 0 terug als alles goed is en 1 bij de eerste fout in de telling. // // Waarom dit bestand er is: dit beleid is de enige plek die bepaalt wie er op de // relay mag schrijven. De duurste fout zit hier niet in een berekening maar in // een guard, en dat is precies wat hieronder de meeste aandacht krijgt. // // **Sinds 0.7.0 is er één weg naar binnen: de wachtlijst.** De getimede leerstand // is eruit. Twee toetsen hieronder bewaken dat die niet stiekem terugkomt: een // onbekende eigenaar wordt geweigerd ook als er `learning: true` in het // staatsbestand staat, en `set-learning` is een onbekende actie. // ═══════════════════════════════════════════════════════════════════════════════ import { MAX_OWNER_ID_LENGTH, MAX_REJECTED, applyCommand, createEmptyState, decideOwner, normalizeState, } from '../tools/evolu-relay/src/policy.js'; let goed = 0; let fout = 0; const toets = (omschrijving, voorwaarde) => { if (voorwaarde) { goed += 1; return; } fout += 1; console.log(`FOUT: ${omschrijving}`); }; const NU = '2026-09-09T12:00:00.000Z'; const LATER = '2026-09-09T13:00:00.000Z'; const EIGEN = 'owner-van-de-gebruiker'; const VREEMD = 'owner-van-een-ander'; /** Een staat met deze eigenaar erin, toegelaten of niet. */ const metEigenaar = (id, allowed, extra = {}) => ({ ...createEmptyState(), owners: [{ id, allowed, firstSeen: NU, lastSeen: NU, ...extra }], }); // ── Een verse installatie laat niemand binnen ───────────────────────────────── { const leeg = createEmptyState(); toets('een verse staat heeft geen eigenaars', leeg.owners.length === 0); toets('en een lege wachtlijst', leeg.rejected.length === 0); toets('en geen leerstand meer', !('learning' in leeg) && !('learningUntil' in leeg)); const uitkomst = decideOwner(leeg, VREEMD, NU); toets('een onbekende eigenaar wordt geweigerd', uitkomst.allowed === false); toets('met reden "waiting"', uitkomst.reason === 'waiting'); toets('en belandt op de wachtlijst', uitkomst.state.rejected.length === 1); toets('met zijn id erbij', uitkomst.state.rejected[0].id === VREEMD); toets('en één poging', uitkomst.state.rejected[0].attempts === 1); toets('en de staat is veranderd', uitkomst.changed === true); toets('en hij staat niet stiekem bij de eigenaars', uitkomst.state.owners.length === 0); } // ── De leerstand is er echt uit ─────────────────────────────────────────────── { // Dit is de toets die de verwijdering van 0.7.0 vasthoudt. Een staatsbestand // van 0.6.0 heeft deze velden nog, en ze horen geen enkel effect te hebben. const metOudeVelden = { ...createEmptyState(), learning: true, learningUntil: null, }; const uitkomst = decideOwner(metOudeVelden, VREEMD, NU); toets('`learning: true` in het bestand laat niemand meer binnen', uitkomst.allowed === false); toets('en zet hem op de wachtlijst', uitkomst.state.rejected[0]?.id === VREEMD); const metVenster = { ...createEmptyState(), learning: true, learningUntil: LATER, }; toets( 'ook een venster dat nog niet verlopen is doet niets', decideOwner(metVenster, VREEMD, NU).allowed === false, ); const opdracht = applyCommand(createEmptyState(), { action: 'set-learning', value: true }, NU); toets('set-learning is een onbekende actie geworden', opdracht.error === 'unknown-action'); toets('en verandert niets', opdracht.changed === false); } // ── De guard die er het meest toe doet ──────────────────────────────────────── { // Een geblokkeerde eigenaar mag er nooit in. Dat was tot 0.6.0 het gevaar dat // de leerstand hem alsnog toeliet; die is er nu uit, maar de guard blijft de // belangrijkste regel van dit bestand. const geblokkeerd = metEigenaar(EIGEN, false); const uitkomst = decideOwner(geblokkeerd, EIGEN, LATER); toets('een geblokkeerde eigenaar blijft buiten', uitkomst.allowed === false); toets('met reden "known-blocked"', uitkomst.reason === 'known-blocked'); toets('en komt niet op de wachtlijst', uitkomst.state.rejected.length === 0); toets('hij blijft geblokkeerd staan', uitkomst.state.owners[0].allowed === false); toets('en "laatst gezien" wordt bijgewerkt', uitkomst.state.owners[0].lastSeen === LATER); const toegelaten = decideOwner(metEigenaar(EIGEN, true), EIGEN, LATER); toets('een toegelaten eigenaar mag erin', toegelaten.allowed === true); toets('met reden "known-allowed"', toegelaten.reason === 'known-allowed'); toets('en "laatst gezien" wordt bijgewerkt', toegelaten.state.owners[0].lastSeen === LATER); const zelfdeMoment = decideOwner(metEigenaar(EIGEN, true), EIGEN, NU); toets('twee keer op hetzelfde moment verandert niets', zelfdeMoment.changed === false); } // ── Onbruikbare invoer ──────────────────────────────────────────────────────── { const leeg = createEmptyState(); for (const onzin of ['', null, 42, {}, [], 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) { const uitkomst = decideOwner(leeg, onzin, NU); toets(`${JSON.stringify(onzin)} wordt geweigerd`, uitkomst.allowed === false); // Onthouden zou hier juist de weg openzetten om het bestand vol te schrijven. toets(`${JSON.stringify(onzin)} wordt niet onthouden`, uitkomst.changed === false); toets(`${JSON.stringify(onzin)} geeft reden invalid-owner-id`, uitkomst.reason === 'invalid-owner-id'); } toets( `een id van precies ${MAX_OWNER_ID_LENGTH} tekens mag wel`, decideOwner(leeg, 'x'.repeat(MAX_OWNER_ID_LENGTH), NU).changed === true, ); } // ── De wachtlijst loopt niet vol ────────────────────────────────────────────── { let staat = createEmptyState(); for (let i = 0; i < MAX_REJECTED + 10; i += 1) { staat = decideOwner(staat, `vreemde-${i}`, NU).state; } toets('de wachtlijst wordt afgekapt', staat.rejected.length === MAX_REJECTED); // Nieuwste vooraan: een aanvaller die blijft proberen duwt zijn eigen eerdere // pogingen weg en niet de eigenaar die er net bij kwam. toets('de nieuwste staat vooraan', staat.rejected[0].id === `vreemde-${MAX_REJECTED + 9}`); const nogmaals = decideOwner(staat, `vreemde-${MAX_REJECTED + 9}`, LATER); toets('een tweede poging telt op', nogmaals.state.rejected[0].attempts === 2); toets('en zet geen tweede regel neer', nogmaals.state.rejected.length === MAX_REJECTED); toets('en houdt het eerste moment vast', nogmaals.state.rejected[0].firstSeen === NU); toets('en werkt het laatste bij', nogmaals.state.rejected[0].lastSeen === LATER); } // ── Toelaten vanaf de wachtlijst ────────────────────────────────────────────── { // De weg die er sinds 0.7.0 als enige is: iemand staat op de wachtlijst en jij // laat hem toe. const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state; const toegelaten = applyCommand(gewacht, { action: 'allow', ownerId: VREEMD }, LATER); toets('toelaten lukt', toegelaten.error === null); toets('en hij staat bij de eigenaars', toegelaten.state.owners[0]?.id === VREEMD); toets('als toegelaten', toegelaten.state.owners[0]?.allowed === true); // De geschiedenis gaat mee. Ging dat mis, dan meldt de pagina "first seen" op // het moment van de klik terwijl die eigenaar al twintig minuten aan de deur // stond, en dat is juist bij een eigenaar die je alsnog toelaat wat je wilt zien. toets('met het eerste moment van de wachtlijst', toegelaten.state.owners[0]?.firstSeen === NU); // En dit is de klacht van de gebruiker van 09-09-2026, die met de leerstand mee // verdween: hij mag niet in beide lijsten tegelijk staan. toets('en hij is van de wachtlijst af', toegelaten.state.rejected.length === 0); toets('en daarna mag hij erin', decideOwner(toegelaten.state, VREEMD, LATER).allowed === true); } // ── De overige opdrachten van de pagina ─────────────────────────────────────── { const staat = metEigenaar(EIGEN, true); const geblokkeerd = applyCommand(staat, { action: 'block', ownerId: EIGEN }, LATER); toets('blokkeren lukt', geblokkeerd.error === null); toets('en zet allowed op false', geblokkeerd.state.owners[0].allowed === false); toets( 'blokkeren van een onbekende wordt geweigerd', applyCommand(staat, { action: 'block', ownerId: VREEMD }, LATER).error === 'unknown-owner', ); const vergeten = applyCommand(staat, { action: 'forget', ownerId: EIGEN }, LATER); toets('vergeten lukt', vergeten.error === null); toets('en de eigenaar is weg', vergeten.state.owners.length === 0); toets( 'vergeten van een onbekende wordt geweigerd', applyCommand(staat, { action: 'forget', ownerId: VREEMD }, LATER).error === 'unknown-owner', ); const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state; const weg = applyCommand(gewacht, { action: 'forget', ownerId: VREEMD }, LATER); toets('iemand van de wachtlijst vergeten lukt ook', weg.error === null); toets('en dan is de wachtlijst leeg', weg.state.rejected.length === 0); for (const onzin of [null, 42, 'tekst', {}, { action: 'iets' }]) { const uitkomst = applyCommand(staat, onzin, NU); toets(`${JSON.stringify(onzin)} als opdracht verandert niets`, uitkomst.changed === false); toets(`${JSON.stringify(onzin)} als opdracht geeft een fout`, uitkomst.error !== null); } for (const onzin of ['', null, 42, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) { toets( `block met ownerId ${JSON.stringify(onzin)} wordt geweigerd`, applyCommand(staat, { action: 'block', ownerId: onzin }, NU).error === 'malformed-command', ); } } // ── Wat er van schijf komt ──────────────────────────────────────────────────── { toets('null is geen staat', normalizeState(null) === null); toets('een array is geen staat', normalizeState([]) === null); toets('een verkeerde versie is geen staat', normalizeState({ version: 2, owners: [], rejected: [] }) === null); toets('zonder owners is het geen staat', normalizeState({ version: 1, rejected: [] }) === null); toets('zonder rejected is het geen staat', normalizeState({ version: 1, owners: [] }) === null); // Dit is de toets die de installatie van de gebruiker beschermt: een bestand // van 0.6.0 heeft `learning` en `learningUntil` en moet gewoon lezen. Zou dat // afgewezen worden, dan schuift store.js de allowlist opzij en gaat de deur // dicht voor eigenaars die er al in stonden. const oud = { version: 1, learning: true, learningUntil: '2026-09-09T12:02:00.000Z', owners: [{ id: EIGEN, allowed: true, firstSeen: NU, lastSeen: NU }], rejected: [{ id: VREEMD, firstSeen: NU, lastSeen: NU, attempts: 3 }], }; const gelezen = normalizeState(oud); toets('een bestand van 0.6.0 leest gewoon', gelezen !== null); toets('met de eigenaar erin', gelezen.owners[0].id === EIGEN); toets('en de wachtlijst erin', gelezen.rejected[0].attempts === 3); toets('en zonder de oude velden', !('learning' in gelezen) && !('learningUntil' in gelezen)); toets( 'een eigenaar zonder allowed is geen staat', normalizeState({ version: 1, owners: [{ id: EIGEN }], rejected: [] }) === null, ); toets( 'een eigenaar met een leeg id is geen staat', normalizeState({ version: 1, owners: [{ id: '', allowed: true }], rejected: [] }) === null, ); toets( 'een wachtende zonder id is geen staat', normalizeState({ version: 1, owners: [], rejected: [{ attempts: 1 }] }) === null, ); toets( 'een onleesbaar aantal pogingen wordt 1 en geen weigering', normalizeState({ version: 1, owners: [], rejected: [{ id: VREEMD, attempts: -3 }] }) ?.rejected[0].attempts === 1, ); } console.log(''); console.log(`${goed} goed, ${fout} fout`); process.exit(fout === 0 ? 0 : 1);