perf/test: Build-Coalescing teilen, syncLibrary drosseln, API-Tests

- coalesce.js: generisches Serialisieren+Koaleszieren je Key; buildSite() in
  hugo.js nutzt es → Publish/Preview/Profil starten nie überlappende Hugo-
  Prozesse, schnelle Folge-Aufrufe lösen nur einen Trailing-Build aus
- dialog-store: syncLibrary() gedrosselt (60s-TTL) statt bei jedem Forum-Read
  Filesystem-Walk + Upsert; Publish forciert Sync (force:true)
- test/: node:test-Suite (19 Tests) für safeRel/normAuthors/urlFor/hasAccess,
  roleOf + lokale JWT-Verifikation, Rate-Limiter, Coalescing; npm test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:14:36 +02:00
parent 8404165f5c
commit f97999c3c0
12 changed files with 269 additions and 22 deletions
+68
View File
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Env vor dem Import setzen: supabase.js bricht ohne URL/Key ab, ADMIN_EMAILS
// und JWT_SECRET werden beim Modul-Load gelesen.
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'dummy';
process.env.JWT_SECRET = 'test-secret';
process.env.ADMIN_EMAILS = 'boss@x.ch';
const { roleOf, requireAuth } = await import('../src/auth.js');
const { sign } = await import('hono/jwt');
test('roleOf: Admin aus ADMIN_EMAILS', () => {
assert.equal(roleOf({ email: 'boss@x.ch' }), 'admin');
assert.equal(roleOf({ email: 'BOSS@X.CH' }), 'admin');
});
test('roleOf: Rolle aus app_metadata', () => {
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'admin' } }), 'admin');
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'editor' } }), 'editor');
assert.equal(roleOf({ email: 'a@x.ch' }), 'user');
});
// Minimaler Hono-Kontext-Stub.
function fakeCtx(authHeader) {
const store = {};
return {
req: { header: (h) => (h === 'Authorization' ? authHeader : undefined) },
set: (k, v) => { store[k] = v; },
get: (k) => store[k],
json: (body, status = 200) => ({ __status: status, body }),
};
}
test('requireAuth: gültiges Token wird lokal verifiziert', async () => {
const token = await sign(
{ sub: 'u1', email: 'A@x.ch', app_metadata: { role: 'editor' }, exp: Math.floor(Date.now() / 1000) + 60 },
'test-secret', 'HS256');
let passed = false;
const c = fakeCtx('Bearer ' + token);
await requireAuth(c, async () => { passed = true; });
assert.equal(passed, true);
assert.equal(c.get('email'), 'a@x.ch'); // kleingeschrieben
assert.equal(c.get('role'), 'editor');
assert.equal(c.get('canModerate'), true);
assert.equal(c.get('isAdmin'), false);
});
test('requireAuth: fehlendes Token → 401', async () => {
const c = fakeCtx('');
const r = await requireAuth(c, async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
});
test('requireAuth: kaputtes/falsch signiertes Token → 401', async () => {
const bad = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }, 'falsches-secret', 'HS256');
for (const t of ['Bearer garbage', 'Bearer ' + bad]) {
const r = await requireAuth(fakeCtx(t), async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
}
});
test('requireAuth: abgelaufenes Token → 401', async () => {
const expired = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) - 10 }, 'test-secret', 'HS256');
const r = await requireAuth(fakeCtx('Bearer ' + expired), async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { coalesce } = await import('../src/coalesce.js');
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
test('coalesce: nie mehr als ein Lauf gleichzeitig pro Key', async () => {
let active = 0, maxActive = 0, runs = 0;
const fn = async () => { active++; maxActive = Math.max(maxActive, active); await tick(10); runs++; active--; return runs; };
// 5 gleichzeitige Aufrufe.
await Promise.all(Array.from({ length: 5 }, () => coalesce('k1', fn)));
assert.equal(maxActive, 1, 'parallele Läufe');
// Erster Lauf bedient den ersten Aufruf; die 4 während des Laufs eingetroffenen
// teilen sich GENAU EINEN nachgelagerten Lauf → insgesamt 2.
assert.equal(runs, 2);
});
test('coalesce: Wartende teilen sich das Ergebnis des nachgelagerten Laufs', async () => {
let n = 0;
const fn = async () => { await tick(10); return ++n; };
const first = coalesce('k2', fn); // startet sofort → Ergebnis 1
await tick(2); // sicherstellen, dass er läuft
const a = coalesce('k2', fn); // wartet → nachgelagerter Lauf
const b = coalesce('k2', fn); // wartet → selber Lauf wie a
assert.equal(await first, 1);
const [ra, rb] = await Promise.all([a, b]);
assert.equal(ra, 2);
assert.equal(rb, 2); // a und b teilen sich Lauf 2
});
test('coalesce: Fehler wird an die Wartenden propagiert, Key bleibt nutzbar', async () => {
let fail = true;
const fn = async () => { await tick(5); if (fail) throw new Error('boom'); return 'ok'; };
await assert.rejects(() => coalesce('k3', fn), /boom/);
fail = false;
assert.equal(await coalesce('k3', fn), 'ok'); // danach wieder verwendbar
});
test('coalesce: verschiedene Keys laufen unabhängig', async () => {
const fn = async () => { await tick(5); return 'done'; };
const [x, y] = await Promise.all([coalesce('kA', fn), coalesce('kB', fn)]);
assert.equal(x, 'done');
assert.equal(y, 'done');
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { safeRel, normAuthors, hasAccess, urlFor } = await import('../src/files.js');
test('safeRel: gültiger relativer .md-Pfad bleibt erhalten', () => {
assert.equal(safeRel('library/software/stack.md'), 'library/software/stack.md');
assert.equal(safeRel('a/./b.md'), 'a/b.md');
});
test('safeRel: Path-Traversal wird abgelehnt', () => {
assert.throws(() => safeRel('../etc/passwd.md'));
assert.throws(() => safeRel('a/../../b.md'));
assert.throws(() => safeRel('/absolut.md'));
});
test('safeRel: nur .md erlaubt, leer/falsch wirft', () => {
assert.throws(() => safeRel('note.txt'));
assert.throws(() => safeRel(''));
assert.throws(() => safeRel(null));
});
test('normAuthors: String/Array/Leer normalisieren', () => {
assert.deepEqual(normAuthors('a@x.ch'), ['a@x.ch']);
assert.deepEqual(normAuthors(['a@x.ch', 'b@y.ch']), ['a@x.ch', 'b@y.ch']);
assert.deepEqual(normAuthors(null), []);
assert.deepEqual(normAuthors([]), []);
});
test('hasAccess: case-insensitive Mitgliedschaft', () => {
assert.equal(hasAccess(['Karim@x.ch'], 'karim@x.ch'), true);
assert.equal(hasAccess(['a@x.ch'], 'b@y.ch'), false);
assert.equal(hasAccess([], 'a@x.ch'), false);
});
test('urlFor: Hugo-URLs aus relativem Pfad', () => {
assert.equal(urlFor('_index.md'), '/');
assert.equal(urlFor('manifest.md'), '/manifest/');
assert.equal(urlFor('library/software/stack.md'), '/library/software/stack/');
assert.equal(urlFor('software/_index.md'), '/software/');
});
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { rateLimit } = await import('../src/ratelimit.js');
function fakeCtx() {
const headers = {};
return {
req: { header: () => undefined },
header: (k, v) => { headers[k] = v; },
json: (body, status = 200) => ({ __status: status, body }),
_headers: headers,
};
}
test('rateLimit: blockt nach Überschreiten mit 429 + Retry-After', async () => {
const mw = rateLimit({ max: 2, windowMs: 10_000, keyFn: () => 'fix' });
let calls = 0;
const run = async () => { const c = fakeCtx(); const r = await mw(c, async () => { calls++; }); return { c, r }; };
assert.equal((await run()).r, undefined); // 1 → durch (next, kein Return)
assert.equal((await run()).r, undefined); // 2 → durch
const { c, r } = await run(); // 3 → blockiert
assert.equal(r.__status, 429);
assert.ok(c._headers['Retry-After']);
assert.equal(calls, 2); // next nur zweimal aufgerufen
});
test('rateLimit: getrennte Schlüssel zählen getrennt', async () => {
let key = 'a';
const mw = rateLimit({ max: 1, windowMs: 10_000, keyFn: () => key });
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // a:1 ok
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); // a:2 blockiert
key = 'b';
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // b:1 ok
});
test('rateLimit: Fenster läuft ab → wieder frei', async () => {
const mw = rateLimit({ max: 1, windowMs: 30, keyFn: () => 'win' });
assert.equal((await mw(fakeCtx(), async () => {})), undefined);
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429);
await new Promise((r) => setTimeout(r, 40));
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // Fenster neu
});