// 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') })