Files
Module-Rust/server/test/links.test.js
wtclaude 0876a1d568
All checks were successful
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / client-build (pull_request) Successful in 7m49s
fix(rust): answer refusals in the field core reads, and show the name the game last saw
The two defects the phase 6 browser walk found and #6 described but did not
carry. They were written, walked and left uncommitted; `edge` still has the
shapes the walk condemned.

**Every refusal sentence was invisible.** Core's request primitive reads one
field — `(data && data.message) || res.statusText` — and this module has
answered `{ error: … }` since phase 1. It got away with it because every
failure until phase 6 landed in `ErrorState` on a page whose whole content was
missing, where a generic sentence is honest. A form is different: the sentence
IS the outcome, and the link page showed *Service Unavailable* for all four of
the refusals phase 6 exists to write. All 23 bodies now answer in `message` —
core's `Error` schema, which these routes' own `#swagger.responses` already
referenced, so the annotations stop being a claim the handlers contradict.

`test/errorShape.test.js` drives each outcome rather than grepping for the
field, and asserts the half that is easy to leave behind: a body carrying BOTH
fields renders correctly in a browser and keeps the wrong shape alive for the
next route that copies it.

**The player saw a stale name.** `/player/rust` showed the name recorded at
link time while the admin panel showed the one the game last saw — the same
person labelled two ways on one site, because a Rust name changes on a whim and
only the admin read joined `rust_players`. A LEFT JOIN, because an account can
be linked and never played on.

123 server tests, 39 client tests, `check:imports`, `check:bundle`,
`check:swagger`, `check:externals` — all green.

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

295 lines
11 KiB
JavaScript

