Files
website/server/test/moduleLoader.test.js
wtclaude f5e6025dcc
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 29s
test: re-point core's suite at what core still owns
25 of 82 test files left with the module. Three that core keeps needed splitting
rather than moving, and the split is the boundary in each case.

announceJobs.test.js keeps the announce PIPELINE -- the shared backoff schedule,
the parent-status rollup, core's Discord leg -- and loses the town-crier text
building and classification, which are a module's leg. pushDispatch.test.js
keeps the SSRF guard and publish() delivering a content-free tickle, and loses
mapShardEvent and the shard fan-out, which are a module's catalog.

playerRouteAccess.test.js is the one worth explaining. It guards a real past bug
-- an admin 403'd off their own characters -- and it did so through
/player/shard/accounts, which is now module-owned. The guarantee it protects is
CORE's, though: /player/* is role-agnostic self-service, staff are a superset of
players. So it stays here and asserts that through /player/appeals, a core route
with the same gate. Moving it would have left core with no test of its own tier
rule, which is precisely what regressed once before.

The remaining updates are core's own tests catching up: ctx has four more
members, registerCore now registers only what core owns (one stream, one leg, no
filled slot), and the extension-slot test asks for the DECLARED slot's router
rather than the filled one, since core declares it and a module fills it. The
gated-surface floor drops from >100 to >50 -- it is there so a filter matching
nothing fails loudly, not to track core's exact route count.

616 core tests and 160 client tests pass; the module's own suite is 351.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:08:26 -05:00

756 lines
34 KiB
JavaScript

