Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md 2.7. ensureSchema() now replays every installed module's schema fragment immediately after core's schema.sql, per MODULE_API.md 2.6. The work splits across two files on the line of whether a database is needed to know the answer. loader.js VALIDATES a fragment at load time, before anything is mounted, because every rule 2.6 states about the SQL is knowable by reading it; a module that breaks one never mounts (4.4, left column). modules/schema.js EXECUTES it, so the only failures there are the ones the database alone could report, and those are post-mount and answer 503 (4.4, right column). Validation is a leading-verb allowlist -- CREATE, ALTER, INSERT, UPDATE, the four core's own schema.sql uses -- rather than the DROP denylist 2.6 words it as. A fragment is replayed on every boot, so TRUNCATE and DELETE would empty a table at each restart and RENAME would fail at the second one; a denylist only ever bans what somebody thought of. A CREATE TABLE missing IF NOT EXISTS is rejected for the same reason: it works once and fails every boot after, which presents to an operator as a module that broke on restart. The splitter moves to utils/sqlStatements.js so core's schema and a fragment are split by literally the same code, which is what 2.6 promises. It is its own file rather than an export of utils/db.js because the loader validates fragments at require time and must not drag the mariadb pool into app.js's require chain. The replay sits outside ensureSchema's wait-for-the-database retry loop: a fragment that throws is one module's failure, not a signal the database is coming up, and retrying core's whole schema nine more times over one module's bad SQL would turn a 503'd module into a two-minute boot. Found while wiring it: db/seed.js calls ensureSchema() standalone for `npm run seed`, without ever requiring app.js, so the loader has not scanned and fragments()'s 7.6 throw would have broken seeding outright. The replay asks isLoaded() and logs the skip rather than swallowing it -- a booting server quietly getting no module tables is the thing 7.6 exists to prevent. Verification: - 856 server tests pass, 14 new. moduleSchema.test.js injects the query fn, so the exact statements and their order are asserted with the pool at a dead port like every other suite. - routes.manifest.json and routes.guards.json diffs are zero lines, 229 routes -- the phase 2 exit criterion. swagger-output.json regenerates byte-identical. - Run for real against the local MariaDB with two fixture modules: a good fragment created its table, applied its ALTER and seeded its row; a fragment whose SQL passes validation but the server rejects (`id NOTATYPE`) marked only that module startup_failed, its route answering 503 while the other answered 200; a second ensureSchema on the same database was a clean no-op. Co-Authored-By: Claude <noreply@anthropic.com>
514 lines
22 KiB
JavaScript
514 lines
22 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 { 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}`),
|
|
)
|
|
}
|
|
})
|