Files
Module-Rust/server/test/identityRoutes.test.js
wtclaude 383e89442e feat(rust): what the site has given a player, as the player reads it
Phase 8's website half. Phase 7 made the site the author of in-game
privilege and gave an operator every view of it; this is the other side,
and it is the first time a player can see what they hold without asking
one.

`GET /player/rust/permissions` is self-scoped in SQL and read-only by
construction — a grant a player could change would not be a grant. Three
things make it a different shape from the admin read rather than a
filtered one:

  * the scope arithmetic is answered on the server. A client handed `*`
    would have to know what the fleet is to say anything, and then
    `inScope` exists twice. Each entry carries the servers it reaches,
    already resolved and already marked.
  * `live` is the pushed ledger, never the authored row. A grant is not a
    privilege in a game until a sync confirmed it, and phase 7 is careful
    never to record a push that silently did nothing — so "waiting" is
    honest, and the alternative is the site claiming to have given
    something it has not.
  * nothing says WHY it is waiting. An offline server, a permission no
    loaded plugin registered and a store that has never seen the account
    all look the same from here; telling them apart is an operator's
    diagnosis and an inventory of what is installed.

An entitlement that reaches nobody still lists, and the page says so —
authored against the website account, it exists before a Steam id does,
and hiding it until one turns up is the defect the admin user page
shipped in phase 7 (PLAN.md §20.5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:09:51 -05:00

98 lines
3.9 KiB
JavaScript
Raw 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.

// ── The shape of the identity surface ─────────────────────────────────────
//
// Three properties that are invisible in review and expensive in production:
//
// • **the link route is rate-limited** (R1). Six characters from a 32-glyph
// alphabet is a good code only while a guesser is made to pay per attempt,
// and once phase 7 grants permissions against a link, guessing one is a
// privilege-escalation path rather than a nuisance.
// • **the extension router merges its parent's params**. Without
// `mergeParams`, `req.params.id` is `undefined` and every statement in that
// panel silently scopes to no user — a panel that reads as "this user has no
// Rust account" for everybody.
// • **the extension's paths keep the module's own segment.** Core owns
// `/admin/users/:id`; a bare `/links` would be this module claiming a word on
// a URL it does not own, and the next module to fill a slot would collide.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, fakeApi } = require('./_fakes')
function register(ctx = fakeCtx()) {
require('../core')._reset()
const api = fakeApi()
require('../index')(ctx, api)
return api
}
/** `[{ method, path, handlers }]` for one express router. */
function routesOf(router) {
return router.stack
.filter((layer) => layer.route)
.map((layer) => ({
path: layer.route.path,
method: Object.keys(layer.route.methods)[0].toUpperCase(),
handlers: layer.route.stack.map((s) => s.handle),
}))
}
test('the player tier serves the identity routes, the entitlement read, and nothing else', () => {
const api = register()
const routes = routesOf(api.record.routes.player['/rust'])
assert.deepEqual(
routes.map((r) => `${r.method} ${r.path}`).sort(),
[
'DELETE /links/:steamId',
'GET /links',
// Phase 8: what the site has given the caller in game. Read-only on this
// tier by construction — the authoring routes are all admin.
'GET /permissions',
'GET /servers',
'POST /link',
],
)
})
test('redeeming a code is rate-limited, and by a limiter of its own', () => {
const api = register()
const post = routesOf(api.record.routes.player['/rust']).find((r) => r.method === 'POST')
// The fake's `rateLimit` hands back a pass-through carrying the options it was
// given, so the policy itself is assertable — a limiter that was quietly
// removed, or one built with core's `accountChangeLimiter` shared counter,
// both fail here.
const limiter = post.handlers.find((h) => h.options && h.options.label === 'rust-link-code')
assert.ok(limiter, 'POST /link must carry its own rate limiter (R1)')
assert.equal(limiter.options.max, 10)
assert.equal(limiter.options.windowMs, 15 * 60 * 1000)
// First in the chain: a limiter behind the validator would let an attacker
// spend the cheap half of the request unbounded.
assert.equal(post.handlers[0], limiter)
})
test('the admin.users.detail router merges the parents params and keeps its own segment', () => {
const api = register()
const slot = api.record.extensions.find((e) => e.slot === 'admin.users.detail')
assert.ok(slot, 'the server half of admin.users.detail must be registered')
assert.equal(slot.router.mergeParams, true)
const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort()
assert.deepEqual(paths, [
'DELETE /rust/links/:steamId',
// Phase 7 filled the same panel with what this person may do in game.
'DELETE /rust/permissions/grants/:grantId',
'GET /rust/links',
'GET /rust/permissions',
'POST /rust/permissions/grants',
])
for (const route of routesOf(slot.router)) {
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)
}
})