Files

268 lines
13 KiB
JavaScript
Raw Permalink 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 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);