Skip to main content

max / makenotwork

11.2 KB · 305 lines History Blame Raw
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3
4 import {
5 ChatLedger,
6 MAX_MESSAGE_LEN,
7 MAX_NONCE_LEN,
8 checkBody,
9 mentionsViewer,
10 newNonce,
11 parseFrame,
12 streamUrl,
13 } from "./chat.logic.ts";
14
15 // Body validation. The mirror of MessageBody::parse; the tests below are the
16 // same cases livechat's message.rs asserts, so a divergence shows up on one
17 // side or the other rather than as a message the composer accepts and the
18 // server refuses.
19
20 test("a body is trimmed before anything else happens to it", () => {
21 const check = checkBody(" hi ");
22 assert.equal(check.kind, "ok");
23 assert.equal(check.kind === "ok" && check.body, "hi");
24 });
25
26 test("whitespace alone is empty", () => {
27 assert.equal(checkBody("").kind, "empty");
28 assert.equal(checkBody(" \n\t ").kind, "empty");
29 });
30
31 test("the length boundary is inclusive", () => {
32 assert.equal(checkBody("x".repeat(MAX_MESSAGE_LEN)).kind, "ok");
33
34 const over = checkBody("x".repeat(MAX_MESSAGE_LEN + 1));
35 assert.equal(over.kind, "too-long");
36 assert.equal(over.kind === "too-long" && over.length, MAX_MESSAGE_LEN + 1);
37 });
38
39 test("length is measured after trimming", () => {
40 const padded = ` ${"x".repeat(MAX_MESSAGE_LEN)} `;
41 assert.equal(checkBody(padded).kind, "ok");
42 });
43
44 test("length counts code points, not UTF-16 units", () => {
45 // The whole reason checkBody spreads the string. An astral character is two
46 // UTF-16 units and one Rust `char`, so a body of these at the limit is 1000
47 // by String.length and 500 to the server. Counting the wrong one refuses a
48 // message the server would have accepted.
49 const astral = "\u{1F600}".repeat(MAX_MESSAGE_LEN);
50 assert.equal(astral.length, MAX_MESSAGE_LEN * 2);
51 assert.equal(checkBody(astral).kind, "ok");
52
53 assert.equal(checkBody("\u{1F600}".repeat(MAX_MESSAGE_LEN + 1)).kind, "too-long");
54 });
55
56 // Nonces.
57
58 test("a nonce fits inside the server's cap", () => {
59 for (const r of [0, 0.5, 0.999999]) {
60 const nonce = newNonce(() => r);
61 assert.ok(nonce.length > 0, "empty nonces are dropped by the server");
62 assert.ok(nonce.length <= MAX_NONCE_LEN, `${nonce} is ${nonce.length} chars`);
63 }
64 });
65
66 test("nonces differ across sends", () => {
67 const seen = new Set<string>();
68 for (let i = 0; i < 200; i++) seen.add(newNonce());
69 assert.equal(seen.size, 200);
70 });
71
72 // Frame parsing.
73
74 const message = (id: number, extra: Record<string, unknown> = {}): string =>
75 JSON.stringify({
76 type: "message",
77 id,
78 room_id: "00000000-0000-0000-0000-000000000000",
79 author_id: "11111111-1111-1111-1111-111111111111",
80 body_html: "hi",
81 created_at: 0,
82 ...extra,
83 });
84
85 test("a message frame is flat, tag and all", () => {
86 // serde's internally tagged enum writes `type` into the message object rather
87 // than nesting it, so the parsed frame carries the fields directly.
88 const frame = parseFrame(message(7));
89 assert.equal(frame?.type, "message");
90 assert.equal(frame?.type === "message" && frame.id, 7);
91 assert.equal(frame?.type === "message" && frame.body_html, "hi");
92 });
93
94 test("the other four frames parse", () => {
95 assert.deepEqual(parseFrame('{"type":"delete","id":3}'), { type: "delete", id: 3 });
96 assert.deepEqual(parseFrame('{"type":"purge","author_id":"abc"}'), {
97 type: "purge",
98 author_id: "abc",
99 });
100 assert.deepEqual(parseFrame('{"type":"wipe"}'), { type: "wipe" });
101 assert.deepEqual(parseFrame('{"type":"gap"}'), { type: "gap" });
102 });
103
104 test("an author rides along when the host resolved one", () => {
105 const frame = parseFrame(message(1, { author: { display_name: "max", flair: "owner" } }));
106 assert.equal(frame?.type === "message" && frame.author?.display_name, "max");
107 });
108
109 test("anything unparseable is null rather than a thrown error", () => {
110 // One bad frame must not tear down a working stream.
111 for (const bad of [
112 "",
113 "not json",
114 "null",
115 "[]",
116 '"a string"',
117 '{"type":"typing"}',
118 '{"id":1}',
119 '{"type":"delete"}',
120 '{"type":"delete","id":"3"}',
121 '{"type":"purge"}',
122 '{"type":"message","id":"7","author_id":"a","body_html":"x","created_at":0}',
123 '{"type":"message","id":7,"author_id":"a","created_at":0}',
124 ]) {
125 assert.equal(parseFrame(bad), null, `expected null for ${bad || "(empty)"}`);
126 }
127 });
128
129 // The ledger: cursor tracking, overlap dedup, nonce reconciliation.
130
131 test("a fresh room starts at the server-rendered cursor", () => {
132 assert.equal(new ChatLedger(42).cursor, 42);
133 });
134
135 test("a cursor that is not a message id starts from the beginning", () => {
136 // It arrives as a data attribute, so Number() can hand back 0 or NaN, and a
137 // NaN in the query string would fail the server's `after` parse on every
138 // single reconnect.
139 for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
140 assert.equal(new ChatLedger(bad).cursor, 0);
141 }
142 });
143
144 test("new messages append and advance the cursor", () => {
145 const ledger = new ChatLedger(10);
146 assert.deepEqual(ledger.receive({ id: 11 }), { kind: "append" });
147 assert.equal(ledger.cursor, 11);
148 assert.deepEqual(ledger.receive({ id: 12 }), { kind: "append" });
149 assert.equal(ledger.cursor, 12);
150 });
151
152 test("the replay overlap is deduplicated by the high-water mark", () => {
153 // ChatStream::open subscribes before it fetches the backlog, so a reconnect
154 // can be handed messages it already has. That overlap is the correct trade
155 // (the alternative loses messages) and this is what pays for it.
156 const ledger = new ChatLedger(10);
157 ledger.receive({ id: 11 });
158
159 assert.deepEqual(ledger.receive({ id: 11 }), { kind: "duplicate" });
160 assert.deepEqual(ledger.receive({ id: 5 }), { kind: "duplicate" });
161 assert.equal(ledger.cursor, 11, "a duplicate must not move the cursor");
162 });
163
164 test("our own echo reconciles instead of appending twice", () => {
165 const ledger = new ChatLedger(10);
166 ledger.track("n1");
167 assert.equal(ledger.pending, 1);
168
169 assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "reconcile", nonce: "n1" });
170 assert.equal(ledger.pending, 0);
171 assert.equal(ledger.cursor, 11);
172 });
173
174 test("someone else's nonce is not ours", () => {
175 // Every recipient but the sender gets the frame without a nonce, but a client
176 // must not reconcile against one it never sent even if it somehow sees it.
177 const ledger = new ChatLedger(10);
178 ledger.track("mine");
179 assert.deepEqual(ledger.receive({ id: 11, nonce: "theirs" }), { kind: "append" });
180 assert.equal(ledger.pending, 1, "our send is still in flight");
181 });
182
183 test("an echo below the cursor still reconciles", () => {
184 // Two people sending at once can have their inserts ordered one way and their
185 // broadcasts the other, so our own message can arrive after a higher id has
186 // already been displayed. Reading the cursor first would call this a
187 // duplicate and leave the sender's own message greyed out forever.
188 const ledger = new ChatLedger(10);
189 ledger.track("mine");
190 ledger.receive({ id: 12 });
191
192 assert.deepEqual(ledger.receive({ id: 11, nonce: "mine" }), { kind: "reconcile", nonce: "mine" });
193 assert.equal(ledger.cursor, 12, "reconciling an older id must not rewind the cursor");
194 });
195
196 test("an echo delivered twice reconciles once", () => {
197 const ledger = new ChatLedger(10);
198 ledger.track("n1");
199 ledger.receive({ id: 11, nonce: "n1" });
200
201 assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "duplicate" });
202 });
203
204 test("a failed send stops waiting for an echo", () => {
205 const ledger = new ChatLedger(10);
206 ledger.track("n1");
207 ledger.untrack("n1");
208 assert.equal(ledger.pending, 0);
209
210 // And if the send did land after all, the message is new, not a reconcile.
211 assert.deepEqual(ledger.receive({ id: 11, nonce: "n1" }), { kind: "append" });
212 });
213
214 test("several sends are in flight at once", () => {
215 const ledger = new ChatLedger(0);
216 ledger.track("a");
217 ledger.track("b");
218 ledger.track("c");
219
220 assert.deepEqual(ledger.receive({ id: 3, nonce: "c" }), { kind: "reconcile", nonce: "c" });
221 assert.deepEqual(ledger.receive({ id: 1, nonce: "a" }), { kind: "reconcile", nonce: "a" });
222 assert.equal(ledger.pending, 1);
223 assert.equal(ledger.cursor, 3);
224 });
225
226 // Where the stream is opened.
227
228 test("a first connection asks for no cursor", () => {
229 // Absent means "the recent window"; after=0 would ask the server to replay
230 // the entire retention window a page at a time.
231 assert.equal(streamUrl("rust", 0), "/p/rust/chat/stream");
232 });
233
234 test("a reconnect resumes after the last message handled", () => {
235 assert.equal(streamUrl("rust", 91), "/p/rust/chat/stream?after=91");
236 });
237
238 test("the slug is escaped into the path", () => {
239 assert.equal(streamUrl("a b/c", 0), "/p/a%20b%2Fc/chat/stream");
240 });
241
242 // Mention highlight. In-room only, never a notification, and it has to agree
243 // with docengine's `extract_mentions` about what a username is: the server
244 // leaves `@name` as plain text in the rendered body precisely so this can find
245 // it, and a client with a different idea of the boundaries would highlight
246 // messages the forum's own mention handling does not consider mentions.
247
248 test("a message naming the viewer is a mention", () => {
249 assert.equal(mentionsViewer("<p>hey @max, look</p>", "max"), true);
250 });
251
252 test("a message naming somebody else is not", () => {
253 assert.equal(mentionsViewer("<p>hey @alice</p>", "max"), false);
254 });
255
256 test("a longer name starting with the viewer's is not a mention", () => {
257 // The regex is greedy, so `@maxwell` yields the single name `maxwell`.
258 // A prefix match here would highlight for the wrong person on every message.
259 assert.equal(mentionsViewer("<p>@maxwell said so</p>", "max"), false);
260 });
261
262 test("case does not decide whether you were named", () => {
263 assert.equal(mentionsViewer("<p>@MAX</p>", "max"), true);
264 assert.equal(mentionsViewer("<p>@max</p>", "MAX"), true);
265 });
266
267 test("the username charset stops where docengine's does", () => {
268 // `[A-Za-z0-9_-]`, so a dot ends the name rather than continuing it.
269 assert.equal(mentionsViewer("<p>@max.johnson</p>", "max"), true);
270 assert.equal(mentionsViewer("<p>@max_j</p>", "max_j"), true);
271 assert.equal(mentionsViewer("<p>@max-j</p>", "max-j"), true);
272 });
273
274 test("a name inside a code span is written about, not addressed", () => {
275 assert.equal(mentionsViewer("<p>grep for <code>@max</code></p>", "max"), false);
276 });
277
278 test("a code span does not swallow the rest of the message", () => {
279 assert.equal(mentionsViewer("<p><code>@alice</code> and @max</p>", "max"), true);
280 });
281
282 test("markup inside a code span does not end the exclusion early", () => {
283 assert.equal(mentionsViewer("<p><code><em>x</em> @max</code></p>", "max"), false);
284 });
285
286 test("an at-sign inside an attribute is not a mention", () => {
287 // A link is the common case: nothing in an href addresses anyone in the room.
288 assert.equal(
289 mentionsViewer('<p><a href="mailto:@max" rel="nofollow noopener">mail</a></p>', "max"),
290 false,
291 );
292 });
293
294 test("a name split by markup stays split", () => {
295 // `@ali*ce*` renders as `@ali<em>ce</em>`, and docengine reading the source
296 // finds `ali`. Rejoining the halves would find `alice`.
297 assert.equal(mentionsViewer("<p>@ali<em>ce</em></p>", "alice"), false);
298 assert.equal(mentionsViewer("<p>@ali<em>ce</em></p>", "ali"), true);
299 });
300
301 test("a logged-out reader is never mentioned", () => {
302 assert.equal(mentionsViewer("<p>@max</p>", null), false);
303 assert.equal(mentionsViewer("<p>@max</p>", ""), false);
304 });
305