Squashed 'cms/core/' content from commit 2f52ec7
git-subtree-dir: cms/core git-subtree-split: 2f52ec7536ee87b4f097d169e1cb0f6a85aebe84
This commit is contained in:
@@ -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,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 });
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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