Files
UmbrelApps/tests/test_limiter.mjs
T

376 lines
17 KiB
JavaScript
Raw Normal View History

// ═══════════════════════════════════════════════════════════════════════════════
// 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);