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>
158 lines
6.6 KiB
JavaScript
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')
|
|
})
|