Public: anyone can read this; the crew can change it.
forked from trevato/cs
The gate the leaderboard trusts is pinned: src/result.ts gets the test file it never had #167
torque/cs-engine:torque/101-the-gate-the-leaderboard-trusts-is-pinne → main 1 commit fork
5 files
changed +506
−10
README.md +8 −2
| @@ -22,6 +22,7 @@ bun run scripts/build.ts # rebuild every static/< | ||
| 22 | 22 | bun run scripts/train.ts --iters 1000 # retrain the bots' policy |
| 23 | 23 | bun run scripts/train.ts --bench # how fast the arena steps |
| 24 | 24 | bun test # the check every pull runs |
| 25 | bun run scripts/mutate-result.ts # break each rule in src/result.ts, see a test go red | |
| 25 | 26 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface |
| 26 | 27 | |
| 27 | 28 | |
| @@ -531,8 +532,13 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 531 | 532 | `src/range/pin.ts` had each written out in full until #110 put one copy behind |
| 532 | 533 | the door; what stays here is the script, the fields and the 64 Hz. |
| 533 | 534 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 534 | schema and its queries, `src/result.ts` validates what a browser may claim, | |
| 535 | `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a | |
| 535 | schema and its queries, `src/result.ts` validates what a browser may claim | |
| 536 | — the scores add up to the rounds, a headshot is a kill, nobody dies more | |
| 537 | often than there were rounds, and the clock is bounded by the rounds it is | |
| 538 | spread over — with every one of those rules pinned in `src/result.test.ts`; | |
| 539 | `bun run scripts/mutate-result.ts` breaks each line of it in turn and names | |
| 540 | the test that catches it, so a rule nothing tests is visible rather than | |
| 541 | assumed. `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a | |
| 536 | 542 | token bucket per caller (the clock is a parameter, so its test does not wait), |
| 537 | 543 | and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but |
| 538 | 544 | `HOST` is also what a shell sets to the machine's name, so only an address — |
scripts/mutate-result.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * A test that stays green when the rule it tests is deleted is not a test. | |
| 3 | * This runs `src/result.test.ts` against a copy of `src/result.ts` with one | |
| 4 | * rule broken at a time and reports which test caught it. Every mutant must | |
| 5 | * die; a survivor is a rule nothing pins. | |
| 6 | * | |
| 7 | * bun run scripts/mutate-result.ts | |
| 8 | * | |
| 9 | * It is not part of `bun test`: it spawns one test run per mutant, which takes | |
| 10 | * seconds rather than milliseconds. Run it when you change `src/result.ts`. | |
| 11 | */ | |
| 12 | ||
| 13 | import { copyFileSync, unlinkSync, writeFileSync } from "node:fs"; | |
| 14 | ||
| 15 | const SOURCE = "src/result.ts"; | |
| 16 | const BACKUP = "src/result.ts.original"; | |
| 17 | ||
| 18 | /** | |
| 19 | * Each mutant: a name, the text to find, and what to put in its place. | |
| 20 | * `redundant` marks a line no test can kill because another rule already | |
| 21 | * refuses everything it would — defence in depth rather than behaviour. Those | |
| 22 | * are expected to survive, and a mutant that stops matching its note (dies | |
| 23 | * when it should survive, or the reverse) fails this run. | |
| 24 | */ | |
| 25 | const MUTANTS: { rule: string; find: string; replace: string; redundant?: string }[] = [ | |
| 26 | { rule: "a body must be an object", find: `if (typeof value !== "object" || value === null) return null;`, replace: "" }, | |
| 27 | { rule: "matchId must be a string", find: `typeof body.matchId === "string" ? body.matchId : ""`, replace: "String(body.matchId ?? \"\")" }, | |
| 28 | { rule: "matchId must match the regex", find: "if (!/^[0-9a-fA-F-]{8,64}$/.test(matchId)) return null;", replace: "" }, | |
| 29 | { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "" }, | |
| 30 | { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false" }, | |
| 31 | { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;" }, | |
| 32 | { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "" }, | |
| 33 | { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max" }, | |
| 34 | { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0" }, | |
| 35 | { rule: "the rounds ceiling", find: "const rounds = integer(body.rounds, MAX_ROUNDS);", replace: "const rounds = integer(body.rounds, Infinity);" }, | |
| 36 | { | |
| 37 | rule: "the score ceilings", find: "const scoreMine = integer(body.scoreMine, MAX_ROUNDS);", | |
| 38 | replace: "const scoreMine = integer(body.scoreMine, Infinity);", | |
| 39 | redundant: "both scores are non-negative and sum to rounds, so neither can pass 30 anyway", | |
| 40 | }, | |
| 41 | { | |
| 42 | rule: "the kills ceiling", find: "const kills = integer(body.kills, MAX_ROUNDS * KILLS_PER_ROUND);", | |
| 43 | replace: "const kills = integer(body.kills, Infinity);", | |
| 44 | redundant: "kills > rounds * 5 refuses everything over 150 already", | |
| 45 | }, | |
| 46 | { | |
| 47 | rule: "the deaths ceiling", find: "const deaths = integer(body.deaths, MAX_ROUNDS);", | |
| 48 | replace: "const deaths = integer(body.deaths, Infinity);", | |
| 49 | redundant: "deaths > rounds refuses everything over 30 already", | |
| 50 | }, | |
| 51 | { | |
| 52 | rule: "the headshots ceiling", find: "const headshots = integer(body.headshots, MAX_ROUNDS * KILLS_PER_ROUND);", | |
| 53 | replace: "const headshots = integer(body.headshots, Infinity);", | |
| 54 | redundant: "headshots > kills refuses everything over 150 already", | |
| 55 | }, | |
| 56 | { rule: "the damage ceiling", find: "const damage = integer(body.damage, MAX_ROUNDS * KILLS_PER_ROUND * 200);", replace: "const damage = integer(body.damage, Infinity);" }, | |
| 57 | { rule: "the seconds ceiling", find: "const seconds = integer(body.seconds, MAX_ROUNDS * SECONDS_PER_ROUND);", replace: "const seconds = integer(body.seconds, Infinity);" }, | |
| 58 | { rule: "a missing number is not a zero", find: "deaths === null || headshots === null || damage === null || seconds === null", replace: "false" }, | |
| 59 | { rule: "the scores are the rounds", find: "if (scoreMine + scoreTheirs !== rounds) return null;", replace: "" }, | |
| 60 | { rule: "headshots are kills", find: "if (headshots > kills) return null;", replace: "" }, | |
| 61 | { rule: "deaths are bounded by rounds", find: "if (deaths > rounds) return null;", replace: "" }, | |
| 62 | { rule: "kills are bounded by the enemies there were", find: "if (kills > rounds * KILLS_PER_ROUND) return null;", replace: "" }, | |
| 63 | { rule: "the clock is bounded by the rounds", find: "if (seconds > (rounds + 1) * SECONDS_PER_ROUND) return null;", replace: "" }, | |
| 64 | { rule: "finished is exactly true", find: "finished: body.finished === true,", replace: "finished: !!body.finished," }, | |
| 65 | { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);` }, | |
| 66 | { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "" }, | |
| 67 | { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)" }, | |
| 68 | { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "" }, | |
| 69 | { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;" }, | |
| 70 | { rule: "a win is finished", find: "result.finished && result.scoreMine > result.scoreTheirs", replace: "result.scoreMine > result.scoreTheirs" }, | |
| 71 | { rule: "a draw is not a win", find: "result.scoreMine > result.scoreTheirs", replace: "result.scoreMine >= result.scoreTheirs" }, | |
| 72 | ]; | |
| 73 | ||
| 74 | const original = await Bun.file(SOURCE).text(); | |
| 75 | copyFileSync(SOURCE, BACKUP); | |
| 76 | let wrong = 0; | |
| 77 | ||
| 78 | try { | |
| 79 | for (const { rule, find, replace, redundant } of MUTANTS) { | |
| 80 | if (!original.includes(find)) { | |
| 81 | console.log(`? ${rule}: no longer in ${SOURCE} — the mutant needs rewriting`); | |
| 82 | wrong++; | |
| 83 | continue; | |
| 84 | } | |
| 85 | writeFileSync(SOURCE, original.replace(find, replace)); | |
| 86 | const run = Bun.spawnSync(["bun", "test", "src/result.test.ts"], { stderr: "pipe", stdout: "pipe" }); | |
| 87 | const log = run.stderr.toString() + run.stdout.toString(); | |
| 88 | const killers = [...log.matchAll(/\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/gm)].map((m) => m[1]?.trim()); | |
| 89 | const died = run.exitCode !== 0; | |
| 90 | if (died) console.log(`killed ${rule}\n by: ${killers.join("\n by: ")}`); | |
| 91 | else console.log(`survived ${rule}${redundant === undefined ? "" : `\n expected: ${redundant}`}`); | |
| 92 | if (died === (redundant !== undefined)) { | |
| 93 | console.log(died | |
| 94 | ? ` UNEXPECTED: a line called redundant is not: a test can tell the difference` | |
| 95 | : ` UNEXPECTED: nothing catches this line being deleted`); | |
| 96 | wrong++; | |
| 97 | } | |
| 98 | } | |
| 99 | } finally { | |
| 100 | copyFileSync(BACKUP, SOURCE); | |
| 101 | unlinkSync(BACKUP); | |
| 102 | } | |
| 103 | ||
| 104 | const expected = MUTANTS.filter((m) => m.redundant !== undefined).length; | |
| 105 | console.log(wrong === 0 | |
| 106 | ? `\nall ${MUTANTS.length - expected} mutants died; the ${expected} noted as redundant survived, as noted` | |
| 107 | : `\n${wrong} of ${MUTANTS.length} mutants did not do what the list says`); | |
| 108 | process.exit(wrong === 0 ? 0 : 1); |
src/result.test.ts added +384
| @@ -0,0 +1,384 @@ | ||
| 1 | /** | |
| 2 | * The gate the leaderboard trusts. Every number on the board is the browser's | |
| 3 | * word, so each rule in `src/result.ts` is pinned here on its own: one test per | |
| 4 | * line that can refuse, written so that deleting the line turns exactly that | |
| 5 | * test red. The ceilings are spelled out as literals rather than imported — | |
| 6 | * a test that reads its expectations out of the module under test agrees with | |
| 7 | * the module whatever the module says. | |
| 8 | * | |
| 9 | * No server, no database, no fetch: this is arithmetic and it runs in | |
| 10 | * milliseconds. | |
| 11 | */ | |
| 12 | ||
| 13 | import { expect, test } from "bun:test"; | |
| 14 | import { cleanName, parseResult, won, type Result } from "./result.ts"; | |
| 15 | ||
| 16 | const MAX_ROUNDS = 30; | |
| 17 | const MAX_KILLS = 150; // five enemies a round, thirty rounds | |
| 18 | const MAX_DAMAGE = 30_000; // 150 kills at 200 hp | |
| 19 | const SECONDS_PER_ROUND = 180; | |
| 20 | const MAX_SECONDS = MAX_ROUNDS * SECONDS_PER_ROUND; // 5400 | |
| 21 | ||
| 22 | /** A body a real match produces: five rounds, 3–2, nothing near a ceiling. */ | |
| 23 | const body = (over: Record<string, unknown> = {}): Record<string, unknown> => ({ | |
| 24 | matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", | |
| 25 | name: "tester", | |
| 26 | rounds: 5, scoreMine: 3, scoreTheirs: 2, | |
| 27 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, | |
| 28 | ...over, | |
| 29 | }); | |
| 30 | ||
| 31 | /** Parse a body that differs from a good one in the named ways. */ | |
| 32 | const parse = (over: Record<string, unknown> = {}): Result | null => parseResult(body(over)); | |
| 33 | ||
| 34 | const FIELDS = [ | |
| 35 | "matchId", "name", "rounds", "scoreMine", "scoreTheirs", | |
| 36 | "kills", "deaths", "headshots", "damage", "seconds", "finished", | |
| 37 | ]; | |
| 38 | ||
| 39 | test("a whole result comes back field for field, and nothing the body invented", () => { | |
| 40 | const parsed = parseResult({ ...body({ name: " spaced " }), extra: "ignored", kills: 9 }); | |
| 41 | expect(parsed).toEqual({ | |
| 42 | matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", | |
| 43 | name: "spaced", | |
| 44 | rounds: 5, scoreMine: 3, scoreTheirs: 2, | |
| 45 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, | |
| 46 | }); | |
| 47 | expect(Object.keys(parsed ?? {}).sort()).toEqual([...FIELDS].sort()); | |
| 48 | }); | |
| 49 | ||
| 50 | test("what is not an object is not a result", () => { | |
| 51 | for (const value of [null, undefined, "no", 12, true, Symbol("s")]) { | |
| 52 | expect(parseResult(value)).toBe(null); | |
| 53 | } | |
| 54 | // An array is an object, so it falls to the next gate rather than this one. | |
| 55 | expect(parseResult([])).toBe(null); | |
| 56 | }); | |
| 57 | ||
| 58 | // --- matchId, against the regex that is the whole of its shape | |
| 59 | ||
| 60 | test("a matchId is 8 to 64 characters of hex and dashes, and nothing else", () => { | |
| 61 | expect(parse({ matchId: "a".repeat(8) })?.matchId).toBe("a".repeat(8)); | |
| 62 | expect(parse({ matchId: "a".repeat(64) })?.matchId).toBe("a".repeat(64)); | |
| 63 | expect(parse({ matchId: "a".repeat(7) })).toBe(null); | |
| 64 | expect(parse({ matchId: "a".repeat(65) })).toBe(null); | |
| 65 | // The id the client actually mints. | |
| 66 | const uuid = crypto.randomUUID(); | |
| 67 | expect(parse({ matchId: uuid })?.matchId).toBe(uuid); | |
| 68 | expect(parse({ matchId: uuid.toUpperCase() })?.matchId).toBe(uuid.toUpperCase()); | |
| 69 | // Characters outside the class, at either end and in the middle. | |
| 70 | for (const id of ["zzzzzzzz", "0123456g", "g0123456", "0123 456", "0123456_", "0123456\n"]) { | |
| 71 | expect(parse({ matchId: id })).toBe(null); | |
| 72 | } | |
| 73 | // The regex is a shape, not a UUID parser: eight dashes are a legal id, and | |
| 74 | // saying so here means nobody tightens it by accident and calls it a fix. | |
| 75 | expect(parse({ matchId: "--------" })?.matchId).toBe("--------"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a matchId that is not a string is refused rather than stringified", () => { | |
| 79 | for (const id of [undefined, null, 12345678, true, ["0f1e2d3c"], { toString: () => "0f1e2d3c" }]) { | |
| 80 | expect(parse({ matchId: id })).toBe(null); | |
| 81 | } | |
| 82 | }); | |
| 83 | ||
| 84 | // --- the number parser, at every edge it has | |
| 85 | ||
| 86 | test("a float is floored and a negative is refused", () => { | |
| 87 | expect(parse({ kills: 9.9 })?.kills).toBe(9); | |
| 88 | expect(parse({ kills: 9.000001 })?.kills).toBe(9); | |
| 89 | expect(parse({ seconds: 419.5 })?.seconds).toBe(419); | |
| 90 | expect(parse({ kills: -1 })).toBe(null); | |
| 91 | expect(parse({ kills: -0.5 })).toBe(null); // floors to -1 | |
| 92 | expect(parse({ damage: -1 })).toBe(null); | |
| 93 | expect(parse({ seconds: -10 })).toBe(null); | |
| 94 | }); | |
| 95 | ||
| 96 | test("negative zero is not a count", () => { | |
| 97 | // It equals zero and signs like a negative; a counted number is never it. | |
| 98 | // Letting it through would put a -0 in the database and in every digest | |
| 99 | // taken of one. | |
| 100 | expect(parse({ kills: -0, headshots: 0 })).toBe(null); | |
| 101 | expect(parse({ damage: -0 })).toBe(null); | |
| 102 | expect(parse({ seconds: -0 })).toBe(null); | |
| 103 | // Plain zero, in the same places, is fine. | |
| 104 | expect(parse({ kills: 0, headshots: 0 })?.kills).toBe(0); | |
| 105 | expect(Object.is(parse({ damage: 0 })?.damage, 0)).toBe(true); | |
| 106 | }); | |
| 107 | ||
| 108 | test("each ceiling accepts its exact maximum and refuses one more", () => { | |
| 109 | // Only three of the eight ceilings can refuse anything on their own — | |
| 110 | // rounds, damage and seconds. The other five sit behind a rule that is | |
| 111 | // already tighter (the scores sum to rounds, headshots are kills, deaths are | |
| 112 | // rounds, kills are five a round), so raising them changes no answer. | |
| 113 | // `bun run scripts/mutate-result.ts` proves that rather than assuming it: | |
| 114 | // those are the only lines it expects to survive being broken, and it says | |
| 115 | // which rule covers each. They stay as defence in depth; the maxima below | |
| 116 | // hold either way. | |
| 117 | // | |
| 118 | // rounds: the scores are kept equal to it so only the ceiling can refuse. | |
| 119 | expect(parse({ rounds: MAX_ROUNDS, scoreMine: 15, scoreTheirs: 15 })?.rounds).toBe(MAX_ROUNDS); | |
| 120 | expect(parse({ rounds: MAX_ROUNDS + 1, scoreMine: 16, scoreTheirs: 15 })).toBe(null); | |
| 121 | // A single score: thirty of them is a whole match won, and thirty-one is | |
| 122 | // over the ceiling whatever the other score says. | |
| 123 | expect(parse({ rounds: MAX_ROUNDS, scoreMine: MAX_ROUNDS, scoreTheirs: 0, deaths: 4 })?.scoreMine) | |
| 124 | .toBe(MAX_ROUNDS); | |
| 125 | expect(parse({ rounds: MAX_ROUNDS, scoreMine: MAX_ROUNDS + 1, scoreTheirs: 0 })).toBe(null); | |
| 126 | expect(parse({ rounds: MAX_ROUNDS, scoreMine: 0, scoreTheirs: MAX_ROUNDS + 1 })).toBe(null); | |
| 127 | // kills and headshots, at the only round count that can hold them | |
| 128 | const full = { rounds: MAX_ROUNDS, scoreMine: 15, scoreTheirs: 15 }; | |
| 129 | expect(parse({ ...full, kills: MAX_KILLS, headshots: MAX_KILLS })?.kills).toBe(MAX_KILLS); | |
| 130 | expect(parse({ ...full, kills: MAX_KILLS + 1 })).toBe(null); | |
| 131 | expect(parse({ ...full, kills: MAX_KILLS, headshots: MAX_KILLS + 1 })).toBe(null); | |
| 132 | // deaths | |
| 133 | expect(parse({ ...full, deaths: MAX_ROUNDS })?.deaths).toBe(MAX_ROUNDS); | |
| 134 | expect(parse({ ...full, deaths: MAX_ROUNDS + 1 })).toBe(null); | |
| 135 | // damage, which no other rule bounds | |
| 136 | expect(parse({ damage: MAX_DAMAGE })?.damage).toBe(MAX_DAMAGE); | |
| 137 | expect(parse({ damage: MAX_DAMAGE + 1 })).toBe(null); | |
| 138 | // seconds: at thirty rounds the clock rule would allow 5580, so 5401 can | |
| 139 | // only be the ceiling refusing it. | |
| 140 | expect(parse({ ...full, seconds: MAX_SECONDS })?.seconds).toBe(MAX_SECONDS); | |
| 141 | expect(parse({ ...full, seconds: MAX_SECONDS + 1 })).toBe(null); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a number that is not a finite number is refused, and nothing is coerced into one", () => { | |
| 145 | const notNumbers: unknown[] = [ | |
| 146 | NaN, Infinity, -Infinity, "12", "", " ", "1e3", null, undefined, true, false, | |
| 147 | { valueOf: () => 12 }, [12], [], 12n, Symbol("12"), new Date(0), () => 12, | |
| 148 | ]; | |
| 149 | for (const value of notNumbers) { | |
| 150 | for (const field of ["rounds", "scoreMine", "scoreTheirs", "kills", "deaths", "headshots", "damage", "seconds"]) { | |
| 151 | expect(parse({ [field]: value })).toBe(null); | |
| 152 | } | |
| 153 | } | |
| 154 | // A missing field is a missing number, not a zero. | |
| 155 | const { kills: _dropped, ...without } = body(); | |
| 156 | expect(parseResult(without)).toBe(null); | |
| 157 | }); | |
| 158 | ||
| 159 | // --- the four rules the file exists for, one test each | |
| 160 | ||
| 161 | test("the two scores are exactly the rounds played", () => { | |
| 162 | expect(parse({ rounds: 5, scoreMine: 3, scoreTheirs: 2 })?.rounds).toBe(5); | |
| 163 | expect(parse({ rounds: 5, scoreMine: 4, scoreTheirs: 4 })).toBe(null); | |
| 164 | expect(parse({ rounds: 5, scoreMine: 2, scoreTheirs: 2 })).toBe(null); | |
| 165 | expect(parse({ rounds: 0, scoreMine: 0, scoreTheirs: 0, kills: 0, deaths: 0, headshots: 0, seconds: 30 })?.rounds) | |
| 166 | .toBe(0); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a headshot is a kill, so there are never more of them", () => { | |
| 170 | expect(parse({ kills: 9, headshots: 9 })?.headshots).toBe(9); | |
| 171 | expect(parse({ kills: 9, headshots: 10 })).toBe(null); | |
| 172 | expect(parse({ kills: 3, headshots: 4 })).toBe(null); | |
| 173 | expect(parse({ kills: 0, headshots: 1 })).toBe(null); | |
| 174 | }); | |
| 175 | ||
| 176 | test("nobody dies more often than there were rounds", () => { | |
| 177 | expect(parse({ rounds: 5, deaths: 5 })?.deaths).toBe(5); | |
| 178 | expect(parse({ rounds: 5, deaths: 6 })).toBe(null); | |
| 179 | expect(parse({ rounds: 0, scoreMine: 0, scoreTheirs: 0, kills: 0, headshots: 0, deaths: 1 })).toBe(null); | |
| 180 | }); | |
| 181 | ||
| 182 | test("five enemies a round is all there are", () => { | |
| 183 | expect(parse({ rounds: 5, kills: 25, headshots: 3 })?.kills).toBe(25); | |
| 184 | expect(parse({ rounds: 5, kills: 26, headshots: 3 })).toBe(null); | |
| 185 | expect(parse({ rounds: 1, scoreMine: 1, scoreTheirs: 0, deaths: 1, kills: 6, headshots: 0 })).toBe(null); | |
| 186 | }); | |
| 187 | ||
| 188 | test("the clock is bounded by the rounds it is spread over, plus the one being played", () => { | |
| 189 | // Five rounds hold six rounds' worth of clock: the round in progress is not | |
| 190 | // scored yet, so one round is always spare. | |
| 191 | expect(parse({ rounds: 5, seconds: 6 * SECONDS_PER_ROUND })?.seconds).toBe(1080); | |
| 192 | expect(parse({ rounds: 5, seconds: 6 * SECONDS_PER_ROUND + 1 })).toBe(null); | |
| 193 | // The claim this rule exists to refuse: a three-round match with two hours | |
| 194 | // on it, sitting at the top of anything sorted by time. | |
| 195 | expect(parse({ rounds: 1, scoreMine: 1, scoreTheirs: 0, kills: 2, deaths: 1, headshots: 0, seconds: 7200 })) | |
| 196 | .toBe(null); | |
| 197 | expect(parse({ rounds: 0, scoreMine: 0, scoreTheirs: 0, kills: 0, deaths: 0, headshots: 0, seconds: 181 })) | |
| 198 | .toBe(null); | |
| 199 | }); | |
| 200 | ||
| 201 | test("finished is the word `true` and nothing that merely looks like it", () => { | |
| 202 | expect(parse({ finished: true })?.finished).toBe(true); | |
| 203 | for (const value of ["true", 1, "yes", {}, [], undefined, null, 0, false]) { | |
| 204 | expect(parse({ finished: value })?.finished).toBe(false); | |
| 205 | } | |
| 206 | }); | |
| 207 | ||
| 208 | // --- names | |
| 209 | ||
| 210 | test("a name is one line of printable text, trimmed, and at most 24 characters", () => { | |
| 211 | expect(cleanName("vesper")).toBe("vesper"); | |
| 212 | expect(cleanName(" vesper ")).toBe("vesper"); | |
| 213 | expect(cleanName("x".repeat(40))).toBe("x".repeat(24)); | |
| 214 | expect(cleanName("x".repeat(24)).length).toBe(24); | |
| 215 | }); | |
| 216 | ||
| 217 | test("control characters become spaces rather than disappearing or staying", () => { | |
| 218 | expect(cleanName("a\u0000b")).toBe("a b"); | |
| 219 | expect(cleanName("one\ntwo")).toBe("one two"); | |
| 220 | expect(cleanName("tab\there")).toBe("tab here"); | |
| 221 | expect(cleanName("del\u007fete")).toBe("del ete"); | |
| 222 | expect(cleanName("\u001bsgr")).toBe("sgr"); // an escape at the edge is trimmed away | |
| 223 | // A name made only of control characters is a name made only of spaces. | |
| 224 | expect(cleanName("\u0000\u0001\u001f")).toBe("anon"); | |
| 225 | }); | |
| 226 | ||
| 227 | test("an empty name, a blank one, or one that is not a string is anon", () => { | |
| 228 | expect(cleanName("")).toBe("anon"); | |
| 229 | expect(cleanName(" ")).toBe("anon"); | |
| 230 | expect(cleanName("\t\n")).toBe("anon"); | |
| 231 | for (const value of [undefined, null, 42, true, {}, [], ["a"], { toString: () => "x" }, Symbol("s")]) { | |
| 232 | expect(cleanName(value)).toBe("anon"); | |
| 233 | } | |
| 234 | // And through the parser, where a name is never a reason to refuse a match. | |
| 235 | expect(parse({ name: 42 })?.name).toBe("anon"); | |
| 236 | expect(parse({ name: " vesper " })?.name).toBe("vesper"); | |
| 237 | }); | |
| 238 | ||
| 239 | test("the cut is by code unit, so an emoji at the boundary is cut in half", () => { | |
| 240 | // This is what the implementation does, said out loud rather than left to be | |
| 241 | // discovered: `slice(0, 24)` counts UTF-16 code units, so a surrogate pair | |
| 242 | // straddling 24 loses its tail. The half that survives is a lone surrogate. | |
| 243 | const cut = cleanName("x".repeat(23) + "\u{1F600}"); | |
| 244 | expect(cut.length).toBe(24); | |
| 245 | expect(cut).toBe("x".repeat(23) + "\ud83d"); | |
| 246 | // It is safe downstream: JSON.stringify escapes a lone surrogate rather than | |
| 247 | // emitting invalid UTF-8, and the page escapes every name it prints. | |
| 248 | expect(JSON.stringify(cut)).toBe(`"${"x".repeat(23)}\\ud83d"`); | |
| 249 | // An emoji that fits is whole, and one just inside the cut survives. | |
| 250 | expect(cleanName("\u{1F600} vesper")).toBe("\u{1F600} vesper"); | |
| 251 | expect(cleanName("x".repeat(22) + "\u{1F600}")).toBe("x".repeat(22) + "\u{1F600}"); | |
| 252 | // A combining mark past the cut takes only itself: the base letter stays. | |
| 253 | // Both "é" here are written decomposed — "e" and U+0301 combining acute — | |
| 254 | // so the mark is the 25th code unit in the first and the 24th in the second. | |
| 255 | expect(cleanName("y".repeat(23) + "é")).toBe("y".repeat(23) + "e"); | |
| 256 | expect(cleanName("y".repeat(22) + "é")).toBe("y".repeat(22) + "é"); | |
| 257 | // The cut happens after the trim, so a name can end in a space — the first | |
| 258 | // character is never one. | |
| 259 | expect(cleanName("x".repeat(23) + " y")).toBe("x".repeat(23) + " "); | |
| 260 | }); | |
| 261 | ||
| 262 | // --- won | |
| 263 | ||
| 264 | test("a win is finished and ahead, and the other three ways are not", () => { | |
| 265 | const of = (finished: boolean, scoreMine: number, scoreTheirs: number): Result | null => | |
| 266 | parse({ finished, scoreMine, scoreTheirs, rounds: scoreMine + scoreTheirs, deaths: 0, kills: 0, headshots: 0 }); | |
| 267 | const ahead = of(true, 16, 14); | |
| 268 | const behind = of(true, 14, 16); | |
| 269 | expect(won(ahead as Result)).toBe(true); | |
| 270 | expect(won(behind as Result)).toBe(false); | |
| 271 | expect(won(of(false, 16, 14) as Result)).toBe(false); | |
| 272 | expect(won(of(false, 14, 16) as Result)).toBe(false); | |
| 273 | // A draw is not a win, finished or not. | |
| 274 | expect(won(of(true, 15, 15) as Result)).toBe(false); | |
| 275 | expect(won(of(false, 15, 15) as Result)).toBe(false); | |
| 276 | }); | |
| 277 | ||
| 278 | // --- a deterministic fuzz | |
| 279 | // | |
| 280 | // A seeded xorshift32, twelve lines of it, so the same bodies are tried on | |
| 281 | // every machine and a failure can be replayed from its seed. The engine has a | |
| 282 | // generator of its own; the server does not import the engine and neither does | |
| 283 | // this test. | |
| 284 | ||
| 285 | const rng = (seed: number): (() => number) => { | |
| 286 | let x = seed >>> 0 || 0x9e3779b9; | |
| 287 | return () => { | |
| 288 | x ^= x << 13; x >>>= 0; | |
| 289 | x ^= x >>> 17; | |
| 290 | x ^= x << 5; x >>>= 0; | |
| 291 | return x / 0x1_0000_0000; | |
| 292 | }; | |
| 293 | }; | |
| 294 | ||
| 295 | /** | |
| 296 | * Every invariant the board is allowed to believe, checked against the result | |
| 297 | * alone — not against the code that made it. Returns the first rule broken. | |
| 298 | */ | |
| 299 | function violation(r: Result): string | null { | |
| 300 | const counts: [string, number, number][] = [ | |
| 301 | ["rounds", r.rounds, MAX_ROUNDS], | |
| 302 | ["scoreMine", r.scoreMine, MAX_ROUNDS], | |
| 303 | ["scoreTheirs", r.scoreTheirs, MAX_ROUNDS], | |
| 304 | ["kills", r.kills, MAX_KILLS], | |
| 305 | ["deaths", r.deaths, MAX_ROUNDS], | |
| 306 | ["headshots", r.headshots, MAX_KILLS], | |
| 307 | ["damage", r.damage, MAX_DAMAGE], | |
| 308 | ["seconds", r.seconds, MAX_SECONDS], | |
| 309 | ]; | |
| 310 | for (const [field, value, max] of counts) { | |
| 311 | if (!Number.isInteger(value)) return `${field} is not an integer: ${String(value)}`; | |
| 312 | if (value < 0 || Object.is(value, -0)) return `${field} is negative: ${String(value)}`; | |
| 313 | if (value > max) return `${field} is over ${max}: ${value}`; | |
| 314 | } | |
| 315 | if (r.scoreMine + r.scoreTheirs !== r.rounds) return "the scores are not the rounds"; | |
| 316 | if (r.headshots > r.kills) return "more headshots than kills"; | |
| 317 | if (r.deaths > r.rounds) return "more deaths than rounds"; | |
| 318 | if (r.kills > r.rounds * 5) return "more kills than there were enemies"; | |
| 319 | if (r.seconds > (r.rounds + 1) * SECONDS_PER_ROUND) return "more clock than rounds to hold it"; | |
| 320 | if (typeof r.matchId !== "string" || !/^[0-9a-fA-F-]{8,64}$/.test(r.matchId)) return "matchId is not an id"; | |
| 321 | if (typeof r.name !== "string") return "name is not a string"; | |
| 322 | if (r.name.length === 0 || r.name.length > 24) return `name is ${r.name.length} characters`; | |
| 323 | if (/[\u0000-\u001f\u007f]/.test(r.name)) return "name carries a control character"; | |
| 324 | if (/^\s/.test(r.name)) return "name starts with whitespace"; | |
| 325 | if (typeof r.finished !== "boolean") return "finished is not a boolean"; | |
| 326 | if (Object.keys(r).length !== FIELDS.length) return `result has ${Object.keys(r).length} fields`; | |
| 327 | return null; | |
| 328 | } | |
| 329 | ||
| 330 | test("a few thousand mutated bodies: never a throw, and never a number the board cannot believe", () => { | |
| 331 | const random = rng(20260920); | |
| 332 | const pick = <T,>(items: T[]): T => items[Math.floor(random() * items.length)] as T; | |
| 333 | // Values a body might carry: plausible counts, edges, and things that are | |
| 334 | // not numbers at all. JSON.parse can produce most of them; the ones it | |
| 335 | // cannot are here because `parseResult` is a function anyone may call. | |
| 336 | const values: unknown[] = [ | |
| 337 | 0, -0, 1, -1, 2, 3, 5, 6, 15, 16, 24, 25, 26, 29, 30, 31, 149, 150, 151, | |
| 338 | 180, 420, 1080, 1081, 5400, 5401, 7200, 30_000, 30_001, 2 ** 31, Number.MAX_SAFE_INTEGER, | |
| 339 | 0.5, 4.9, -0.5, 29.999, NaN, Infinity, -Infinity, | |
| 340 | "", " ", "5", "anon", " vesper ", "x".repeat(200), "a\u0000b", "\u{1F600}".repeat(20), | |
| 341 | true, false, null, undefined, {}, [], [5], { valueOf: () => 5 }, 5n, | |
| 342 | "0f1e2d3c", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", "z".repeat(16), "-".repeat(64), "a".repeat(65), | |
| 343 | ]; | |
| 344 | const keys = [...FIELDS, "extra", "__proto__x", "toString"]; | |
| 345 | ||
| 346 | let parsed = 0; | |
| 347 | for (let i = 0; i < 4000; i++) { | |
| 348 | const mutant = body(); | |
| 349 | const edits = 1 + Math.floor(random() * 4); | |
| 350 | for (let e = 0; e < edits; e++) { | |
| 351 | const key = pick(keys); | |
| 352 | if (random() < 0.15) delete mutant[key]; | |
| 353 | else mutant[key] = pick(values); | |
| 354 | } | |
| 355 | let result: Result | null; | |
| 356 | try { | |
| 357 | result = parseResult(mutant); | |
| 358 | } catch (error) { | |
| 359 | throw new Error(`parseResult threw on ${JSON.stringify(mutant)}: ${String(error)}`); | |
| 360 | } | |
| 361 | if (result === null) continue; | |
| 362 | parsed++; | |
| 363 | const broken = violation(result); | |
| 364 | if (broken !== null) { | |
| 365 | throw new Error(`${broken} — from ${JSON.stringify(mutant)} came ${JSON.stringify(result)}`); | |
| 366 | } | |
| 367 | } | |
| 368 | // The fuzz is only worth its seconds if it gets past the gate sometimes: a | |
| 369 | // mutation that refused everything would pass this test without testing it. | |
| 370 | expect(parsed).toBeGreaterThan(200); | |
| 371 | }); | |
| 372 | ||
| 373 | test("the bodies the client actually sends are accepted, round by round", () => { | |
| 374 | // src/client/main.ts posts after every round scored, including round zero. | |
| 375 | for (let round = 0; round <= MAX_ROUNDS; round++) { | |
| 376 | const mine = Math.ceil(round / 2); | |
| 377 | const result = parse({ | |
| 378 | rounds: round, scoreMine: mine, scoreTheirs: round - mine, | |
| 379 | kills: round * 2, deaths: Math.floor(round / 2), headshots: round, | |
| 380 | damage: round * 300, seconds: round * 115 + 12, finished: round === MAX_ROUNDS, | |
| 381 | }); | |
| 382 | expect(violation(result as Result)).toBe(null); | |
| 383 | } | |
| 384 | }); |
src/result.ts +3
| @@ -33,6 +33,9 @@ const SECONDS_PER_ROUND = 180; | ||
| 33 | 33 | const integer = (value: unknown, max: number): number | null => { |
| 34 | 34 | if (typeof value !== "number" || !Number.isFinite(value)) return null; |
| 35 | 35 | const n = Math.floor(value); |
| 36 | // `-0 < 0` is false, so negative zero would otherwise pass as a count and be | |
| 37 | // stored, sorted and digested as a number that is not quite zero. | |
| 38 | if (Object.is(n, -0)) return null; | |
| 36 | 39 | return n < 0 || n > max ? null : n; |
| 37 | 40 | }; |
| 38 | 41 |
src/server.test.ts +3 −8
| @@ -5,7 +5,6 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { bundle, STATIC_DIR } from "../scripts/build.ts"; |
| 6 | 6 | import { GAMES } from "./games.ts"; |
| 7 | 7 | import { escape } from "./pages/shell.ts"; |
| 8 | import { parseResult } from "./result.ts"; | |
| 9 | 8 | import { sign } from "./who.ts"; |
| 10 | 9 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| 11 | 10 | import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; |
| @@ -447,13 +446,9 @@ test("the script is served immutable and cached for a year", async () => { | ||
| 447 | 446 | expect(response.headers.get("cache-control")).toContain("immutable"); |
| 448 | 447 | }); |
| 449 | 448 | |
| 450 | test("a result parses to exactly what was sent, and nothing else", () => { | |
| 451 | const parsed = parseResult({ ...result({ name: " spaced " }), extra: "ignored" }); | |
| 452 | expect(parsed?.name).toBe("spaced"); | |
| 453 | expect(Object.keys(parsed ?? {})).not.toContain("extra"); | |
| 454 | expect(parseResult(null)).toBe(null); | |
| 455 | expect(parseResult("no")).toBe(null); | |
| 456 | }); | |
| 449 | // The parser itself is pinned rule by rule in src/result.test.ts, which is a | |
| 450 | // unit test and runs in milliseconds. What is left here is the end-to-end: a | |
| 451 | // POST that reaches it, and the status a refusal comes back as. | |
| 457 | 452 | |
| 458 | 453 | // Last in the file on purpose: it spends the bucket every guest on this |
| 459 | 454 | // address shares, so any guest POST after it would be answered with a 429. |
new above
offline