Files
two-keys/server/test/sanitize.test.ts
T
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:12 +02:00

195 lines
8.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Role } from '@two-keys/shared';
import type { Clue, Lock, StoryGame } from '../src/content/schemas';
import { continueDefuse, defuseAction, solveButton, type DefuseState } from '../src/engines/defuse';
import type { LabyrinthState } from '../src/engines/labyrinth';
import { continueChapter, requestHint, submitLock, type StoryState } from '../src/engines/story';
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, defuse, games, labyrinth, playingRoom, storyGames, sync } from './helpers';
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'failEnding', 'successEnding'];
function payload(room: Room, role: Role, now = 2_000_000): string {
const player = room.players.find((p) => p.seat === role)!;
return JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now));
}
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (!Array.isArray(value)) out.add(k);
keysOf(v, out);
}
}
return out;
}
const locksOf = (game: StoryGame): Lock[] => [
...game.chapters.map((c) => c.lock),
...(game.finale.type === 'escape' ? [game.finale.lock] : []),
];
const cluesFor = (game: StoryGame, role: Role): Clue[] => [
...game.chapters.flatMap((c) => c.clues[role]),
...(game.finale.type === 'escape' ? game.finale.clues[role] : []),
];
describe.each(storyGames.map((g) => [g.id, g] as const))('sanitized story payloads: %s', (_id, game) => {
// Walk the whole game, checking both players' payloads at every step.
it('never contain answers, unrevealed hints, unearned text, or the other role\'s clues', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as StoryState;
const check = () => {
for (const role of ['A', 'B'] as const) {
const json = payload(room, role);
const other: Role = role === 'A' ? 'B' : 'A';
for (const key of keysOf(JSON.parse(json))) expect(FORBIDDEN_KEYS, `${role} got key "${key}"`).not.toContain(key);
// The other role's private clues never arrive, in any chapter.
const prefix = (c: Clue) => JSON.stringify(c.body).slice(1, 60);
const mine = cluesFor(game, role).map(prefix);
for (const clue of cluesFor(game, other)) {
expect(json, `${role} can see ${other}'s clue ${clue.id}`).not.toContain(JSON.stringify(clue.title));
// (Station Echo hands the same Morse chart to each player in turn.)
if (!mine.includes(prefix(clue))) expect(json).not.toContain(prefix(clue));
}
for (const lock of locksOf(game)) {
const revealed = s.hints[lock.id] ?? 0;
lock.hints.forEach((hint, i) => {
if (i >= revealed) expect(json, `${role} got unrevealed hint ${i + 1} of ${lock.id}`).not.toContain(JSON.stringify(hint));
});
if (!s.solved.includes(lock.id) && lock.successText) {
expect(json, `${role} got successText of unsolved ${lock.id}`).not.toContain(JSON.stringify(lock.successText));
}
// Lock objects carry no answer list at all. (Answer *words* may legitimately
// appear inside a player's own clues, so those can't be grepped for.)
const sent = (JSON.parse(json).views[role].chapters as { lock: object }[]).map((c) => c.lock);
for (const l of sent) expect(Object.keys(l)).not.toContain('answers');
}
// Signal patterns only ever ride on signal clues.
const view = JSON.parse(json).views[role];
const clues = [...view.chapters.flatMap((c: { clues: Clue[] }) => c.clues), ...(view.finale?.clues ?? [])];
for (const clue of clues) if (clue.type !== 'signal') expect(clue.signal).toBeUndefined();
if (room.phase !== 'finished') {
expect(json).not.toContain(JSON.stringify(game.finale.successEnding));
if (game.finale.type === 'accusation') expect(json).not.toContain(JSON.stringify(game.finale.reveal));
}
// Future chapters are not sent ahead of time.
expect(view.chapters.length).toBeLessThanOrEqual(Math.min(s.chapter, game.chapters.length - 1) + 1);
}
};
check();
for (const ch of game.chapters) {
requestHint(game, t.ctx(), ch.lock.id);
check();
submitLock(game, t.ctx(), ch.lock.id, 'wrong');
check();
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
check();
continueChapter(game, t.ctx());
check();
}
});
it('sends each player only their own and shared clues', () => {
const room = playingRoom(game);
const view = JSON.parse(payload(room, 'A')).views.A;
const ids = view.chapters[0].clues.map((c: Clue) => c.id);
const ch = game.chapters[0];
expect(ids).toEqual([...ch.clues.A, ...ch.clues.shared].map((c) => c.id));
expect(JSON.parse(payload(room, 'A')).views.B).toBeUndefined();
});
});
describe('sanitized payloads: other engines', () => {
it('defuse: the Technician never gets the manual, the Expert never gets the device', () => {
const game = defuse();
const room = playingRoom(game);
const t = clock(room);
continueDefuse(game, t.ctx());
const s = room.engine as DefuseState;
const tech = payload(room, 'A', t.now);
const expert = payload(room, 'B', t.now);
expect(tech).not.toContain('Bomb Defusal Manual');
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
expect(tech).toContain(s.device!.serial);
expect(expert).toContain('Bomb Defusal Manual');
expect(expert).not.toContain(s.device!.serial);
for (const key of ['device', 'serial', 'batteries', 'wires', 'modules', 'strip', 'seed']) {
expect(keysOf(JSON.parse(expert)), `expert got "${key}"`).not.toContain(key);
}
for (const json of [tech, expert]) {
for (const key of FORBIDDEN_KEYS) expect(keysOf(JSON.parse(json))).not.toContain(key);
expect(keysOf(JSON.parse(json))).not.toContain('seed');
}
});
it('defuse: the strip colour is only sent while the button is held', () => {
const game = defuse();
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 1;
// Find a seed whose button has to be held.
for (s.seed = 1; ; s.seed++) {
s.status = 'ready';
continueDefuse(game, t.ctx());
if (solveButton({ ...s.device!.button!, batteries: s.device!.batteries }).type === 'hold') break;
}
const button = () => JSON.parse(payload(room, 'A', t.now)).views.A.device.modules.find((m: { id: string }) => m.id === 'button');
expect(button().strip).toBeNull();
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' });
expect(button()).toMatchObject({ holding: true, strip: s.device!.button!.strip });
});
it('sync: the partner\'s answer is hidden until both have answered', () => {
const game = sync();
const room = playingRoom(game);
const t = clock(room);
continueSync(game, t.ctx());
const q = (room.engine as SyncState).questions[0];
answerSync(game, t.ctx(), 'A', q.id, 2);
const b = JSON.parse(payload(room, 'B')).views.B;
expect(b).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
expect(JSON.parse(payload(room, 'A')).views.A).toMatchObject({ myAnswer: 2, partnerAnswered: false, reveal: null });
answerSync(game, t.ctx(), 'B', q.id, 1);
expect(JSON.parse(payload(room, 'B')).views.B.reveal).toEqual({ A: 2, B: 1, match: false });
});
it('labyrinth: the Explorer never gets the map, the Navigator never gets the position', () => {
const game = labyrinth();
const room = playingRoom(game);
const s = room.engine as LabyrinthState;
Object.assign(s, { x: 5, y: 3 }); // standing on the crescent moon
const explorer = JSON.parse(payload(room, 'B')).views.B;
for (const key of ['grid', 'glyphs', 'lanterns', 'x', 'y', 'traps']) {
expect(keysOf(explorer), `explorer got "${key}"`).not.toContain(key);
}
expect(explorer.glyphLabel).toMatch(/crescent moon/);
expect(explorer.open).toEqual({ n: true, e: false, s: false, w: true });
expect(JSON.stringify(explorer)).not.toContain('#');
const navigator = JSON.parse(payload(room, 'A')).views.A;
expect(navigator.grid).toEqual(game.grid);
// The only coordinates the Navigator has are the static map features.
const { glyphs, lanterns, grid, ...rest } = navigator;
expect(keysOf(rest)).not.toContain('x');
expect(keysOf(rest)).not.toContain('y');
expect(Object.keys(navigator)).not.toEqual(expect.arrayContaining(['open', 'glyphLabel', 'atExit', 'message']));
});
});