Files
website/server/test/modules.model.test.js
wtclaude 732927a6bb fix(modules): three defects a real install exposed (phase 4, slice 1)
Standing the slice-2 screen up against a live server and installing the
published module-uo v0.3.0 through it found three things, none of which any
unit test in this repo could have caught. Two of them are older than this
phase.

1. The boot refresh nulled every install's provenance
--------------------------------------------------------
`installed_modules.source` and `.sha256` exist so the admin panel can say
where a module came from. They never survived a restart.

`lifecycle.boot()` re-records every scanned module with no source and no
sha256 -- correctly, because a scan finds a directory and never where it came
from -- and `upsert` assigned both columns unconditionally. So an install's
provenance lasted exactly until the restart that install asked for, and the
screen then described a module installed from a URL as "placed on the volume
by hand". Verified live: install, restart, provenance gone.

Nothing could have caught it before now. Phase 4 wrote the first non-null
value these columns had ever had, so lifecycle.js's comment asserting that
"recordInstalled leaves what it is not given" described an intention rather
than the statement below it -- and modules.model.test.js's fake reproduced
the defect faithfully, assigning unconditionally just like the SQL.

Fixed with COALESCE(VALUES(col), col): a value overwrites, a NULL leaves what
is there. The fake now matches, and two tests pin both directions -- a boot
refresh must not wipe it, and a re-install from a new URL must still replace
it, or the column would become write-once and an upgrade would for ever show
where the first version came from.

2. The restart killed the server on Windows instead of stopping it
------------------------------------------------------------------
The route called `process.kill(process.pid, 'SIGTERM')` to reach server.js's
graceful-shutdown handler. That works on Linux. **Windows has no POSIX
signals, and Node documents SIGTERM there as unconditional termination of the
target process** -- so on a Windows host the restart killed the server
outright: no module onShutdown, no listener close, no pool close, no log
flush. Observed exactly that: the process was gone and the shutdown handler
had logged nothing at all.

`process.on('SIGTERM', ...)` is an ordinary EventEmitter listener, so
`process.emit('SIGTERM')` reaches the same handler on every platform without
involving the OS. One shutdown path, still; it just gets there by an event.

Deployment is Linux containers and would never have shown this. Development
is not, and neither is the smoke that found it.

The test was worse than useless: it stubbed `process.kill` and asserted it
had been called with SIGTERM, which is precisely the call whose MEANING
differs by platform. It now waits for the SIGTERM EVENT -- what server.js is
actually subscribed to -- so a pass here means the handler would run.

3. `present()` did not publish the running version
--------------------------------------------------
An upgrade writes new files and a new row while the old code stays loaded, so
the row's version is a promise about the next boot rather than a description
of this one. Adds `liveVersion` from the loader beside `liveState`, so the
screen can tell the two apart instead of reporting the new version as running.

723 server tests (+2), manifest and OpenAPI both unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 03:48:27 -05:00

