/** * The TypeScript half of the shared sanitizer contract. * * Every hostile input lives in `tests/fixtures/chat-cases.json`, which * `services/flysim/crates/flysim/tests/chat.rs` loads too — the fixture is what stops the two * implementations drifting. Cases that are specific to *this* language (a non-string argument, for * instance, which Rust's `&str` signature makes impossible) are added below the fixture run. */ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { CHAT_MAX_TEXT_LENGTH, CHAT_RING_MAX, classifyChatText, sanitizeChatText, type ChatRejectReason, } from '../src/chat'; import { FakeFlysim } from '../src/fake/simulator'; import { validateDisplayName } from '../src/names'; type FixtureString = string | { repeat: { unit: string; count: number } }; interface FixtureCase { name: string; input: FixtureString; expected: FixtureString | null; reason?: ChatRejectReason; } interface Fixture { maxTextLength: number; cases: FixtureCase[]; } const here = fileURLToPath(new URL('.', import.meta.url)); const fixture = JSON.parse(readFileSync(join(here, 'fixtures/chat-cases.json'), 'utf8')) as Fixture; /** `"abc"`, or `{ repeat: { unit, count } }` expanded. The Rust loader does exactly this. */ function resolve(value: FixtureString): string { return typeof value === 'string' ? value : value.repeat.unit.repeat(value.repeat.count); } void test('the fixture agrees with this implementation about the length cap', () => { assert.equal(fixture.maxTextLength, CHAT_MAX_TEXT_LENGTH); assert.ok(fixture.cases.length >= 30, `only ${fixture.cases.length} shared cases`); }); for (const testCase of fixture.cases) { void test(`chat-cases.json: ${testCase.name}`, () => { const input = resolve(testCase.input); const result = classifyChatText(input); if (testCase.expected === null) { assert.equal(result.ok, false, `expected a rejection, got ${JSON.stringify(result)}`); if (!result.ok) assert.equal(result.reason, testCase.reason); assert.equal(sanitizeChatText(input), null); return; } const expected = resolve(testCase.expected); assert.equal(result.ok, true, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(result)}`); assert.equal(sanitizeChatText(input), expected); }); } void test('a sanitized line is stable under a second pass', () => { // The page and the service both re-run the sanitizer as defence in depth, so it has to be // idempotent — otherwise a legitimate line could be dropped on the second look. for (const testCase of fixture.cases) { if (testCase.expected === null) continue; const once = sanitizeChatText(resolve(testCase.input)); assert.notEqual(once, null); assert.equal(sanitizeChatText(once as string), once, testCase.name); } }); void test('a non-string argument is refused rather than coerced', () => { for (const value of [undefined, null, 42, {}, [], { toString: () => 'go left' }, Symbol('x')]) { assert.equal(sanitizeChatText(value), null, String(typeof value)); const result = classifyChatText(value); assert.equal(result.ok, false); if (!result.ok) assert.equal(result.reason, 'malformed'); } }); void test('the ring bound is the one the feed header schema pins', () => { const schema = JSON.parse(readFileSync(join(here, '../src/schema.json'), 'utf8')) as { $defs: { FeedHeader: { properties: { chat: { maxItems: number } } } }; }; assert.equal(schema.$defs.FeedHeader.properties.chat.maxItems, CHAT_RING_MAX); }); void test('a line of the maximum length is accepted and one code point more is not', () => { const atCap = 'x'.repeat(CHAT_MAX_TEXT_LENGTH); assert.equal(sanitizeChatText(atCap), atCap); assert.equal(sanitizeChatText(`${atCap}x`), null); // The cap counts code points, not UTF-16 units: a line of 200 astral letters would be 400 // units. Astral *letters* (not emoji) are allowed, so this is a real case. const astral = '\u{10400}'.repeat(CHAT_MAX_TEXT_LENGTH); // DESERET CAPITAL LETTER LONG I assert.equal(sanitizeChatText(astral), astral); assert.equal(sanitizeChatText(astral + '\u{10400}'), null); }); // -- The fake flysim's chat ring --------------------------------------------------------------- void test('the fake simulator fills the chat ring with plausible lines, including a bot line', () => { const sim = new FakeFlysim({ scenario: 'running', seed: 0xc0ffee }); let header = sim.tick(33).header; for (let elapsed = 0; elapsed < 180_000 && (header.chat?.length ?? 0) < 6; elapsed += 100) { header = sim.tick(100).header; } const chat = header.chat ?? []; assert.ok(chat.length >= 6, `only ${chat.length} lines after three simulated minutes`); assert.ok(chat.some((line) => line.bot === true), 'no bot line in the ring'); assert.ok(chat.some((line) => line.bot === undefined), 'no viewer line in the ring'); for (const line of chat) { assert.equal(sanitizeChatText(line.text), line.text, line.text); assert.equal(validateDisplayName(line.by), line.by, line.by); assert.ok(Number.isInteger(line.id) && line.id > 0); assert.ok(line.wallMs > 0); } // Every accepted line is also one `viewer` event labelled "chat" — and the event carries the // name only, never the text, so the event log stays free of chat bodies. const events = sim.events(0, 1000).events.filter((event) => event.kind === 'viewer'); assert.ok(events.length >= chat.length); for (const event of events) { assert.equal(event.label, 'chat'); assert.ok(event.by !== undefined); assert.ok(!chat.some((line) => event.label.includes(line.text))); } }); void test('the fake simulator never lets the ring exceed CHAT_RING_MAX', () => { const sim = new FakeFlysim({ scenario: 'running', seed: 7 }); for (let tick = 0; tick < 2_000; tick++) sim.tick(200); assert.equal(sim.tick(33).header.chat?.length, CHAT_RING_MAX); }); void test('the kill switch omits the header field and 403s the endpoint', () => { const sim = new FakeFlysim({ scenario: 'running', seed: 1, chatEnabled: false }); for (let tick = 0; tick < 100; tick++) sim.tick(200); const { header } = sim.tick(33); assert.equal(header.chat, undefined); assert.equal(sim.chatRingLines(), null); assert.deepEqual(sim.chat({ by: 'alex', text: 'hello' }), { ok: false, kind: 'disabled' }); }); void test('POST /chat refuses hostile text and invalid names, and accepts a clean line', () => { const sim = new FakeFlysim({ scenario: 'running', seed: 2, chatChatter: false }); let now = 1_700_000_000_000; const accepted = sim.chat({ by: 'mothra_fan', text: ' go LEFT! ' }, now); assert.equal(accepted.ok, true); assert.deepEqual(sim.chatRingLines()?.map((line) => line.text), ['go LEFT!']); now += 10_000; for (const [text, reason] of [ ['visit www.evil.tv', 'url'], ['zero​width', 'charset'], ['', 'control'], [' ', 'empty'], ['x'.repeat(201), 'too_long'], ] as const) { const result = sim.chat({ by: 'mothra_fan', text }, now); assert.deepEqual(result, { ok: false, kind: 'rejected', reason }); } const badName = sim.chat({ by: 'not a name!', text: 'hello' }, now); assert.deepEqual(badName, { ok: false, kind: 'rejected', reason: 'name' }); // Nothing refused reached the ring. assert.equal(sim.chatRingLines()?.length, 1); }); void test('POST /chat rate-limits per name at 1 per 2 s and globally at 5 per s', () => { const sim = new FakeFlysim({ scenario: 'running', seed: 3, chatChatter: false }); const now = 1_700_000_000_000; assert.equal(sim.chat({ by: 'ari_9', text: 'one' }, now).ok, true); const again = sim.chat({ by: 'ari_9', text: 'two' }, now + 500); assert.equal(again.ok, false); if (!again.ok && again.kind === 'rate_limited') assert.equal(again.retryAfterMs, 1_500); assert.equal(sim.chat({ by: 'ari_9', text: 'three' }, now + 2_000).ok, true); // Five different names in the same second is the global ceiling; the sixth waits. const flood = new FakeFlysim({ scenario: 'running', seed: 4, chatChatter: false }); for (let index = 0; index < 5; index++) { assert.equal(flood.chat({ by: `viewer_${index}`, text: 'hi' }, now + index).ok, true, `#${index}`); } const sixth = flood.chat({ by: 'viewer_5', text: 'hi' }, now + 5); assert.equal(sixth.ok, false); if (!sixth.ok) assert.equal(sixth.kind, 'rate_limited'); assert.equal(flood.chat({ by: 'viewer_5', text: 'hi' }, now + 1_001).ok, true); }); void test('whitespace collapsing cannot be used to smuggle length past the cap', () => { // 300 letters separated by runs of spaces collapses to 300 letters plus separators, which is // over the cap — the check runs after collapsing, not before. const long = Array.from({ length: 300 }, () => 'a').join(' '); assert.equal(sanitizeChatText(long), null); });