diff --git a/server/test/authController.test.js b/server/test/authController.test.js new file mode 100644 index 0000000..8f026f3 --- /dev/null +++ b/server/test/authController.test.js @@ -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) +}) diff --git a/server/test/inviteController.test.js b/server/test/inviteController.test.js new file mode 100644 index 0000000..0baaab6 --- /dev/null +++ b/server/test/inviteController.test.js @@ -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') +}) diff --git a/server/test/passwordResetController.test.js b/server/test/passwordResetController.test.js new file mode 100644 index 0000000..b49108b --- /dev/null +++ b/server/test/passwordResetController.test.js @@ -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') +}) diff --git a/server/test/publicController.test.js b/server/test/publicController.test.js new file mode 100644 index 0000000..ddea6e8 --- /dev/null +++ b/server/test/publicController.test.js @@ -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) +}) diff --git a/server/test/shardControllerPublic.test.js b/server/test/shardControllerPublic.test.js new file mode 100644 index 0000000..1f92c24 --- /dev/null +++ b/server/test/shardControllerPublic.test.js @@ -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') +})