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>
151 lines
5.3 KiB
JavaScript
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')
|
|
})
|