Files
UmbrelApps/tests/test_client_register.mjs
T
HarmenandClaude Opus 5 f0809a80c2 Testclient fase 2, 3 en 5: opdrachtregel, bedieningsvlak en toetsen
De client staat. Een register (owners.json met naam, mnemonic en OwnerId), acht
opdrachten op de opdrachtregel, en een bedieningsvlak op 127.0.0.1:4380 met per
eigenaar de knoppen uit PLAN.md 4b. Start.bat en Start.command erbij op verzoek
van de gebruiker: die draaien npm install als het nodig is, waarschuwen als .env
ontbreekt, openen de browser en houden het venster open bij een fout.

OPEN.md punt 1 is opgelost voordat het een probleem werd. src/probe.js doet de
WebSocket-upgrade met node:http in plaats van met de WebSocket van Node, want
die geeft je bij een weigering een error en geen statuscode. De opdracht klop en
de knop Aankloppen tonen dus 101 of 401, en dat is precies wat de proeven uit
4c moeten kunnen aflezen.

Twee echte fouten gevonden en vastgezet in een toets. De databasemap werd niet
aangemaakt, en omdat better-sqlite3 dat in een worker meldt was het symptoom een
lees die nooit antwoordde. En evolu.insert geeft meteen een id terug maar zet de
schrijfactie in de wachtrij van de worker, dus wie vlak daarna afsluit is de rij
kwijt; schrijf wacht nu op een query erachter en de toets sluit af en heropent.

tests/test_client_register.mjs heeft geen pakketten nodig, want register.js,
argumenten.js en config.js raken Evolu niet aan. Drie mutaties geprobeerd en
alle drie lieten de juiste toets omvallen.

In de browser nagekeken: schrijven en teruglezen werkt vanuit de pagina, de
gegevens zijn dezelfde als die van de opdrachtregel, en licht en donker kloppen.

Alles wat de relay raakt is gebouwd en niets ervan is beproefd; daarvoor wacht
er een RELAY_URL in .env, en die komt van de gebruiker.

Suite: 475 goed, 0 fout over alle negen de toetsen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:39:47 +02:00