// ── Identity: the fleet loop and the refusal ──────────────────────────────
//
// Two things in this file are worth more than the rest, and both are about
// telling answers apart that a naive implementation collapses:
//
// • **A code is minted by ONE server** and the player types six characters into
// a browser. Every server is asked in turn (D24), and "every reachable server
// said no" is NOT the same answer as "a server could not be reached" — the
// second is the case where the player's code is perfectly good and the advice
// "run /link again" is useless, because it sends them back to the server that
// is down.
//
// • **A Steam id another account holds is refused, never moved** (D23). Once
// phase 7 grants permissions against a link and phase 13 hangs entitlements
// off it, a silent move is an account takeover performed by typing six
// characters.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/**
* Installs a ctx whose `db.query` answers from a small script.
*
* `rows` is consulted by the first word of the statement, which is as much SQL as
* these tests should know: the point of each one is the decision the model makes,
* not the shape of a SELECT it delegates.
*/
function withCore({ select = [], onInsert = null } = {}) {
const queries = []
const ctx = fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql, params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') {
const next = Array.isArray(select) ? select.shift() : select
return Promise.resolve(next || [])
}
if (verb === 'INSERT' && onInsert) return onInsert(params)
return Promise.resolve({ affectedRows: 1 })
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, queries }
}
/** A fleet of `n` servers, and a sidecar that answers from a script. */
function fleetOf(replies) {
const servers = require('../model/servers/servers.model')
const sidecar = require('../sidecarClient')
const asked = []
const ids = Object.keys(replies)
servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' }))
sidecar.confirmLink = async (server, code) => {
asked.push({ server: server.id, code })
return replies[server.id]
}
return asked
}
/** The two replies a reachable sidecar can carry, and the one it cannot. */
const linkOk = (steamId, name) => ({ ok: true, status: 'ok', data: { kind: 'link.ok', steamId, name } })
const linkRefused = { ok: true, status: 'ok', data: { kind: 'link.error', reason: 'unknown' } }
const unreachable = { ok: false, status: 'transport-error', data: null }
test('every server is asked until one recognises the code, and the one that answered is recorded', async () => {
const { queries } = withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'b' }]] })
const links = require('../model/links/links.model')
const asked = fleetOf({ a: linkRefused, b: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, true)
assert.equal(result.link.steamId, '7656')
// Both servers were asked, in order, with the same code — and the loop stopped
// at the one that said yes.
assert.deepEqual(asked, [{ server: 'a', code: 'K7M2PQ' }, { server: 'b', code: 'K7M2PQ' }])
// The server that minted it is stored. It is not part of the identity — a link
// is fleet-wide — but it is where a support conversation starts.
const insert = queries.find((q) => q.sql.trim().toUpperCase().startsWith('INSERT'))
assert.deepEqual(insert.params, ['7656', 4, 'Wanderer', 'b'])
})
test('a server after the one that answered is never asked', async () => {
withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
const links = require('../model/links/links.model')
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused })
await links.redeem({ code: 'K7M2PQ', userId: 4 })
// A code is spent on the plugin's FIRST lookup, so carrying on after a yes
// would be asking four other game hosts to look up a secret that has already
// been redeemed.
assert.deepEqual(asked.map((a) => a.server), ['a'])
})
test('a Steam id another account holds is refused, not moved — and the loop stops', async () => {
// The whole of D23 in one assertion. The holder is named because the player is
// signed in and the advice ("sign in as that account, or run /unlink") is
// unusable without it.
withCore({ select: [[{ steamId: '7656', userId: 9, username: 'someone-else' }]] })
const links = require('../model/links/links.model')
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, false)
assert.equal(result.reason, 'taken')
assert.equal(result.username, 'someone-else')
// Asking the rest of the fleet would answer the same question more slowly: the
// verdict is about the Steam id, not about this server.
assert.deepEqual(asked.map((a) => a.server), ['a'])
})
test('a code already redeemed by the SAME user is a success, not an error', async () => {
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]] })
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
// A player who pressed the button twice, or whose confirmation was applied on a
// request that then timed out. Reporting that as a failure would send them to
// run `/link` again for a link they already have.
assert.equal(result.ok, true)
assert.equal(result.already, true)
})
test('"every reachable server refused" is not the same answer as "a server was unreachable"', async () => {
withCore()
const links = require('../model/links/links.model')
fleetOf({ a: linkRefused, b: unreachable })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
// The failure this prevents: a player linked on the server that is down, is
// told their code is wrong, runs `/link` again on that same server, and is told
// the same thing for as long as it stays down.
assert.equal(result.reason, 'unsure')
})
test('a fleet nobody can reach is offline, and a fleet that all refused is a bad code', async () => {
withCore()
let links = require('../model/links/links.model')
fleetOf({ a: unreachable, b: unreachable })
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'offline')
withCore()
links = require('../model/links/links.model')
fleetOf({ a: linkRefused, b: linkRefused })
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'rejected')
})
test('a site with no servers configured says so rather than that the code is wrong', async () => {
withCore()
const links = require('../model/links/links.model')
fleetOf({})
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'no-servers')
})
test('two confirmations of one Steam id race into the primary key, not into a 500', async () => {
// The window the PRIMARY KEY exists for: both requests read "not linked", both
// write. The second insert is refused by the key, and the refusal has to become
// the same sentence the check above produces — otherwise one of two players
// pressing a button at the same moment gets an internal error.
const dup = Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' })
withCore({
select: [[], [{ steamId: '7656', userId: 9, username: 'someone-else' }]],
onInsert: () => Promise.reject(dup),
})
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, false)
assert.equal(result.reason, 'taken')
assert.equal(result.username, 'someone-else')
})
test('the same race, won by the caller, is a success', async () => {
const dup = Object.assign(new Error('duplicate'), { errno: 1062 })
withCore({
select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]],
onInsert: () => Promise.reject(dup),
})
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, true)
assert.equal(result.already, true)
})
test('a link is never shaped with anything a code could be recovered from', async () => {
withCore()
const links = require('../model/links/links.model')
const shaped = links.shape({
steamId: '7656',
userId: 4,
username: 'someone',
name: 'Wanderer',
serverId: 'a',
linkedAt: '2026-09-21T00:00:00Z',
})
// `userId` and `username` are deliberately absent: the caller is the user, and
// a list that carried somebody's website username would be a different fact
// from "you hold this Steam id".
assert.deepEqual(Object.keys(shaped).sort(), ['linkedAt', 'name', 'serverId', 'steamId'])
})
test('an unlink is scoped by user in the statement, not checked before it', async () => {
const { queries } = withCore()
const links = require('../model/links/links.model')
await links.unlinkOwned('7656', 4)
const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
// Read-then-write would leave a gap between the ownership test and the
// deletion; one statement closes it, and the row count is what tells "removed"
// from "was not yours".
assert.ok(del.sql.includes('user_id = ?'))
assert.deepEqual(del.params, ['7656', 4])
})
test('the in-game unlink is scoped by Steam id alone, because that is the authority', async () => {
const { queries } = withCore()
const links = require('../model/links/links.model')
await links.unlinkFromGame('7656')
const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
// Whoever is connected to the game as that Steam account is who it is — a
// stronger proof of ownership than the site can obtain any other way. Scoping
// this by website user would make `/unlink` fail for the one player who needs
// it: the one who linked the wrong account.
assert.ok(!del.sql.includes('user_id'))
assert.deepEqual(del.params, ['7656'])
})
test('a player sees the name the GAME last saw, not the one they linked under', async () => {
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: 'Wanderer', serverId: 'a', linkedAt: 'x' }]] })
const links = require('../model/links/links.model')
const [link] = await links.listForUser(4)
// Found in a browser: staff saw `Wanderer` on the admin panel and the player
// saw `Wanderer-old` on their own page — the same person, labelled two ways on
// one site, because a Rust name changes on a whim and only one of the two reads
// was joining `rust_players`.
assert.equal(link.name, 'Wanderer')
// And the fallback still holds for a link whose account has never played.
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: null, linkedAt: 'x' }]] })
const again = require('../model/links/links.model')
assert.equal((await again.listForUser(4))[0].name, 'Wanderer-old')
})