// ── Admin · Modules: the delivery surface ────────────────────────────────── // // Phase 4, slice 1 of MODULE_SYSTEM.md §2.7.2. The controller is tested directly // with a mock `res` and stubbed models — the same shape adminUsers.test.js uses — // because what is interesting here is not the HTTP plumbing but the ORDER of // operations and which of the three sources of truth answers which question. // // Two of these tests exist to pin decisions that are easy to "fix" back into // being wrong: // // - **enable must not touch the loader.** Disable ran the module's onShutdown; // there is no onBoot re-dispatch, so flipping the record back would put a // module with closed sockets and cleared timers back on the nav. // - **purge must run before the directory is removed.** purge.sql lives inside // that directory. Reorder those two lines and the feature silently stops // working, with a 200 and no data deleted. // // Point the DB at a closed port BEFORE requiring anything that builds the pool. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const ctrl = require('../src/router/v1/admin/modules.controller') const modules = require('../src/model/modules/modules.model') const activity = require('../src/model/activity/activity.model') const settings = require('../src/model/settings/settings.model') const loader = require('../src/modules/loader') const lifecycle = require('../src/modules/lifecycle') const install = require('../src/modules/install') const schema = require('../src/modules/schema') const db = require('../src/utils/db') after(() => db.close()) function mockRes() { return { statusCode: 200, body: null, status(c) { this.statusCode = c; return this }, json(b) { this.body = b; return this }, } } const req = (extra = {}) => ({ user: { id: 1, username: 'admin' }, params: {}, query: {}, body: {}, ...extra, }) // Everything the controller reaches for, replaced wholesale per test. Restored // from these originals rather than from a snapshot taken mid-run, so one test // leaking a stub cannot quietly become another test's fixture. const originals = { modules: { ...modules }, activity: { log: activity.log }, settings: { get: settings.get, set: settings.set }, loader: { isLoaded: loader.isLoaded, list: loader.list }, lifecycle: { stop: lifecycle.stop }, install: { install: install.install, isInstalled: install.isInstalled, purgeFile: install.purgeFile, removeDir: install.removeDir, }, schema: { runPurge: schema.runPurge }, } let logged beforeEach(() => { Object.assign(modules, originals.modules) Object.assign(activity, originals.activity) Object.assign(settings, originals.settings) Object.assign(loader, originals.loader) Object.assign(lifecycle, originals.lifecycle) Object.assign(install, originals.install) Object.assign(schema, originals.schema) logged = [] activity.log = async (entry) => { logged.push(entry) } settings.get = async () => 'gitea.whitlocktech.com' loader.isLoaded = () => true loader.list = () => [] }) // ── list ─────────────────────────────────────────────────────────────────── test('list reconciles the row, the loader and the volume without picking a winner', async () => { // The case §2.4 creates and decision 3 makes routine: the row says `enabled` // because the operator just switched it back on, the loader still says // `disabled` because its onShutdown has run and there is no way back without a // restart. Rendering either one alone would be a lie. modules.list = async () => [{ id: 'uo', name: 'UO', version: '1.0.0', state: 'enabled', failureStage: null, failureReason: null, source: 'https://x/y.json', sha256: 'a'.repeat(64), installedAt: null, startedAt: null, }] loader.list = () => [{ id: 'uo', name: 'UO', version: '1.0.0', state: 'disabled', stage: null, reason: null, capabilities: ['shard'] }] install.isInstalled = () => true install.purgeFile = () => '/modules/uo/server/db/purge.sql' const res = mockRes() await ctrl.list(req(), res) const [m] = res.body.modules assert.equal(m.state, 'enabled', 'what the operator decided') assert.equal(m.liveState, 'disabled', 'what is actually answering') assert.equal(m.onVolume, true) assert.equal(m.canPurge, true) assert.deepEqual(m.capabilities, ['shard']) assert.deepEqual(res.body.sourceHosts, ['gitea.whitlocktech.com']) }) test('list includes a module on the volume that has no row yet', async () => { // A hand-placed directory before its first boot. §2.5 keeps that a supported // install, and its routes are already being served — a screen showing nothing // for it would be showing the wrong thing. modules.list = async () => [] loader.list = () => [{ id: 'byhand', name: 'By Hand', version: '0.1.0', state: 'started', stage: null, reason: null, capabilities: [] }] install.isInstalled = () => true install.purgeFile = () => null const res = mockRes() await ctrl.list(req(), res) assert.equal(res.body.modules.length, 1) assert.equal(res.body.modules[0].id, 'byhand') assert.equal(res.body.modules[0].state, null, 'no row means no recorded state, not a guessed one') assert.equal(res.body.modules[0].liveState, 'started') }) test('list survives a process where the loader never scanned', async () => { loader.isLoaded = () => false loader.list = () => { throw new Error('modules.list() before modules.load()') } modules.list = async () => [{ id: 'uo', name: 'UO', version: '1', state: 'disabled' }] install.isInstalled = () => false install.purgeFile = () => null const res = mockRes() await ctrl.list(req(), res) assert.equal(res.statusCode, 200) assert.equal(res.body.modules[0].liveState, null) }) // ── install ──────────────────────────────────────────────────────────────── test('install records provenance and says a restart is needed', async () => { const calls = [] install.install = async ({ url, hosts }) => { calls.push({ url, hosts }) return { id: 'uo', name: 'UO', version: '1.0.0', sha256: 'b'.repeat(64), source: url, replaced: false } } modules.recordInstalled = async (row) => { calls.push(row); return { ...row, state: 'installed' } } const res = mockRes() await ctrl.create(req({ body: { url: 'https://gitea.whitlocktech.com/x/uo.json' } }), res) assert.equal(res.statusCode, 201) assert.equal(res.body.restartRequired, true) assert.deepEqual(calls[0].hosts, ['gitea.whitlocktech.com'], 'the allowlist comes from the setting') // Provenance is written HERE and nowhere else — the boot reconcile records a // module with null source/sha256 and leaves what it is not given. assert.equal(calls[1].source, 'https://gitea.whitlocktech.com/x/uo.json') assert.equal(calls[1].sha256, 'b'.repeat(64)) assert.equal(logged[0].action, 'module.install') }) test('an install refusal is reported to the operator, with its own status', async () => { install.install = async () => { const err = new Error('"evil.net" is not an allowed module source host') err.name = 'InstallError' err.status = 400 throw err } const res = mockRes() await ctrl.create(req({ body: { url: 'https://evil.net/x.json' } }), res) assert.equal(res.statusCode, 400) // The message is the useful part: the operator pasted a URL and needs to know // what was wrong with what came back. assert.match(res.body.message, /not an allowed module source host/) assert.equal(logged.length, 0, 'a refused install is not an audit-log entry') }) test('an unreachable host is a 502, not a 400', async () => { install.install = async () => { const err = new Error('could not reach x: timeout') err.name = 'InstallError' err.status = 502 throw err } const res = mockRes() await ctrl.create(req({ body: { url: 'https://gitea.whitlocktech.com/x.json' } }), res) assert.equal(res.statusCode, 502) }) test('an unexpected failure is a 500 and does not leak its message', async () => { install.install = async () => { throw new Error('ENOENT /some/internal/path') } const res = mockRes() await ctrl.create(req({ body: { url: 'https://gitea.whitlocktech.com/x.json' } }), res) assert.equal(res.statusCode, 500) assert.equal(res.body.message, 'Internal Server Error') }) // ── enable / disable ─────────────────────────────────────────────────────── test('enable moves the row and does NOT touch the loader', async () => { // The decision-3 invariant. Re-enabling cannot restart a module: its // onShutdown has run, and MODULE_API.md has never promised onBoot is safe to // run twice. Flipping the record would put it back on the nav with a // torn-down world behind it. let setStateCalled = false loader.setState = () => { setStateCalled = true } modules.enable = async (id) => ({ id, state: 'enabled' }) const res = mockRes() await ctrl.enable(req({ params: { id: 'uo' } }), res) assert.equal(res.body.module.state, 'enabled') assert.equal(res.body.restartRequired, true) assert.equal(setStateCalled, false, 'enable must not move the in-memory record') assert.equal(logged[0].action, 'module.enable') loader.setState = originals.loader.setState }) test('enabling a module with no row is a 404', async () => { modules.enable = async () => null const res = mockRes() await ctrl.enable(req({ params: { id: 'ghost' } }), res) assert.equal(res.statusCode, 404) }) test('an illegal transition is a 409, not a 500', async () => { modules.enable = async () => { const err = new Error("module 'uo': cannot move from 'x' to 'enabled'") err.name = 'ModuleStateError' throw err } const res = mockRes() await ctrl.enable(req({ params: { id: 'uo' } }), res) assert.equal(res.statusCode, 409) }) test('disable stops the module and reports whether the hook ran', async () => { const calls = [] modules.get = async (id) => ({ id, state: 'started' }) lifecycle.stop = async (id) => { calls.push(id); return { stopped: true, error: null } } const res = mockRes() await ctrl.disable(req({ params: { id: 'uo' } }), res) assert.deepEqual(calls, ['uo']) assert.equal(res.body.stopped, true) // No restart: this is the one action that takes effect immediately, and it is // the one an operator reaches for when something is going wrong. assert.equal(res.body.restartRequired, undefined) assert.equal(logged[0].action, 'module.disable') }) test('a shutdown hook that failed is reported rather than swallowed', async () => { modules.get = async (id) => ({ id, state: 'started' }) lifecycle.stop = async () => ({ stopped: false, error: 'socket would not close' }) const res = mockRes() await ctrl.disable(req({ params: { id: 'uo' } }), res) // It IS disabled either way; the operator should be told it did not close // cleanly while they still have the logs in front of them. assert.equal(res.statusCode, 200) assert.match(res.body.shutdownError, /socket would not close/) }) // ── uninstall and purge ──────────────────────────────────────────────────── test('uninstall purges BEFORE it removes the directory', async () => { // The ordering that makes decision 5 work at all: purge.sql is a file inside // the directory being deleted. Swap these two and the endpoint still answers // 200 and deletes nothing. const order = [] modules.get = async (id) => ({ id, state: 'started' }) install.isInstalled = () => true install.purgeFile = () => '/modules/uo/server/db/purge.sql' schema.runPurge = async () => { order.push('purge'); return 12 } lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } } install.removeDir = async () => { order.push('removeDir'); return true } modules.remove = async () => { order.push('removeRow') } const res = mockRes() await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res) assert.deepEqual(order, ['purge', 'stop', 'removeDir', 'removeRow']) assert.equal(res.body.purged, 12) assert.equal(res.body.restartRequired, true) assert.equal(logged[0].action, 'module.purge') }) test('a plain uninstall keeps the row and does not purge', async () => { const order = [] modules.get = async (id) => ({ id, state: 'started' }) install.isInstalled = () => true schema.runPurge = async () => { order.push('purge'); return 1 } lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } } install.removeDir = async () => { order.push('removeDir'); return true } modules.remove = async () => { order.push('removeRow') } const res = mockRes() await ctrl.remove(req({ params: { id: 'uo' } }), res) // §2.5's default: the directory goes, the data stays, and the disabled row is // what keeps the retained data visible and the module reinstallable. assert.deepEqual(order, ['stop', 'removeDir']) assert.equal(res.body.purged, null) assert.equal(logged[0].action, 'module.uninstall') }) test('asking to purge a module that ships no purge.sql refuses instead of pretending', async () => { modules.get = async (id) => ({ id, state: 'started' }) install.isInstalled = () => true install.purgeFile = () => null let removed = false install.removeDir = async () => { removed = true; return true } const res = mockRes() await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res) assert.equal(res.statusCode, 400) assert.match(res.body.message, /ships no purge.sql/) // Nothing happened. The operator asked for the module AND its data to go; the // data cannot go, so doing half of it silently would be the worst answer. assert.equal(removed, false) }) test('uninstalling something that is neither on the volume nor in a row is a 404', async () => { modules.get = async () => null install.isInstalled = () => false const res = mockRes() await ctrl.remove(req({ params: { id: 'ghost' } }), res) assert.equal(res.statusCode, 404) }) test('standalone purge refuses while the module is still running', async () => { // Dropping the tables under a module that is still serving leaves it answering // out of a world that no longer exists. Disabling first is one click. modules.get = async (id) => ({ id, state: 'started' }) let ran = false schema.runPurge = async () => { ran = true; return 1 } const res = mockRes() await ctrl.purge(req({ params: { id: 'uo' } }), res) assert.equal(res.statusCode, 409) assert.match(res.body.message, /Disable this module before purging/) assert.equal(ran, false) }) test('standalone purge runs on a disabled module', async () => { modules.get = async (id) => ({ id, state: 'disabled' }) install.purgeFile = () => '/modules/uo/server/db/purge.sql' schema.runPurge = async () => 7 const res = mockRes() await ctrl.purge(req({ params: { id: 'uo' } }), res) assert.equal(res.body.purged, 7) assert.equal(logged[0].action, 'module.purge') }) // ── the allowlist ────────────────────────────────────────────────────────── test('setSources stores a normalised list and audits the change', async () => { let stored = null settings.set = async (key, value) => { stored = { key, value } } const res = mockRes() await ctrl.setSources(req({ body: { hosts: 'Gitea.Example.com, releases.example.org' } }), res) assert.deepEqual(res.body.sourceHosts, ['gitea.example.com', 'releases.example.org']) assert.equal(stored.key, ctrl.HOSTS_KEY) assert.equal(stored.value, 'gitea.example.com,releases.example.org') // Before AND after: this setting decides what code the site will execute, so // the audit entry has to say what it used to be. assert.equal(logged[0].action, 'module.sources') assert.deepEqual(logged[0].detail.before, ['gitea.whitlocktech.com']) }) test('setSources refuses anything that is not a bare hostname', async () => { let stored = false settings.set = async () => { stored = true } for (const bad of ['https://x.com', 'x.com/path', 'x.com:8443', '*.x.com', 'x_y.com']) { const res = mockRes() // eslint-disable-next-line no-await-in-loop await ctrl.setSources(req({ body: { hosts: bad } }), res) assert.equal(res.statusCode, 400, `${bad} should be refused`) } assert.equal(stored, false) }) test('an empty allowlist is storable, and means no installs', async () => { // Not a wildcard, and not an error: "nothing may be installed" is a position // an operator is entitled to take. let stored = null settings.set = async (key, value) => { stored = value } const res = mockRes() await ctrl.setSources(req({ body: { hosts: '' } }), res) assert.equal(res.statusCode, 200) assert.deepEqual(res.body.sourceHosts, []) assert.equal(stored, '') }) // ── restart ──────────────────────────────────────────────────────────────── test('restart answers before it signals, and signals its own process', async () => { // It raises SIGTERM rather than calling the shutdown path directly, so that // server.js's handler stays the ONE graceful-shutdown path and this route // cannot drift from it. const originalKill = process.kill const signals = [] process.kill = (pid, signal) => { signals.push({ pid, signal }) } try { const res = mockRes() ctrl.restart(req(), res) // Answered synchronously: once the signal lands there is no listener left to // flush a response through, so the operator would be told nothing. assert.equal(res.statusCode, 202) assert.equal(res.body.restarting, true) await new Promise((resolve) => setTimeout(resolve, 400)) assert.deepEqual(signals, [{ pid: process.pid, signal: 'SIGTERM' }]) assert.equal(logged[0].action, 'module.restart') } finally { process.kill = originalKill } }) test('a failure to write the audit entry does not cancel the restart', async () => { const originalKill = process.kill const signals = [] process.kill = (pid, signal) => { signals.push(signal) } activity.log = async () => { throw new Error('database is gone') } try { ctrl.restart(req(), mockRes()) await new Promise((resolve) => setTimeout(resolve, 400)) assert.deepEqual(signals, ['SIGTERM']) } finally { process.kill = originalKill } })