chat / client / test / app.test.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
/**
 * Golden-vector checks for chat's own layer: the storage namespace, the LogOp
 * and live-payload wire bytes, the projector, the TOFU store, the room
 * directory + same-identity merge, and the two migrations.
 *
 * Fixtures: test/vectors/{config,logops,call,projector,trust,rooms,migrate,
 * storage,history}.json, extracted from the TypeScript by
 * `SUETA_TS=1 node test/vectors/generate.mjs`. The scenario drivers live in
 * helpers/app-fakes.mjs and are shared with that generator, so this file
 * replays the exact same script.
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { installDom, localStorageStub } from "./harness/dom.mjs";
import { mods, readVector, priv } from "./helpers/load.mjs";

installDom();
const M = await mods();
const {
  chatOpsScript, callScript, projectorScript, trustScript, roomsScript, migrateScript, storageScript, historyScript,
  withClock, fakeChatLog, fakeChatNet, settle,
} = await import("./helpers/app-fakes.mjs");

test("the storage namespace: every localStorage key and IndexedDB name", () => {
  const v = readVector("config");
  assert.equal(M.APP_SALT, v.appSalt);
  assert.equal(M.NS, v.ns);
  // A change to ANY of these orphans existing users' identity seed, TOFU
  // bindings, room list and block store. They are the app's storage contract.
  for (const [k, expected] of v.keys) assert.equal(M.nsKey(k), expected, `nsKey(${k})`);
  for (const [k, expected] of v.perRoomKeys) assert.equal(M.nsKey(`${k}:ROOMID`), expected, `nsKey(${k}:…)`);
  for (const [d, expected] of v.dbs) assert.equal(M.nsDb(d), expected, `nsDb(${d})`);
});

test("LogOp and live-payload wire bytes are byte-identical", async () => {
  const v = readVector("logops");
  const got = await chatOpsScript({ ...M, priv });
  // the img op is EIGHT keys plain and NINE with a thread — the exact width at
  // which #js {} / js-obj silently reorders (dev/docs/CLJS.md)
  assert.deepEqual(got.appended, v.appended);
  assert.deepEqual(got.appendedBeforeImages, v.appendedBeforeImages);
  assert.deepEqual(got.imgPutBytes, v.imgPutBytes);
  assert.deepEqual(got.sent, v.sent);
  assert.equal(got.idFields, v.idFields);
  assert.deepEqual(got.anonSent, v.anonSent);
  assert.equal(got.anonIdFields, v.anonIdFields);
  assert.deepEqual(got.partialSent, v.partialSent);
  assert.equal(got.roomsyncShape, v.roomsyncShape);
  assert.equal(got.callOpShape, v.callOpShape);
  // belt and braces: spell the key ORDER out, so a reordering fails loudly
  // even if a future fixture regeneration ever normalised it away
  const keys = (s) => Object.keys(JSON.parse(s));
  assert.deepEqual(keys(v.appended[0]), ["t", "ts", "name", "text"]);
  assert.deepEqual(keys(v.appended[1]), ["t", "ts", "name", "text", "thread"]);
  assert.deepEqual(keys(v.appended[3]), ["t", "ts", "name", "target", "emoji", "op"]);
  assert.deepEqual(keys(v.appended[5]), ["t", "ts", "name", "cid", "mime", "bytes", "w", "h"]);
  assert.deepEqual(keys(v.appended[6]), ["t", "ts", "name", "cid", "mime", "bytes", "w", "h", "thread"]);
  assert.deepEqual(keys(v.sent[0][1]), ["kind", "name", "idPub", "idSig", "hue", "glyph"]);
  assert.deepEqual(keys(v.callOpShape), ["t", "ts", "name"]);
  assert.deepEqual(keys(v.roomsyncShape), ["kind", "rooms", "gone"]);
});

test("cross-check: the LogOp shapes rooms-kit's ChatClient speaks", () => {
  // rooms-kit's headless ChatClient is a second, independent implementation of
  // this exact protocol, released and running the bot fleet. Its recorded
  // appends must be the same shapes chat produces — same keys, same order.
  const rk = readVector("rk-protocol");
  const mine = readVector("logops");
  const keys = (s) => Object.keys(JSON.parse(s));
  assert.deepEqual(keys(rk.chat.appended[0]), keys(mine.appended[0]), "chat op");
  assert.deepEqual(keys(rk.chat.appended[1]), keys(mine.appended[1]), "threaded chat op");
  assert.deepEqual(keys(rk.chat.appended[2]), keys(mine.appended[3]), "react op");
  // and the hello/name live payloads: {kind, name, idPub, idSig} in that order
  assert.deepEqual(keys(rk.chat.sent[0][1]), ["kind", "name", "idPub", "idSig"]);
  assert.deepEqual(keys(mine.sent[0][1]).slice(0, 4), ["kind", "name", "idPub", "idSig"]);
  assert.deepEqual(keys(rk.chatAnon.sent[0][1]), keys(JSON.parse(mine.anonSent[0][1]) && mine.anonSent[0][1]));
  assert.equal(rk.salts.CHAT_SALT, readVector("config").appSalt);
});

test("the call membership payload and roster machine", async () => {
  const v = readVector("call");
  const got = await callScript({ ...M, priv });
  assert.deepEqual(got.payloads, v.payloads);
  assert.deepEqual(got.roster, v.roster);
  assert.equal(got.active, v.active);
  assert.deepEqual(got.self, v.self);
  assert.deepEqual(got.dropped, v.dropped);
  assert.deepEqual(got.afterGone, v.afterGone);
  assert.deepEqual(got.sent, v.sent);
  assert.deepEqual(Object.keys(JSON.parse(v.payloads.join)), ["kind", "op", "audio", "video", "name"]);
});

test("the projector: ordering, reaction fold, clamps, legacy import, image window", async () => {
  const v = readVector("projector");
  const got = await projectorScript({ ...M, priv });
  assert.deepEqual(got.messages, v.messages);
  assert.equal(got.changes, v.changes);
  assert.deepEqual(got.incoming, v.incoming);
  assert.deepEqual(got.liveIncoming, v.liveIncoming);
  assert.deepEqual(got.threadRepliesOfA, v.threadRepliesOfA);
  assert.deepEqual(got.hasMessage, v.hasMessage);
  assert.deepEqual(got.imageFetches, v.imageFetches);
  assert.deepEqual(got.pruneCalls, v.pruneCalls);
  assert.deepEqual(got.expired, v.expired);
  assert.deepEqual(got.windowFetches, v.windowFetches);
});

test("the reaction fold's clock ordering, and two projector edges nothing reached", async () => {
  // projector.json's `zz` entry pins the HASH tiebreak at an equal clock, and
  // flipping that comparison is caught. Flipping the CLOCK comparison is NOT:
  // in the recorded script the emoji is present at the end either way, because
  // an inverted fold simply freezes on the FIRST op it saw — which happened to
  // be an add — and the vector only ever inspects the final state. That is the
  // single most load-bearing line in the projector, so it gets a scenario whose
  // outcomes differ.
  const T0 = 1_700_000_000_000;
  const meta = (hash, from, clock, op) => ({ hash, from, clock, op });
  const react = (hash, clock, op) =>
    meta(hash, "AUTHOR-X", clock, { t: "react", ts: 3000, name: "x", target: "m", emoji: "👍", op });
  // `reactions` is a Clojure map now. Its `forEach` matches js/Map's (value,
  // key) order exactly; `keys()` does NOT spread, because CLJS hands back an
  // ES6 iterator rather than an iterable (see app-fakes's msgView).
  const emojis = (store) => {
    const out = [];
    store.messages[0].reactions.forEach((_by, emoji) => out.push(emoji));
    return out;
  };
  const fresh = () => {
    const store = new M.ChatStore(fakeChatNet(), fakeChatLog("ME"), "me", { appSalt: M.APP_SALT, roomId: "R" }, null);
    store.applyEntry(meta("m", "AUTHOR-X", 1, { t: "chat", ts: 1000, name: "x", text: "target" }));
    return store;
  };

  await withClock(T0, async () => {
    // in order: the later REMOVE wins over the earlier add
    const a = fresh();
    a.applyEntry(react("ra", 1, "add"));
    assert.deepEqual(emojis(a), ["👍"], "the add lands");
    a.applyEntry(react("rb", 2, "remove"));
    assert.deepEqual(emojis(a), [], "a remove at a higher clock wins");

    // OUT of order: the stale add arriving after the newer remove must lose.
    // This is the case an inverted clock comparison gets backwards.
    const b = fresh();
    b.applyEntry(react("rb", 2, "remove"));
    b.applyEntry(react("ra", 1, "add"));
    assert.deepEqual(emojis(b), [], "a stale add at a lower clock loses");

    // equal clock: the higher HASH wins, and it is a STRING comparison
    const c = fresh();
    c.applyEntry(react("zz", 5, "add"));
    c.applyEntry(react("aa", 5, "remove"));
    assert.deepEqual(emojis(c), ["👍"], 'hash "aa" loses to "zz"');
    const d = fresh();
    d.applyEntry(react("aa", 5, "add"));
    d.applyEntry(react("zz", 5, "remove"));
    assert.deepEqual(emojis(d), [], 'hash "zz" beats "aa"');

    // a non-string name is COERCED, never dropped: `String(name ?? "?")`
    const e = fresh();
    e.applyEntry(meta("n1", "AUTHOR-X", 1, { t: "chat", ts: 1000, name: 42, text: "numeric name" }));
    e.applyEntry(meta("n2", "AUTHOR-X", 1, { t: "chat", ts: 1001, text: "no name at all" }));
    const byId = Object.fromEntries(e.messages.map((m) => [m.id, m.name]));
    assert.equal(byId.n1, "42");
    assert.equal(byId.n2, "?");

    // the image guard is written as `!(bytes <= 0) && !(bytes > MAX)`, which a
    // NaN passes — so a NaN-byte op is KEPT. Spelling it `> 0 && <= MAX` would
    // silently start dropping it. JSON cannot carry NaN, but the guard is the
    // policy and this is what it says.
    const f = fresh();
    f.applyEntry(meta("g1", "AUTHOR-X", 1,
      { t: "img", ts: 5000, name: "x", cid: "c", mime: "image/webp", bytes: NaN, w: 1, h: 1 }));
    assert.ok(f.hasMessage("g1"), "a NaN-byte image op is kept, not dropped");

    // and the key ORDER of the two projected shapes. Only DEFINED keys, so the
    // assertion reads the same whether an absent field is omitted or present-
    // and-undefined.
    const definedKeys = (o) => Object.keys(o).filter((k) => o[k] !== undefined);
    const g = fresh();
    g.applyEntry(meta("t1", "AUTHOR-X", 1, { t: "chat", ts: 1000, name: "x", text: "x", thread: "m" }));
    const threaded = g.messages.find((m) => m.id === "t1");
    assert.deepEqual(definedKeys(threaded), ["id", "from", "name", "ts", "text", "thread", "reactions", "mine"]);
    g.applyEntry(meta("g2", "AUTHOR-X", 1,
      { t: "img", ts: 5000, name: "x", cid: "c2", mime: "image/webp", bytes: 10, w: 1, h: 1 }));
    const img = g.messages.find((m) => m.id === "g2").img;
    assert.deepEqual(definedKeys(img), ["cid", "mime", "w", "h", "bytes", "received", "complete"]);
    await settle(() => 1);
  });
});

test("the image window never re-fetches an attachment it already holds", () => {
  // projector.json cannot reach this: its log stub deliberately returns null
  // from getImage, so no attachment in the fixture ever becomes complete and
  // the "skip what we already have" rule is never exercised at all.
  //
  // That rule is stated TWICE — once when the window decides what to queue and
  // once inside the queued job, which re-checks because the world moves while
  // the queue drains. Each alone keeps this test green, so the two really are
  // redundant; losing BOTH is caught only here, and the cost of losing them is
  // that every later image op re-fetches the whole window over bitswap.
  const T0 = 1_700_000_000_000;
  const meta = (hash, from, clock, op) => ({ hash, from, clock, op });
  const imgOp = (cid, ts) => ({ t: "img", ts, name: "x", cid, mime: "image/webp", bytes: 4, w: 1, h: 1 });
  const log = { ...fakeChatLog("ME"), getCalls: [], async getImage(cid) { this.getCalls.push(cid); return new Uint8Array(4); } };

  return withClock(T0, async () => {
    const store = new M.ChatStore(fakeChatNet(), log, "me", { appSalt: M.APP_SALT, roomId: "R" }, null);
    store.applyEntry(meta("a", "X", 1, imgOp("c1", 5000)));
    await settle(() => log.getCalls.length);
    assert.deepEqual(log.getCalls, ["c1"]);
    assert.equal(store.messages[0].img.complete, true, "the fetch completed");

    store.applyEntry(meta("b", "X", 1, imgOp("c2", 5001)));
    await settle(() => log.getCalls.length);
    assert.deepEqual(log.getCalls, ["c1", "c2"], "c1 is already held and is not fetched again");
    // the blob URLs live in the store's CID registry now, not on the message;
    // nothing here falls out of the newest-20 window, so nothing is revoked
    // and two object URLs outlive this test inside the node process
    assert.equal(store.messages[0].img.url, undefined, "no blob URL hangs off an attachment");
  });
});

test("the TOFU trust store (and its null-prototype name maps)", async () => {
  const v = readVector("trust");
  const got = await trustScript({ ...M, localStorageStub });
  assert.deepEqual(got.steps, v.steps);
  // the stored bytes: a changed shape would orphan every existing binding
  assert.equal(got.storedRaw, v.storedRaw);
  // …and the verdict's own key order, which the deepEqual above cannot see
  assert.deepEqual(Object.keys(got.steps[0][1]), ["verified", "keyChanged"]);
  assert.deepEqual(Object.keys(v.steps[0][1]), ["verified", "keyChanged"]);
});

test("the trust store's two record invariants", () => {
  // Both survive every assertion above, and both are the kind of rule a
  // rewrite quietly loses: one is JS truthiness at a boundary, the other is
  // the type filter that keeps an attacker's nested value out of the record.
  const raw = () => localStorageStub.getItem(M.nsKey("trust"));

  // 1. An EMPTY stored binding is no binding. TS wrote `!!known`, so "" means
  //    "nothing was ever bound" and TOFU claims the name — it does NOT report
  //    a key change. A plain `known != null` inverts this.
  localStorageStub.clear();
  localStorageStub.setItem(M.nsKey("trust"), JSON.stringify({ names: { ana: "" }, verified: {} }));
  assert.deepEqual(M.trustObserve("Ana", "PUB-A"), { verified: false, keyChanged: false });
  assert.equal(raw(), '{"names":{"ana":"PUB-A"},"verified":{}}');

  // 2. Only STRING values survive the load. A record whose values came from
  //    the network must hold nothing but keys and keys; a number (or an object)
  //    is dropped outright rather than carried back into storage.
  localStorageStub.setItem(M.nsKey("trust"),
    JSON.stringify({ names: { a: 1, b: "PUB", c: { nested: "x" } }, verified: {} }));
  M.trustObserve("d", "PUB-D");
  assert.equal(raw(), '{"names":{"b":"PUB","d":"PUB-D"},"verified":{}}');
  localStorageStub.clear();
});

test("the room directory and the same-identity merge (PROTOCOL.md §6)", async () => {
  const v = readVector("rooms");
  const got = await roomsScript({ ...M, localStorageStub });
  assert.deepEqual(got.steps, v.steps);
  assert.deepEqual(got.firstVisit, v.firstVisit);
  assert.deepEqual(got.rejoin, v.rejoin);
  assert.deepEqual(got.buildRoomSync, v.buildRoomSync);
  assert.deepEqual(got.merges, v.merges);
  assert.equal(got.finalCount, v.finalCount);

  // and the key ORDER of both persisted/synced shapes, which every deepEqual
  // above is blind to (node's deep comparison ignores key order). A RoomEntry
  // and a tombstone are stored AND put on the self-sync channel, where the
  // kit hashes the serialised payload — a reordering there is a shape change
  // that would otherwise pass this whole test. Asserted against BOTH sides, so
  // it pins the fixture's recorded order, not just today's output.
  const ks = (o) => Object.keys(o);
  for (const [where, got_, want] of [
    ["firstVisit {s,label,ts}", got.firstVisit, v.firstVisit],
    ["renamed {s,label,ts,lts}", got.steps[1][1].rooms[0], v.steps[1][1].rooms[0]],
    ["tombstone {s,ts}", got.steps[2][1].gone[0], v.steps[2][1].gone[0]],
    ["merged-in entry", got.merges[1][2].rooms[0], v.merges[1][2].rooms[0]],
  ]) {
    assert.deepEqual(ks(got_), ks(want), `key order: ${where}`);
  }
  assert.deepEqual(ks(v.firstVisit), ["s", "label", "ts"]);
  assert.deepEqual(ks(v.steps[1][1].rooms[0]), ["s", "label", "ts", "lts"]);
  assert.deepEqual(ks(v.steps[2][1].gone[0]), ["s", "ts"]);
  assert.deepEqual(ks(v.buildRoomSync), ["rooms", "gone"]);
});

test("the room merge's equal-timestamp tiebreaks", async () => {
  // Neither tie is reachable from rooms.json: mutating `>=` to `>` in the
  // tombstone-vs-entry comparison, or `>` to `>=` in the in-payload dedupe,
  // leaves every assertion above green. They are the room directory's analogue
  // of projector.json's `zz` equal-clock entry — a tie whose resolution is the
  // whole point of the rule — so they get pinned here rather than inferred.
  const T0 = 1_700_000_000_000;
  const A = "A".repeat(43);
  const B = "B".repeat(43);
  const C = "C".repeat(43);
  await withClock(T0, () => {
    localStorageStub.clear();
    M.touchRoom(A);
    M.forgetRoom(A); // tombstone at exactly T0, same ms as the visit
    // A remote entry whose visit is exactly as old as the forget does NOT come
    // back: the tombstone wins the tie, which is what makes a forget stick.
    assert.equal(M.mergeRooms({ rooms: [{ s: A, label: "back", ts: T0 }], gone: [] }, B), 0);
    assert.deepEqual(M.loadRooms().map((r) => r.s), []);
    // Within ONE payload the FIRST of two equal-ts entries keeps the slot.
    assert.equal(M.mergeRooms({
      rooms: [{ s: C, label: "first", ts: T0 }, { s: C, label: "second", ts: T0 }],
      gone: [],
    }, B), 1);
    assert.deepEqual(M.loadRooms().map((r) => r.label), ["first"]);
    localStorageStub.clear();
  });
});

test("the one-shot v1 history import", async () => {
  const v = readVector("migrate");
  const got = await migrateScript({ ...M, localStorageStub });
  assert.equal(got.roomIdV1, v.roomIdV1);
  assert.equal(got.flagKey, v.flagKey);
  assert.equal(got.published, v.published);
  assert.deepEqual(got.appended, v.appended);
  assert.deepEqual(got.legacyMine, v.legacyMine);
  assert.equal(got.flag, v.flag);
  assert.equal(got.secondRun, v.secondRun);
  assert.equal(got.probeGuard, v.probeGuard);
  assert.equal(got.probeGuardAppended, v.probeGuardAppended);
  assert.equal(got.absent, v.absent);
  assert.equal(got.batched, v.batched);
  assert.deepEqual(got.batchSizes, v.batchSizes);
});

test("the legacy-namespace storage migration", async () => {
  const v = readVector("storage");
  const got = await storageScript({ ...M, localStorageStub });
  assert.deepEqual(got.fresh, v.fresh);
  assert.deepEqual(got.migrated, v.migrated);
  assert.deepEqual(got.warnings, v.warnings);
  assert.equal(got.markerIsTimestamp, v.markerIsTimestamp);
  assert.deepEqual(got.blocks, v.blocks);
  assert.deepEqual(got.data, v.data);
  assert.deepEqual(got.legacyKept, v.legacyKept, "the legacy stores are COPIED, never deleted");
  assert.equal(got.secondRun, v.secondRun);
});

test("the read-only v1 history loader", async () => {
  const v = readVector("history");
  const got = await historyScript({ ...M });
  assert.deepEqual(got.hit, v.hit);
  assert.equal(got.miss, v.miss);
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git