Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font shortlist, and /admin/appearance to drive them. The design put the presets in theme.css as [data-theme] blocks. That does not work: SiteContext writes --accent as an inline style on <html>, which beats any attribute-selector block, so a preset's accent would have been painted over by BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app themes itself from -- reported the other one. Presets now live in server/src/config/themePresets.js. themeResolve.js layers :root <- preset <- custom per field into a token map, getPublic() returns it as `theme`, and the client writes it onto <html>. One authority for the merge, and brand.accent is by construction the accent the site paints. theme.css's :root is untouched, so an instance with no row gets no theme block and renders as today. Also: presets carry the full 15-token palette (eight would have left Fantasy with blue-grey borders); the option catalog is served from GET /settings/theme/options so the form cannot offer what the server rejects; validation is strict on write and forgiving on read; and the Discord bot now fetches the effective accent instead of its boot-time env copy. Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger factory rather than calling it, so a DB fault would have thrown a TypeError inside the catch instead of returning 500. Co-Authored-By: Claude <noreply@anthropic.com>
324 lines
14 KiB
JavaScript
324 lines
14 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()
|
|
}
|
|
})
|
|
|
|
// ── 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' })
|
|
})
|