// ── The loader's failure guarantees ──────────────────────────────────────── // // docs/website/MODULE_API.md §4.4 promises that a module which fails ANYWHERE in // its lifecycle fails alone: the site comes up, other modules are unaffected, and // the failure is recorded rather than thrown. That is the property most worth a // test, because the failure paths are the ones nobody exercises by hand — every // manual check runs the happy path. // // Each test builds a throwaway modules directory, points MODULES_DIR at it and // re-requires the loader with a clean cache, so the scan is genuinely redone. // MODULES_DIR is read into a const at require time (it has to be: the scan is // synchronous and happens during app.js's require), so busting the cache is the // only honest way to point the loader somewhere else. // // Point the pool at a closed port BEFORE requiring anything: buildCtx pulls in // the models, which build a mariadb pool at require time. No query is ever run. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const fs = require('fs') const os = require('os') const path = require('path') const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const express = require('express') const db = require('../src/utils/db') const { startApp } = require('./_helper') after(() => db.close()) let tmpRoot /** Three empty routers standing in for core's tiers — nothing owned, nothing gated. */ const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router(), }) function freshLoader(dir, tiers = emptyTiers()) { process.env.MODULES_DIR = dir delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const loader = require('../src/modules/loader') loader.load(tiers) return loader } function writeModule(id, { manifest = {}, server, schema } = {}) { const dir = path.join(tmpRoot, id) fs.mkdirSync(dir, { recursive: true }) const full = { id, name: id, version: '1.0.0', coreApi: '^1.0.0', ...(server === undefined ? {} : { server: 'index.js' }), ...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }), ...manifest, } fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(full)) if (server !== undefined) fs.writeFileSync(path.join(dir, 'index.js'), server) if (schema !== undefined) { fs.writeFileSync(path.join(dir, 'schema.sql'), schema) fs.writeFileSync(path.join(dir, 'purge.sql'), '') } return dir } /** A module that registers one router answering 200 at its prefix root. */ const oneRoute = (prefix, tier = 'public') => ({ manifest: { mounts: { [tier]: [prefix] } }, server: `module.exports = (ctx, api) => { const r = ctx.express.Router() r.get('/', (req, res) => res.json({ ok: true })) api.registerRoutes({ ${tier}: { '${prefix}': r } }) }`, }) const stateOf = (loader, id) => loader.list().find((m) => m.id === id) beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-')) }) // ── Discovery and the explicit trigger ───────────────────────────────────── test('a missing modules directory is the normal case, not an error', () => { const loader = freshLoader(path.join(tmpRoot, 'does-not-exist')) assert.deepEqual(loader.list(), []) }) test('reading the module list before load() throws instead of answering []', () => { // §7.6. The spike's scan was lazy and silent, so a caller that required the // loader and read nothing got an empty list — indistinguishable from a core // with no modules installed. It cost one confusing failure; it now costs an // error naming the missing call. process.env.MODULES_DIR = tmpRoot delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const loader = require('../src/modules/loader') assert.throws(() => loader.list(), /modules\.list\(\) before modules\.load\(\)/) }) test('load() refuses to run without all three tier routers', () => { writeModule('aaa', { server: 'module.exports = () => {}' }) process.env.MODULES_DIR = tmpRoot delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const loader = require('../src/modules/loader') // Not a module's failure — a wiring mistake in core, and the one thing in this // file that is allowed to throw past the caller. assert.throws(() => loader.load({ public: express.Router() }), /missing the "admin" tier router/) }) test('load() is once-only, so a second call cannot double-mount', () => { writeModule('aaa', oneRoute('/thing')) const tiers = emptyTiers() const loader = freshLoader(tmpRoot, tiers) const before = tiers.public.stack.length loader.load(tiers) assert.equal(tiers.public.stack.length, before) assert.equal(loader.list().length, 1) }) test('modules load in alphabetical order, since nothing computes a precedence', () => { for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' }) const loader = freshLoader(tmpRoot) assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc']) }) // ── The failing module fails alone ───────────────────────────────────────── test('a module whose entry point throws does not stop the others loading', () => { writeModule('aaa', { server: 'module.exports = () => {}' }) writeModule('bbb', { server: 'throw new Error("boom")' }) writeModule('ccc', { server: 'module.exports = () => {}' }) const loader = freshLoader(tmpRoot) assert.equal(stateOf(loader, 'aaa').state, 'registered') assert.equal(stateOf(loader, 'ccc').state, 'registered') const bad = stateOf(loader, 'bbb') assert.equal(bad.state, 'startup_failed') assert.match(bad.reason, /boom/) }) test('an entry point that exports something other than a function is rejected', () => { writeModule('notfn', { server: 'module.exports = { register: () => {} }' }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'notfn').reason, /does not export a function/) }) test('a coreApi mismatch is refused before the module is required at all', () => { // The entry point would throw if it ran; the version gate must run first. writeModule('old', { manifest: { coreApi: '^99.0.0' }, server: 'throw new Error("should never be required")', }) const loader = freshLoader(tmpRoot) const mod = stateOf(loader, 'old') assert.equal(mod.state, 'startup_failed') assert.match(mod.reason, /needs core API \^99\.0\.0/) }) test('an unknown manifest key is rejected, not ignored', () => { // A typo'd key must be loud: an operator who believes they configured // something and silently did not is worse off than one who sees a failure. writeModule('typo', { manifest: { mount: { public: ['/x'] } } }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'typo').reason, /unknown key "mount"/) }) test('a module id that does not match its directory is rejected', () => { writeModule('onedir', { manifest: { id: 'another' } }) const loader = freshLoader(tmpRoot) // Recorded under the DIRECTORY name — the id it claimed is exactly what is // not trusted here. assert.match(stateOf(loader, 'onedir').reason, /does not match directory/) }) test('an unknown extension slot is rejected; only core may declare a slot', () => { writeModule('presumptuous', { manifest: { extensions: ['admin.users.detail', 'admin.invented'] } }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'presumptuous').reason, /unknown extension slot "admin\.invented"/) }) // ── Prefix ownership ─────────────────────────────────────────────────────── test('two modules cannot claim the same prefix; the first one wins', () => { writeModule('aaa', oneRoute('/thing')) writeModule('bbb', oneRoute('/thing')) const loader = freshLoader(tmpRoot) assert.equal(stateOf(loader, 'aaa').state, 'registered') assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/) }) test('a module cannot take a prefix core owns, asked of the live tier routers', () => { // Deliberately the REAL routers rather than a hardcoded prefix list. The spike // hardcoded core's ~24 prefixes and they were already stale; asking express // itself is what stops the check drifting the next time core adds a capability // router. /* eslint-disable global-require */ const real = { public: require('../src/router/v1/public'), admin: require('../src/router/v1/admin'), player: require('../src/router/v1/player'), } /* eslint-enable global-require */ writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } }) writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } }) // Free in every tier core actually mounts, so it must be allowed through. writeModule('polite', oneRoute('/widgets', 'player')) const loader = freshLoader(tmpRoot, real) assert.match(stateOf(loader, 'greedy').reason, /owned by core/) assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/) assert.equal(stateOf(loader, 'polite').state, 'registered') }) test('a root-mounted core layer does not make every prefix look taken', () => { // public/index.js ends with `use('/', siteRouter)` and admin with the dashboard // router; both match every path. Counting them would report every prefix as // owned and no module could ever mount. const tiers = emptyTiers() tiers.public.use('/posts', express.Router()) tiers.public.use('/', express.Router()) writeModule('fine', oneRoute('/widgets')) writeModule('taken', oneRoute('/posts')) const loader = freshLoader(tmpRoot, tiers) assert.equal(stateOf(loader, 'fine').state, 'registered') assert.match(stateOf(loader, 'taken').reason, /owned by core/) }) test('a prefix with a slash or a parameter in it is rejected', () => { // A prefix that could contain either would let a module reach outside the slot // it was given. writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } }) writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/) assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/) }) test('registering a prefix that was never declared is rejected', () => { // module.json is what the admin panel, the collision check and the reviewer // all read, so it has to be the truth rather than a hint. writeModule('sneaky', { manifest: { mounts: { public: ['/declared'] } }, server: `module.exports = (ctx, api) => api.registerRoutes({ public: { '/declared': ctx.express.Router(), '/undeclared': ctx.express.Router() }, })`, }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'sneaky').reason, /registered public\/undeclared without declaring it/) }) test('declaring a prefix and never registering it is rejected too', () => { writeModule('forgetful', { manifest: { mounts: { public: ['/a', '/b'] } }, server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/a': ctx.express.Router() } })", }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'forgetful').reason, /declared public\/b but never registered it/) }) test('registering the same thing twice is an error, not a silent overwrite', () => { writeModule('twice', { manifest: { mounts: { public: ['/x'] } }, server: `module.exports = (ctx, api) => { api.registerRoutes({ public: { '/x': ctx.express.Router() } }) api.registerRoutes({ public: { '/x': ctx.express.Router() } }) }`, }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/) }) // ── Schema fragment validation (the replay itself is moduleSchema.test.js) ── test('a schema fragment declaring a core table is rejected', () => { writeModule('thief', { schema: 'CREATE TABLE IF NOT EXISTS users (id INT);' }) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'thief').reason, /declares core table "users"/) }) test('a schema fragment table must carry the module id as a prefix', () => { writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS widgets (id INT);' }) assert.match(stateOf(freshLoader(tmpRoot), 'mine').reason, /not prefixed "mine_"/) tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-')) writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' }) assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered') }) test('two modules cannot own the same table either', () => { writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' }) writeModule('bbb', { manifest: { id: 'bbb' }, schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);', }) const loader = freshLoader(tmpRoot) assert.equal(stateOf(loader, 'aaa').state, 'registered') assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/) }) test('declaring a schema without a purge is rejected', () => { const dir = path.join(tmpRoot, 'noway') fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync( path.join(dir, 'module.json'), JSON.stringify({ id: 'noway', name: 'x', version: '1.0.0', coreApi: '^1.0.0', schema: 'schema.sql' }), ) const loader = freshLoader(tmpRoot) // A module that can create tables and cannot drop them leaves an operator with // orphaned data and no supported way to remove it. assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/) }) test('a declared purge file that is not there is rejected', () => { writeModule('gone', { schema: 'CREATE TABLE IF NOT EXISTS gone_x (id INT);' }) fs.unlinkSync(path.join(tmpRoot, 'gone', 'purge.sql')) const loader = freshLoader(tmpRoot) assert.match(stateOf(loader, 'gone').reason, /purge file "purge\.sql" is missing/) }) test('a fragment may only use the four verbs core\'s own schema.sql uses', () => { // An allowlist rather than a DROP denylist. §2.6 bans DROP, but this file is // REPLAYED ON EVERY BOOT, so TRUNCATE and DELETE would empty a table at every // restart and RENAME would fail at the second one — a denylist only ever bans // what somebody thought of. for (const [id, sql, verb] of [ ['dropper', 'DROP TABLE dropper_x;', 'DROP'], ['nuker', 'TRUNCATE TABLE nuker_x;', 'TRUNCATE'], ['wiper', 'DELETE FROM wiper_x;', 'DELETE'], ['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'], ]) { writeModule(id, { schema: sql }) const reason = stateOf(freshLoader(tmpRoot), id).reason assert.match(reason, new RegExp(`starts with "${verb}"`)) } }) test('a fragment may INSERT and UPDATE its own seed data', () => { // Core's schema.sql does both (INSERT IGNORE INTO settings, one UPDATE), so a // module that seeds a lookup table the same way must not be rejected. writeModule('seeder', { schema: [ 'CREATE TABLE IF NOT EXISTS seeder_kinds (id INT PRIMARY KEY, label VARCHAR(32));', "INSERT IGNORE INTO seeder_kinds (id, label) VALUES (1, 'first');", "UPDATE seeder_kinds SET label = 'first' WHERE id = 1;", ].join('\n'), }) assert.equal(stateOf(freshLoader(tmpRoot), 'seeder').state, 'registered') }) test('a CREATE TABLE without IF NOT EXISTS is rejected', () => { // It succeeds exactly once and fails every boot after it, which presents as a // module that worked until the first restart — the worst kind of bug to ship // to an operator, and free to catch by reading the file. writeModule('once', { schema: 'CREATE TABLE once_x (id INT);' }) assert.match(stateOf(freshLoader(tmpRoot), 'once').reason, /CREATE TABLE without IF NOT EXISTS/) }) test('a fragment carrying an unreadable file fails the module, not the boot', () => { writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' }) fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql')) writeModule('fine', oneRoute('/ok')) const loader = freshLoader(tmpRoot) assert.equal(stateOf(loader, 'missing').state, 'startup_failed') assert.equal(stateOf(loader, 'fine').state, 'registered') }) test('fragments() lists only registered modules that have one', () => { writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' }) writeModule('nodb', oneRoute('/plain')) writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' }) const loader = freshLoader(tmpRoot) const frags = loader.fragments() assert.deepEqual(frags.map((f) => f.id), ['withdb']) // An absolute path, so the replay never has to know how a module dir is laid out. assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql')) }) test('fragments() before load() throws, like every other accessor', () => { process.env.MODULES_DIR = tmpRoot delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const loader = require('../src/modules/loader') assert.throws(() => loader.fragments(), /modules\.fragments\(\) before modules\.load\(\)/) }) // ── Mounting and the dispatch guard ──────────────────────────────────────── test('a registered module answers on its prefix; a failed one is simply absent', async () => { writeModule('good', oneRoute('/widgets')) writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' }) const tiers = emptyTiers() freshLoader(tmpRoot, tiers) const app = await startApp((a) => a.use('/public', tiers.public)) try { assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200) // Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do // not exist at all (§4.4, left-hand column). assert.equal((await fetch(`${app.url}/public/broken`)).status, 404) } finally { await app.close() } }) test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => { // The right-hand column of §4.4, and the reason routes.manifest.json can be // generated off a dead database: the URL surface must not depend on whether a // boot step succeeded on the generating machine. PR 3 (schema replay) and PR 5 // (onBoot) are the two things that will trip this in real life; here the state // is moved by hand, because the loader is the thing under test. writeModule('later', oneRoute('/widgets')) const tiers = emptyTiers() const loader = freshLoader(tmpRoot, tiers) const app = await startApp((a) => a.use('/public', tiers.public)) try { assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200) assert.equal(stateOf(loader, 'later').state, 'registered') loader.setState('later', 'startup_failed', 'schema fragment blew up') assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503) assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up') // The 404 leg becomes reachable for real in PR 5, when the boot reconcile // reads a `disabled` row out of installed_modules. A disabled module is // mounted and guarded, never unmounted (§4.5) — same reason as the 503. loader.setState('later', 'disabled') assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404) loader.setState('later', 'started') assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200) } finally { await app.close() } }) test('setState refuses a state that is not one, and shrugs at an unknown id', () => { writeModule('here', oneRoute('/widgets')) const loader = freshLoader(tmpRoot) assert.throws(() => loader.setState('here', 'enabled'), /unknown module state "enabled"/) // An id with no record is not the boot path's problem to escalate. assert.doesNotThrow(() => loader.setState('never-installed', 'started')) }) // ── ctx ──────────────────────────────────────────────────────────────────── test('ctx exposes exactly the documented surface, and is frozen', () => { const seen = path.join(tmpRoot, 'probe-out.json') writeModule('probe', { server: `const fs = require('fs') module.exports = (ctx) => { let mutable = true try { ctx.db.query = null; mutable = ctx.db.query === null } catch { mutable = false } fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ keys: Object.keys(ctx).sort(), middleware: Object.keys(ctx.middleware).sort(), moduleId: ctx.moduleId, mutable, })) }`, }) assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered') const probe = JSON.parse(fs.readFileSync(seen, 'utf8')) assert.deepEqual(probe.keys, [ 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', 'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator', ]) assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate']) assert.equal(probe.moduleId, 'probe') assert.equal(probe.mutable, false, 'ctx members must be frozen') }) test('the register calls PR 4 and PR 5 own throw rather than silently accepting', () => { // An accepting no-op would let a module believe it had registered a // notification stream or a boot hook and fail silently at the far end. for (const [call, pr] of [ ['registerExtension', 4], ['registerNotificationStreams', 4], ['registerAnnounceLeg', 4], ['onBoot', 5], ['onShutdown', 5], ]) { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-')) writeModule('early', { server: `module.exports = (ctx, api) => api.${call}(() => {})` }) assert.match( stateOf(freshLoader(tmpRoot), 'early').reason, new RegExp(`${call}: not available until phase 2 PR ${pr}`), ) } })