// ═══════════════════════════════════════════════════════════════════════════════ // 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 getoetst wordt: een geblokkeerde // eigenaar mag niet alsnog binnenkomen doordat de leerstand aanstaat. // ═══════════════════════════════════════════════════════════════════════════════ import { MAX_LEARNING_SECONDS, MAX_OWNER_ID_LENGTH, MAX_REJECTED, applyCommand, createEmptyState, decideOwner, expireLearning, isLearningOpen, 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-08-28T12:00:00.000Z'; const LATER = '2026-08-28T13:00:00.000Z'; // Binnen en buiten een venster van twee minuten dat op NU begint. const BINNEN = '2026-08-28T12:01:00.000Z'; const NA = '2026-08-28T12:02:30.000Z'; const EIGEN = 'owner-van-de-gebruiker'; const VREEMD = 'owner-van-een-ander'; const TWEE_MINUTEN = 120; // ── De leerstand ────────────────────────────────────────────────────────────── { const vers = createEmptyState(); toets('een verse staat staat in de leerstand', vers.learning === true); const eerste = decideOwner(vers, EIGEN, NU); toets('de eerste eigenaar wordt toegelaten', eerste.allowed === true); toets('en onthouden als toegelaten', eerste.state.owners.length === 1); toets('met de reden dat hij geleerd is', eerste.reason === 'learned'); const nogEens = decideOwner(eerste.state, EIGEN, LATER); toets('dezelfde eigenaar blijft toegelaten', nogEens.allowed === true); toets('en komt er niet dubbel in', nogEens.state.owners.length === 1); toets('de reden is nu dat hij bekend is', nogEens.reason === 'known-allowed'); } // ── De leerstand uit ────────────────────────────────────────────────────────── { const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state; const dicht = applyCommand(geleerd, { action: 'set-learning', value: false }, NU).state; const eigen = decideOwner(dicht, EIGEN, LATER); toets('met de leerstand uit mag de bekende eigenaar er nog steeds in', eigen.allowed === true); const vreemd = decideOwner(dicht, VREEMD, LATER); toets('maar een onbekende wordt geweigerd', vreemd.allowed === false); toets('de weigering wordt onthouden voor de pagina', vreemd.state.rejected.length === 1); toets('en hij belandt niet in de allowlist', vreemd.state.owners.length === 1); } // ── De guard die er het meest toe doet ──────────────────────────────────────── { const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state; const geblokkeerd = applyCommand(geleerd, { action: 'block', ownerId: EIGEN }, NU).state; toets('de leerstand staat hier nog aan', geblokkeerd.learning === true); const opnieuw = decideOwner(geblokkeerd, EIGEN, LATER); toets( 'een geblokkeerde eigenaar komt er niet alsnog in via de leerstand', opnieuw.allowed === false, ); toets('en de reden zegt dat hij geblokkeerd is', opnieuw.reason === 'known-blocked'); } // ── Onbruikbare invoer ──────────────────────────────────────────────────────── { const vers = createEmptyState(); for (const rommel of ['', null, undefined, 42, {}, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) { const uitkomst = decideOwner(vers, rommel, NU); toets(`een onbruikbaar id (${typeof rommel}) wordt geweigerd`, uitkomst.allowed === false); toets('en wordt niet onthouden', uitkomst.state.owners.length === 0); toets('ook niet als geweigerde poging', uitkomst.state.rejected.length === 0); } } // ── De lijst met weigeringen loopt niet vol ─────────────────────────────────── { let staat = { ...createEmptyState(), learning: false }; for (let i = 0; i < MAX_REJECTED + 5; i += 1) { staat = decideOwner(staat, `indringer-${i}`, NU).state; } toets('de lijst met weigeringen wordt afgekapt', staat.rejected.length === MAX_REJECTED); toets('en de nieuwste staat vooraan', staat.rejected[0].id === `indringer-${MAX_REJECTED + 4}`); const herhaling = decideOwner(staat, `indringer-${MAX_REJECTED + 4}`, LATER); toets('een herhaalde poging telt op', herhaling.state.rejected[0].attempts === 2); toets('en voegt geen tweede regel toe', herhaling.state.rejected.length === MAX_REJECTED); } // ── De opdrachten van de pagina ─────────────────────────────────────────────── { const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state; const uit = applyCommand(geleerd, { action: 'set-learning', value: false }, NU); toets('de leerstand gaat uit', uit.state.learning === false); toets('en dat is een wijziging', uit.changed === true); const nogEensUit = applyCommand(uit.state, { action: 'set-learning', value: false }, NU); toets('nog een keer uitzetten verandert niets', nogEensUit.changed === false); toets('en is geen fout', nogEensUit.error === null); const vergeten = applyCommand(geleerd, { action: 'forget', ownerId: EIGEN }, NU); toets('vergeten haalt de eigenaar uit de lijst', vergeten.state.owners.length === 0); const onbekend = applyCommand(geleerd, { action: 'forget', ownerId: VREEMD }, NU); toets('een onbekende vergeten is een fout', onbekend.error === 'unknown-owner'); toets('en verandert niets', onbekend.changed === false); const blokkeerOnbekend = applyCommand(geleerd, { action: 'block', ownerId: VREEMD }, NU); toets('een onbekende blokkeren is een fout', blokkeerOnbekend.error === 'unknown-owner'); const dicht = { ...createEmptyState(), learning: false }; const geweigerd = decideOwner(dicht, VREEMD, NU).state; const alsnog = applyCommand(geweigerd, { action: 'allow', ownerId: VREEMD }, LATER); toets('een geweigerde poging kan alsnog toegelaten worden', alsnog.state.owners.length === 1); toets('en verdwijnt dan uit de weigeringen', alsnog.state.rejected.length === 0); toets( 'en houdt het moment waarop hij zich meldde, niet dat van de klik', alsnog.state.owners[0].firstSeen === NU, ); toets( 'en mag er daarna in, ook met de leerstand uit', decideOwner(alsnog.state, VREEMD, LATER).allowed === true, ); toets( 'een onbekende actie is een fout', applyCommand(geleerd, { action: 'sudo' }, NU).error === 'unknown-action', ); toets( 'een opdracht zonder vorm is een fout', applyCommand(geleerd, 'zet alles open', NU).error === 'malformed-command', ); toets( 'een leerstand-opdracht zonder boolean is een fout', applyCommand(geleerd, { action: 'set-learning', value: 'ja' }, NU).error === 'malformed-command', ); } // ── Het tijdvenster voor nieuwe eigenaars ───────────────────────────────────── // // De pagina zet de deur twee minuten open in plaats van een schakelaar om te // zetten die je moet onthouden. De teller hoort in dít bestand en niet in de // browser: een teller in een tabblad dat je sluit, sluit de deur niet. { const vers = createEmptyState(); toets('een verse staat heeft geen tijdslot', vers.learningUntil === null); toets('en staat dus open zonder dat er een klok loopt', isLearningOpen(vers, LATER) === true); const open = applyCommand( vers, { action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, NU, ); toets('openzetten met seconden is een wijziging', open.changed === true); toets('en zet een tijdslot', open.state.learningUntil === '2026-08-28T12:02:00.000Z'); toets('binnen het venster staat de deur open', isLearningOpen(open.state, BINNEN) === true); toets('erna niet meer', isLearningOpen(open.state, NA) === false); } // De guard die er het meest toe doet, en dit is de reden dat `decideOwner` naar // `isLearningOpen` kijkt en niet naar het veld `learning`: tussen het aflopen van // het venster en de ronde die het bestand opruimt zitten een paar seconden. In dat // gat mag er niemand binnenkomen. { const open = applyCommand( createEmptyState(), { action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, NU, ).state; toets('in het bestand staat de leerstand nog aan', open.learning === true); const binnen = decideOwner(open, EIGEN, BINNEN); toets('binnen het venster wordt een onbekende geleerd', binnen.allowed === true); const erna = decideOwner(open, VREEMD, NA); toets( 'na het venster wordt hij geweigerd, ook al staat learning nog op true', erna.allowed === false, ); toets('met de reden dat er niet geleerd wordt', erna.reason === 'not-learning'); toets('en de poging wordt onthouden voor de pagina', erna.state.rejected.length === 1); // En wie al binnen was, blijft binnen. Het venster gaat over onbekenden. const bekend = decideOwner(binnen.state, EIGEN, LATER); toets('een geleerde eigenaar mag er na het venster nog steeds in', bekend.allowed === true); } { const open = applyCommand( createEmptyState(), { action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, NU, ).state; // Opnieuw indrukken verlengt. Zonder deze uitzondering zou `applyCommand` // 'changed: false' teruggeven omdat learning al true was, en dan liep het // venster af terwijl de gebruiker net verlengde. const opnieuw = applyCommand( open, { action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, BINNEN, ); toets('opnieuw openzetten is een wijziging', opnieuw.changed === true); toets('en zet de klok terug', opnieuw.state.learningUntil === '2026-08-28T12:03:00.000Z'); toets('dus is de deur na het oude venster nog open', isLearningOpen(opnieuw.state, NA) === true); // Vroegtijdig sluiten haalt het tijdslot mee weg. Zou het blijven staan, dan // erfde een volgende 'open zonder seconden' een venster dat niet gevraagd is. const dicht = applyCommand(open, { action: 'set-learning', value: false }, BINNEN); toets('vroegtijdig sluiten kan', dicht.state.learning === false); toets('en haalt het tijdslot weg', dicht.state.learningUntil === null); toets('de deur is dan dicht', isLearningOpen(dicht.state, BINNEN) === false); const zonderSlot = applyCommand(dicht.state, { action: 'set-learning', value: true }, BINNEN); toets('openzetten zonder seconden blijft bestaan', zonderSlot.state.learning === true); toets('en heeft dan geen tijdslot', zonderSlot.state.learningUntil === null); } // Wat de agent hoort tegen te houden, houdt dit bestand ook tegen. Twee zeven, en // dat is met opzet: dit is de kant die van buiten komt. { const vers = createEmptyState(); const rommel = [ { action: 'set-learning', value: true, seconds: 0 }, { action: 'set-learning', value: true, seconds: -60 }, { action: 'set-learning', value: true, seconds: 1.5 }, { action: 'set-learning', value: true, seconds: '120' }, { action: 'set-learning', value: true, seconds: true }, { action: 'set-learning', value: true, seconds: MAX_LEARNING_SECONDS + 1 }, ]; for (const opdracht of rommel) { const uitkomst = applyCommand(vers, opdracht, NU); toets( `een onbruikbaar aantal seconden wordt geweigerd: ${JSON.stringify(opdracht.seconds)}`, uitkomst.error === 'malformed-command', ); toets('en verandert niets', uitkomst.changed === false); } toets( 'de bovengrens zelf mag wel', applyCommand(vers, { action: 'set-learning', value: true, seconds: MAX_LEARNING_SECONDS }, NU) .error === null, ); // Een onleesbaar `now` mag geen venster zonder einde opleveren. Dat is de reden // dat `addSeconds` null teruggeeft in plaats van iets te verzinnen. const kapotteKlok = applyCommand( vers, { action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, 'geen tijdstip', ); toets('met een onleesbare klok wordt de opdracht geweigerd', kapotteKlok.error === 'malformed-command'); toets('en blijft de staat zoals hij was', kapotteKlok.changed === false); } // Het opruimen. Correctheid hangt hier niet aan, want `isLearningOpen` weigert al; // wat dit oplevert is dat het bestand en de pagina hetzelfde zeggen als de klok. { const open = applyCommand( createEmptyState(), { action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, NU, ).state; const nogNiet = expireLearning(open, BINNEN); toets('binnen het venster wordt er niets opgeruimd', nogNiet.changed === false); toets('en de staat blijft dezelfde', nogNiet.state === open); const opgeruimd = expireLearning(open, NA); toets('na het venster gaat de leerstand uit', opgeruimd.state.learning === false); toets('en het tijdslot weg', opgeruimd.state.learningUntil === null); toets('en dat is een wijziging', opgeruimd.changed === true); toets( 'nog een keer opruimen doet niets', expireLearning(opgeruimd.state, LATER).changed === false, ); toets( 'een leerstand zonder tijdslot wordt niet opgeruimd', expireLearning(createEmptyState(), LATER).changed === false, ); // Een onleesbaar tijdstip in het bestand is dicht, niet open. Dezelfde regel als // bij een onleesbare owners.json in store.js: bij twijfel weigert de app. const kapot = { ...createEmptyState(), learningUntil: 'ergens volgende week' }; toets('een onleesbaar tijdslot leest als dicht', isLearningOpen(kapot, NU) === false); toets('en een onleesbare klok ook', isLearningOpen(open, 'geen tijdstip') === false); } // ── Lezen van schijf ────────────────────────────────────────────────────────── { const goedeStaat = decideOwner(createEmptyState(), EIGEN, NU).state; const rondgang = normalizeState(JSON.parse(JSON.stringify(goedeStaat))); toets('een eigen staat overleeft de rondgang door JSON', rondgang !== null); toets('met de eigenaar er nog in', rondgang.owners[0].id === EIGEN); const rommel = [ null, 'tekst', [], { version: 999, learning: true, owners: [], rejected: [] }, { version: 1, learning: 'ja', owners: [], rejected: [] }, { version: 1, learning: true, owners: {}, rejected: [] }, { version: 1, learning: true, owners: [{ id: '', allowed: true }], rejected: [] }, { version: 1, learning: true, owners: [{ id: EIGEN }], rejected: [] }, ]; for (const invoer of rommel) { toets(`onbegrepen staat wordt geweigerd: ${JSON.stringify(invoer)}`, normalizeState(invoer) === null); } // Het tijdslot is toegevoegd zonder STATE_VERSION te verhogen, en dat is de // toets die dat verdedigt: een bestand van een draaiende installatie mag niet // ineens onleesbaar worden. Zou dat gebeuren, 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, owners: [{ id: EIGEN, allowed: true }], rejected: [] }; const gelezen = normalizeState(oud); toets('een bestand van vóór het tijdslot leest nog', gelezen !== null); toets('met de leerstand die erin stond', gelezen.learning === true); toets('en zonder tijdslot', gelezen.learningUntil === null); toets('en de eigenaar er nog in', gelezen.owners[0].id === EIGEN); const metSlot = normalizeState({ ...oud, learningUntil: '2026-08-28T12:02:00.000Z' }); toets('een geldig tijdslot blijft staan', metSlot.learningUntil === '2026-08-28T12:02:00.000Z'); for (const onzin of ['morgen', '', 42, {}, null]) { toets( `een onleesbaar tijdslot wordt null en geen weigering: ${JSON.stringify(onzin)}`, normalizeState({ ...oud, learningUntil: onzin }) !== null && normalizeState({ ...oud, learningUntil: onzin }).learningUntil === null, ); } } console.log(''); console.log(`${goed} goed, ${fout} fout`); process.exit(fout === 0 ? 0 : 1);