// Point the DB at a closed port BEFORE anything builds the pool. Every model // call below is monkeypatched, so no query runs; db.close() releases the pool so // the process exits cleanly. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, after, afterEach } = require('node:test') const assert = require('node:assert/strict') // Phase 0 of the admin theming & navigation feature // (docs/website/THEMING_AND_NAV.md): the settings-store groundwork the rest of // the feature is built on. Three things are load-bearing enough to lock here — // the reset-by-delete allowlist, who may read the nav overrides, and the // fail-safe JSON parse — plus the guarantee that registering the new keys did // not change what an untouched instance serves. const { startApp } = require('./_helper') const settingsRouter = require('../src/router/v1/admin/settings.router') const navSettingsRouter = require('../src/router/v1/settings') const settingsDb = require('../src/model/settings/settings.db') const settings = require('../src/model/settings/settings.model') const { parseJsonSetting } = require('../src/utils/settingsJson') const sessionService = require('../src/auth/session.service') // The admin group applies `noindex, isLoggedIn, staffOnly` before mounting the // settings router, and requireRole reads the req.user that requireAuth attaches. // Mounting the router bare would 403 every caller for the wrong reason. const { requireAuth } = require('../src/auth/session.middleware') const users = require('../src/model/users/users.model') const activity = require('../src/model/activity/activity.model') const db = require('../src/utils/db') after(() => db.close()) const originals = { validateSession: sessionService.validateSession, isSessionRevoked: sessionService.isSessionRevoked, sessionMeta: sessionService.sessionMeta, getById: users.getById, getAll: settingsDb.getAll, remove: settingsDb.remove, set: settingsDb.set, log: activity.log, } afterEach(() => { Object.assign(sessionService, { validateSession: originals.validateSession, isSessionRevoked: originals.isSessionRevoked, sessionMeta: originals.sessionMeta, }) users.getById = originals.getById settingsDb.getAll = originals.getAll settingsDb.remove = originals.remove activity.log = originals.log }) // Sign every request in as the given DB user (role decides the gate outcome). function signInAs(user) { sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' }) sessionService.isSessionRevoked = async () => false sessionService.sessionMeta = () => ({}) users.getById = async () => user activity.log = async () => {} } // ── DELETE /admin/settings/:key — reset is delete, and only for some keys ── test('resetting a theming key deletes its row', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) const deleted = [] settingsDb.remove = async (key) => deleted.push(key) const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { for (const key of settings.THEMING_KEYS) { const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' }) assert.equal(res.status, 200, `${key} should be resettable`) } assert.deepEqual(deleted, settings.THEMING_KEYS) } finally { await app.close() } }) // The whole "no migration seeds defaults" principle (§2) rests on this: reset // must not write a stored copy of the defaults, or a later change to a default // would never reach an instance that once pressed reset. test('reset never writes a value, only deletes', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.remove = async () => {} settingsDb.set = () => assert.fail('reset must not write a settings row') const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' }) assert.equal(res.status, 200) } finally { settingsDb.set = originals.set await app.close() } }) // An unrestricted DELETE would let a stray request drop site_mode or the // uo-link config, where an absent row means something else entirely. test('a key outside the allowlist is rejected and nothing is deleted', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.remove = async () => assert.fail('must not delete a non-resettable key') const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { for (const key of ['site_mode', 'uo_link_token', 'player_registration', 'hero_layout']) { const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' }) assert.equal(res.status, 400, `${key} must not be resettable`) } } finally { await app.close() } }) // Reset is idempotent: the UI resets without first knowing whether a row exists. test('resetting a key that was never set still succeeds', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.remove = async () => {} // DELETE of a missing row affects 0 rows const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const res = await fetch(`${app.url}/api/v1/admin/settings/nav_public`, { method: 'DELETE' }) assert.equal(res.status, 200) } finally { await app.close() } }) test('reset is admin-only — an editor is refused', async () => { signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' }) settingsDb.remove = async () => assert.fail('an editor must not reset a setting') const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' }) assert.equal(res.status, 403) } finally { await app.close() } }) // ── GET /settings/nav — the reason this endpoint exists at all ───────────── // AdminLayout renders for editors and moderators, PlayerPortalLayout for // players, and none of them can read GET /admin/settings. Without this route // their nav override would silently never apply (§4.2). for (const role of ['admin', 'editor', 'moderator', 'player']) { test(`GET /settings/nav is readable by an authenticated ${role}`, async () => { signInAs({ id: 7, username: 'u', role, status: 'active' }) settingsDb.getAll = async () => [ { key: 'nav_admin', value: '{"/admin/posts":{"label":"Blog Posts"}}' }, { key: 'nav_player', value: '{"/portal/characters":{"hidden":true}}' }, ] const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) try { const res = await fetch(`${app.url}/api/v1/settings/nav`) assert.equal(res.status, 200, `${role} should reach the handler, got ${res.status}`) const body = await res.json() assert.equal(body.nav_admin, '{"/admin/posts":{"label":"Blog Posts"}}') assert.equal(body.nav_player, '{"/portal/characters":{"hidden":true}}') } finally { await app.close() } }) } test('GET /settings/nav rejects an anonymous caller', async () => { sessionService.validateSession = () => null const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) try { const res = await fetch(`${app.url}/api/v1/settings/nav`) assert.equal(res.status, 401) } finally { await app.close() } }) test('GET /settings/nav returns null for a nav that was never overridden', async () => { signInAs({ id: 7, username: 'u', role: 'player', status: 'active' }) settingsDb.getAll = async () => [] const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) try { const res = await fetch(`${app.url}/api/v1/settings/nav`) assert.deepEqual(await res.json(), { nav_admin: null, nav_player: null }) } finally { await app.close() } }) // ── getPublic(): the new keys appear only when a row exists ─────────────── test('an untouched instance exposes none of the new keys publicly', async () => { settingsDb.getAll = async () => [] const pub = await settings.getPublic() for (const key of settings.THEMING_KEYS) { assert.equal(pub[key], undefined, `${key} must be absent, not empty`) } }) test('theme_visual / brand_assets / nav_public are public once set; nav_admin / nav_player never are', async () => { settingsDb.getAll = async () => [ { key: 'theme_visual', value: '{"preset":"modern"}' }, { key: 'brand_assets', value: '{"logo":"/uploads/a.png"}' }, { key: 'nav_public', value: '{"/news":{"order":1}}' }, { key: 'nav_admin', value: '{"/admin/posts":{"hidden":true}}' }, { key: 'nav_player', value: '{"/portal":{"label":"Home"}}' }, ] const pub = await settings.getPublic() assert.equal(pub.theme_visual, '{"preset":"modern"}') assert.equal(pub.brand_assets, '{"logo":"/uploads/a.png"}') assert.equal(pub.nav_public, '{"/news":{"order":1}}') // The admin nav's labels describe the shape of the admin surface, and an // anonymous visitor has no use for either — they stay behind /settings/nav. assert.equal(pub.nav_admin, undefined) assert.equal(pub.nav_player, undefined) }) // ── PUT /admin/settings — theme_visual is validated on the way in ────────── // // The read path drops anything invalid anyway, so this is about feedback, not // safety: an admin whose save appears to succeed and then does nothing has no // way to tell what was wrong. test('a valid theme_visual is stored stringified', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) const written = {} settingsDb.set = async (key, value) => { written[key] = value } settingsDb.getAll = async () => [] const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const theme = { preset: 'fantasy', custom: { colors: { accent: '#123456' } } } const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ theme_visual: theme }), }) assert.equal(res.status, 200) // settings.value is TEXT — an object body must reach the store stringified. assert.equal(written.theme_visual, JSON.stringify(theme)) } finally { settingsDb.set = originals.set await app.close() } }) test('an invalid theme_visual is rejected and nothing is written', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.set = () => assert.fail('an invalid theme must not be stored') const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const bad = [ { preset: 'parchment' }, { preset: 'custom', custom: { colors: { accent: 'red' } } }, { preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS' } } }, { preset: 'custom', custom: { structure: { radiusCard: '4em' } } }, { preset: 'custom', custom: { spacing: { unit: '8px' } } }, 'not json', ] for (const theme_visual of bad) { const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ theme_visual }), }) assert.equal(res.status, 400, JSON.stringify(theme_visual)) const body = await res.json() assert.match(body.message, /theme_visual/) } } finally { settingsDb.set = originals.set await app.close() } }) // ── PUT /admin/settings — the nav rows (phases 6-8) ─────────────────────── // // The nav keys reach the same validate-then-stringify block. Without it they // would fall through to settingsDb.set as objects and be stored as the string // "[object Object]" — a row that parses as absent forever, silently. test('a valid nav override is stored stringified', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) const written = {} settingsDb.set = async (key, value) => { written[key] = value } settingsDb.getAll = async () => [] const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const nav = { '/site/news': { label: 'Announcements', order: 1 }, '/site/market': { hidden: true } } const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nav_public: nav }), }) assert.equal(res.status, 200) assert.equal(typeof written.nav_public, 'string') assert.notEqual(written.nav_public, '[object Object]') assert.deepEqual(JSON.parse(written.nav_public), nav) } finally { settingsDb.set = originals.set await app.close() } }) test('an invalid nav override is rejected, named, and nothing is written', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) settingsDb.set = () => assert.fail('an invalid nav override must not be stored') const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const bad = [ { '//evil.example/x': { order: 1 } }, // protocol-relative key { '/site/news': { roles: ['admin'] } }, // a gate is not overridable { '/site/news': { to: '/elsewhere' } }, // an override cannot introduce a route { '/site/news': { order: 'first' } }, { '/site/news': 'hidden' }, 'not json', ] for (const nav_public of bad) { const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nav_public }), }) assert.equal(res.status, 400, JSON.stringify(nav_public)) const body = await res.json() assert.match(body.message, /nav_public|nav field/) } } finally { settingsDb.set = originals.set await app.close() } }) test('the write path drops hidden on the nav editor and never stores hidden: false', async () => { signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) const written = {} settingsDb.set = async (key, value) => { written[key] = value } settingsDb.getAll = async () => [] const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) try { const res = await fetch(`${app.url}/api/v1/admin/settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nav_admin: { '/admin/navigation': { hidden: true, order: 3 }, '/admin/posts': { hidden: false, label: 'Blog' }, '/admin/wiki': { hidden: true }, }, }), }) assert.equal(res.status, 200) // The editor keeps its order but not its hiding; a `hidden: false` is the // default, so it is dropped rather than stored as an un-hide instruction. assert.deepEqual(JSON.parse(written.nav_admin), { '/admin/navigation': { order: 3 }, '/admin/posts': { label: 'Blog' }, '/admin/wiki': { hidden: true }, }) } finally { settingsDb.set = originals.set await app.close() } }) // ── GET /settings/theme-options — the catalog the admin form is built from ── test('GET /settings/theme/options serves the catalog to an authenticated caller', async () => { signInAs({ id: 7, username: 'u', role: 'admin', status: 'active' }) const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) try { const res = await fetch(`${app.url}/api/v1/settings/theme/options`) assert.equal(res.status, 200) const body = await res.json() assert.ok(Array.isArray(body.presets) && body.presets.length === 4, 'three presets plus Custom') assert.ok(body.fonts.serif.length && body.fonts.display.length && body.fonts.sans.length) assert.ok(body.shadows.length) assert.ok(body.colorFields.some((f) => f.name === 'accent' && f.token === '--accent')) assert.equal(body.shippedTokens['--accent'], '#7f99bd') } finally { await app.close() } }) test('GET /settings/theme/options rejects an anonymous caller', async () => { sessionService.validateSession = () => null const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) try { const res = await fetch(`${app.url}/api/v1/settings/theme/options`) assert.equal(res.status, 401) } finally { await app.close() } }) // ── parseJsonSetting: malformed reads as absent, never as an error ───────── test('parseJsonSetting returns null for absent, empty and malformed values', () => { for (const input of [null, undefined, '', '{', 'not json', '[]', '"str"', '4', 'null']) { assert.equal(parseJsonSetting(input), null, `${JSON.stringify(input)} should read as absent`) } }) test('parseJsonSetting returns the parsed object for a well-formed value', () => { assert.deepEqual(parseJsonSetting('{"preset":"modern"}'), { preset: 'modern' }) }) // A wrong-shaped value must fall back to the default whole, never partially — // half a theme applied is worse than no theme applied. test('parseJsonSetting treats a validator rejection as absent', () => { const isThemeVisual = (v) => typeof v.preset === 'string' assert.equal(parseJsonSetting('{"custom":{}}', isThemeVisual), null) assert.deepEqual(parseJsonSetting('{"preset":"fantasy"}', isThemeVisual), { preset: 'fantasy' }) })