205 lines
8.8 KiB
TypeScript
205 lines
8.8 KiB
TypeScript
/**
|
||
* 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'],
|
||
['zerowidth', 'charset'],
|
||
[' |