Files
website/server/test/navOverrides.test.js
wtclaude b517d7b2df feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:42:19 -05:00

290 lines
13 KiB
JavaScript

// Point the DB at a closed port before anything builds the pool — this file only
// exercises pure functions, but requiring the util pulls in nothing else.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test } = require('node:test')
const assert = require('node:assert/strict')
// Phases 6-8 of docs/website/THEMING_AND_NAV.md: the server half of the nav
// overrides. The merge itself is the client's (client/src/lib/navOverrides.js,
// tested there); this module decides only what may be *stored*, and the two
// properties worth locking are the asymmetry — strict on write, forgiving on
// read — and the two things a stored row may never carry: a field that is not
// one of the four, and `hidden` on the nav editor's own row.
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../src/utils/navOverrides')
// ── validateNavOverrides — strict, and names what it rejected ──────────
test('null and undefined are valid — that is how every override is cleared', () => {
assert.equal(validateNavOverrides(null).ok, true)
assert.equal(validateNavOverrides(undefined).ok, true)
})
test('an empty object is valid — the caller deletes the row instead of storing it', () => {
assert.equal(validateNavOverrides({}).ok, true)
})
test('a non-object is rejected under the key it was written to', () => {
for (const bad of [4, 'x', true, [], [{ to: '/' }]]) {
const check = validateNavOverrides(bad, 'nav_public')
assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected`)
assert.match(check.message, /nav_public must be a JSON object/)
}
})
test('a key that is not an app path is rejected and named', () => {
for (const bad of [
'site/news', // relative
'//evil.example/x', // protocol-relative: looks like a path, leaves the origin
'https://evil.example', // scheme
'/site/ news', // whitespace
'/site/"news"', // quotes
'',
]) {
const check = validateNavOverrides({ [bad]: { order: 1 } }, 'nav_public')
assert.equal(check.ok, false, `'${bad}' should be rejected as a key`)
assert.match(check.message, /must be an app path/)
}
})
test('a valid app path is accepted as a key even when no nav declares it', () => {
// Membership is the client's question: applyNavOverrides drops an unknown `to`
// at merge time, so a route deleted in code needs no migration here.
assert.equal(validateNavOverrides({ '/site/gone': { order: 3 } }).ok, true)
})
test('an entry that is not an object is rejected', () => {
for (const bad of ['x', 4, null, []]) {
const check = validateNavOverrides({ '/site/news': bad }, 'nav_public')
assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected as an entry`)
assert.match(check.message, /must be an object/)
}
})
test('an unknown field is rejected rather than silently ignored', () => {
// Storing a value that will never apply is a bad admin experience, and a
// field nobody validates is where a future `to`/`roles` would try to sneak in.
for (const field of ['to', 'roles', 'feature', 'icon', 'end', 'href']) {
const check = validateNavOverrides({ '/site/news': { [field]: 'x' } }, 'nav_public')
assert.equal(check.ok, false, `'${field}' should be rejected`)
assert.match(check.message, new RegExp(`Unknown nav field '${field}'`))
}
})
test('each of the four fields is type-checked and named on failure', () => {
const cases = [
[{ label: 4 }, /label must be text/],
[{ label: 'x'.repeat(65) }, /label must be text/],
[{ group: 4 }, /group must be text/],
[{ group: 'x'.repeat(65) }, /group must be text/],
[{ order: '1' }, /order must be a number/],
[{ order: Number.NaN }, /order must be a number/],
[{ order: Number.POSITIVE_INFINITY }, /order must be a number/],
[{ hidden: 'true' }, /hidden must be true or false/],
[{ hidden: 1 }, /hidden must be true or false/],
]
for (const [entry, pattern] of cases) {
const check = validateNavOverrides({ '/site/news': entry }, 'nav_public')
assert.equal(check.ok, false, `${JSON.stringify(entry)} should be rejected`)
assert.match(check.message, pattern)
assert.match(check.message, /\/site\/news/)
}
})
test('all four fields together are accepted', () => {
const check = validateNavOverrides({
'/admin/houses': { label: 'Houses', order: 2, hidden: true, group: 'Moderation' },
})
assert.equal(check.ok, true)
})
test('an absurd number of entries is refused', () => {
const many = {}
for (let i = 0; i < 201; i += 1) many[`/site/p${i}`] = { order: i }
const check = validateNavOverrides(many, 'nav_public')
assert.equal(check.ok, false)
assert.match(check.message, /at most 200 entries/)
})
// ── resolveNavOverrides — forgiving, and drops what would do nothing ───
test('unusable entries are dropped and their neighbours kept', () => {
const out = resolveNavOverrides({
'/site/news': { label: 'Announcements' },
'not-a-path': { label: 'Ignored' },
'/site/wiki': 'garbage',
'/site/market': { hidden: true },
})
assert.deepEqual(out, {
'/site/news': { label: 'Announcements' },
'/site/market': { hidden: true },
})
})
test('a label is trimmed, and a whitespace-only label falls back to the coded one', () => {
assert.deepEqual(resolveNavOverrides({ '/x': { label: ' News ' } }), { '/x': { label: 'News' } })
assert.deepEqual(resolveNavOverrides({ '/x': { label: ' ' } }), {})
})
test('hidden: false is never stored — hiding is subtractive only', () => {
// A stored `false` could read to a later consumer as "force visible", and
// nothing in this layer may un-hide a role- or feature-gated item (§7).
assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false } }), {})
assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false, order: 2 } }), { '/x': { order: 2 } })
})
test('the nav editor cannot be hidden, even by a hand-written row', () => {
// Hiding /admin/navigation would remove the only screen that can un-hide it.
const out = resolveNavOverrides({ '/admin/navigation': { hidden: true, order: 9 } }, 'nav_admin')
assert.deepEqual(out, { '/admin/navigation': { order: 9 } })
// An entry that carried nothing else disappears entirely rather than storing
// an empty object.
assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_admin'), {})
})
test('the un-hideable rule is scoped to the admin nav', () => {
// The same path in another row is meaningless, but it is also not special:
// the rule protects the admin sidebar, which is the nav that renders it.
assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_public'), {
'/admin/navigation': { hidden: true },
})
})
test('an entry left with no usable field is dropped, so `{}` is never stored', () => {
assert.deepEqual(resolveNavOverrides({ '/x': {}, '/y': { label: 4 } }), {})
})
test('order survives as a number, including zero and negatives', () => {
const out = resolveNavOverrides({ '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
assert.deepEqual(out, { '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
})
test('a non-object resolves to {} rather than throwing', () => {
for (const bad of [null, undefined, 'x', 4, []]) {
assert.deepEqual(resolveNavOverrides(bad), {})
}
})
test('NAV_KEYS names the three rows the controller validates', () => {
assert.deepEqual(NAV_KEYS, ['nav_public', 'nav_admin', 'nav_player'])
})
// ── Sections and added links (phase 10) ───────────────────────────────────
//
// The public header may carry admin-created dropdown sections and links the
// admin authored. The invariant that has to survive is structural: `items` may
// only key routes the code declares, so it can never introduce one, while
// `links` is the one place an arbitrary path may be named — and is therefore
// the one place the path rule is applied.
const WRAPPED = {
items: { '/site/champs': { section: 'sec_abcd', order: 0 } },
sections: [{ id: 'sec_abcd', label: 'The World', order: 3 }],
links: [{ id: 'lnk_wxyz', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_abcd', order: 1 }],
}
test('a bare items map is still valid and still stored as-is', () => {
// Phases 6-8 wrote this shape, and a nav that does not use sections keeps it.
assert.equal(validateNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public').ok, true)
assert.deepEqual(resolveNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public'), {
'/site/news': { label: 'N' },
})
})
test('a wrapped value round-trips with its sections and links', () => {
assert.equal(validateNavOverrides(WRAPPED, 'nav_public').ok, true)
assert.deepEqual(resolveNavOverrides(WRAPPED, 'nav_public'), WRAPPED)
})
test('an added link must point at this site', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', 'wiki/guide', '/a b', '/a"b']) {
const check = validateNavOverrides(
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Bad', to }] },
'nav_public',
)
assert.equal(check.ok, false, `${to} should be refused`)
assert.match(check.message, /must be a path on this site/)
}
})
test('a link to a path that happens to be gated is allowed — the page is the gate', () => {
// An added link carries no roles/feature of its own and does not need one: the
// route behind it enforces its own access, exactly as typing the URL would.
const check = validateNavOverrides(
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Admin', to: '/admin/users' }] },
'nav_public',
)
assert.equal(check.ok, true)
})
test('section and link ids are constrained, and duplicates refused', () => {
const bad = [
[{ sections: [{ id: 'nope', label: 'X' }] }, /invalid id/],
[{ sections: [{ id: 'sec_AB', label: 'X' }] }, /invalid id/],
[{ sections: [{ id: 'sec_abcd', label: '' }] }, /label must be text/],
[{ sections: [{ id: 'sec_abcd', label: 'A' }, { id: 'sec_abcd', label: 'B' }] }, /duplicate id/],
[{ links: [{ id: 'sec_abcd', label: 'X', to: '/x' }] }, /invalid id/],
[{ links: [{ id: 'lnk_abcd', label: 'A', to: '/a' }, { id: 'lnk_abcd', label: 'B', to: '/b' }] }, /duplicate id/],
]
for (const [extra, pattern] of bad) {
const check = validateNavOverrides({ items: {}, ...extra }, 'nav_public')
assert.equal(check.ok, false, JSON.stringify(extra))
assert.match(check.message, pattern)
}
})
test('sections and links are bounded', () => {
const sections = Array.from({ length: 13 }, (_, i) => ({ id: `sec_a${String(i).padStart(3, '0')}`, label: 'S' }))
assert.match(validateNavOverrides({ items: {}, sections }, 'nav_public').message, /at most 12 sections/)
const links = Array.from({ length: 41 }, (_, i) => ({ id: `lnk_a${String(i).padStart(3, '0')}`, label: 'L', to: '/x' }))
assert.match(validateNavOverrides({ items: {}, links }, 'nav_public').message, /at most 40 added links/)
})
test('sections and links are dropped for the navs that cannot render them', () => {
// The admin sidebar has its own coded sections and the player portal is three
// flat rows; only the public header supports this.
for (const key of ['nav_admin', 'nav_player']) {
const out = resolveNavOverrides(WRAPPED, key)
assert.equal(out.sections, undefined, key)
assert.equal(out.links, undefined, key)
// The item survives, minus the section it can no longer belong to.
assert.deepEqual(out, { '/site/champs': { order: 0 } })
}
})
test('an item or link naming a section that does not exist falls to the top level', () => {
const out = resolveNavOverrides(
{
items: { '/site/champs': { section: 'sec_gone', order: 2 } },
sections: [{ id: 'sec_abcd', label: 'Real' }],
links: [{ id: 'lnk_wxyz', label: 'L', to: '/x', section: 'sec_gone' }],
},
'nav_public',
)
assert.equal(out.items['/site/champs'].section, undefined)
assert.equal(out.links[0].section, undefined)
})
test('an unusable section or link is dropped, its neighbours kept', () => {
const out = resolveNavOverrides(
{
items: {},
sections: [{ id: 'sec_abcd', label: 'Keep' }, { id: 'bad', label: 'Drop' }],
links: [
{ id: 'lnk_aaaa', label: 'Keep', to: '/keep' },
{ id: 'lnk_bbbb', label: 'Drop', to: 'https://evil.example' },
],
},
'nav_public',
)
assert.deepEqual(out.sections.map((s) => s.label), ['Keep'])
assert.deepEqual(out.links.map((l) => l.label), ['Keep'])
})
test('a wrapper that resolves to nothing usable comes back empty', () => {
// The caller deletes the row rather than storing a wrapper that says nothing.
assert.deepEqual(resolveNavOverrides({ items: {}, sections: [], links: [] }, 'nav_public'), {})
assert.deepEqual(resolveNavOverrides({ items: 'nope' }, 'nav_public'), {})
})