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:
285
server/test/authController.test.js
Normal file
285
server/test/authController.test.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// 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 the auth controller's security-sensitive decision logic. These are
|
||||||
|
// the rules a regression must never silently break:
|
||||||
|
// - a tripped honeypot fails generically and is never issued a session;
|
||||||
|
// - every credential failure returns the SAME generic message (no user
|
||||||
|
// enumeration, no "which field was wrong");
|
||||||
|
// - correct password on an inactive account is refused (no session, no TOTP);
|
||||||
|
// - a TOTP-enabled user gets a challenge, NOT a session, until the code checks;
|
||||||
|
// - registration is gated by the registration mode; a dup username is a 409;
|
||||||
|
// - logout always clears the cookie and revokes the session when present.
|
||||||
|
const ctrl = require('../src/router/v1/auth/auth.controller')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const settings = require('../src/model/settings/settings.model')
|
||||||
|
const sessionService = require('../src/auth/session.service')
|
||||||
|
const totp = require('../src/utils/totp')
|
||||||
|
const botScore = require('../src/middleware/botScore')
|
||||||
|
const loginProtection = require('../src/middleware/loginProtection')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
cookies: {},
|
||||||
|
cleared: [],
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
cookie(name, val) {
|
||||||
|
this.cookies[name] = val
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
clearCookie(name) {
|
||||||
|
this.cleared.push(name)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spies / stubs for the collaborators the controller drives.
|
||||||
|
let sessionsCreated
|
||||||
|
const orig = {}
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionsCreated = []
|
||||||
|
for (const [mod, name] of [
|
||||||
|
[users, 'getRawByUsername'], [users, 'validatePassword'], [users, 'recordLogin'],
|
||||||
|
[users, 'getRawById'], [users, 'getById'], [users, 'createUser'], [users, 'isDuplicateUsername'],
|
||||||
|
[activity, 'log'], [settings, 'getRegistrationMode'],
|
||||||
|
[sessionService, 'createSession'], [sessionService, 'createPartialSession'], [sessionService, 'upgradeSessionAfterTotp'], [sessionService, 'revokeSession'],
|
||||||
|
[totp, 'verifyCode'],
|
||||||
|
[botScore, 'recordHoneypot'], [botScore, 'recordLoginFailure'],
|
||||||
|
[loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
|
||||||
|
]) {
|
||||||
|
orig[`${name}`] = orig[`${name}`] || { mod, val: mod[name] }
|
||||||
|
}
|
||||||
|
// Safe defaults; individual tests override.
|
||||||
|
users.recordLogin = async () => {}
|
||||||
|
users.getById = async (id) => ({ id, username: 'u', role: 'player' })
|
||||||
|
activity.log = async () => {}
|
||||||
|
botScore.recordHoneypot = () => {}
|
||||||
|
botScore.recordLoginFailure = () => {}
|
||||||
|
loginProtection.recordFailure = () => {}
|
||||||
|
loginProtection.recordSuccess = () => {}
|
||||||
|
sessionService.createSession = (user) => {
|
||||||
|
sessionsCreated.push(user)
|
||||||
|
return { token: 'session-token' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(orig)) {
|
||||||
|
orig[key].mod[key] = orig[key].val
|
||||||
|
delete orig[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {}, session: null })
|
||||||
|
|
||||||
|
// ── honeypot ────────────────────────────────────────────────────────────
|
||||||
|
test('login: a filled honeypot field fails generically and issues no session', async () => {
|
||||||
|
let scored = false
|
||||||
|
botScore.recordHoneypot = () => {
|
||||||
|
scored = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'x', password: 'y', company: 'ACME Bot' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(res.body.message, 'Incorrect username or password.')
|
||||||
|
assert.equal(scored, true)
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── credential failures are indistinguishable ───────────────────────────
|
||||||
|
test('login: an unknown username and a wrong password return the identical generic failure', async () => {
|
||||||
|
users.getRawByUsername = async () => null // unknown user
|
||||||
|
users.validatePassword = async () => false
|
||||||
|
const res1 = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'ghost', password: 'z' }), res1)
|
||||||
|
|
||||||
|
users.getRawByUsername = async () => ({ id: 1, username: 'real', status: 'active' })
|
||||||
|
users.validatePassword = async () => false // wrong password
|
||||||
|
const res2 = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'real', password: 'wrong' }), res2)
|
||||||
|
|
||||||
|
assert.equal(res1.statusCode, 401)
|
||||||
|
assert.equal(res2.statusCode, 401)
|
||||||
|
assert.deepEqual(res1.body, res2.body) // no enumeration: same message either way
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── inactive account: correct password, still refused ───────────────────
|
||||||
|
test('login: correct password on a non-active account is a 403 with no session', async () => {
|
||||||
|
users.getRawByUsername = async () => ({ id: 1, username: 'banned', status: 'banned' })
|
||||||
|
users.validatePassword = async () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'banned', password: 'right' }), res)
|
||||||
|
assert.equal(res.statusCode, 403)
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── TOTP gate: password ok but a second factor is required ──────────────
|
||||||
|
test('login: a TOTP-enabled user gets a challenge, not a session', async () => {
|
||||||
|
users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 })
|
||||||
|
users.validatePassword = async () => true
|
||||||
|
sessionService.createPartialSession = () => 'challenge-jwt'
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
|
||||||
|
assert.equal(res.body.totpRequired, true)
|
||||||
|
assert.equal(res.body.challenge, 'challenge-jwt')
|
||||||
|
assert.equal(sessionsCreated.length, 0, 'no real session until the code is verified')
|
||||||
|
assert.equal(res.cookies['auth_token'] || res.cookies.token, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('login: a user without TOTP is logged straight in (session + cookie)', async () => {
|
||||||
|
users.getRawByUsername = async () => ({ id: 9, username: 'plain', role: 'player', status: 'active', totp_enabled: 0 })
|
||||||
|
users.validatePassword = async () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'plain', password: 'right' }), res)
|
||||||
|
assert.equal(sessionsCreated.length, 1)
|
||||||
|
assert.equal(sessionsCreated[0].id, 9)
|
||||||
|
assert.equal(res.body.user.id, 9)
|
||||||
|
assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── loginTotp second step ───────────────────────────────────────────────
|
||||||
|
test('loginTotp: an expired/invalid challenge is a 401 (no user lookup)', async () => {
|
||||||
|
sessionService.upgradeSessionAfterTotp = () => null
|
||||||
|
let lookedUp = false
|
||||||
|
users.getRawById = async () => {
|
||||||
|
lookedUp = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.loginTotp(baseReq({ challenge: 'stale', code: '000000' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(lookedUp, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loginTotp: a wrong code is a 401 and issues no session', async () => {
|
||||||
|
sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
|
||||||
|
users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => false
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '123456' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loginTotp: a valid code issues the session with authMethod totp', async () => {
|
||||||
|
sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
|
||||||
|
users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => true
|
||||||
|
let method
|
||||||
|
sessionService.createSession = (user, authMethod) => {
|
||||||
|
method = authMethod
|
||||||
|
sessionsCreated.push(user)
|
||||||
|
return { token: 't' }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321' }), res)
|
||||||
|
assert.equal(sessionsCreated.length, 1)
|
||||||
|
assert.equal(method, 'totp')
|
||||||
|
assert.equal(res.body.user.id, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── register gating ─────────────────────────────────────────────────────
|
||||||
|
test('register: refused (403) when the registration mode excludes passwords', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'sso' // password path closed
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'newbie', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('register: an invalid username is a 400 before any user is created', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'password'
|
||||||
|
let created = false
|
||||||
|
users.createUser = async () => {
|
||||||
|
created = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'x', password: 'pw' }), res) // too short
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.equal(created, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('register: a duplicate username surfaces as a 409', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'both'
|
||||||
|
users.createUser = async () => {
|
||||||
|
throw new Error('dup')
|
||||||
|
}
|
||||||
|
users.isDuplicateUsername = () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'takenname', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 409)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('register: a valid new player is created and auto-logged-in', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'password'
|
||||||
|
users.createUser = async ({ username, role }) => ({ id: 30, username, role })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'freshplayer', password: 'pw', email: ' a@b.c ' }), res)
|
||||||
|
assert.equal(res.body.user.id, 30)
|
||||||
|
assert.equal(res.body.user.role, 'player')
|
||||||
|
assert.equal(sessionsCreated.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── logout always clears, revokes when a session exists ─────────────────
|
||||||
|
test('logout: clears the cookie and revokes the server-side session when present', async () => {
|
||||||
|
let revoked = null
|
||||||
|
sessionService.revokeSession = async (id) => {
|
||||||
|
revoked = id
|
||||||
|
}
|
||||||
|
const req = { ...baseReq(), session: { sessionId: 'jti-1', userId: 7, expiresAt: 123 } }
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.logout(req, res)
|
||||||
|
assert.equal(res.cleared.length, 1, 'cookie cleared')
|
||||||
|
assert.equal(revoked, 'jti-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logout: still succeeds (cookie cleared) when there is no session to revoke', async () => {
|
||||||
|
let revokeCalled = false
|
||||||
|
sessionService.revokeSession = async () => {
|
||||||
|
revokeCalled = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.logout(baseReq(), res)
|
||||||
|
assert.equal(res.cleared.length, 1)
|
||||||
|
assert.equal(revokeCalled, false)
|
||||||
|
assert.match(res.body.message, /logged out/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logout: a revocation error never fails the logout', async () => {
|
||||||
|
sessionService.revokeSession = async () => {
|
||||||
|
throw new Error('store unreachable')
|
||||||
|
}
|
||||||
|
const req = { ...baseReq(), session: { sessionId: 'jti', userId: 1, expiresAt: 1 } }
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.logout(req, res) // must resolve
|
||||||
|
assert.equal(res.cleared.length, 1)
|
||||||
|
assert.match(res.body.message, /logged out/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── me ──────────────────────────────────────────────────────────────────
|
||||||
|
test('me: returns 401 when the backing user no longer exists', async () => {
|
||||||
|
users.getById = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.me({ ...baseReq(), user: { id: 99 } }, res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
})
|
||||||
157
server/test/inviteController.test.js
Normal file
157
server/test/inviteController.test.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// 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')
|
||||||
|
})
|
||||||
191
server/test/passwordResetController.test.js
Normal file
191
server/test/passwordResetController.test.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model/mailer 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 the self-service password reset controller. The security invariants:
|
||||||
|
// - requestReset NEVER reveals whether an email exists — empty, unmatched,
|
||||||
|
// matched, and even an internal error all return the same generic 200;
|
||||||
|
// - one account's mail failure does not abort the others or change the answer;
|
||||||
|
// - confirmReset consumes the token atomically (a lost double-submit race is a
|
||||||
|
// 404) and, on success, rotates the password AND revokes mobile sessions,
|
||||||
|
// without auto-logging the user in.
|
||||||
|
const ctrl = require('../src/router/v1/auth/passwordReset.controller')
|
||||||
|
const passwordResets = require('../src/model/passwordResets/passwordResets.model')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const mailer = require('../src/utils/mailer')
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_MATCH = /if an account exists/i
|
||||||
|
|
||||||
|
const orig = {}
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const [mod, name] of [
|
||||||
|
[users, 'getActiveByEmail'], [users, 'getById'], [users, 'update'],
|
||||||
|
[passwordResets, 'create'], [passwordResets, 'findValidByToken'], [passwordResets, 'consume'], [passwordResets, 'invalidatePendingForUser'],
|
||||||
|
[mobileSessions, 'revokeAllForUser'], [activity, 'log'], [mailer, 'sendPasswordReset'],
|
||||||
|
]) {
|
||||||
|
orig[name] = { mod, val: mod[name] }
|
||||||
|
}
|
||||||
|
activity.log = async () => {}
|
||||||
|
mailer.sendPasswordReset = async () => ({ sent: true })
|
||||||
|
passwordResets.create = async () => ({ token: 'opaque-token' })
|
||||||
|
passwordResets.invalidatePendingForUser = async () => {}
|
||||||
|
mobileSessions.revokeAllForUser = async () => {}
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(orig)) {
|
||||||
|
orig[key].mod[key] = orig[key].val
|
||||||
|
delete orig[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = (body = {}) => ({ body, ip: '10.0.0.1' })
|
||||||
|
|
||||||
|
// ── requestReset never enumerates ───────────────────────────────────────
|
||||||
|
test('requestReset returns the generic OK for an empty email without any lookup', async () => {
|
||||||
|
let lookedUp = false
|
||||||
|
users.getActiveByEmail = async () => {
|
||||||
|
lookedUp = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: ' ' }), res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.match(res.body.message, GENERIC_MATCH)
|
||||||
|
assert.equal(lookedUp, false, 'a blank email is short-circuited before the DB')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requestReset returns the SAME generic OK whether or not the email matched', async () => {
|
||||||
|
users.getActiveByEmail = async () => [] // no account
|
||||||
|
const resNone = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'ghost@x.io' }), resNone)
|
||||||
|
|
||||||
|
users.getActiveByEmail = async () => [{ id: 1, email: 'real@x.io', username: 'real' }]
|
||||||
|
const resHit = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'real@x.io' }), resHit)
|
||||||
|
|
||||||
|
assert.deepEqual(resNone.body, resHit.body) // indistinguishable
|
||||||
|
assert.equal(resHit.statusCode, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requestReset emails every account matching a (non-unique) address', async () => {
|
||||||
|
users.getActiveByEmail = async () => [
|
||||||
|
{ id: 1, email: 'shared@x.io', username: 'alpha' },
|
||||||
|
{ id: 2, email: 'shared@x.io', username: 'beta' },
|
||||||
|
]
|
||||||
|
const sent = []
|
||||||
|
mailer.sendPasswordReset = async ({ username }) => {
|
||||||
|
sent.push(username)
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
|
await ctrl.requestReset(req({ email: 'shared@x.io' }), mockRes())
|
||||||
|
assert.deepEqual(sent.sort(), ['alpha', 'beta'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("requestReset: one account's mail failure does not abort the others or change the response", async () => {
|
||||||
|
users.getActiveByEmail = async () => [
|
||||||
|
{ id: 1, email: 'a@x.io', username: 'alpha' },
|
||||||
|
{ id: 2, email: 'b@x.io', username: 'beta' },
|
||||||
|
]
|
||||||
|
const sent = []
|
||||||
|
mailer.sendPasswordReset = async ({ username }) => {
|
||||||
|
if (username === 'alpha') throw new Error('smtp reject')
|
||||||
|
sent.push(username)
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'a@x.io' }), res)
|
||||||
|
assert.deepEqual(sent, ['beta'], 'beta still got its link after alpha failed')
|
||||||
|
assert.match(res.body.message, GENERIC_MATCH)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requestReset stays generic even when the account lookup itself throws', async () => {
|
||||||
|
users.getActiveByEmail = async () => {
|
||||||
|
throw new Error('pool down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'x@x.io' }), res)
|
||||||
|
assert.equal(res.statusCode, 200) // internal error is not an enumeration oracle
|
||||||
|
assert.match(res.body.message, GENERIC_MATCH)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── lookupReset ─────────────────────────────────────────────────────────
|
||||||
|
test('lookupReset 404s an invalid/expired token and returns only the username on success', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => null
|
||||||
|
const res404 = mockRes()
|
||||||
|
await ctrl.lookupReset({ params: { token: 'bad' } }, res404)
|
||||||
|
assert.equal(res404.statusCode, 404)
|
||||||
|
|
||||||
|
passwordResets.findValidByToken = async () => ({ user_id: 7 })
|
||||||
|
users.getById = async () => ({ id: 7, username: 'target', email: 'secret@x.io' })
|
||||||
|
const resOk = mockRes()
|
||||||
|
await ctrl.lookupReset({ params: { token: 'good' } }, resOk)
|
||||||
|
assert.deepEqual(resOk.body, { username: 'target' }) // email/token never surfaced
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── confirmReset consume race + revoke-everywhere ───────────────────────
|
||||||
|
test('confirmReset 404s when the token is not valid', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.confirmReset({ params: { token: 'bad' }, body: { password: 'new' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirmReset 404s the loser of a double-submit race and never rotates the password', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 })
|
||||||
|
passwordResets.consume = async () => false // lost the race
|
||||||
|
let rotated = false
|
||||||
|
users.update = async () => {
|
||||||
|
rotated = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'new' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
assert.equal(rotated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirmReset rotates the password, revokes mobile sessions, and retires other links — no auto-login', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 })
|
||||||
|
passwordResets.consume = async () => true
|
||||||
|
const calls = { update: null, revoke: null, invalidate: null }
|
||||||
|
users.update = async (id, patch) => {
|
||||||
|
calls.update = { id, patch }
|
||||||
|
}
|
||||||
|
mobileSessions.revokeAllForUser = async (id) => {
|
||||||
|
calls.revoke = id
|
||||||
|
}
|
||||||
|
passwordResets.invalidatePendingForUser = async (id) => {
|
||||||
|
calls.invalidate = id
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'brand-new' } }, res)
|
||||||
|
assert.deepEqual(calls.update, { id: 7, patch: { password: 'brand-new' } })
|
||||||
|
assert.equal(calls.revoke, 7)
|
||||||
|
assert.equal(calls.invalidate, 7)
|
||||||
|
assert.equal(res.body.ok, true)
|
||||||
|
assert.equal(res.body.user, undefined, 'no session/user is returned — the user signs in fresh')
|
||||||
|
})
|
||||||
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)
|
||||||
|
})
|
||||||
150
server/test/shardControllerPublic.test.js
Normal file
150
server/test/shardControllerPublic.test.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// 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 shard controller's SECURITY BOUNDARIES and shaping — the
|
||||||
|
// bits that decide what the anonymous public may and may not see:
|
||||||
|
// - getFeed serves only kinds on the public allowlist (staff audit / cheat /
|
||||||
|
// login events are stored for the admin channel and must never leak here);
|
||||||
|
// - getHouses exposes only IDOC houses and only their location — owner, price,
|
||||||
|
// co-owners and decay detail are staff-only and must be stripped;
|
||||||
|
// - getStatus assembles the connection/economy summary;
|
||||||
|
// - a model failure degrades to a 500, never a thrown/uncaught error.
|
||||||
|
const ctrl = require('../src/router/v1/public/shard.controller')
|
||||||
|
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
||||||
|
const shardState = require('../src/model/shardState/shardState.model')
|
||||||
|
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const broadcast = require('../src/utils/shardBroadcast')
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
eventsList: shardEvents.list,
|
||||||
|
listIdoc: shardState.listIdoc,
|
||||||
|
onlineCount: shardState.onlineCount,
|
||||||
|
latestEconomy: shardState.latestEconomy,
|
||||||
|
getSafe: uoLinkConfig.getSafe,
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
shardEvents.list = originals.eventsList
|
||||||
|
shardState.listIdoc = originals.listIdoc
|
||||||
|
shardState.onlineCount = originals.onlineCount
|
||||||
|
shardState.latestEconomy = originals.latestEconomy
|
||||||
|
uoLinkConfig.getSafe = originals.getSafe
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getFeed: the public-safe allowlist is a security boundary ───────────
|
||||||
|
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
|
||||||
|
let queried = false
|
||||||
|
shardEvents.list = async () => {
|
||||||
|
queried = true
|
||||||
|
return [{ kind: 'staff.audit' }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
|
||||||
|
assert.deepEqual(res.body, [])
|
||||||
|
assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed serves a specific kind when it IS public-safe', async () => {
|
||||||
|
const publicKind = [...broadcast.PUBLIC_KINDS][0]
|
||||||
|
let seen
|
||||||
|
shardEvents.list = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return [{ kind: publicKind }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
|
||||||
|
assert.equal(seen.kind, publicKind)
|
||||||
|
assert.equal(seen.limit, 5)
|
||||||
|
assert.equal(res.body[0].kind, publicKind)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed with no kind restricts the query to the whole public allowlist', async () => {
|
||||||
|
let seen
|
||||||
|
shardEvents.list = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
await ctrl.getFeed({ query: {} }, mockRes())
|
||||||
|
assert.deepEqual(new Set(seen.kinds), broadcast.PUBLIC_KINDS)
|
||||||
|
// Sanity: a known admin-only kind is absent from what the public feed queries.
|
||||||
|
assert.ok(!seen.kinds.includes('staff.audit'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getHouses: the public house view must strip owner/price ─────────────
|
||||||
|
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
|
||||||
|
shardState.listIdoc = async () => [
|
||||||
|
{
|
||||||
|
serial: 1,
|
||||||
|
name: 'Keep',
|
||||||
|
region: 'Britain',
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
z: 3,
|
||||||
|
// The following are staff-only and must NOT appear in the public payload:
|
||||||
|
ownerName: 'Lord British',
|
||||||
|
ownerAcct: 'secret',
|
||||||
|
price: 999999,
|
||||||
|
coOwners: 'a,b',
|
||||||
|
decay: 'IDOC',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getHouses({}, res)
|
||||||
|
const [h] = res.body
|
||||||
|
assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
|
||||||
|
assert.equal(h.isIdoc, true)
|
||||||
|
assert.equal(h.ownerName, undefined)
|
||||||
|
assert.equal(h.price, undefined)
|
||||||
|
assert.equal(h.coOwners, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getStatus assembles the summary ─────────────────────────────────────
|
||||||
|
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => ({
|
||||||
|
enabled: true,
|
||||||
|
status: 'connected',
|
||||||
|
pluginConnected: true,
|
||||||
|
lastEventAt: 'ts',
|
||||||
|
})
|
||||||
|
shardState.onlineCount = async () => 12
|
||||||
|
shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getStatus({}, res)
|
||||||
|
assert.equal(res.body.enabled, true)
|
||||||
|
assert.equal(res.body.onlineCount, 12)
|
||||||
|
assert.equal(res.body.economy.gold, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => {
|
||||||
|
throw new Error('pool down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getStatus({}, res) // must resolve, not reject
|
||||||
|
assert.equal(res.statusCode, 500)
|
||||||
|
assert.equal(res.body.message, 'Internal Server Error')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user