flybrain/packages/feed/tests/chat.test.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

205 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});