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>
This commit is contained in:
Harmen
2026-09-09 15:45:55 +02:00
co-authored by Claude Opus 5
parent 04c20a37f0
commit a092464e4d
13 changed files with 449 additions and 804 deletions
+169 -277
View File
@@ -8,19 +8,20 @@
//
// 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.
// 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_LEARNING_SECONDS,
MAX_OWNER_ID_LENGTH,
MAX_REJECTED,
applyCommand,
createEmptyState,
decideOwner,
expireLearning,
isLearningOpen,
normalizeState,
} from '../tools/evolu-relay/src/policy.js';
@@ -36,338 +37,229 @@ const toets = (omschrijving, voorwaarde) => {
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 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';
const TWEE_MINUTEN = 120;
// ── De leerstand ──────────────────────────────────────────────────────────────
/** 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 vers = createEmptyState();
toets('een verse staat staat in de leerstand', vers.learning === true);
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 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');
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 uit ──────────────────────────────────────────────────────────
// ── De leerstand is er echt uit ───────────────────────────────────────────────
{
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
const dicht = applyCommand(geleerd, { action: 'set-learning', value: false }, NU).state;
// 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 eigen = decideOwner(dicht, EIGEN, LATER);
toets('met de leerstand uit mag de bekende eigenaar er nog steeds in', eigen.allowed === true);
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 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);
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 ────────────────────────────────────────
{
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
const geblokkeerd = applyCommand(geleerd, { action: 'block', ownerId: EIGEN }, NU).state;
// 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);
toets('de leerstand staat hier nog aan', geblokkeerd.learning === true);
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 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');
const zelfdeMoment = decideOwner(metEigenaar(EIGEN, true), EIGEN, NU);
toets('twee keer op hetzelfde moment verandert niets', zelfdeMoment.changed === false);
}
// ── 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);
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 lijst met weigeringen loopt niet vol ───────────────────────────────────
// ── De wachtlijst 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;
let staat = createEmptyState();
for (let i = 0; i < MAX_REJECTED + 10; i += 1) {
staat = decideOwner(staat, `vreemde-${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}`);
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 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);
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);
}
// ── De opdrachten van de pagina ───────────────────────────────────────────────
// ── Toelaten vanaf de wachtlijst ──────────────────────────────────────────────
{
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
// 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);
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);
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);
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',
);
toets('en daarna mag hij erin', decideOwner(toegelaten.state, VREEMD, LATER).allowed === true);
}
// ── 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.
// ── De overige opdrachten van de pagina ───────────────────────────────────────
{
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 staat = metEigenaar(EIGEN, 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);
}
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);
// 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,
'blokkeren van een onbekende wordt geweigerd',
applyCommand(staat, { action: 'block', ownerId: VREEMD }, LATER).error === 'unknown-owner',
);
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,
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',
);
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 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);
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);
}
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);
}
// 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);
for (const onzin of ['', null, 42, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
toets(
`een onbruikbaar aantal seconden wordt geweigerd: ${JSON.stringify(opdracht.seconds)}`,
uitkomst.error === 'malformed-command',
`block met ownerId ${JSON.stringify(onzin)} wordt geweigerd`,
applyCommand(staat, { action: 'block', ownerId: onzin }, NU).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 ──────────────────────────────────────────────────────────
// ── Wat er van schijf komt ────────────────────────────────────────────────────
{
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);
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);
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: [] };
// 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 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);
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));
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,
);
}
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('');
+10 -110
View File
@@ -1,4 +1,4 @@
"""Toetst de agent van Evolu Relay: de labels en het tijdvenster.
"""Toetst de agent van Evolu Relay: de labels.
Waarom deze test bestaat. De agent van deze app beslist niets over toegang, en dat
is precies waarom hij tot 30-08-2026 ongetoetst kon blijven: hij las een bestand en
@@ -9,9 +9,9 @@ eigen logica hebben:
- **hij schrijft zelf een bestand**, `labels.json`, en is daar de enige schrijver
van. Dat is de eerste plek waar de agent staat bijhoudt in plaats van doorgeeft,
en labelen is lezen-wijzigen-schrijven, dus er zit een slot om;
- **hij rekent het tijdvenster uit** dat de pagina toont. Een verlopen venster moet
hij als dicht rapporteren, ook al staat er in owners.json nog dat de leerstand
aan is: het relay-proces ruimt dat op zijn eigen moment op.
Tot 0.6.0 rekende hij daarnaast het tijdvenster voor nieuwe eigenaars uit. Dat
venster is er in 0.7.0 uit: er is nog één weg naar binnen, en dat is de wachtlijst
met handmatig toelaten. De toetsen daarover zijn met de functie mee verdwenen.
Wat deze test NIET dekt: de HTTP-laag. De handlers zitten in een
BaseHTTPRequestHandler en die is zonder socket niet aan te roepen; wat eronder
@@ -105,66 +105,6 @@ def test_template_is_invulbaar_zonder_schade(u):
"een envsubst-stap zou die invullen en de Python-code slopen")
# ── Het tijdvenster ─────────────────────────────────────────────────────────
def test_venster_dat_nog_loopt(agent, u):
feiten = agent.learning_facts({"learning": True, "learningUntil": iso(90)})
u.check("een lopend venster leest als open", feiten["learning"] is True)
u.check("met een resterende tijd", isinstance(feiten["learningSecondsLeft"], int))
u.check("die ergens rond de negentig seconden ligt",
85 <= feiten["learningSecondsLeft"] <= 90,
"kreeg %r" % feiten["learningSecondsLeft"])
def test_venster_dat_verlopen_is(agent, u):
"""De toets die er het meest toe doet aan deze kant.
In het bestand staat `learning: true` en een tijdstip dat voorbij is. Het
relay-proces ruimt dat op in zijn lus van twee seconden, dus er is een moment
waarop het bestand nog "aan" zegt terwijl de deur dicht is. De pagina hoort dan
"closed" te tonen en geen open deur.
"""
feiten = agent.learning_facts({"learning": True, "learningUntil": iso(-30)})
u.check("een verlopen venster leest als dicht", feiten["learning"] is False)
u.check("met nul seconden over", feiten["learningSecondsLeft"] == 0)
u.check("en zonder tijdstip", feiten["learningUntil"] is None)
def test_venster_zonder_tijdslot(agent, u):
feiten = agent.learning_facts({"learning": True, "learningUntil": None})
u.check("open zonder tijdslot blijft open", feiten["learning"] is True)
u.check("en heeft geen teller", feiten["learningSecondsLeft"] is None)
def test_onleesbaar_tijdslot(agent, u):
"""Onleesbaar hoort niet als "voor altijd open" te lezen, maar wel als open.
Anders dan bij `policy.js`, en dat is met opzet: dit is de weergave en niet de
beslissing. Het relay-proces beslist, en dáár is een onleesbaar tijdstip dicht.
De pagina die iets anders toont dan het bestand zegt zou verwarrender zijn dan
een pagina die de leerstand toont die er staat.
"""
for onzin in ["morgen", "", 42, {}, []]:
feiten = agent.learning_facts({"learning": True, "learningUntil": onzin})
u.check("een onleesbaar tijdslot valt terug op de leerstand zelf: %r" % (onzin,),
feiten["learning"] is True and feiten["learningSecondsLeft"] is None)
def test_leerstand_onbekend(agent, u):
"""Onbekend is geen synonym voor uit.
Ontbreekt owners.json, dan is er nog nooit iets geschreven en weet niemand wat
de leerstand is. Uit is een keuze, onbekend is een reden om te kijken, en de
pagina hoort dat verschil te tonen.
"""
feiten = agent.learning_facts({})
u.check("een ontbrekende leerstand blijft onbekend", feiten["learning"] is None)
u.check("en heeft geen teller", feiten["learningSecondsLeft"] is None)
uit = agent.learning_facts({"learning": False, "learningUntil": None})
u.check("en uit blijft uit", uit["learning"] is False)
# ── De labels ───────────────────────────────────────────────────────────────
def test_label_zetten_en_weghalen(agent, u):
@@ -253,43 +193,7 @@ def test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir):
u.check("een label van alleen witruimte valt weg", "owner-f" not in labels)
# ── De opdracht met seconden ────────────────────────────────────────────────
def test_set_learning_met_seconden(agent, u):
goed, problem = agent.valid_command({"action": "set-learning", "value": True, "seconds": 120})
u.check("openzetten met seconden mag", problem is None)
u.check("en de seconden gaan mee naar de relay", goed.get("seconds") == 120)
zonder, problem = agent.valid_command({"action": "set-learning", "value": True})
u.check("openzetten zonder seconden mag ook", problem is None)
u.check("en dan staat er geen seconds in de opdracht", "seconds" not in zonder)
for seconden, waarom in [
(0, "nul"),
(-60, "negatief"),
(1.5, "geen heel getal"),
("120", "tekst"),
# isinstance(True, int) is in Python waar, dus zonder de uitsluiting in de
# agent zou een boolean hier als aantal seconden doorglippen.
(True, "een boolean"),
(agent.MAX_LEARNING_SECONDS + 1, "boven de bovengrens"),
]:
_, problem = agent.valid_command(
{"action": "set-learning", "value": True, "seconds": seconden})
u.check("een onbruikbaar aantal seconden wordt geweigerd: %s" % waarom,
problem is not None)
_, problem = agent.valid_command(
{"action": "set-learning", "value": True, "seconds": agent.MAX_LEARNING_SECONDS})
u.check("de bovengrens zelf mag wel", problem is None)
# Seconden bij dichtzetten is onzin en hoort dus geweigerd te worden in plaats
# van stil genegeerd: stil negeren zou een sluitopdracht met een tikfout laten
# slagen terwijl er iets anders gebeurt dan er staat.
_, problem = agent.valid_command(
{"action": "set-learning", "value": False, "seconds": 120})
u.check("seconden bij dichtzetten wordt geweigerd", problem is not None)
# ── De status ───────────────────────────────────────────────────────────────
def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
"""De pagina krijgt het label bij de eigenaar, en het bestand van de relay
@@ -320,9 +224,11 @@ def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
u.check("en krijgt zijn label", owners["rejected"][0]["label"] == "Onbekend apparaat")
u.check("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3)
u.check("de leerstand komt uit learning_facts", owners["learning"] is False)
u.check("en de statusvelden voor de teller staan erin",
"learningSecondsLeft" in owners and "learningUntil" in owners)
# De leerstand is er in 0.7.0 uit. Deze toets houdt vast dat de agent hem ook
# niet meer dóórgeeft: een pagina die het veld nog zou lezen, zou anders een
# knop kunnen tonen voor iets dat niet meer bestaat.
u.check("de leerstand staat niet meer in de status",
"learning" not in owners and "learningSecondsLeft" not in owners)
with open(os.path.join(state_dir, "owners.json"), "r", encoding="utf-8") as f:
op_schijf = json.load(f)
@@ -338,18 +244,12 @@ def main():
with tempfile.TemporaryDirectory() as state_dir:
agent = load_agent(state_dir)
test_venster_dat_nog_loopt(agent, u)
test_venster_dat_verlopen_is(agent, u)
test_venster_zonder_tijdslot(agent, u)
test_onleesbaar_tijdslot(agent, u)
test_leerstand_onbekend(agent, u)
test_label_zetten_en_weghalen(agent, u)
test_label_blijft_op_schijf(agent, u, state_dir)
test_label_validatie(agent, u)
test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir)
test_set_learning_met_seconden(agent, u)
test_status_hangt_labels_aan_de_regels(agent, u, state_dir)
# Als laatste: deze vult het labelbestand tot het plafond en laat dus geen