Files
website/server/test/selfTrustedDevices.test.js
wtclaude 6e61146678
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 10m32s
refactor(api): collapse /admin/account and /player/account onto /auth/me/account
Self-service account security had three URL surfaces onto one controller. All
three mounted the same `admin/account.controller.js` handlers; each of the three
router files carried a header comment apologising for the arrangement.

`/auth/me/account` was already a strict superset, which settles which to keep:

  /admin/account   6 routes  noindex, isLoggedIn, staffOnly
  /player/account  8 routes  noindex, requireAuth
  /auth/me/account 10 routes noindex, requireAuth

Neither of the deleted surfaces carried recovery codes, and /admin/account
carried no username or password change at all — so client.js already called
/auth/me/account/recovery-codes/* for two operations on a screen it otherwise
served from /admin/account. The split was leaking before this change.

Gating is equivalent where it overlapped: /player and /auth/me apply identical
`noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower
while buying nothing, since every handler is self-scoped to req.user.id. There
is no CSRF layer to differ.

  - 14 routes deleted, 0 added, no handler changed.
  - account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the
    one router that still reaches it.
  - Web client: 14 call sites move onto a root-level api.myAccount /
    api.changeUsername / ... group, matching the /auth/me methods already there.
  - Android app: no change. MeApi.kt was already 100% /auth/me/account/*.
  - Two swagger tags, `Admin · Account` and `Player`, were declared only by the
    deleted routes and go with them. The orphaned `AccountStatus` schema goes
    too; `PlayerAccount` is re-described as the any-role /auth/me/account shape
    (the name is kept so existing $refs resolve).

Breaking to the published OpenAPI surface, accepted deliberately: both consumers
are in this org, and deprecate-then-delete would leave the next phase deciding
whether to add routes to surfaces already marked for removal.

Verification: routes.manifest.json shows exactly 14 deletions and 0 additions.
The OpenAPI spec loses the same 14 paths with zero surviving path definitions
changed; its large textual diff is pure reordering, because removing the
first-mounted router shifts every later path. 1203 server tests, 288 client
tests, 53 bot tests green; check:modules, check:hosts and routes:manifest
--check all pass.

Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of
engagement Phase 1b, which adds a self-service email field — written once here
rather than three times.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 00:49:25 -05:00

163 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every collaborator is monkeypatched, so no query runs.
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 trusted-device + recovery-code account handlers.
// Invariants:
// - enabling TOTP hands back a one-time batch of recovery codes;
// - disabling TOTP clears BOTH trusted devices and recovery codes;
// - trusting the current device is ownership-scoped and honors the cap (409);
// - self-revoke is scoped to the caller's own id;
// - regenerating recovery codes is a password step-up (wrong password → 400).
const ctrl = require('../src/router/v1/auth/account.controller')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const sessionService = require('../src/auth/session.service')
const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
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: {},
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.cookies[name] = undefined; return this },
}
}
const orig = {}
beforeEach(() => {
for (const [mod, name] of [
[users, 'getRawById'], [users, 'validatePassword'], [users, 'enableTotp'], [users, 'disableTotp'], [users, 'setTotpSecret'],
[activity, 'log'], [totp, 'verifyCode'],
[sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'],
[trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'], [trustedDevices, 'revokeByIdForUser'], [trustedDevices, 'revokeAllForUser'],
[recoveryCodes, 'generateForUser'], [recoveryCodes, 'clearForUser'], [recoveryCodes, 'remainingForUser'],
[botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'],
]) {
orig[name] = orig[name] || { mod, val: mod[name] }
}
activity.log = async () => {}
botScore.recordLoginFailure = () => {}
loginProtection.recordFailure = () => {}
sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' })
})
afterEach(() => {
for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val; delete orig[key] }
})
const req = (extra = {}) => ({ body: {}, params: {}, ip: '10.0.0.1', headers: {}, user: { id: 5, username: 'u' }, session: { authMethod: 'local' }, ...extra })
// ── TOTP enable/disable ↔ recovery codes ─────────────────────────────────
test('totpEnable returns a one-time batch of recovery codes', async () => {
users.getRawById = async () => ({ id: 5, username: 'u', totp_secret: 'S', totp_enabled: 0 })
totp.verifyCode = () => true
users.enableTotp = async () => {}
recoveryCodes.generateForUser = async () => ['aaaa-bbbb', 'cccc-dddd']
const res = mockRes()
await ctrl.totpEnable({ ...req(), body: { code: '123456' }, user: { id: 5, username: 'u', totp_enabled: 0 } }, res)
assert.equal(res.body.totp_enabled, true)
assert.deepEqual(res.body.recoveryCodes, ['aaaa-bbbb', 'cccc-dddd'])
})
test('totpDisable clears trusted devices and recovery codes', async () => {
users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
totp.verifyCode = () => true
users.disableTotp = async () => {}
let revokedTrust = false
let clearedCodes = false
trustedDevices.revokeAllForUser = async () => { revokedTrust = true }
recoveryCodes.clearForUser = async () => { clearedCodes = true }
const res = mockRes()
await ctrl.totpDisable({ ...req(), body: { code: '123456' } }, res)
assert.equal(res.body.totp_enabled, false)
assert.equal(revokedTrust, true)
assert.equal(clearedCodes, true)
assert.equal(res.cookies.rg_trust, undefined, 'trust cookie cleared')
})
// ── trust current device ─────────────────────────────────────────────────
test('trustThisDevice sets the web trust cookie under the cap', async () => {
sessionService.trustDeviceCapReached = async () => false
sessionService.mintTrustToken = () => ({ trustToken: 'RAW', trustHash: 'H', deviceHash: null, userAgent: null, expiresAt: new Date() })
let stored = null
trustedDevices.store = async (row) => { stored = row }
const res = mockRes()
await ctrl.trustThisDevice(req({ body: { deviceName: 'Desk' } }), res)
assert.equal(res.body.trusted, true)
assert.equal(res.cookies.rg_trust, 'RAW')
assert.equal(stored.userId, 5)
assert.equal(stored.platform, 'web')
})
test('trustThisDevice returns 409 with the device list at the cap', async () => {
sessionService.trustDeviceCapReached = async () => true
trustedDevices.listActiveForUser = async () => [{ id: 1, platform: 'web', device_name: 'A', created_at: 'c', last_used_at: 'l', expires_at: 'e' }]
const res = mockRes()
await ctrl.trustThisDevice(req(), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.error, 'trusted_device_limit')
assert.equal(res.body.devices.length, 1)
})
test('trustThisDevice on a mobile (bearer) session returns the token in the body, no cookie', async () => {
sessionService.trustDeviceCapReached = async () => false
sessionService.mintTrustToken = () => ({ trustToken: 'RAW', trustHash: 'H', deviceHash: null, userAgent: null, expiresAt: new Date() })
trustedDevices.store = async () => {}
const res = mockRes()
await ctrl.trustThisDevice(req({ session: { authMethod: 'mobile' } }), res)
assert.equal(res.body.trustToken, 'RAW')
assert.equal(res.cookies.rg_trust, undefined, 'native clients get no cookie')
})
// ── self-revoke ownership scoping ─────────────────────────────────────────
test('revokeTrustedDevice is scoped to the callers own id', async () => {
let scoped = null
trustedDevices.revokeByIdForUser = async (id, userId) => { scoped = { id, userId }; return 1 }
const res = mockRes()
await ctrl.revokeTrustedDevice(req({ params: { id: '9' } }), res)
assert.deepEqual(scoped, { id: 9, userId: 5 })
assert.equal(res.body.revoked, true)
})
// ── recovery-code regeneration is a password step-up ──────────────────────
test('generateRecoveryCodes rejects a wrong current password with 400 (no regeneration)', async () => {
users.getRawById = async () => ({ id: 5, totp_enabled: 1, password_hash: 'H' })
users.validatePassword = async () => false
let generated = false
recoveryCodes.generateForUser = async () => { generated = true; return [] }
const res = mockRes()
await ctrl.generateRecoveryCodes(req({ body: { currentPassword: 'wrong' } }), res)
assert.equal(res.statusCode, 400)
assert.equal(generated, false)
})
test('generateRecoveryCodes returns a fresh set when the password checks out', async () => {
users.getRawById = async () => ({ id: 5, totp_enabled: 1, password_hash: 'H' })
users.validatePassword = async () => true
recoveryCodes.generateForUser = async () => ['new1-new1', 'new2-new2']
const res = mockRes()
await ctrl.generateRecoveryCodes(req({ body: { currentPassword: 'right' } }), res)
assert.deepEqual(res.body.recoveryCodes, ['new1-new1', 'new2-new2'])
})
test('generateRecoveryCodes refuses when two-factor is off', async () => {
users.getRawById = async () => ({ id: 5, totp_enabled: 0, password_hash: 'H' })
const res = mockRes()
await ctrl.generateRecoveryCodes(req({ body: { currentPassword: 'right' } }), res)
assert.equal(res.statusCode, 400)
})