| 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 |
|
|---|
| 11 | import { test } from 'node:test';
|
|---|
| 12 | import assert from 'node:assert/strict';
|
|---|
| 13 | import { execFileSync } from 'node:child_process';
|
|---|
| 14 | import fs from 'node:fs';
|
|---|
| 15 | import os from 'node:os';
|
|---|
| 16 | import path from 'node:path';
|
|---|
| 17 | import { fileURLToPath } from 'node:url';
|
|---|
| 18 | import Database from 'better-sqlite3';
|
|---|
| 19 | import bcrypt from 'bcryptjs';
|
|---|
| 20 |
|
|---|
| 21 | const wortel = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|---|
| 22 | const script = path.join(wortel, 'scripts/reset-admin.mjs');
|
|---|
| 23 |
|
|---|
| 24 | /** Een database met één god-user erin, op een weggooiplek. */
|
|---|
| 25 | function 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 |
|
|---|
| 40 | function 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 |
|
|---|
| 56 | test('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 |
|
|---|
| 76 | test('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 |
|
|---|
| 90 | test('--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 |
|
|---|
| 104 | test('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 |
|
|---|
| 118 | test('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 |
|
|---|
| 130 | test('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 | });
|
|---|