Het relay-programma: de relay van Evolu met onze eigen allowlist
tools/evolu-relay/src/ bevat nu een eigen programma dat createRelay uit @evolu/nodejs aanroept met de twee terugroepfuncties die het bedoelde uitbreidpunt vormen. De relay zelf komt uit npm en wordt niet nagebouwd of aangepast; de opstartvolgorde is overgenomen uit apps/relay/src/index.ts van Evolu zelf. De opzet is drie bestanden met een harde scheiding, en die scheiding is de reden dat hier iets te testen valt. policy.js bevat het beleid als pure functies: geen bestanden, geen netwerk, geen klok. store.js is de enige plek met schijf erin. index.js doet niets anders dan lezen, doorgeven en opslaan. Het beleid: de eerste eigenaar die zich meldt wordt geleerd, een schakelaar bepaalt of er nog nieuwe bij mogen, en een eigenaar is te blokkeren, alsnog toe te laten of te vergeten. Geweigerde pogingen worden onthouden voor de pagina, afgekapt op twintig, want elke poging is een id dat de ander zelf verzint. Een onleesbaar owners.json wordt opzij geschoven en de app gaat dan dicht in plaats van open: we weten dan niet wie er toegelaten was, en met de leerstand aan zou de eerstvolgende die verbindt de nieuwe eigenaar worden. Met test: node tests/test_limiter.mjs, 60 toetsen, en de toetsen gaan over de guards en niet over het gelukkige pad. De beslissende regel is muteertest gedaan en de juiste toets viel om: een geblokkeerde eigenaar mag er niet alsnog in doordat de leerstand aanstaat. Het bestand is .mjs omdat de repo-root geen package.json heeft en een .js daar als CommonJS gelezen zou worden. build.sh bouwt niet langer de repo van Trezor maar onze eigen Dockerfile, dus git is er niet meer voor nodig en de pin zit nu in package.json. De image is node:24-slim en niet alpine, want better-sqlite3 heeft binaries voor glibc en niet voor musl. Er is nog geen package-lock.json; het script waarschuwt daarvoor en het staat als taak. Onderweg bleek een aanname van vanmiddag fout: de 1 MB uit de gepubliceerde image geldt per schrijfactie en niet per eigenaar. Er valt dus geen labelgeschiedenis tegenaan te lopen. Dat is rechtgezet in het plan en in de naslag, en het getal is overgenomen als bewuste keuze met RELAY_MAX_WRITE_BYTES ernaast. Wat er niet in zit en ook niet gegokt is: data per eigenaar wissen. Het beleid kan een eigenaar vergeten, maar zijn berichten staan in de SQLite van de relay, en dat is andermans schema. Tests: alle vier groen (32, 54, 39 en 60 goed, 0 fout). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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_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-08-28T12:00:00.000Z';
|
||||
const LATER = '2026-08-28T13:00:00.000Z';
|
||||
const EIGEN = 'owner-van-de-gebruiker';
|
||||
const VREEMD = 'owner-van-een-ander';
|
||||
|
||||
// ── 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 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',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`${goed} goed, ${fout} fout`);
|
||||
process.exit(fout === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user