Merge commit 'bba1df32cb1de7e595a6ecd9bcb84afe8aa178c3' as 'cms/core'

This commit is contained in:
2026-06-30 01:46:20 +02:00
59 changed files with 7398 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rm, readFile } from 'node:fs/promises';
// Isolated users file + a known secret BEFORE importing the module (it reads env
// at import time). No Supabase needed — this is the DB-less path.
const USERS = join(tmpdir(), `cms-users-${process.pid}.json`);
process.env.USERS_FILE = USERS;
process.env.JWT_SECRET ||= 'test-secret-xyz';
const local = await import('../src/auth-local.js');
const { verify } = await import('hono/jwt');
test.after(async () => { await rm(USERS, { force: true }); });
test('create → list → login mints a GoTrue-shaped, verifiable token', async () => {
const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' });
assert.ok(id);
const list = await local.listUsers(['someone@else.com']);
assert.equal(list.length, 1);
assert.equal(list[0].email, 'Karim@Example.com');
assert.equal(list[0].role, 'admin');
// wrong password rejected, right password accepted (case-insensitive email)
assert.equal(await local.login('karim@example.com', 'nope'), null);
const res = await local.login('karim@example.com', 'hunter2');
assert.ok(res?.access_token);
assert.equal(res.user.email, 'Karim@Example.com');
const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256');
assert.equal(claims.sub, id);
assert.equal(claims.email, 'Karim@Example.com');
assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read
assert.ok(claims.exp > Math.floor(Date.now() / 1000));
});
test('passwords are bcrypt-hashed at rest (never plaintext)', async () => {
const raw = JSON.parse(await readFile(USERS, 'utf8'));
assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix
assert.notEqual(raw[0].password, 'hunter2');
});
test('countByRole reflects roles; fixed-admin email forced to admin', async () => {
await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' });
const counts = await local.countByRole([]);
assert.equal(counts.total, 2);
assert.equal(counts.admin, 1);
assert.equal(counts.editor, 1);
// an env-admin email overrides the stored role in the listing
const forced = await local.listUsers(['red@x.com']);
assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin');
});
test('update + delete', async () => {
const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' });
await local.updateUser(id, { role: 'editor' });
assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor');
await local.deleteUser(id);
assert.equal((await local.listUsers([])).some((u) => u.id === id), false);
await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/);
});
test('duplicate email rejected', async () => {
await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/);
});
+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');
});
+101
View File
@@ -0,0 +1,101 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, buildPath, compareEntries } from '../src/collections.js';
import openbureau from '../../examples/openbureau.config.js';
import kgva from '../../examples/kgva.config.js';
const ob = openbureau.collections;
const kg = kgva.collections;
// --- openbureau: must reproduce the original hard-coded classify() 1:1 ---
test('classify (openbureau): Beitrag = archiv/<section>/<slug>', () => {
assert.deepEqual(classify('archiv/software/stack.md', ob), { kind: 'beitrag', section: 'software' });
assert.deepEqual(classify('archiv/buerofuehrung/x.md', ob), { kind: 'beitrag', section: 'buerofuehrung' });
});
test('classify (openbureau): Library = library/<slug>, section konstant', () => {
assert.deepEqual(classify('library/stack.md', ob), { kind: 'biblio', section: 'library' });
});
test('classify (openbureau): _index.md = Rubrik, section = Elternordner|home', () => {
assert.deepEqual(classify('_index.md', ob), { kind: 'rubrik', section: 'home' });
assert.deepEqual(classify('software/_index.md', ob), { kind: 'rubrik', section: 'software' });
});
test('classify (openbureau): alles andere = Seite (fallback)', () => {
assert.deepEqual(classify('manifest.md', ob), { kind: 'seite', section: null });
// _index hat Vorrang vor jedem Pfad-Pattern (wie im Original)
assert.deepEqual(classify('library/_index.md', ob), { kind: 'rubrik', section: 'library' });
// archiv strikt drei-tief: tiefer faellt auf Seite (Original: len !== 3)
assert.deepEqual(classify('archiv/software/a/b.md', ob), { kind: 'seite', section: null });
});
test('buildPath (openbureau): aus Pfad-Pattern + Formulardaten', () => {
assert.equal(buildPath('beitrag', { section: 'software', slug: 'neu' }, ob), 'archiv/software/neu.md');
assert.equal(buildPath('biblio', { slug: 'stack' }, ob), 'library/stack.md');
assert.equal(buildPath('seite', { slug: 'impressum' }, ob), 'impressum.md');
assert.equal(buildPath('beitrag', { section: 'software', slug: '' }, ob), '');
});
test('compareEntries (openbureau): Reihenfolge Beitrag<Library<Seite<Rubrik', () => {
const items = [
{ kind: 'rubrik', date: null, title: 'R' },
{ kind: 'seite', date: null, title: 'S' },
{ kind: 'beitrag', date: '2024-01-01', title: 'B' },
{ kind: 'biblio', date: null, title: 'L' },
];
const sorted = [...items].sort(compareEntries(ob)).map((e) => e.kind);
assert.deepEqual(sorted, ['beitrag', 'biblio', 'seite', 'rubrik']);
});
test('compareEntries: gleiche kind -> Datum absteigend, dann Titel', () => {
const items = [
{ kind: 'beitrag', date: '2023-05-01', title: 'B' },
{ kind: 'beitrag', date: '2024-05-01', title: 'A' },
{ kind: 'beitrag', date: '2024-05-01', title: 'C' },
];
const sorted = [...items].sort(compareEntries(ob)).map((e) => e.title);
assert.deepEqual(sorted, ['A', 'C', 'B']);
});
// --- kgva: a different content model, same engine ---
test('classify (kgva): Portfolio / Fallback-Page / Index-Section', () => {
assert.deepEqual(classify('portfolio/projekt.md', kg), { kind: 'project', section: 'portfolio' });
assert.deepEqual(classify('ueber.md', kg), { kind: 'page', section: null });
assert.deepEqual(classify('_index.md', kg), { kind: 'section', section: 'home' });
assert.deepEqual(classify('arbeiten/_index.md', kg), { kind: 'section', section: 'arbeiten' });
});
test('buildPath (kgva)', () => {
assert.equal(buildPath('project', { slug: 'haus' }, kg), 'portfolio/haus.md');
assert.equal(buildPath('page', { slug: 'ueber' }, kg), 'ueber.md');
});
import { contentStats } from '../src/collections.js';
test('contentStats (openbureau): statKey-Zähler + entwuerfe = Beitrag-Entwürfe', () => {
const entries = [
{ kind: 'beitrag', draft: false },
{ kind: 'beitrag', draft: true },
{ kind: 'beitrag', draft: true },
{ kind: 'biblio', draft: true }, // Library-Entwurf zählt NICHT als entwuerfe
{ kind: 'seite', draft: false },
{ kind: 'rubrik', draft: false },
];
assert.deepEqual(contentStats(entries, ob), {
beitraege: 3, entwuerfe: 2, library: 1, rubriken: 1, seiten: 1,
});
});
test('contentStats (kgva): nur deklarierte statKeys, kein entwuerfe', () => {
const entries = [
{ kind: 'project', draft: true },
{ kind: 'project', draft: false },
{ kind: 'page', draft: false }, // page hat keinen statKey
{ kind: 'section', draft: false }, // section hat keinen statKey
];
assert.deepEqual(contentStats(entries, kg), { projects: 2 });
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
// Stage-5 cutover: index.js/stats.js/publish.js no longer hard-code the dialog;
// they go through the shared plugin manager singleton (src/plugins.js), loaded
// from config.plugins. This proves the singleton + the rewired route modules
// resolve (guards the moved-file import paths) — handlers aren't called, so
// dummy supabase/JWT env is enough, as in dialog-plugin.test.js.
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { manager } = await import('../src/plugins.js');
test('plugins.js: manager loaded from config.plugins (openbureau → dialog)', () => {
assert.deepEqual(manager.names(), ['dialog']);
assert.equal(manager.has('dialog'), true);
});
test('cutover: stats.js still resolves with the manager dependency', async () => {
const stats = (await import('../src/routes/stats.js')).default;
assert.equal(typeof stats.fetch, 'function'); // a Hono app
});
test('cutover: publish.js still resolves with the manager dependency', async () => {
const publish = (await import('../src/routes/publish.js')).default;
assert.equal(typeof publish.fetch, 'function');
});
test('cutover: collectStats exposes dialog under its plugin name', async () => {
// Counts hit a dummy supabase → each count() swallows the error and returns 0,
// but the SHAPE (the stats.js contract) must hold: { dialog: { forums, … } }.
const merged = await manager.collectStats({});
assert.deepEqual(merged.dialog, { forums: 0, threads: 0, comments: 0 });
const dialog = merged.dialog || { forums: 0, threads: 0, comments: 0 };
assert.deepEqual(Object.keys(dialog).sort(), ['comments', 'forums', 'threads']);
});
+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Hono } from 'hono';
// The dialog modules import supabase.js, which exits without these; dummies are
// enough since this test only checks structure/mounting, never calls a handler.
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { loadPlugins } = await import('../src/plugin-manager.js');
const m = await loadPlugins(['dialog']);
test('dialog plugin loads with a valid manifest', () => {
assert.deepEqual(m.names(), ['dialog']);
assert.equal(m.has('dialog'), true);
});
test('dialog: route tiers as in index.js', () => {
const routes = m.plugins[0].routes;
const pub = routes.filter((r) => r.public).map((r) => `${r.method || 'route'} ${r.path}`);
const priv = routes.filter((r) => !r.public).map((r) => `${r.method || 'route'} ${r.path}`);
assert.deepEqual(pub, [
'get /comments', 'get /forums', 'get /forums/:slug', 'get /recent', 'get /thread', 'post /auth/login',
]);
assert.deepEqual(priv, [
'post /comments', 'delete /comments/:id', 'post /threads', 'route /mod', 'route /admin/forums',
]);
});
test('dialog: widget login is rate-limited', () => {
const loginRoute = m.plugins[0].routes.find((r) => r.path === '/auth/login');
assert.equal(loginRoute.public, true);
assert.equal(Array.isArray(loginRoute.use) && loginRoute.use.length, 1);
});
test('dialog: lifecycle hooks + stats present', () => {
const p = m.plugins[0];
assert.equal(typeof p.onBoot, 'function');
assert.equal(typeof p.onPublish, 'function');
assert.equal(typeof p.stats, 'function');
});
test('dialog: mounts into the pipeline without throwing', () => {
const app = new Hono();
const requireAdmin = async (c, next) => next();
assert.doesNotThrow(() => {
m.mountPublic(app);
app.use('/api/*', async (c, next) => next());
m.mountPrivate(app, '/api', requireAdmin);
});
});
+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/');
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runMigrations } from '../src/migrate.js';
import { createManager } from '../src/plugin-manager.js';
// Dialog modules import supabase.js, which exits without these (loadPlugins for
// the real dialog manifest in the last test touches them transitively).
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
// A fake SQL executor over an in-memory schema_migrations set. Records every
// non-bookkeeping statement so we can assert what actually ran.
function fakeExec(seed = []) {
const applied = new Set(seed);
const ran = [];
const exec = async (text, params) => {
if (/^\s*SELECT 1 FROM public\.schema_migrations/i.test(text)) {
return applied.has(params[0]) ? [{ '?column?': 1 }] : [];
}
if (/^\s*INSERT INTO public\.schema_migrations/i.test(text)) { applied.add(params[0]); return []; }
if (/^\s*(CREATE TABLE IF NOT EXISTS public\.schema_migrations|BEGIN|COMMIT|ROLLBACK)/i.test(text)) return [];
ran.push(text); // the actual migration file SQL
return [];
};
return { exec, ran, applied };
}
test('no declared migrations → no-op, never needs a DB', async () => {
const mgr = createManager([{ name: 'kgva-ish' }]); // no migrations
const res = await runMigrations(mgr, {}); // no exec, no databaseUrl — must not throw
assert.deepEqual(res, { applied: [], skipped: [], pending: false });
});
test('declared migrations but no DATABASE_URL → pending, nothing applied', async () => {
const mgr = createManager([{ name: 'p', migrations: ['001.sql'], __dir: new URL('file:///x/') }]);
const warns = [];
const res = await runMigrations(mgr, { log: { warn: (m) => warns.push(m) } });
assert.equal(res.pending, true);
assert.deepEqual(res.skipped, ['p/001.sql']);
assert.equal(res.applied.length, 0);
assert.match(warns[0], /DATABASE_URL fehlt/);
});
test('fresh apply: real dialog migration runs once and is recorded', async () => {
const { loadPlugins } = await import('../src/plugin-manager.js');
const mgr = await loadPlugins(['dialog']); // real manifest → real 001_dialog.sql url
const fx = fakeExec();
const res = await runMigrations(mgr, { exec: fx.exec, log: {} });
assert.deepEqual(res.applied, ['dialog/001_dialog.sql']);
assert.deepEqual(res.skipped, []);
assert.equal(fx.ran.length, 1);
assert.match(fx.ran[0], /create table if not exists public\.forums/i); // the captured DDL
assert.equal(fx.applied.has('dialog/001_dialog.sql'), true);
});
test('idempotent: already-recorded migration is skipped, file SQL not re-run', async () => {
const { loadPlugins } = await import('../src/plugin-manager.js');
const mgr = await loadPlugins(['dialog']);
const fx = fakeExec(['dialog/001_dialog.sql']); // pretend already applied
const res = await runMigrations(mgr, { exec: fx.exec, log: {} });
assert.deepEqual(res.skipped, ['dialog/001_dialog.sql']);
assert.deepEqual(res.applied, []);
assert.equal(fx.ran.length, 0); // file SQL never executed again
});
test('a failing migration rolls back and surfaces the id', async () => {
const mgr = createManager([{ name: 'boom', migrations: ['x.sql'], __dir: new URL('file:///does-not-exist/') }]);
const calls = [];
const exec = async (text) => {
calls.push(text.trim().split(/\s+/).slice(0, 2).join(' '));
return [];
};
// readFile on the missing url throws before BEGIN; ensure the error names the id.
await assert.rejects(() => runMigrations(mgr, { exec, log: {} }), /boom\/x\.sql/);
});
+113
View File
@@ -0,0 +1,113 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Hono } from 'hono';
import { createManager } from '../src/plugin-manager.js';
// Tiny sub-app that answers GET / with a fixed text.
function mk(text) { const a = new Hono(); a.get('/', (c) => c.text(text)); return a; }
function fixture() {
const state = { booted: false, published: null, previewed: null };
const demo = {
name: 'demo',
routes: [
// sub-app routes
{ path: '/demo-pub', app: mk('pub'), public: true },
{ path: '/demo-priv', app: mk('priv') },
{ path: '/demo-adm', app: mk('adm'), admin: true },
// handler routes (method + handler)
{ method: 'get', path: '/h-pub', handler: (c) => c.text('hpub'), public: true },
{ method: 'post', path: '/h-priv', handler: (c) => c.text('hpriv') },
// per-route middleware (e.g. a rate limit)
{
method: 'post', path: '/h-limited', public: true,
use: [async (c, next) => (c.req.header('x-pass') ? next() : c.json({ e: 'limited' }, 429))],
handler: (c) => c.text('ok'),
},
],
onBoot: async () => { state.booted = true; },
onPublish: async (_ctx, p) => { state.published = p.path; },
onPreview: async (_ctx, p) => { state.previewed = p.path; },
stats: async () => ({ widgets: 3 }),
migrations: ['001_demo.sql'],
};
return { demo, state };
}
// Build the same pipeline as index.js: public → requireAuth → private(+admin).
function buildApp(m) {
const app = new Hono();
m.mountPublic(app);
app.use('/api/*', async (c, next) => (c.req.header('x-auth') ? next() : c.json({ error: 'no auth' }, 401)));
const requireAdmin = async (c, next) => (c.req.header('x-admin') ? next() : c.json({ error: 'no admin' }, 403));
m.mountPrivate(app, '/api', requireAdmin);
return app;
}
test('manager: names / has', () => {
const { demo } = fixture();
const m = createManager([demo]);
assert.deepEqual(m.names(), ['demo']);
assert.equal(m.has('demo'), true);
assert.equal(m.has('nope'), false);
});
test('manager: public routes reachable WITHOUT auth (sub-app + handler)', async () => {
const app = buildApp(createManager([fixture().demo]));
const sub = await app.request('/api/demo-pub');
assert.equal(sub.status, 200); assert.equal(await sub.text(), 'pub');
const h = await app.request('/api/h-pub');
assert.equal(h.status, 200); assert.equal(await h.text(), 'hpub');
});
test('manager: private routes require auth (sub-app + handler)', async () => {
const app = buildApp(createManager([fixture().demo]));
assert.equal((await app.request('/api/demo-priv')).status, 401);
assert.equal((await app.request('/api/h-priv', { method: 'POST' })).status, 401);
const ok = await app.request('/api/h-priv', { method: 'POST', headers: { 'x-auth': '1' } });
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'hpriv');
});
test('manager: admin route guarded by requireAdmin', async () => {
const app = buildApp(createManager([fixture().demo]));
assert.equal((await app.request('/api/demo-adm', { headers: { 'x-auth': '1' } })).status, 403);
const ok = await app.request('/api/demo-adm', { headers: { 'x-auth': '1', 'x-admin': '1' } });
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'adm');
});
test('manager: per-route use middleware runs (rate-limit style)', async () => {
const app = buildApp(createManager([fixture().demo]));
assert.equal((await app.request('/api/h-limited', { method: 'POST' })).status, 429);
const ok = await app.request('/api/h-limited', { method: 'POST', headers: { 'x-pass': '1' } });
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'ok');
});
test('manager: lifecycle hooks run', async () => {
const { demo, state } = fixture();
const m = createManager([demo]);
await m.runBoot({});
await m.runPublish({}, { path: 'a.md' });
await m.runPreview({}, { path: 'b.md' });
assert.equal(state.booted, true);
assert.equal(state.published, 'a.md');
assert.equal(state.previewed, 'b.md');
});
test('manager: stats merged under plugin name', async () => {
const m = createManager([fixture().demo]);
assert.deepEqual(await m.collectStats({}), { demo: { widgets: 3 } });
});
test('manager: declared migrations surfaced', () => {
const m = createManager([fixture().demo]);
assert.deepEqual(m.migrations(), [{ plugin: 'demo', file: '001_demo.sql', url: null }]);
});
test('manager: empty plugin set is a no-op', async () => {
const m = createManager([]);
assert.deepEqual(m.names(), []);
assert.deepEqual(await m.collectStats({}), {});
assert.deepEqual(m.migrations(), []);
const app = buildApp(m);
assert.equal((await app.request('/api/anything', { headers: { 'x-auth': '1' } })).status, 404);
});
+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
});
+30
View File
@@ -0,0 +1,30 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { systemInfo } = await import('../src/routes/system.js');
test('systemInfo reflects core version, plugins and content model', () => {
const info = systemInfo({ version: '0.6.0', hugo: '0.161.1+extended' });
assert.equal(info.core.name, 'openbureau-core');
assert.equal(info.core.version, '0.6.0');
assert.equal(info.site, 'openbureau');
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
assert.equal(info.hugo, '0.161.1+extended');
// dialog plugin is active with its routes + the one migration
const dialog = info.plugins.find((p) => p.name === 'dialog');
assert.ok(dialog, 'dialog plugin listed');
assert.ok(dialog.routes > 0);
assert.equal(dialog.migrations, 1);
// content model surfaced from the config collections
assert.deepEqual(info.collections.map((c) => c.kind).sort(), ['beitrag', 'biblio', 'rubrik', 'seite']);
const beitrag = info.collections.find((c) => c.kind === 'beitrag');
assert.equal(beitrag.label, 'Beiträge');
assert.ok(beitrag.fields > 0);
});