Files
website/server/test/shardControllerPublic.test.js
wtclaude 35e5269ec5 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>
2026-07-21 00:33:01 -05:00

151 lines
5.3 KiB
JavaScript

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