Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin sidebar and the player portal now read their override row, and /admin/navigation writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a row into another existing section. The merge always runs BEFORE the role and shard-feature filters in the layouts, which are unchanged and remain the boundary. An override is presentation: it cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored `hidden: false` on a gated item shows nobody anything. The design scoped these phases as client work, but the server had no way to store a nav row: updateSettings validates and stringifies theme_visual and brand_assets and lets everything else through, so a nav object would have been written as "[object Object]" and read as absent for ever. utils/navOverrides.js mirrors utils/brandAssets.js — strict on write with the offending key named, forgiving on read. It validates shape only; whether a `to` exists is settled client-side at merge time, because the base NAV arrays are client constants and a server-side copy would be a second source of truth that drifts. The nav editor cannot be hidden — its own toggle is disabled, the write path drops `hidden` on that one `to`, and AdminLayout strips it again before merging, which also covers a row edited straight in the database. Orders are written only when the sequence actually differs from the code's, and the comparison is restricted to the rows the editing admin can see, so renaming one item does not pin the position of every other one and a role- or feature-gated item missing from their palette is not mistaken for a reorder. Co-Authored-By: Claude <noreply@anthropic.com>
418 lines
18 KiB
JavaScript
418 lines
18 KiB
JavaScript
// 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' })
|
|
})
|