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>
This commit is contained in:
215
server/test/publicController.test.js
Normal file
215
server/test/publicController.test.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// 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)
|
||||
})
|
||||
Reference in New Issue
Block a user