Files
website/server/test/publicController.test.js
wtclaude 35e5269ec5 test(server): unit-test auth, invite, password-reset, and public controllers
Add controller-level unit tests (mock req/res, monkeypatched collaborators)
focused on security boundaries and decision logic the API must not regress:

- auth.controller: honeypot handling, non-enumerating generic-fail for every
  credential failure, inactive-account refusal, the TOTP challenge branch that
  must NOT issue a session, register-mode gating + dup-username 409, and logout
  that always clears the cookie and revokes the session (even on error).
- invite.controller: user created at the invite's PRESET role, and the lost
  double-accept race rolling back the just-created user.
- passwordReset.controller: identical generic 200 whether or not the email
  matched (incl. internal errors), per-account mail-failure isolation, the
  single-use consume race, and revoke-everywhere-on-reset with no auto-login.
- public.controller: staff-only draft visibility, token-gated page preview,
  wiki search precedence + unknown-filter handling, contact 502.
- shard.controller (public): the PUBLIC_KINDS feed allowlist and the public
  house view stripping owner/price — both leak-prevention boundaries.

Lifts: auth.controller 46%→94%, passwordReset 33%→93%,
public.controller 28%→65%, shard.controller 45%→68% line coverage;
server aggregate 63.5%→70.4%.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:33:01 -05:00

216 lines
8.0 KiB
JavaScript

// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
// the end 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')
// Unit-test the public CMS/wiki controller's decision logic:
// - getPage: staff see drafts (live preview); the public gets a 404 for a draft,
// indistinguishable from a missing page (draft visibility is a boundary);
// - getPagePreview: only a valid, matching preview token unlocks a draft;
// - getWikiList: full-text search takes precedence, and an unknown category/tag
// yields [] rather than an error;
// - getPost(s): an unknown category is a 404;
// - contact: a mailer failure surfaces as a 502, not a 500 or a throw.
const ctrl = require('../src/router/v1/public/public.controller')
const posts = require('../src/model/posts/posts.model')
const wiki = require('../src/model/wiki/wiki.model')
const pages = require('../src/model/pages/pages.model')
const mailer = require('../src/utils/mailer')
const token = require('../src/auth/token')
const sessionService = require('../src/auth/session.service')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// getUserFromRequest (destructured into the controller) delegates to
// sessionService.validateSession — drive staff/public from there.
function asStaff(role = 'admin') {
sessionService.validateSession = () => ({ userId: 1, username: 'boss', role })
}
function asPublic() {
sessionService.validateSession = () => null
}
const originals = {
validateSession: sessionService.validateSession,
getBySlug: pages.getBySlug,
getById: pages.getById,
isValidUrlCategory: posts.isValidUrlCategory,
listPublished: posts.listPublished,
getPublished: posts.getPublished,
wikiSearch: wiki.search,
getCategoryBySlug: wiki.getCategoryBySlug,
getTagBySlug: wiki.getTagBySlug,
wikiListPublished: wiki.listPublished,
sendContactMessage: mailer.sendContactMessage,
}
afterEach(() => {
sessionService.validateSession = originals.validateSession
pages.getBySlug = originals.getBySlug
pages.getById = originals.getById
posts.isValidUrlCategory = originals.isValidUrlCategory
posts.listPublished = originals.listPublished
posts.getPublished = originals.getPublished
wiki.search = originals.wikiSearch
wiki.getCategoryBySlug = originals.getCategoryBySlug
wiki.getTagBySlug = originals.getTagBySlug
wiki.listPublished = originals.wikiListPublished
mailer.sendContactMessage = originals.sendContactMessage
})
// ── getPage draft visibility ────────────────────────────────────────────
test('getPage lets staff include unpublished drafts', async () => {
asStaff('editor')
let sawOpts
pages.getBySlug = async (slug, opts) => {
sawOpts = opts
return { slug, status: 'draft' }
}
const res = mockRes()
await ctrl.getPage({ params: { slug: 'wip' } }, res)
assert.equal(sawOpts.includeUnpublished, true)
assert.equal(res.body.slug, 'wip')
})
test('getPage hides drafts from the public and 404s (model returns null)', async () => {
asPublic()
let sawOpts
pages.getBySlug = async (slug, opts) => {
sawOpts = opts
return null // model already filtered the draft out for a public caller
}
const res = mockRes()
await ctrl.getPage({ params: { slug: 'wip' } }, res)
assert.equal(sawOpts.includeUnpublished, false)
assert.equal(res.statusCode, 404)
})
test('getPage treats a player role as non-staff (no draft access)', async () => {
asStaff('player') // a player is NOT in STAFF_ROLES
let sawOpts
pages.getBySlug = async (slug, opts) => {
sawOpts = opts
return null
}
await ctrl.getPage({ params: { slug: 'wip' } }, mockRes())
assert.equal(sawOpts.includeUnpublished, false)
})
// ── getPagePreview token gate ───────────────────────────────────────────
test('getPagePreview unlocks a draft with a valid, matching preview token', async () => {
asPublic()
const validToken = token.signPagePreview(42)
pages.getById = async (id) => ({ id, status: 'draft' })
const res = mockRes()
await ctrl.getPagePreview({ params: { id: '42', token: validToken } }, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.id, 42)
})
test('getPagePreview 404s when the token is for a different page', async () => {
const tokenForOther = token.signPagePreview(7)
let loaded = false
pages.getById = async () => {
loaded = true
return { id: 42 }
}
const res = mockRes()
await ctrl.getPagePreview({ params: { id: '42', token: tokenForOther } }, res)
assert.equal(res.statusCode, 404)
assert.equal(loaded, false, 'a mismatched token never loads the page')
})
test('getPagePreview 404s on a garbage token', async () => {
const res = mockRes()
await ctrl.getPagePreview({ params: { id: '42', token: 'not-a-jwt' } }, res)
assert.equal(res.statusCode, 404)
})
// ── getWikiList precedence + unknown filters ────────────────────────────
test('getWikiList runs a full-text search when q is present, ignoring filters', async () => {
let searched
wiki.search = async (q, opts) => {
searched = { q, opts }
return [{ slug: 'hit' }]
}
wiki.listPublished = async () => {
throw new Error('listPublished should not run when q is set')
}
const res = mockRes()
await ctrl.getWikiList({ query: { q: ' dragon ', category: 'bestiary' } }, res)
assert.equal(searched.q, 'dragon') // trimmed
assert.equal(searched.opts.publishedOnly, true)
assert.equal(res.body[0].slug, 'hit')
})
test('getWikiList returns [] for an unknown category filter without listing pages', async () => {
wiki.getCategoryBySlug = async () => null
let listed = false
wiki.listPublished = async () => {
listed = true
return []
}
const res = mockRes()
await ctrl.getWikiList({ query: { category: 'ghosts' } }, res)
assert.deepEqual(res.body, [])
assert.equal(listed, false)
})
test('getWikiList combines a known category and tag into the list filter', async () => {
wiki.getCategoryBySlug = async () => ({ id: 3 })
wiki.getTagBySlug = async () => ({ id: 9 })
let filters
wiki.listPublished = async (f) => {
filters = f
return []
}
await ctrl.getWikiList({ query: { category: 'lore', tag: 'undead' } }, mockRes())
assert.deepEqual(filters, { categoryId: 3, tagId: 9 })
})
// ── posts category validation ───────────────────────────────────────────
test('getPosts 404s an unknown url category', async () => {
posts.isValidUrlCategory = () => false
const res = mockRes()
await ctrl.getPosts({ params: { category: 'nope' } }, res)
assert.equal(res.statusCode, 404)
})
test('getPost 404s a valid category with no matching post', async () => {
posts.isValidUrlCategory = () => true
posts.getPublished = async () => null
const res = mockRes()
await ctrl.getPost({ params: { category: 'news', idOrSlug: 'missing' } }, res)
assert.equal(res.statusCode, 404)
})
// ── contact failure path ────────────────────────────────────────────────
test('contact surfaces a mailer failure as a 502 (not a 500 or a throw)', async () => {
mailer.sendContactMessage = async () => {
throw new Error('smtp down')
}
const res = mockRes()
await ctrl.contact({ body: { name: 'A', email: 'a@b.c', message: 'hi' } }, res)
assert.equal(res.statusCode, 502)
assert.match(res.body.message, /send/i)
})