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:
@@ -92,6 +92,9 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/
|
|||||||
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
|
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
|
||||||
`/_preview` an den laufenden Container auf :8080).
|
`/_preview` an den laufenden Container auf :8080).
|
||||||
|
|
||||||
|
Tests der API (ohne DB/Container, reine Logik): `cd api && npm test`
|
||||||
|
(`node --test` — Pfad-Sicherheit, Rollen/Auth, Rate-Limit, Build-Coalescing).
|
||||||
|
|
||||||
### Demo-Inhalt fürs Forum (optional)
|
### Demo-Inhalt fürs Forum (optional)
|
||||||
|
|
||||||
`db/seed-demo.sql` füllt die Forum-Kategorien mit ein paar Beispiel-Threads und
|
`db/seed-demo.sql` füllt die Forum-Kategorien mit ein paar Beispiel-Threads und
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Serialisiert asynchrone Aufgaben je `key` und koalesziert Wartende:
|
||||||
|
// - Es läuft nie mehr als eine Aufgabe pro Key gleichzeitig.
|
||||||
|
// - Kommen während eines Laufs weitere Aufrufe rein, wird GENAU EIN weiterer
|
||||||
|
// Durchlauf nachgelagert (egal wie viele warten) — sie teilen sich dessen
|
||||||
|
// Ergebnis. So sehen alle den jüngsten Stand, ohne einen Lauf-Sturm.
|
||||||
|
//
|
||||||
|
// Einsatz: teure, idempotente Vorgänge wie der Hugo-Build (siehe hugo.js).
|
||||||
|
const state = new Map();
|
||||||
|
|
||||||
|
export function coalesce(key, fn) {
|
||||||
|
let s = state.get(key);
|
||||||
|
if (!s) { s = { running: false, rerun: false, fn, waiters: [] }; state.set(key, s); }
|
||||||
|
s.fn = fn; // jüngste Variante gewinnt für den nächsten Lauf
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
s.waiters.push({ resolve, reject });
|
||||||
|
if (!s.running) drain(key);
|
||||||
|
else s.rerun = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(key) {
|
||||||
|
const s = state.get(key);
|
||||||
|
s.running = true;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
s.rerun = false;
|
||||||
|
const waiters = s.waiters;
|
||||||
|
s.waiters = [];
|
||||||
|
try {
|
||||||
|
const r = await s.fn();
|
||||||
|
waiters.forEach((w) => w.resolve(r));
|
||||||
|
} catch (e) {
|
||||||
|
waiters.forEach((w) => w.reject(e));
|
||||||
|
}
|
||||||
|
} while (s.rerun);
|
||||||
|
} finally {
|
||||||
|
s.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,15 @@ export async function profileFor(email) {
|
|||||||
|
|
||||||
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
||||||
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
||||||
export async function syncLibrary() {
|
//
|
||||||
|
// Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche
|
||||||
|
// Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms.
|
||||||
|
// `force: true` (z.B. nach Publish) überspringt die Drosselung.
|
||||||
|
const SYNC_TTL = 60_000;
|
||||||
|
let lastSync = 0;
|
||||||
|
export async function syncLibrary({ force = false } = {}) {
|
||||||
|
if (!force && Date.now() - lastSync < SYNC_TTL) return;
|
||||||
|
lastSync = Date.now();
|
||||||
const { data: forum } = await supabase
|
const { data: forum } = await supabase
|
||||||
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
||||||
if (!forum) return;
|
if (!forum) return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import { coalesce } from './coalesce.js';
|
||||||
|
|
||||||
const execFileP = promisify(execFile);
|
const execFileP = promisify(execFile);
|
||||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
@@ -16,6 +17,13 @@ export async function hugoBuild({ dest, drafts = false } = {}) {
|
|||||||
return { stdout, stderr };
|
return { stdout, stderr };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Koaleszierter Build je Ziel: nie zwei `hugo`-Prozesse für dasselbe dest
|
||||||
|
// parallel; schnelle Folge-Aufrufe lösen nur einen nachgelagerten Build aus.
|
||||||
|
// Publish (public), Preview (preview) und Profil teilen sich diesen Weg.
|
||||||
|
export function buildSite({ dest, drafts = false } = {}) {
|
||||||
|
return coalesce(`build:${dest}:${drafts ? 'd' : 'p'}`, () => hugoBuild({ dest, drafts }));
|
||||||
|
}
|
||||||
|
|
||||||
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
|
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
|
||||||
// das Publish soll an einem Git-Problem nicht scheitern.
|
// das Publish soll an einem Git-Problem nicht scheitern.
|
||||||
export async function gitCommit(message) {
|
export async function gitCommit(message) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { urlFor, safeRel } from '../files.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { hugoBuild } from '../hugo.js';
|
import { buildSite } from '../hugo.js';
|
||||||
|
|
||||||
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
||||||
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
||||||
@@ -10,7 +10,7 @@ preview.post('/', async (c) => {
|
|||||||
const { path: rel } = await c.req.json();
|
const { path: rel } = await c.req.json();
|
||||||
try {
|
try {
|
||||||
const safe = safeRel(rel);
|
const safe = safeRel(rel);
|
||||||
const build = await hugoBuild({ dest: 'preview', drafts: true });
|
const build = await buildSite({ dest: 'preview', drafts: true });
|
||||||
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json({ error: String(e.message || e) }, 500);
|
return c.json({ error: String(e.message || e) }, 500);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { hugoBuild } from '../hugo.js';
|
import { buildSite } from '../hugo.js';
|
||||||
|
|
||||||
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
|
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
|
||||||
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
|
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
|
||||||
@@ -19,20 +19,6 @@ function slugify(s) {
|
|||||||
}
|
}
|
||||||
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
|
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
|
||||||
|
|
||||||
// Hugo-Build koaleszieren: nie zwei parallel, und schnelle Folge-Speicherungen
|
|
||||||
// lösen nur EINEN nachgelagerten Build aus (verhindert Build-Sturm/DoS).
|
|
||||||
let building = false, rerun = false;
|
|
||||||
async function rebuildSite() {
|
|
||||||
if (building) { rerun = true; return; }
|
|
||||||
building = true;
|
|
||||||
try {
|
|
||||||
do {
|
|
||||||
rerun = false;
|
|
||||||
await hugoBuild({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
|
|
||||||
} while (rerun);
|
|
||||||
} finally { building = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = new Hono();
|
const profile = new Hono();
|
||||||
|
|
||||||
profile.get('/', async (c) => {
|
profile.get('/', async (c) => {
|
||||||
@@ -61,7 +47,7 @@ profile.put('/', async (c) => {
|
|||||||
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
|
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
|
||||||
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
|
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
|
||||||
// Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
|
// Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
|
||||||
await rebuildSite();
|
await buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ ok: true, slug });
|
return c.json({ ok: true, slug });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { urlFor, safeRel } from '../files.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { hugoBuild, gitCommit } from '../hugo.js';
|
import { buildSite, gitCommit } from '../hugo.js';
|
||||||
|
import { syncLibrary } from '../dialog-store.js';
|
||||||
|
|
||||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||||
const publish = new Hono();
|
const publish = new Hono();
|
||||||
@@ -9,7 +10,9 @@ publish.post('/', async (c) => {
|
|||||||
const { path: rel } = await c.req.json();
|
const { path: rel } = await c.req.json();
|
||||||
try {
|
try {
|
||||||
const safe = safeRel(rel);
|
const safe = safeRel(rel);
|
||||||
const build = await hugoBuild({ dest: 'public', drafts: false });
|
const build = await buildSite({ dest: 'public', drafts: false });
|
||||||
|
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
|
||||||
|
await syncLibrary({ force: true }).catch(() => {});
|
||||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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/');
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user