// ── GET /api/v1/public/modules ───────────────────────────────────────────── // // Phase 2, PR 6. The endpoint is small; what is worth testing is the boundary it // draws. `installed_modules` records five states and the loader records a // failure stage and reason beside them, and exactly none of that may reach an // anonymous caller — the public answer is "what is serving", and a module that // is not serving is ABSENT (MODULE_API.md §4.4, MODULE_SYSTEM.md §2.4). // // A leak here would not fail anything else: the routes still 503, the nav is // still absent, the site still boots. It would just quietly publish that a // module broke and how far it got. So the tests below assert the negative — no // `state`, no `stage`, no `reason`, no extra key — as well as the positive. // // Like the other module tests, each case builds a throwaway modules directory, // points MODULES_DIR at it and re-requires the loader AND the controller with a // clean cache: the controller holds the loader in a file-scope const, so a stale // cache would leave it reading the previous test's module list. // // Point the pool at a closed port before requiring anything — buildCtx pulls in // the models, which build a mariadb pool at require time. That no test here has // to stub a query is itself the point: this endpoint never touches a database. 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') after(() => db.close()) let tmpRoot const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router(), }) /** * Load a throwaway modules directory and hand back the loader plus a live app * serving the real router at the real path. */ function freshApp(dir) { process.env.MODULES_DIR = dir registries._reset() delete require.cache[require.resolve('../src/modules/loader')] delete require.cache[require.resolve('../src/router/v1/public/modules.controller')] delete require.cache[require.resolve('../src/router/v1/public/modules.router')] /* eslint-disable global-require */ const loader = require('../src/modules/loader') loader.load(emptyTiers()) const modulesRouter = require('../src/router/v1/public/modules.router') /* eslint-enable global-require */ return { loader, mount: (app) => app.use('/api/v1/public/modules', modulesRouter) } } function writeModule(id, manifest = {}) { const dir = path.join(tmpRoot, id) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.0.0', ...manifest, })) return dir } async function get(mount) { const app = await startApp(mount) try { const res = await fetch(`${app.url}/api/v1/public/modules`) return { status: res.status, body: await res.json() } } finally { await app.close() } } beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-pubmod-')) }) // ── The answer ───────────────────────────────────────────────────────────── test('a core with no modules answers an empty list, not a 404', async () => { // The shipping state of Phase 2: the endpoint exists and is truthful before // any module does. A client that has to distinguish "no modules" from "old // backend" would otherwise have to read a status code to find out. const { mount } = freshApp(path.join(tmpRoot, 'does-not-exist')) const res = await get(mount) assert.equal(res.status, 200) assert.deepEqual(res.body, { modules: [] }) }) test('a started module is published with exactly id, name, version and capabilities', async () => { writeModule('uo', { name: 'Ultima Online', version: '2.1.0', capabilities: ['shard', 'atlas'] }) const { loader, mount } = freshApp(tmpRoot) loader.setState('uo', 'started') const res = await get(mount) assert.deepEqual(res.body, { modules: [{ id: 'uo', name: 'Ultima Online', version: '2.1.0', capabilities: ['shard', 'atlas'] }], }) // Asserted by key set as well as by value: a field added to loader.list() // later must not reach the public surface just because it was added. assert.deepEqual(Object.keys(res.body.modules[0]).sort(), ['capabilities', 'id', 'name', 'version']) }) test('a module that declares no capabilities publishes an empty array, never undefined', async () => { // `capabilities` is optional in module.json (§2.1). A client iterating the // array must not have to null-check it. writeModule('bare') const { loader, mount } = freshApp(tmpRoot) loader.setState('bare', 'started') const res = await get(mount) assert.deepEqual(res.body.modules[0].capabilities, []) }) test('modules are published in scan order', async () => { for (const id of ['zeta', 'alpha', 'mid']) writeModule(id) const { loader, mount } = freshApp(tmpRoot) for (const id of ['zeta', 'alpha', 'mid']) loader.setState(id, 'started') const res = await get(mount) // Alphabetical, because that is the loader's scan order and there is no // dependency resolution — any other order would imply a precedence nothing // computes (§4.2). assert.deepEqual(res.body.modules.map((m) => m.id), ['alpha', 'mid', 'zeta']) }) // ── The boundary ─────────────────────────────────────────────────────────── test('a registered-but-not-yet-started module is absent', async () => { // The state between a clean load and onBoot. It is not serving yet, so it is // not published — the endpoint answers what IS serving, not what will be. writeModule('uo') const { mount } = freshApp(tmpRoot) const res = await get(mount) assert.deepEqual(res.body.modules, []) }) test('a disabled module is absent — the operator switched it off', async () => { writeModule('uo') const { loader, mount } = freshApp(tmpRoot) loader.setState('uo', 'disabled') const res = await get(mount) assert.deepEqual(res.body.modules, []) }) test('a failed module is absent, and its stage and reason never leave the server', async () => { // The leak this whole file exists to prevent. `startup_failed` carries the // step that broke and the error message; both belong to the admin Modules // screen and neither is anonymous-visitor business. writeModule('uo', { capabilities: ['shard'] }) const { loader, mount } = freshApp(tmpRoot) loader.setState('uo', 'startup_failed', { stage: 'require', reason: 'Cannot find module ./nope' }) const res = await get(mount) assert.deepEqual(res.body.modules, []) const raw = JSON.stringify(res.body) for (const leak of ['require', 'nope', 'startup_failed', 'stage', 'reason']) { assert.ok(!raw.includes(leak), `published "${leak}"`) } }) test('one failed module does not hide the ones that started', async () => { writeModule('broken') writeModule('working', { capabilities: ['shard'] }) const { loader, mount } = freshApp(tmpRoot) loader.setState('broken', 'startup_failed', { stage: 'schema', reason: 'boom' }) loader.setState('working', 'started') const res = await get(mount) assert.deepEqual(res.body.modules.map((m) => m.id), ['working']) }) // ── Failure ──────────────────────────────────────────────────────────────── test('the module list read before load() is a 500, not a lie', async () => { // §7.6: `{ modules: [] }` is a true answer for a core with no modules and a // caller cannot tell it from a mis-ordered boot. So the guard's throw becomes // a 500 rather than an empty list — the one case where an error is the honest // answer. process.env.MODULES_DIR = tmpRoot registries._reset() delete require.cache[require.resolve('../src/modules/loader')] delete require.cache[require.resolve('../src/router/v1/public/modules.controller')] delete require.cache[require.resolve('../src/router/v1/public/modules.router')] // eslint-disable-next-line global-require const modulesRouter = require('../src/router/v1/public/modules.router') const res = await get((app) => app.use('/api/v1/public/modules', modulesRouter)) assert.equal(res.status, 500) })