source: Klonkt/test/reset-admin.test.js@ 1d5ffc0

main
Last change on this file since 1d5ffc0 was 0f09302, checked in by Robin <roboburr@…>, 3 weeks ago

reset-admin wijst een instantie aan, en maakt nooit een database aan

In de gedeelde opstelling van deploy/klonkt@.service werkte het noodpad niet.
De code staat daar op /opt/klonkt en de configuratie per instantie in
/var/lib/klonkt/<slug>/.env, waar systemd hem leest via EnvironmentFile. Een
script dat je zelf start krijgt die omgeving niet: dotenv zoekt naast de code,
vindt niets, en draait zonder DATABASE_PATH.

Gemeten op een nagebouwde opstelling: 'npm run reset-admin' legde een lege
database in /opt/klonkt/storage/ -- de gedeelde codemap, die volgens de unit
juist read-only is -- en klapte daarna op 'no such table: users'. Een
stacktrace op de plek waar een aanwijzing hoort.

Drie dingen:

  • --instance <slug> leest dezelfde .env als systemd, met override, want wie de instantie aanwijst hoort te winnen van een DATABASE_PATH die nog in de shell hangt van een vorige.
  • De controle staat VOOR de import van src/config/database.js, want die maakt map en bestand aan zodra hij geladen wordt. Ontbreekt de database, dan stopt hij en noemt het pad; hij maakt er geen.
  • deploy/MULTI-INSTANCE.md noemt het commando nu, met de reden erbij. De hulppagina in de app zegt 'vanuit de projectmap', en dat klopt alleen bij een eigen checkout per instantie.