244 lines
11 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het register, de opdrachtregel en de instellingen van de testclient.
//
// Draaien: node tests/test_client_register.mjs
//
// Géén afhankelijkheden nodig, anders dan test_client_lokaal.mjs: register.js,
// argumenten.js en config.js raken Evolu niet aan. Dat is met opzet zo gebouwd
// en het is dezelfde scheiding die policy.js aan de relay-kant heeft: alles wat
// te toetsen valt zonder netwerk, staat los van alles wat dat niet is.
//
// Waar dit op let: het register bevat mnemonics. De duurste fout zit hier niet
// in een berekening maar in stil overschrijven of stil vervangen, en dat is dus
// wat hieronder het meeste aandacht krijgt.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isBruikbareNaam, normaliseer, openRegister } from '../tools/relay-client/src/register.js';
import { leesArgumenten, OPDRACHTEN } from '../tools/relay-client/src/argumenten.js';
import { leesConfig, leesEnvBestand, naarRelayUrl } from '../tools/relay-client/src/config.js';
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
const gooit = (aan) => {
try {
aan();
return false;
} catch {
return true;
}
};
const verseMap = () => mkdtempSync(join(tmpdir(), 'relay-client-register-'));
const mappen = [];
const map = () => {
const nieuw = verseMap();
mappen.push(nieuw);
return nieuw;
};
// ── Namen ─────────────────────────────────────────────────────────────────────
{
for (const naam of ['a', 'proef-1', 'trezor_mac', 'A'.repeat(40)]) {
toets(`"${naam}" mag als naam`, isBruikbareNaam(naam));
}
for (const naam of ['', ' ', 'met spatie', '../ontsnapt', 'pad/naam', 'A'.repeat(41), null, 42]) {
toets(`${JSON.stringify(naam)} mag niet als naam`, !isBruikbareNaam(naam));
}
}
// ── Aanmaken, teruglezen, weggooien ───────────────────────────────────────────
{
const waar = map();
const register = openRegister(waar);
toets('een vers register is leeg', register.lijst().length === 0);
toets('en een onbekende naam geeft null', register.zoek('piet') === null);
const toegevoegd = register.voegToe({ naam: 'piet', mnemonic: 'woord ' .repeat(11) + 'woord', ownerId: 'AAA' });
toets('toevoegen geeft de regel terug', toegevoegd.naam === 'piet' && toegevoegd.ownerId === 'AAA');
toets('met een tijdstip erbij', typeof toegevoegd.aangemaakt === 'string');
toets('en hij staat in de lijst', register.lijst().length === 1);
toets('en is op naam te vinden', register.zoek('piet')?.ownerId === 'AAA');
// Het niet-gelukkige pad, en het belangrijkste van dit bestand: overschrijven
// zou de mnemonic van de vorige eigenaar weggooien.
toets(
'dezelfde naam nog eens toevoegen wordt geweigerd',
gooit(() => register.voegToe({ naam: 'piet', mnemonic: 'iets anders', ownerId: 'BBB' })),
);
toets('en de eerste staat er nog', register.zoek('piet')?.ownerId === 'AAA');
toets(
'een onbruikbare naam wordt geweigerd',
gooit(() => register.voegToe({ naam: 'met spatie', mnemonic: 'x', ownerId: 'CCC' })),
);
toets('vergeten van een onbekende naam wordt geweigerd', gooit(() => register.vergeet('jan')));
const weg = register.vergeet('piet');
toets('vergeten geeft de regel terug', weg.ownerId === 'AAA');
toets('en daarna is het register leeg', register.lijst().length === 0);
}
// ── Wat er op schijf komt, komt er weer af ────────────────────────────────────
{
const waar = map();
const eerste = openRegister(waar);
eerste.voegToe({ naam: 'een', mnemonic: 'mnemonic een', ownerId: 'ID1' });
eerste.voegToe({ naam: 'twee', mnemonic: 'mnemonic twee', ownerId: 'ID2' });
const tweede = openRegister(waar);
toets('een tweede register leest hetzelfde bestand', tweede.lijst().length === 2);
toets('in dezelfde volgorde', tweede.lijst()[0].naam === 'een' && tweede.lijst()[1].naam === 'twee');
toets('met de mnemonic erbij', tweede.zoek('twee')?.mnemonic === 'mnemonic twee');
// Een register dat je aanpast mag geen lijst teruggeven die meeverandert:
// dan zou een aanroeper per ongeluk in de staat kunnen schrijven.
const kopie = tweede.lijst();
kopie[0].ownerId = 'GEHACKT';
toets('de lijst is een kopie', tweede.zoek('een')?.ownerId === 'ID1');
}
// ── Een onleesbaar bestand wordt niet stil vervangen ──────────────────────────
{
const waar = map();
const register = openRegister(waar);
register.voegToe({ naam: 'een', mnemonic: 'geheim', ownerId: 'ID1' });
const pad = join(waar, 'owners.json');
const origineel = readFileSync(pad, 'utf8');
writeFileSync(pad, '{ dit is geen json', 'utf8');
toets('onleesbare JSON wordt gemeld', gooit(() => openRegister(waar)));
toets('en het bestand blijft staan', readFileSync(pad, 'utf8') === '{ dit is geen json');
writeFileSync(pad, JSON.stringify({ version: 2, owners: [] }), 'utf8');
toets('een onbekende versie wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, JSON.stringify({ version: 1, owners: [{ naam: 'een' }] }), 'utf8');
toets('een halve regel wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, origineel, 'utf8');
toets('en het originele bestand leest weer goed', openRegister(waar).zoek('een')?.ownerId === 'ID1');
}
// ── normaliseer, los ──────────────────────────────────────────────────────────
{
toets('null is geen staat', normaliseer(null) === null);
toets('een array is geen staat', normaliseer([]) === null);
toets('zonder owners is het geen staat', normaliseer({ version: 1 }) === null);
toets(
'twee keer dezelfde naam is geen staat',
normaliseer({
version: 1,
owners: [
{ naam: 'a', mnemonic: 'x', ownerId: '1' },
{ naam: 'a', mnemonic: 'y', ownerId: '2' },
],
}) === null,
);
toets(
'een lege mnemonic is geen staat',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: '', ownerId: '1' }] }) === null,
);
toets(
'een geldige staat komt er heel uit',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: 'x', ownerId: '1' }] })?.owners[0]
?.naam === 'a',
);
}
// ── De opdrachtregel ──────────────────────────────────────────────────────────
{
toets('niets geeft geen opdracht en geen fout', (() => {
const uit = leesArgumenten([]);
return uit.fout === null && uit.opdracht === null;
})());
toets('een onbekende opdracht is een fout', leesArgumenten(['bestaatniet']).fout !== null);
const nieuw = leesArgumenten(['nieuw', 'piet']);
toets('nieuw piet leest goed', nieuw.fout === null && nieuw.waarden.naam === 'piet');
toets('nieuw zonder naam is een fout', leesArgumenten(['nieuw']).fout !== null);
// Streng, en dat is de bedoeling: `schrijf piet mijn label` zou anders stil
// "mijn" als label nemen en je naar een blob laten kijken die anders heet.
toets('een argument te veel is een fout', leesArgumenten(['nieuw', 'piet', 'jan']).fout !== null);
const schrijf = leesArgumenten(['schrijf', 'piet', 'mijn label', '--bytes', '512']);
toets('schrijf leest beide argumenten', schrijf.waarden.naam === 'piet' && schrijf.waarden.label === 'mijn label');
toets('en de vlag ernaast', schrijf.vlaggen.bytes === '512');
toets('--bytes=512 leest ook', leesArgumenten(['lijst', '--bytes=512']).vlaggen.bytes === '512');
toets('een losse vlag is true', leesArgumenten(['lijst', '--lokaal']).vlaggen.lokaal === true);
toets(
'een losse vlag gevolgd door een vlag blijft true',
leesArgumenten(['lijst', '--lokaal', '--bytes=8']).vlaggen.lokaal === true,
);
toets('elke opdracht heeft uitleg', Object.values(OPDRACHTEN).every((v) => typeof v.uitleg === 'string' && v.uitleg.length > 0));
}
// ── De instellingen ───────────────────────────────────────────────────────────
{
const gelezen = leesEnvBestand(
['# commentaar', '', 'RELAY_URL=ws://voorbeeld:3852', 'MET_QUOTES="waarde"', 'ROMMEL', 'LEEG='].join('\n'),
);
toets('een gewone regel leest', gelezen.RELAY_URL === 'ws://voorbeeld:3852');
toets('aanhalingstekens gaan eraf', gelezen.MET_QUOTES === 'waarde');
toets('een regel zonder = wordt overgeslagen', gelezen.ROMMEL === undefined);
toets('een lege waarde is een lege string', gelezen.LEEG === '');
toets('commentaar telt niet mee', Object.keys(gelezen).length === 3);
toets('ws:// blijft', naarRelayUrl('ws://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('wss:// blijft', naarRelayUrl('wss://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('http:// wordt ws://', naarRelayUrl('http://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('https:// wordt wss://', naarRelayUrl('https://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('zonder schema wordt ws://', naarRelayUrl('umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('spaties eromheen doen niets', naarRelayUrl(' umbrel.local:3852 ') === 'ws://umbrel.local:3852');
toets('een leeg adres wordt geweigerd', gooit(() => naarRelayUrl('')));
toets('een onbekend schema wordt geweigerd', gooit(() => naarRelayUrl('ftp://umbrel.local')));
// Een map zonder .env: dan is er geen relay-adres, en dat is geen fout maar een
// toestand die de UI moet kunnen tonen.
const zonder = leesConfig({ env: {}, wortel: map() });
toets('zonder .env is er geen relay-adres', zonder.relayUrl === null);
toets('en staat de UI-poort op de standaard', zonder.uiPoort === 4380);
const wortel = map();
writeFileSync(join(wortel, '.env'), 'RELAY_URL=umbrel.local:3852\nUI_PORT=5000\n', 'utf8');
const met = leesConfig({ env: {}, wortel });
toets('een .env wordt gelezen', met.relayUrl === 'ws://umbrel.local:3852');
toets('en de poort ook', met.uiPoort === 5000);
const overstemd = leesConfig({ env: { RELAY_URL: 'ws://anders:1234' }, wortel });
toets('de omgeving wint van het bestand', overstemd.relayUrl === 'ws://anders:1234');
}
for (const weg of mappen) rmSync(weg, { recursive: true, force: true });
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);