346 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Point the DB pool at a dead port before it's built; every modules.db method is
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
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 pool = require('../src/utils/db')
after(() => pool.close())
// Unit-test the module state machine (docs/website/MODULE_SYSTEM.md §2.4) against an
// in-memory fake by monkeypatching modules.db (no DB). What is locked here is
// everything the boot path and the admin panel will lean on in later phases:
// - `installed` is transient and a re-install never overwrites the operator's
// enable/disable decision;
// - beginBoot() recomputes outcomes and leaves `disabled` alone — the property that
// makes a fixed module recover on a restart with no panel visit;
// - a failure carries its stage and reason, and every non-failing transition clears
// them, so a running module can never display a stale reason;
// - a disabled module's failure is a no-op, because the boot path must never turn
// one module's failure into a re-enable of a module the operator switched off;
// - an illegal move throws instead of writing a row that misrepresents the state.
const modulesDb = require('../src/model/modules/modules.db')
const modules = require('../src/model/modules/modules.model')
let rows // id → row, snake_case exactly as modules.db returns it
const saved = { ...modulesDb }
function reset() {
rows = new Map()
}
modulesDb.listAll = async () => [...rows.values()].sort((a, b) => a.id.localeCompare(b.id))
modulesDb.getOne = async (id) => (rows.has(id) ? [rows.get(id)] : [])
modulesDb.upsert = async ({ id, name, version, source, sha256 }) => {
const existing = rows.get(id)
if (existing) {
// COALESCE, matching the real statement: a value overwrites, a NULL leaves
// what is there. This fake used to assign unconditionally — faithfully
// reproducing the defect it was supposed to be able to catch, which is why
// the boot refresh nulling an install's provenance survived until a browser
// showed it.
Object.assign(existing, {
name,
version,
source: source ?? existing.source ?? null,
sha256: sha256 ?? existing.sha256 ?? null,
})
return { affectedRows: 1 }
}
rows.set(id, {
id,
name,
version,
state: 'installed',
failure_stage: null,
failure_reason: null,
source: source ?? null,
sha256: sha256 ?? null,
installed_at: '2026-08-10T00:00:00Z',
started_at: null,
updated_at: '2026-08-10T00:00:00Z',
})
return { affectedRows: 1 }
}
modulesDb.setState = async ({ id, state, failureStage, failureReason, stampStarted }) => {
const row = rows.get(id)
if (!row) return { affectedRows: 0 }
row.state = state
row.failure_stage = failureStage ?? null
row.failure_reason = failureReason ?? null
if (stampStarted) row.started_at = '2026-08-10T12:00:00Z'
return { affectedRows: 1 }
}
modulesDb.resetForBoot = async () => {
let n = 0
for (const row of rows.values()) {
if (row.state === 'disabled') continue
row.state = 'enabled'
row.failure_stage = null
row.failure_reason = null
n += 1
}
return { affectedRows: n }
}
modulesDb.remove = async (id) => ({ affectedRows: rows.delete(id) ? 1 : 0 })
after(() => Object.assign(modulesDb, saved))
beforeEach(reset)
// Install one module and put it in a given state, bypassing the machine so a test
// can start from any state without asserting its way there.
async function seed(id, state = 'installed', extra = {}) {
await modules.recordInstalled({ id, name: `Module ${id}`, version: '1.0.0', ...extra })
rows.get(id).state = state
return modules.get(id)
}
// ── recordInstalled ───────────────────────────────────────────────────
test('a new install lands in the transient installed state', async () => {
const mod = await modules.recordInstalled({
id: 'uo',
name: 'Ultima Online',
version: '1.2.0',
source: 'https://gitea.example/releases/module-uo-1.2.0.tar.gz',
sha256: 'a'.repeat(64),
})
assert.equal(mod.state, 'installed')
assert.equal(mod.version, '1.2.0')
assert.equal(mod.sha256, 'a'.repeat(64))
assert.equal(mod.startedAt, null)
assert.equal(mod.failureReason, null)
})
test('a hand-placed module records with no provenance', async () => {
const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' })
assert.equal(mod.source, null)
assert.equal(mod.sha256, null)
})
test('the boot refresh does not wipe an install\'s provenance', async () => {
// The defect a browser found in Phase 4, and the reason the upsert COALESCEs.
//
// lifecycle.boot() re-records every scanned module with NO source and NO
// sha256, because a hand-placed directory genuinely has neither. That refresh
// used to write both columns as NULL, so an admin-panel install's provenance
// survived exactly until the restart the install asked for — and the screen
// then described a module installed from a URL as "placed on the volume by
// hand". Nothing could see it until Phase 4: no caller had ever passed a
// non-null value.
await modules.recordInstalled({
id: 'uo',
name: 'Ultima Online',
version: '1.0.0',
source: 'https://gitea.example/x/uo-1.0.0.json',
sha256: 'b'.repeat(64),
})
// What the next boot does.
const after = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' })
assert.equal(after.source, 'https://gitea.example/x/uo-1.0.0.json')
assert.equal(after.sha256, 'b'.repeat(64))
})
test('a re-install from a new URL does replace the provenance', async () => {
// The other half: COALESCE must not make the columns write-once, or an upgrade
// would for ever show where the FIRST version came from.
await modules.recordInstalled({
id: 'uo', name: 'Ultima Online', version: '1.0.0', source: 'https://old/x.json', sha256: 'c'.repeat(64),
})
const after = await modules.recordInstalled({
id: 'uo', name: 'Ultima Online', version: '2.0.0', source: 'https://new/y.json', sha256: 'd'.repeat(64),
})
assert.equal(after.source, 'https://new/y.json')
assert.equal(after.sha256, 'd'.repeat(64))
assert.equal(after.version, '2.0.0')
})
test('recordInstalled refuses a manifest missing id, name or version', async () => {
await assert.rejects(
() => modules.recordInstalled({ id: 'uo', version: '1.0.0' }),
(err) => err.code === 'invalid_module',
)
})
test('an upgrade refreshes metadata and leaves the operator decision alone', async () => {
await seed('uo', 'disabled')
const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '2.0.0' })
assert.equal(mod.version, '2.0.0')
assert.equal(mod.name, 'Ultima Online')
assert.equal(mod.state, 'disabled', 're-installing must not silently re-enable')
})
test('an upgrade of a started module does not switch it off', async () => {
await seed('uo', 'started')
const mod = await modules.recordInstalled({ id: 'uo', name: 'Module uo', version: '1.1.0' })
assert.equal(mod.state, 'started')
})
// ── the machine ───────────────────────────────────────────────────────
test('installed → enabled → started, stamping the start', async () => {
await seed('uo')
assert.equal((await modules.enable('uo')).state, 'enabled')
const started = await modules.markStarted('uo')
assert.equal(started.state, 'started')
assert.ok(started.startedAt, 'a successful start is stamped')
})
test('a first boot may start a module straight from installed', async () => {
await seed('uo')
assert.equal((await modules.markStarted('uo')).state, 'started')
})
test('a disabled module can be re-enabled', async () => {
await seed('uo', 'disabled')
assert.equal((await modules.enable('uo')).state, 'enabled')
})
test('a failed module is retried by enabling it, which clears the reason', async () => {
await seed('uo', 'enabled')
await modules.markStartupFailed('uo', { stage: 'boot', reason: 'atlas refresh threw' })
const retried = await modules.enable('uo')
assert.equal(retried.state, 'enabled')
assert.equal(retried.failureStage, null)
assert.equal(retried.failureReason, null)
})
test('a running module can be disabled', async () => {
await seed('uo', 'started')
assert.equal((await modules.disable('uo')).state, 'disabled')
})
test('a disabled module is never started — that would be a core bug, so it throws', async () => {
await seed('uo', 'disabled')
await assert.rejects(
() => modules.markStarted('uo'),
(err) => err.name === 'ModuleStateError' && err.code === 'illegal_transition',
)
assert.equal((await modules.get('uo')).state, 'disabled')
})
test('a transition on a module with no row writes nothing and returns null', async () => {
assert.equal(await modules.enable('ghost'), null)
assert.equal(await modules.markStarted('ghost'), null)
assert.equal(rows.size, 0)
})
// ── failures ──────────────────────────────────────────────────────────
test('a failure records its stage and reason', async () => {
await seed('uo', 'enabled')
const failed = await modules.markStartupFailed('uo', {
stage: 'core_api',
reason: "module 'uo' needs coreApi ^2.0.0, core provides 1.0.0",
})
assert.equal(failed.state, 'startup_failed')
assert.equal(failed.failureStage, 'core_api')
assert.match(failed.failureReason, /coreApi/)
})
test('an unrecognised stage is still recorded, as require', async () => {
await seed('uo', 'enabled')
const failed = await modules.markStartupFailed('uo', { stage: 'nonsense', reason: 'boom' })
assert.equal(failed.failureStage, 'require')
assert.equal(failed.failureReason, 'boom')
})
test('a missing reason still produces a displayable one', async () => {
await seed('uo', 'enabled')
const failed = await modules.markStartupFailed('uo', { stage: 'register' })
assert.equal(failed.failureReason, 'unknown error')
})
test('a runaway reason is truncated rather than refused', async () => {
await seed('uo', 'enabled')
const failed = await modules.markStartupFailed('uo', { stage: 'boot', reason: 'x'.repeat(9000) })
assert.equal(failed.failureReason.length, 4000)
})
test("a disabled module's failure is a no-op, not a re-enable", async () => {
await seed('uo', 'disabled')
const unchanged = await modules.markStartupFailed('uo', { stage: 'require', reason: 'broken' })
assert.equal(unchanged.state, 'disabled')
assert.equal(unchanged.failureReason, null)
})
test('starting successfully clears the previous failure', async () => {
await seed('uo', 'enabled')
await modules.markStartupFailed('uo', { stage: 'schema', reason: 'bad fragment' })
await modules.enable('uo')
const started = await modules.markStarted('uo')
assert.equal(started.failureStage, null)
assert.equal(started.failureReason, null)
})
// ── boot ──────────────────────────────────────────────────────────────
test('beginBoot recomputes outcomes and leaves disabled alone', async () => {
await seed('a', 'started')
await seed('b', 'startup_failed')
await seed('c', 'disabled')
await seed('d', 'installed')
rows.get('b').failure_reason = 'last boot blew up'
rows.get('b').failure_stage = 'boot'
assert.equal(await modules.beginBoot(), 3)
const byId = Object.fromEntries((await modules.list()).map((m) => [m.id, m]))
assert.equal(byId.a.state, 'enabled')
assert.equal(byId.b.state, 'enabled', 'a failed module is retried on the next boot')
assert.equal(byId.b.failureReason, null, 'and last boots reason is cleared')
assert.equal(byId.c.state, 'disabled', 'the operator decision survives a boot')
assert.equal(byId.d.state, 'enabled')
})
test('beginBoot keeps the start stamp of a module that was running', async () => {
await seed('uo', 'enabled')
await modules.markStarted('uo')
await modules.beginBoot()
assert.ok((await modules.get('uo')).startedAt, 'started_at is the last successful start')
})
// ── list / purge ──────────────────────────────────────────────────────
test('list returns every module, id-ordered and serialized', async () => {
await seed('zzz')
await seed('aaa')
const all = await modules.list()
assert.deepEqual(
all.map((m) => m.id),
['aaa', 'zzz'],
)
assert.deepEqual(Object.keys(all[0]).sort(), [
'failureReason',
'failureStage',
'id',
'installedAt',
'name',
'sha256',
'source',
'startedAt',
'state',
'updatedAt',
'version',
])
})
test('purge drops the row; uninstall is a disable and keeps it', async () => {
await seed('uo', 'started')
await modules.disable('uo')
assert.ok(await modules.get('uo'), 'uninstall keeps the row and its data')
await modules.remove('uo')
assert.equal(await modules.get('uo'), null)
})