Files
website/client/test/moduleAdmin.test.js
wtclaude 9b16f39a52
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s
feat(modules): the declarative Docker path (phase 4, slice 3)
MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.

Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.

A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.

Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.

741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.

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

282 lines
13 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'
// lib/moduleAdmin.js — what the Modules screen says about a module and what it
// lets you do to it. Phase 4, slice 2 of MODULE_SYSTEM.md §2.7.2.
//
// This is the part of the screen worth testing, and it is plain JS so this
// runner can reach it (there is no DOM here). What it encodes is §2.4's rule
// that the row, the loader and the volume are three sources of truth which are
// ALLOWED to disagree — so most of these cases are combinations that a screen
// picking one source would render as a lie.
/** A module as GET /admin/modules returns it, with the running case as default. */
const mod = (over = {}) => ({
id: 'uo',
name: 'Ultima Online',
version: '1.0.0',
state: 'started',
failureStage: null,
failureReason: null,
source: 'https://gitea.example.com/x/uo.json',
sha256: 'a'.repeat(64),
installedAt: null,
startedAt: null,
liveState: 'started',
liveVersion: '1.0.0',
capabilities: [],
onVolume: true,
canPurge: true,
declared: false,
declaredVersion: null,
declaredError: null,
...over,
})
// ── statusOf ───────────────────────────────────────────────────────────────
test('a mounted, started module is Running and needs nothing', () => {
const s = statusOf(mod())
assert.equal(s.label, 'Running')
assert.equal(s.tone, 'ok')
assert.equal(s.pending, false)
})
test('enabled in the row but disabled in the loader is "Restart to start"', () => {
// THE case decision 3 creates on purpose: disable ran the module's onShutdown,
// then the operator enabled it again. The row says enabled; nothing can start
// it before a restart. Showing either "Running" or "Disabled" would be false.
const s = statusOf(mod({ state: 'enabled', liveState: 'disabled' }))
assert.equal(s.label, 'Restart to start')
assert.equal(s.tone, 'warn')
assert.equal(s.pending, true)
assert.match(s.detail, /cannot be restarted in place/)
})
test('freshly installed and never booted into is also "Restart to start"', () => {
const s = statusOf(mod({ state: 'installed', liveState: null }))
assert.equal(s.label, 'Restart to start')
assert.equal(s.pending, true)
assert.match(s.detail, /mounts when the server next starts/)
})
test('a disabled module is Disabled, and that is not pending anything', () => {
// Disable takes effect immediately — it is the one action that does — so there
// is nothing for a restart banner to be about.
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled' }))
assert.equal(s.label, 'Disabled')
assert.equal(s.pending, false)
})
test('a fresh install over a failed row is pending, not failed', () => {
// THE defect the §7.7 browser smoke found, and one no test here had modelled.
// Installing over a row the previous boot left `startup_failed` rendered
// "Failed at the require stage: module directory not present on the volume" a
// second after the files had been written — and suppressed the restart banner
// the install had just told the operator to use.
//
// `liveState === null` with the module on the volume means the loader's scan
// never saw it, so it arrived after boot and everything the row says predates
// it.
const s = statusOf(mod({
state: 'startup_failed',
liveState: null,
failureStage: 'require',
failureReason: 'module directory not present on the volume',
}))
assert.equal(s.label, 'Restart to start')
assert.equal(s.pending, true)
assert.doesNotMatch(s.detail, /not present on the volume/, 'the stale reason must not survive the install')
})
test('the restart banner appears for that install', () => {
// The second half of the same defect: the banner is driven by `pending`, so a
// row wrongly classified as failed silently removed the only way to act on it.
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: null })]), true)
})
test('an upgrade that has not been restarted into says so', () => {
// Same class as the stale-failure defect: the row is a promise about the next
// boot, not a description of this one. Reporting "Running v2.0.0" while the
// process is serving v1.0.0 would hide the only action that fixes it.
const s = statusOf(mod({ version: '2.0.0', liveVersion: '1.0.0' }))
assert.equal(s.label, 'Restart to finish upgrading')
assert.equal(s.pending, true)
assert.match(s.detail, /v2\.0\.0 is installed; v1\.0\.0 is still running/)
})
test('reinstalling the SAME version is not an upgrade in progress', () => {
assert.equal(statusOf(mod({ version: '1.0.0', liveVersion: '1.0.0' })).label, 'Running')
})
test('a failed module reports the stage and the reason it recorded', () => {
const s = statusOf(mod({
state: 'startup_failed',
liveState: 'startup_failed',
failureStage: 'schema',
failureReason: "Unknown column 'x' in 'field list'",
}))
assert.equal(s.label, 'Failed to start')
assert.equal(s.tone, 'bad')
assert.match(s.detail, /schema stage/)
assert.match(s.detail, /Unknown column/)
})
test('a failure with no recorded reason says so rather than showing a blank', () => {
const s = statusOf(mod({ state: 'startup_failed', liveState: 'startup_failed' }))
assert.match(s.detail, /recorded no reason/)
})
test('a row whose directory is gone by hand is bad, not merely disabled', () => {
// The boot reconcile marks this `startup_failed` because a row claiming to be
// enabled for a module that is not on the volume is simply untrue.
const s = statusOf(mod({ state: 'startup_failed', liveState: null, onVolume: false, failureStage: 'require', failureReason: 'module directory not present on the volume' }))
assert.equal(s.label, 'Missing from the volume')
assert.equal(s.tone, 'bad')
})
test('an uninstalled module reads as uninstalled, and says the data was kept', () => {
// Uninstall leaves the row `disabled` and the data alone — which is the whole
// point of keeping the row, so the screen has to say it.
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled', onVolume: false }))
assert.equal(s.label, 'Uninstalled')
assert.equal(s.tone, 'idle')
assert.match(s.detail, /data was kept/i)
})
test('missing-from-the-volume beats every other status', () => {
// Ordering: a module with no files is described that way whatever its row
// still claims, because there is nothing there to be running.
for (const state of ['started', 'enabled', 'installed', 'startup_failed']) {
assert.match(statusOf(mod({ state, onVolume: false })).label, /Missing from the volume/)
}
})
// ── actionsFor ─────────────────────────────────────────────────────────────
test('a running module offers disable, uninstall and a blocked purge', () => {
const a = actionsFor(mod())
assert.equal(a.disable.shown, true)
assert.equal(a.enable.shown, false)
assert.equal(a.uninstall.shown, true)
assert.equal(a.purge.shown, true)
// Shown but not clickable: the server refuses a standalone purge on anything
// that is not disabled, so offering the click would only produce a 409.
assert.equal(a.purge.enabled, false)
assert.match(a.purge.reason, /Disable it first/)
})
test('a disabled module offers enable, and purge is now live', () => {
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled' }))
assert.equal(a.enable.shown, true)
assert.equal(a.disable.shown, false)
assert.equal(a.purge.enabled, true)
})
test('a module with no purge.sql never offers purge, and says why', () => {
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled', canPurge: false }))
assert.equal(a.purge.shown, false)
assert.match(a.purge.reason, /ships no purge.sql/)
})
test('a module with no files offers only clearing the row', () => {
const a = actionsFor(mod({ state: 'disabled', liveState: null, onVolume: false }))
assert.equal(a.uninstall.shown, false)
assert.equal(a.disable.shown, false)
assert.equal(a.enable.shown, false)
assert.equal(a.purge.shown, false, 'there is no purge.sql left to run')
assert.equal(a.forget.shown, true)
})
test('a directory with no row yet is actionable, and offers nothing to forget', () => {
// A hand-placed install before its first boot: it has no row, so `state` is
// null. Its routes are already being served, so it must be disableable.
const a = actionsFor(mod({ state: null, liveState: 'started' }))
assert.equal(a.disable.shown, true)
assert.equal(a.uninstall.shown, true)
assert.equal(a.forget.shown, false)
})
// ── needsRestart ───────────────────────────────────────────────────────────
test('the restart banner is driven by the list, not by any one module', () => {
// A restart is a property of the SERVER. One pending module is enough, and
// three do not mean three restarts.
assert.equal(needsRestart([mod(), mod({ id: 'b' })]), false)
assert.equal(needsRestart([mod(), mod({ id: 'b', state: 'installed', liveState: null })]), true)
assert.equal(needsRestart([]), false)
})
test('a disabled module does not ask for a restart', () => {
// Disable is immediate; a banner here would be asking for a restart that
// would change nothing.
assert.equal(needsRestart([mod({ state: 'disabled', liveState: 'disabled' })]), false)
})
test('a failed module does not ask for a restart either', () => {
// It is retried on every boot anyway, and the operator has to fix the cause
// first — a banner would suggest restarting is the remedy.
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: 'startup_failed' })]), false)
})
// ── parseHosts ─────────────────────────────────────────────────────────────
test('parseHosts previews exactly what the server will store', () => {
assert.deepEqual(parseHosts('A.com, b.com\n c.com'), ['a.com', 'b.com', 'c.com'])
assert.deepEqual(parseHosts(' '), [])
assert.deepEqual(parseHosts(undefined), [])
})
// ── the declaration (slice 3) ──────────────────────────────────────────────
test('a declared module that has never installed says so, with the reason', () => {
// No row, no directory, nothing mounted — invisible to the other three
// sources, so without this branch the screen would describe a module it has
// never had as a row gone stale.
const s = statusOf(mod({
state: null,
liveState: null,
liveVersion: null,
version: null,
onVolume: false,
declared: true,
declaredVersion: '0.3.0',
declaredError: 'could not reach releases.example.com',
}))
assert.equal(s.label, 'Declared, not installed')
assert.equal(s.tone, 'bad')
assert.equal(s.pending, false, 'a restart will not fix an unreachable host')
assert.match(s.detail, /could not reach releases.example.com/)
})
test('a declared module waiting for its first resolution is not reported as failed', () => {
const s = statusOf(mod({ state: null, liveState: null, version: null, onVolume: false, declared: true, declaredVersion: '0.3.0' }))
assert.match(s.detail, /installed when the server next starts/)
})
test('a running module whose declared upgrade is failing is still Running', () => {
// Both facts are true at once. The status is one label, so the declaration
// gets its own line rather than overwriting it.
const m = mod({ declared: true, declaredVersion: '2.0.0', declaredError: 'sha256 did not match' })
assert.equal(statusOf(m).label, 'Running')
const note = declarationNoteFor(m)
assert.equal(note.tone, 'warn')
assert.match(note.text, /sha256 did not match/)
})
test('uninstalling a declared module is told that its files come back', () => {
// The sentence that saves an afternoon: MODULES owns what is on the volume,
// the row owns whether it runs.
const note = declarationNoteFor(mod({ state: 'disabled', liveState: null, onVolume: false, declared: true, declaredVersion: '1.0.0' }))
assert.match(note.text, /come back when the server next starts/)
assert.match(note.text, /switched off/)
})
test('an ordinary declared module gets a quiet note, and an undeclared one none', () => {
assert.equal(declarationNoteFor(mod()), null)
const note = declarationNoteFor(mod({ declared: true, declaredVersion: '1.0.0' }))
assert.equal(note.tone, 'idle')
assert.match(note.text, /MODULES/)
})