Zes tests, waaronder de geslaagde weg: het getoonde wachtwoord moet echt op de
gebruiker passen. Controleproef gedraaid -- zonder de wacht valt de test om die
eist dat er niets wordt aangemaakt.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 6.5 KB
Line 
1// reset-admin is het noodpad: geen e-mail, geen sessie, alleen shell-toegang.
2// Juist daarom hoort hier de GESLAAGDE weg getest te worden en niet alleen de
3// weigering -- een break-glass die stilletjes de verkeerde database opent doet
4// precies wat hij niet mag: hij lijkt te werken.
5//
6// Deze tests starten het script als los proces, want de fout die we in
7// augustus vonden zat in de volgorde van imports: src/config/database.js maakt
8// zijn bestand aan zodra hij geladen wordt. Dat is alleen zichtbaar als je het
9// echt draait, niet als je functies importeert.
10
11import { test } from 'node:test';
12import assert from 'node:assert/strict';
13import { execFileSync } from 'node:child_process';
14import fs from 'node:fs';
15import os from 'node:os';
16import path from 'node:path';
17import { fileURLToPath } from 'node:url';
18import Database from 'better-sqlite3';
19import bcrypt from 'bcryptjs';
20
21const wortel = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
22const script = path.join(wortel, 'scripts/reset-admin.mjs');
23
24/** Een database met één god-user erin, op een weggooiplek. */
25function maakSite(dir, username = 'baas') {
26 fs.mkdirSync(dir, { recursive: true });
27 const pad = path.join(dir, 'database.sqlite');
28 const D = new Database(pad);
29 D.exec(`CREATE TABLE users (
30 id INTEGER PRIMARY KEY, username TEXT, email TEXT, role TEXT,
31 password_hash TEXT, reset_token TEXT, reset_token_expires TEXT,
32 created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT
33 )`);
34 D.prepare('INSERT INTO users (username, email, role, password_hash) VALUES (?,?,?,?)')
35 .run(username, `${username}@voorbeeld.nl`, 'god', bcrypt.hashSync('oudwachtwoord', 4));
36 D.close();
37 return pad;
38}
39
40function draai(args, env = {}) {
41 // Een lege waarde WIST de variabele hier, in plaats van hem leeg te zetten:
42 // dotenv kijkt of de sleutel bestaat, niet of hij gevuld is, dus een lege
43 // DATABASE_PATH zou een .env-waarde tegenhouden en de test iets anders laten
44 // meten dan ze denkt te meten.
45 const kind = { ...process.env, ...env };
46 for (const [k, v] of Object.entries(kind)) if (v === '' || v === undefined) delete kind[k];
47 try {
48 return { code: 0, uit: execFileSync(process.execPath, [script, ...args], {
49 encoding: 'utf8', env: kind, cwd: wortel, stdio: 'pipe',
50 }) };
51 } catch (e) {
52 return { code: e.status ?? 1, uit: (e.stdout || '') + (e.stderr || '') };
53 }
54}
55
56test('reset-admin zet echt een nieuw wachtwoord (de geslaagde weg)', () => {
57 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
58 const pad = maakSite(path.join(tmp, 'data'));
59
60 const r = draai([], { DATABASE_PATH: pad });
61 assert.equal(r.code, 0, r.uit);
62 assert.match(r.uit, /Wachtwoord gereset voor baas/);
63 assert.match(r.uit, /Nieuw wachtwoord: (\S+)/);
64 // Het pad hoort in de uitvoer: bij meerdere datamappen is "welke database"
65 // de enige vraag die ertoe doet.
66 assert.match(r.uit, new RegExp(`Database: ${pad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
67
68 // En het wachtwoord dat hij toont, werkt ook echt.
69 const nieuw = r.uit.match(/Nieuw wachtwoord: (\S+)/)[1];
70 const D = new Database(pad, { readonly: true });
71 const u = D.prepare('SELECT password_hash FROM users WHERE username = ?').get('baas');
72 assert.equal(bcrypt.compareSync(nieuw, u.password_hash), true, 'het getoonde wachtwoord moet geldig zijn');
73 fs.rmSync(tmp, { recursive: true, force: true });
74});
75
76test('een zelfgekozen wachtwoord wordt gezet, en te kort wordt geweigerd', () => {
77 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
78 const pad = maakSite(path.join(tmp, 'data'));
79
80 assert.equal(draai(['baas', 'kort'], { DATABASE_PATH: pad }).code, 1);
81 const r = draai(['baas', 'eenlangwachtwoord'], { DATABASE_PATH: pad });
82 assert.equal(r.code, 0, r.uit);
83
84 const D = new Database(pad, { readonly: true });
85 const u = D.prepare('SELECT password_hash FROM users WHERE username = ?').get('baas');
86 assert.equal(bcrypt.compareSync('eenlangwachtwoord', u.password_hash), true);
87 fs.rmSync(tmp, { recursive: true, force: true });
88});
89
90test('--instance leest de .env van die instantie, zoals systemd dat doet', () => {
91 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
92 // De opstelling uit deploy/klonkt@.service nagebouwd: gedeelde code, en de
93 // configuratie per instantie onder de dataroot.
94 const dataRoot = path.join(tmp, 'var-lib-klonkt');
95 const pad = maakSite(path.join(dataRoot, 'boiert'), 'opie');
96 fs.writeFileSync(path.join(dataRoot, 'boiert', '.env'), `DATABASE_PATH=${pad}\n`);
97
98 const r = draai(['--instance', 'boiert'], { KLONKT_DATA_ROOT: dataRoot, DATABASE_PATH: '' });
99 assert.equal(r.code, 0, r.uit);
100 assert.match(r.uit, /Wachtwoord gereset voor opie/);
101 fs.rmSync(tmp, { recursive: true, force: true });
102});
103
104test('zonder database maakt hij er GEEN aan, maar noemt het pad', () => {
105 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
106 const pad = path.join(tmp, 'nergens', 'database.sqlite');
107
108 const r = draai([], { DATABASE_PATH: pad });
109 assert.equal(r.code, 1);
110 assert.match(r.uit, /Geen database op/);
111 assert.match(r.uit, new RegExp(pad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
112 // De kern van de fix: geen lege database achterlaten waar je toevallig stond.
113 assert.equal(fs.existsSync(pad), false, 'er mag niets aangemaakt zijn');
114 assert.equal(fs.existsSync(path.dirname(pad)), false, 'ook de map niet');
115 fs.rmSync(tmp, { recursive: true, force: true });
116});
117
118test('een database zonder users-tabel is het verkeerde bestand, geen lege site', () => {
119 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
120 const pad = path.join(tmp, 'vreemd.sqlite');
121 const D = new Database(pad); D.exec('CREATE TABLE iets (a INTEGER)'); D.close();
122
123 const r = draai([], { DATABASE_PATH: pad });
124 assert.equal(r.code, 1);
125 assert.match(r.uit, /geen users-tabel/);
126 assert.doesNotMatch(r.uit, /SqliteError/, 'een aanwijzing, geen stacktrace');
127 fs.rmSync(tmp, { recursive: true, force: true });
128});
129
130test('een onbekende instantie noemt wat er wel is', () => {
131 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
132 const dataRoot = path.join(tmp, 'var-lib-klonkt');
133 maakSite(path.join(dataRoot, 'boiert'));
134 fs.writeFileSync(path.join(dataRoot, 'boiert', '.env'), 'DATABASE_PATH=/dev/null\n');
135
136 const r = draai(['--instance', 'bestaatniet'], { KLONKT_DATA_ROOT: dataRoot });
137 assert.equal(r.code, 1);
138 assert.match(r.uit, /Geen \.env voor instantie "bestaatniet"/);
139 assert.match(r.uit, /boiert/, 'noemt de instanties die er wel zijn');
140 fs.rmSync(tmp, { recursive: true, force: true });
141});
Note: See TracBrowser for help on using the repository browser.