Files
HarmenandClaude Opus 5 a092464e4d Relay 0.7.0: een weg naar binnen, en de melding bij elke klik weg
Vier punten van de gebruiker, opgekomen tijdens het beproeven met de testclient.

De getimede leerstand is eruit; de wachtlijst is de enige weg naar binnen. Zijn
redenering: allebei de wegen vragen iemand die bij de app kan, dus het is
dubbelop, en het venster is de zwakste omdat het iedereen toelaat die er
toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek wss-adres
kan staan. De oorspronkelijke reden voor de leerstand, dat je je eigen OwnerId
nergens kon aflezen, verviel toen de weigerlijst dat id ging tonen. Daarmee
verdwijnt ook de bug die hij dezelfde dag meldde: een geleerde eigenaar bleef in
de weigerlijst staan terwijl hij al kon schrijven en lezen, want decideOwner
haalde hem niet van die lijst af en de knop allow wel.

"Refused owners" heet "Waiting list", met de badge Waiting en een teller waar de
widget van het tijdvenster stond. Het veld op schijf blijft rejected: hernoemen
zou een migratie zijn voor een woord dat niemand ziet.

Het adres onderaan zei http:// en dat kan nergens werken, want de relay spreekt
WebSocket en nooit HTTP. Nu ws://<host>:3852, met een regel over wss://<domein>
zonder poort achter een reverse proxy. Dat is precies de fout die diezelfde dag
een ronde kostte bij het koppelen van de testclient.

De melding bij elke klik is weg. Die stond in de gewone stroom van de pagina,
dus alles eronder schoof omlaag en weer omhoog. Nu gaan de knoppen in de lijsten
even op slot tot de ronde de nieuwe stand heeft; foutmeldingen blijven wel staan,
want die zeggen iets wat je nergens anders ziet.

STATE_VERSION blijft 1 en een owners.json van 0.6.0 leest door: learning en
learningUntil worden gelezen, genegeerd en niet teruggeschreven. Een verhoging
zou store.js de allowlist van een werkende installatie opzij laten schuiven.

Twee toetsen bewaken dat de leerstand niet terugsluipt: een onbekende eigenaar
wordt geweigerd ook met learning: true in het bestand, en set-learning is een
onbekende actie. Beide mutatie-getest.

De compose staat op 0.7.0 zonder digest, zodat het hard faalt tot de image
bestaat. Bouwen, duwen en pinnen ligt bij de gebruiker.

Suite: 424 goed, 0 fout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:45:55 +02:00

268 lines
13 KiB
JavaScript

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