Files
website/server/test/mobileDeviceSessions.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

66 lines
2.6 KiB
JavaScript

// Active Devices (mobile session view/revoke) — controller tests. The
// mobileSessions model is stubbed, so these are DB-free.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const account = require('../src/router/v1/auth/account.controller')
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
after(() => db.close())
let logged
beforeEach(() => {
logged = []
activity.log = async (e) => { logged.push(e) }
})
function res() {
return {
statusCode: 200, body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
}
}
test('listSessions returns active devices without any token material', async () => {
mobileSessions.listActiveForUser = async (uid) => {
assert.equal(uid, 7)
return [
{ id: 3, device_name: 'Pixel 8', user_agent: 'okhttp', created_at: 'c3', last_used_at: 'u3', expires_at: 'e3', token_hash: 'SECRET' },
{ id: 1, device_name: null, user_agent: null, created_at: 'c1', last_used_at: null, expires_at: 'e1' },
]
}
const r = res()
await account.listSessions({ user: { id: 7 } }, r)
assert.equal(r.body.length, 2)
assert.deepEqual(r.body[0], { id: 3, deviceName: 'Pixel 8', userAgent: 'okhttp', createdAt: 'c3', lastUsedAt: 'u3', expiresAt: 'e3' })
// last_used_at falls back to created_at when null; no token/hash leaks.
assert.equal(r.body[1].lastUsedAt, 'c1')
assert.equal(JSON.stringify(r.body).includes('SECRET'), false)
})
test('revokeSession is ownership-scoped and audits a real revoke', async () => {
let args = null
mobileSessions.revokeByIdForUser = async (id, uid) => { args = [id, uid]; return 1 }
const r = res()
await account.revokeSession({ user: { id: 7 }, params: { id: '3' } }, r)
assert.deepEqual(args, [3, 7], 'revokes by id scoped to the caller')
assert.deepEqual(r.body, { revoked: true })
assert.equal(logged.at(-1).action, 'auth.mobile.session.revoke')
})
test('revokeSession on a non-owned/absent id is idempotent and not audited', async () => {
mobileSessions.revokeByIdForUser = async () => 0
const r = res()
await account.revokeSession({ user: { id: 7 }, params: { id: '999' } }, r)
assert.deepEqual(r.body, { revoked: false })
assert.equal(logged.length, 0, 'nothing revoked → nothing logged')
})