Files
website/server/test/inviteController.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

158 lines
6.6 KiB
JavaScript

// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model/service 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, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Unit-test invite acceptance. The two invariants that matter most:
// - the new user is created at the invite's PRESET role (an invite is its own
// authority — it bypasses the registration gate but not the role);
// - a lost double-accept race rolls back the just-created user, so a spent
// invite can never yield two accounts.
// Plus the usual guards: honeypot, invalid token, invalid username, dup username.
const ctrl = require('../src/router/v1/auth/invite.controller')
const authCtrl = require('../src/router/v1/auth/auth.controller')
const invites = require('../src/model/invites/invites.model')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const sessionService = require('../src/auth/session.service')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
cookies: {},
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
cookie(name, val) {
this.cookies[name] = val
return this
},
}
}
const orig = {}
beforeEach(() => {
for (const [mod, name] of [
[invites, 'findValidByToken'], [invites, 'accept'],
[users, 'createUser'], [users, 'isDuplicateUsername'], [users, 'remove'], [users, 'recordLogin'],
[activity, 'log'], [sessionService, 'createSession'],
]) {
orig[name] = { mod, val: mod[name] }
}
activity.log = async () => {}
users.recordLogin = async () => {}
sessionService.createSession = () => ({ token: 'session-token' })
})
afterEach(() => {
for (const key of Object.keys(orig)) {
orig[key].mod[key] = orig[key].val
delete orig[key]
}
})
const HONEYPOT = authCtrl.HONEYPOT_FIELD
const req = (body = {}, token = 'tok') => ({ body, ip: '10.0.0.1', headers: {}, params: { token } })
// ── getInvite ───────────────────────────────────────────────────────────
test('getInvite 404s an invalid token and otherwise returns only email + role', async () => {
invites.findValidByToken = async () => null
const res404 = mockRes()
await ctrl.getInvite(req({}, 'bad'), res404)
assert.equal(res404.statusCode, 404)
invites.findValidByToken = async () => ({ id: 1, email: 'invitee@x.io', role: 'moderator', token_hash: 'SECRET' })
const resOk = mockRes()
await ctrl.getInvite(req({}, 'good'), resOk)
assert.deepEqual(resOk.body, { email: 'invitee@x.io', role: 'moderator' }) // no id/token/hash
})
// ── acceptInvite guards ─────────────────────────────────────────────────
test('acceptInvite rejects a tripped honeypot with a 400 before any lookup', async () => {
let lookedUp = false
invites.findValidByToken = async () => {
lookedUp = true
return null
}
const res = mockRes()
await ctrl.acceptInvite(req({ username: 'x', password: 'p', [HONEYPOT]: 'bot' }), res)
assert.equal(res.statusCode, 400)
assert.equal(lookedUp, false)
})
test('acceptInvite 404s an invalid/expired invite token', async () => {
invites.findValidByToken = async () => null
const res = mockRes()
await ctrl.acceptInvite(req({ username: 'validname', password: 'pw' }), res)
assert.equal(res.statusCode, 404)
})
test('acceptInvite 400s an invalid username without creating a user', async () => {
invites.findValidByToken = async () => ({ id: 1, email: 'a@x.io', role: 'editor' })
let created = false
users.createUser = async () => {
created = true
}
const res = mockRes()
await ctrl.acceptInvite(req({ username: 'x', password: 'pw' }), res) // too short
assert.equal(res.statusCode, 400)
assert.equal(created, false)
})
// ── the preset-role invariant ───────────────────────────────────────────
test('acceptInvite creates the user at the invite role (not player) and logs them in', async () => {
invites.findValidByToken = async () => ({ id: 2, email: 'mod@x.io', role: 'moderator' })
let createArgs
users.createUser = async (args) => {
createArgs = args
return { id: 50, username: args.username, role: args.role }
}
invites.accept = async () => true
const res = mockRes()
await ctrl.acceptInvite(req({ username: 'newmod', password: 'pw' }), res)
assert.equal(createArgs.role, 'moderator') // preset role carried through
assert.equal(createArgs.email, 'mod@x.io') // email comes from the invite, not the body
assert.equal(createArgs.emailVerified, true) // using the link proves control of the address
assert.equal(res.body.user.id, 50)
assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
})
// ── dup username ────────────────────────────────────────────────────────
test('acceptInvite surfaces a duplicate username as a 409', async () => {
invites.findValidByToken = async () => ({ id: 3, email: 'a@x.io', role: 'player' })
users.createUser = async () => {
throw new Error('dup')
}
users.isDuplicateUsername = () => true
const res = mockRes()
await ctrl.acceptInvite(req({ username: 'takenname', password: 'pw' }), res)
assert.equal(res.statusCode, 409)
})
// ── the double-accept race rolls back the created user ──────────────────
test('acceptInvite rolls back the new user and 409s when it loses the accept race', async () => {
invites.findValidByToken = async () => ({ id: 4, email: 'a@x.io', role: 'player' })
users.createUser = async () => ({ id: 77, username: 'racer', role: 'player' })
invites.accept = async () => false // someone else consumed the invite first
let removed = null
users.remove = async (id) => {
removed = id
}
const res = mockRes()
await ctrl.acceptInvite(req({ username: 'racername', password: 'pw' }), res)
assert.equal(res.statusCode, 409)
assert.equal(removed, 77, 'the orphaned user is deleted — a spent invite never yields two accounts')
})