// ── 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 registries = require('../src/modules/registries')
const { startApp } = require('./_helper')
// Requiring the real admin router declares the `admin.users.detail` extension
// slot exactly the way production does (users.router.js, at require time). Doing
// it here rather than calling declareSlot by hand matters: one test below builds
// the real tier routers, and a hand-declared slot would collide with that
// require's own declaration.
require('../src/router/v1/admin')
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
// The registries are process-global (there is one core), so hand the process
// back between tests. Without this a module's staged registrations from a
// previous test would still be committed, and every collision assertion below
// would be asserting against the wrong history.
registries._reset()
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. The schema replay and onBoot
// are the two things that trip this in real life (moduleSchema.test.js and
// moduleLifecycle.test.js cover both); 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', { stage: 'schema', reason: 'schema fragment blew up' })
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
assert.equal(stateOf(loader, 'later').stage, 'schema')
// The 404 leg is reached for real by the boot reconcile, when it finds a
// `disabled` row in 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)
// Every non-failing move clears the failure, so a running module can never
// show the reason it failed two boots ago (§2.4).
assert.equal(stateOf(loader, 'later').reason, null)
assert.equal(stateOf(loader, 'later').stage, null)
} 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'))
// API 1.1.0 added activity, users and site — each because module-uo's
// extraction needed it and none could be vendored: an admin action a module
// performs belongs in core's one audit log, the extension slot needs the user
// its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL.
assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator',
])
// is core's limiter FACTORY, not a limiter: a module states its own
// window and cap and takes the plumbing, so there is one express-rate-limit in
// the process and one place a breach is logged. is
// handed over whole because it is shared policy — core's /auth/me and
// /player/account sit behind the same counter.
assert.deepEqual(probe.middleware, [
'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate',
])
assert.equal(probe.moduleId, 'probe')
assert.equal(probe.mutable, false, 'ctx members must be frozen')
})
// ── Lifecycle hooks ────────────────────────────────────────────────────────
test('a lifecycle hook must be a function, and may be registered once', () => {
// Both are register-time failures, so they cost the module its mount entirely
// rather than surfacing at boot — the far end of a hook that was never really
// registered is a module that silently never warms up.
for (const [body, expected] of [
['api.onBoot("later")', /onBoot: expected a function/],
['api.onShutdown("later")', /onShutdown: expected a function/],
['api.onBoot(() => {}); api.onBoot(() => {})', /onBoot\(\) called twice/],
]) {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
writeModule('hooked', { server: `module.exports = (ctx, api) => { ${body} }` })
const state = stateOf(freshLoader(tmpRoot), 'hooked')
assert.match(state.reason, expected)
assert.equal(state.stage, 'register')
}
})
test('a module with no hooks is bootable, and offers nothing to shut down', () => {
writeModule('quiet', oneRoute('/widgets'))
const loader = freshLoader(tmpRoot)
// Listed with a null hook rather than filtered out: it still has to reach
// `started`, or the admin panel and the dispatch guard would disagree about
// whether it is serving.
assert.deepEqual(loader.bootable().map((b) => b.id), ['quiet'])
assert.equal(loader.bootable()[0].hook, null)
assert.deepEqual(loader.shutdownHooks(), [])
})
test('shutdown hooks come back in reverse order, and only for started modules', () => {
const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
writeModule('aaa', { server: hook })
writeModule('bbb', { server: hook })
writeModule('ccc', { server: hook })
const loader = freshLoader(tmpRoot)
// Nothing has started yet, so there is nothing to tear down.
assert.deepEqual(loader.shutdownHooks(), [])
loader.setState('aaa', 'started')
loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
loader.setState('ccc', 'started')
// Reverse registration order (§2.5), and `bbb` is absent: a module whose
// onBoot threw is mid-way through a warm-up it never finished, and handing it
// a half-built world to tear down is worse than not closing cleanly.
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
})
// ── Failure stages ─────────────────────────────────────────────────────────
test('a failure is recorded against the §4.3 step that produced it', () => {
// installed_modules.failure_stage exists so the admin panel can say WHERE a
// module broke. The four steps readManifest covers in one pass have to label
// themselves; the rest are inferred from how far load() had got.
const cases = [
['a-manifest', { manifest: { nonsense: true } }, 'manifest'],
['b-coreapi', { manifest: { coreApi: '^99.0.0' } }, 'core_api'],
['c-mounts', { manifest: { mounts: { public: ['/bad prefix'] } } }, 'mounts'],
['d-slots', { manifest: { extensions: ['no.such.slot'] } }, 'extensions'],
['e-schema', { schema: 'DELETE FROM x;' }, 'schema'],
['f-require', { server: 'throw new Error("boom")' }, 'require'],
['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
]
for (const [id, spec] of cases) writeModule(id, spec)
const loader = freshLoader(tmpRoot)
for (const [id, , stage] of cases) {
const state = stateOf(loader, id)
assert.equal(state.state, 'startup_failed', `${id} should have failed`)
assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
}
})
// ── Staged registrations are committed only for a module that survives ─────
test('a module that fails AFTER registering leaves nothing in the registries', () => {
// The registry-side twin of the second-pass mount rule. register() runs before
// checkDeclared, so a module can stage a stream catalog and then be rejected —
// and a half-registered catalog is worse than a missing one, because it is a
// subscribable stream nothing will ever publish to.
writeModule('halfway', {
manifest: { mounts: { public: ['/declared'] } },
server: `module.exports = (ctx, api) => {
api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
// declared /declared and never registered it → rejected by checkDeclared
}`,
})
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
assert.equal(registries.isValidStream('halfway.thing'), false)
assert.equal(registries.announceLeg('halfway.leg'), null)
})
test('a module colliding with an already-registered name fails alone, unmounted', () => {
const tiers = emptyTiers()
writeModule('first', {
manifest: { mounts: { public: ['/first'] } },
server: `module.exports = (ctx, api) => {
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
}`,
})
writeModule('second', {
manifest: { mounts: { public: ['/second'] } },
server: `module.exports = (ctx, api) => {
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
}`,
})
const loader = freshLoader(tmpRoot, tiers)
assert.equal(stateOf(loader, 'first').state, 'registered')
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
// Not even the claim that did not collide.
assert.equal(registries.isValidStream('second.ok'), false)
// And the loser is not mounted at all. Asked of the live router the way the
// prefix-ownership check asks it, rather than by counting layers — one mount
// produces two (the dispatch guard, then the module's router).
const claims = (prefix) =>
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
assert.equal(claims('/first'), true)
assert.equal(claims('/second'), false)
})
// ── The client chunk (MODULE_API.md §3.1) ──────────────────────────────────
/** A module shipping a prebuilt chunk at the conventional client/dist/entry.js. */
function withChunk(id, { entry = 'client/dist/entry.js', write = true, body = 'export default 1' } = {}) {
const dir = writeModule(id, { manifest: { client: { entry } } })
if (write) {
const file = path.join(dir, entry)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, body)
}
return dir
}
test('a module with a chunk publishes where to serve it from and its URL', () => {
const dir = withChunk('uo')
const loader = freshLoader(tmpRoot)
const [chunk] = loader.clientChunks()
assert.equal(chunk.id, 'uo')
assert.equal(chunk.url, '/modules/uo')
assert.equal(chunk.entryUrl, '/modules/uo/entry.js')
// The DIRECTORY THE ENTRY IS IN, never the module root: one express.static over
// a module root would publish its server source, its module.json and its schema
// fragment.
assert.equal(chunk.dir, path.join(dir, 'client', 'dist'))
assert.equal(typeof chunk.guard, 'function')
})
test('a server-only module contributes no chunk', () => {
writeModule('plain', { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientChunks(), [])
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('an entry directly in the module root is refused — its directory is served', () => {
// The rule with the largest blast radius in this file. Accepting it would root
// the static mount at the module root and publish everything in it.
const dir = writeModule('uo', { manifest: { client: { entry: 'entry.js' } } })
fs.writeFileSync(path.join(dir, 'entry.js'), 'export default 1')
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /must be in a subdirectory/)
assert.deepEqual(loader.clientChunks(), [])
})
test('an entry that escapes the module directory is refused before anything else', () => {
// `../../server/src/config/csp.js` is a real, readable file, and every check
// after containment would have passed.
withChunk('uo', { entry: '../../server/src/config/csp.js', write: false })
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /escapes the module directory/)
})
test('an entry that is not a .js file, or is missing, is refused', () => {
withChunk('aaa', { entry: 'client/dist/entry.mjs' })
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
assert.match(stateOf(loader, 'bbb').reason, /is missing/)
})
test('a malformed client key is a loud failure, not an ignored setting', () => {
writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
writeModule('ccc', { manifest: { client: {} } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).stage, 'manifest')
})
test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
// The mount is a standing offer answered by a guard; the tag is a decision
// taken per render, when the state is known. A module answering 503 on its API
// must not also be handing the browser the script that calls it.
withChunk('uo')
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientEntryUrls(), [], 'registered is not yet serving')
loader.setState('uo', 'started')
assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])
loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'nope' })
assert.deepEqual(loader.clientEntryUrls(), [])
assert.equal(loader.clientChunks().length, 1, 'the mount stays; the guard answers for it')
loader.setState('uo', 'disabled')
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('the chunk accessors throw before load(), like every other one', () => {
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.clientChunks(), /modules\.clientChunks\(\) before modules\.load\(\)/)
assert.throws(() => loader.clientEntryUrls(), /modules\.clientEntryUrls\(\) before modules\.load\(\)/)
})