social-kit / test / presence-flows.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
354
355
356
357
358
359
360
361
362
363
364
/**
 * Characterization net for the two SocialAgent paths that had none: the
 * self-sync change gate and inbound invite routing.
 *
 * presence-brains.test.mjs drives a started agent over a fake pubsub and pins
 * the beacon field it was written for; consumers.test.mjs pins which option
 * keys the agent reads. Neither reaches sync-self (80 lines, a localStorage
 * hash gate, a live push and a drop deposit) or route-envelope (88 lines, the
 * seen-ring dedup, the block check, the friends-only invite rule and the
 * capability-URL check). Those are the functions the idiomatic rewrite split
 * apart, so this is the net that says the split changed nothing.
 *
 * PROVEN AGAINST THE PRE-REWRITE IMPLEMENTATION: every assertion here was run
 * against presence.cljs at 55cb3f4 before the rewrite landed, and passes
 * identically on both. Black-box throughout — nothing reaches past the public
 * API and the wire.
 *
 * Not golden vectors: no byte of this crossed over from the retired
 * TypeScript. It pins behaviour that was previously pinned by nothing.
 */
import test from "node:test";
import assert from "node:assert/strict";
import { installFakeDom } from "./helpers/fake-dom.mjs";

const env = installFakeDom();

// The agent registers its visibility handler on document; capturing it is how
// a beacon/catch-up round is fired on demand.
const docListeners = new Map();
env.document.addEventListener = (type, cb) => {
  if (!docListeners.has(type)) docListeners.set(type, []);
  docListeners.get(type).push(cb);
};

const { SocialAgent, FriendStore, pairChannel, selfChannel, buildEnvelope, openEnvelope, envCtx } =
  await import("../dist/index.js");
const { Identity } = await import("ardegazu-id-kit");
const { deriveSuiteXKeyPair, signXCert, SUITE_SALT } = await import("ardegazu-id-kit/xkey");

const dec = new TextDecoder();
const enc = new TextEncoder();
const utf8 = (s) => enc.encode(s);
const tick = (ms = 80) => new Promise((r) => setTimeout(r, ms));

const seedA = Identity.newSeed();
const seedB = Identity.newSeed();
const idA = await Identity.fromSeed(seedA);
const idB = await Identity.fromSeed(seedB);
const xA = await deriveSuiteXKeyPair(seedA);
const xB = await deriveSuiteXKeyPair(seedB);
const certA = await signXCert(idA, SUITE_SALT, xA.pubB64);
const certB = await signXCert(idB, SUITE_SALT, xB.pubB64);

const chB = await pairChannel(xB, idB.publicKeyB64, idA.publicKeyB64, xA.pubB64);
const selfA = await selfChannel(seedA);
const actorA = { identity: idA, x: xA, xCert: certA, name: "ana" };
const selfB = { identity: idB, x: xB, xCert: certB, name: "bot-b" };

let nsSeq = 0;

/** A started agent for A, with B in `state`, over a capturing fake pubsub.
 *  `extra` goes straight into the SocialAgent options (the optional self-sync
 *  hooks live there). */
async function mkAgent(state = "friend", extra = {}) {
  const ns = `t-flow-${nsSeq++}`;
  const store = new FriendStore(ns);
  store.upsert({
    pub: idB.publicKeyB64, x: xB.pubB64, xs: certB,
    pet: "", name: "bot-b", state,
    addedTs: Date.now(), petTs: 0, lastSeenTs: 0, lastApp: "",
  });
  const published = [];
  const listeners = [];
  const pubsub = {
    subscribe: () => {},
    unsubscribe: () => {},
    addEventListener: (_ev, fn) => listeners.push(fn),
    publish: async (topic, data) => void published.push([topic, dec.decode(data)]),
  };
  const invites = [];
  const agent = new SocialAgent({
    ns, app: "game1.ardegazu.ro", identity: idA, seed: seedA,
    myName: () => "ana",
    store, pubsub,
    events: { invite: (inv) => invites.push(inv) },
    ...extra,
  });
  await agent.start();
  await tick();

  /** fsync envelopes A published on its own self channel, oldest first. */
  const selfSyncs = async () => {
    const out = [];
    for (const [topic, wire] of published) {
      if (topic !== selfA.topic) continue;
      const opened = await selfA.open(wire, [idA.publicKeyB64]);
      if (!opened || opened.payload?.k !== "env") continue;
      const inner = await openEnvelope(xA, envCtx(idA.publicKeyB64), utf8(JSON.stringify(opened.payload.env)));
      if (inner) out.push(inner);
    }
    return out;
  };
  /** Seal an envelope from B and hand it to A's wire handler. */
  const deliver = async (outer) => {
    const wire = await chB.seal(idB.publicKeyB64, { k: "env", env: outer });
    for (const fn of listeners) fn({ detail: { topic: chB.topic, data: enc.encode(wire) } });
    await tick();
  };
  const invite = (body, ttl = 3600000) =>
    buildEnvelope(selfB, envCtx(idA.publicKeyB64), idA.publicKeyB64, xA.pubB64, "inv", body, ttl);
  /** An fsync from A's OWN key, arriving on A's self channel — the only shape
   *  the self-origin guard lets through. */
  const deliverSelfSync = async (body) => {
    const built = await buildEnvelope(actorA, envCtx(idA.publicKeyB64), idA.publicKeyB64, xA.pubB64, "fsync", body, 3600000);
    const wire = await selfA.seal(idA.publicKeyB64, { k: "env", env: built.outer });
    for (const fn of listeners) fn({ detail: { topic: selfA.topic, data: enc.encode(wire) } });
    await tick();
  };
  /** The same fsync body, but signed by B and delivered over the pair channel:
   *  inbox room ids are publicly derivable, so this is the shape a stranger can
   *  actually produce. */
  const deliverForgedSync = async (body) => {
    const built = await buildEnvelope(selfB, envCtx(idA.publicKeyB64), idA.publicKeyB64, xA.pubB64, "fsync", body, 3600000);
    await deliver(built.outer);
  };

  return { agent, store, published, selfSyncs, deliver, deliverSelfSync, deliverForgedSync, invite, invites, ns };
}

const ROOM = "https://game1.ardegazu.ro/#abc";

// ---- self-sync ---------------------------------------------------------------

test("boot publishes exactly one fsync, carrying the friend blob", async () => {
  const a = await mkAgent();
  const syncs = await a.selfSyncs();
  assert.equal(syncs.length, 1, "the boot deposit is unconditional");
  assert.equal(syncs[0].t, "fsync");
  assert.equal(syncs[0].from.pub, idA.publicKeyB64, "sealed to my own self channel, from me");
  assert.equal(syncs[0].body.soc.v, 1);
  assert.deepEqual(syncs[0].body.soc.friends.map((f) => f.pub), [idB.publicKeyB64]);
  assert.equal("profile" in syncs[0].body, false, "no profileSync hook, no section");
  assert.equal("app" in syncs[0].body, false, "no appState hook, no section");
  assert.equal("apps" in syncs[0].body, false, "no appsSync hook, no section");
  a.agent.stop();
});

test("the hash gate suppresses an unchanged repeat, and records it in localStorage", async () => {
  const a = await mkAgent();
  assert.equal((await a.selfSyncs()).length, 1);
  const stamped = JSON.parse(env.localStorage.getItem(`${a.ns}:soc:last-self-sync`));
  assert.equal(typeof stamped.h, "string", "the fingerprint is stored");
  assert.equal(typeof stamped.ts, "number");

  // a second sync with nothing changed: fires the gate, publishes nothing
  a.agent.syncNow();
  await new Promise((r) => setTimeout(r, 1700)); // SYNC_DEBOUNCE_MS is 1500
  assert.equal((await a.selfSyncs()).length, 1, "unchanged payload does not reach the wire");
  a.agent.stop();
});

test("a real change defeats the gate — socBlob's own ts does not", async () => {
  const a = await mkAgent();
  assert.equal((await a.selfSyncs()).length, 1);
  // socBlob mints ts: Date.now() per call, so a payload that is otherwise
  // identical must still hash the same. Let a millisecond pass, then sync.
  await tick(30);
  a.agent.syncNow();
  await new Promise((r) => setTimeout(r, 1700));
  assert.equal((await a.selfSyncs()).length, 1, "a fresh socBlob ts is not a change");

  a.store.setPet(idB.publicKeyB64, "vecinul");
  a.agent.syncNow();
  await new Promise((r) => setTimeout(r, 1700));
  const syncs = await a.selfSyncs();
  assert.equal(syncs.length, 2, "a petname edit is a change");
  assert.equal(syncs[1].body.soc.friends[0].pet, "vecinul");
  a.agent.stop();
});

// ---- the apps section (cross-device app-scoped state) ------------------------

const TS = 1735689600000; // 2025-01-01, safely in the past — never clamped

test("appsSync.get() rides the fsync as an additive `apps` section, sanitized", async () => {
  const a = await mkAgent("friend", {
    appsSync: {
      get: () => ({
        chat: { state: { unread: 3 }, ts: TS },
        "BAD KEY": { state: 1, ts: TS },
        board: { state: 1, ts: 0 }, // ts must be a positive number
      }),
      apply: () => {},
    },
  });
  const syncs = await a.selfSyncs();
  assert.equal(syncs.length, 1);
  assert.deepEqual(syncs[0].body.apps, { chat: { state: { unread: 3 }, ts: TS } },
                   "only the well-formed entry travels");
  assert.equal("profile" in syncs[0].body, false, "the sections stay independent");
  assert.equal(syncs[0].body.soc.v, 1, "and the friend blob is untouched");
  a.agent.stop();
});

test("an apps map with nothing usable in it omits the section entirely", async () => {
  const a = await mkAgent("friend", {
    appsSync: { get: () => ({ "BAD KEY": { state: 1, ts: TS } }), apply: () => {} },
  });
  const syncs = await a.selfSyncs();
  assert.equal("apps" in syncs[0].body, false, "empty is absent, not {}");
  a.agent.stop();
});

test("a throwing appsSync.get() omits the section instead of failing the sync", async () => {
  const a = await mkAgent("friend", {
    appsSync: { get: () => { throw new Error("app code"); }, apply: () => {} },
  });
  const syncs = await a.selfSyncs();
  assert.equal(syncs.length, 1, "the fsync still went out");
  assert.equal("apps" in syncs[0].body, false);
  assert.equal(syncs[0].body.soc.v, 1);
  a.agent.stop();
});

test("an fsync from my own device hands the apps section to appsSync.apply", async () => {
  const applied = [];
  const a = await mkAgent("friend", {
    appsSync: { get: () => null, apply: (m) => applied.push(m) },
  });
  await a.deliverSelfSync({
    apps: { board: { state: { zoom: 2 }, ts: TS }, "BAD KEY": { state: 1, ts: TS } },
  });
  assert.deepEqual(applied, [{ board: { state: { zoom: 2 }, ts: TS } }],
                   "applied once, sanitized — the fold is the app's own business");
  a.agent.stop();
});

test("a forged fsync's apps section never reaches appsSync.apply", async () => {
  // Inbox room ids are publicly derivable: anyone can seal a VALID fsync
  // signed with their own key. The self-origin guard is what stops it, and
  // this section must sit behind it exactly as the other two do.
  const applied = [];
  const a = await mkAgent("friend", {
    appsSync: { get: () => null, apply: (m) => applied.push(m) },
  });
  await a.deliverForgedSync({ apps: { board: { state: "pwned", ts: TS } } });
  assert.deepEqual(applied, [], "not from me, not applied");
  // and the same body from my own key does land, so the check above is not vacuous
  await a.deliverSelfSync({ apps: { board: { state: "mine", ts: TS } } });
  assert.deepEqual(applied, [{ board: { state: "mine", ts: TS } }]);
  a.agent.stop();
});

test("a throwing profileSync.apply does not starve the apps section", async () => {
  const applied = [];
  const a = await mkAgent("friend", {
    profileSync: { get: () => null, apply: () => { throw new Error("app code"); } },
    appsSync: { get: () => null, apply: (m) => applied.push(m) },
  });
  await a.deliverSelfSync({
    profile: { name: "ana", hue: null, glyph: null, lang: null, ts: TS },
    apps: { chat: { state: 1, ts: TS } },
  });
  assert.deepEqual(applied, [{ chat: { state: 1, ts: TS } }], "its own try block");
  a.agent.stop();
});

// ---- inbound invites ---------------------------------------------------------

test("an invite from a friend fires the event with every field clamped", async () => {
  const a = await mkAgent("friend");
  const built = await a.invite({
    app: "g".repeat(100),
    url: ROOM,
    label: "L".repeat(200),
    m: { k: "j".repeat(50), v: 1 },
  });
  await a.deliver(built.outer);
  assert.equal(a.invites.length, 1);
  const inv = a.invites[0];
  assert.equal(inv.id, built.id, "the dedup key is the envelope id verbatim");
  assert.equal(inv.url, ROOM, "the capability URL is passed through unchanged");
  assert.equal(inv.app.length, 64);
  assert.equal(inv.label.length, 80);
  assert.equal(inv.msg.k.length, 24);
  assert.equal(inv.from.pub, idB.publicKeyB64, "`from` is the stored friend record");
  assert.equal(typeof inv.ts, "number");
  a.agent.stop();
});

test("the same invite twice fires once — the seen ring is per envelope id", async () => {
  const a = await mkAgent("friend");
  const built = await a.invite({ app: "g1", url: ROOM, label: "come" });
  await a.deliver(built.outer);
  await a.deliver(built.outer);
  assert.equal(a.invites.length, 1);
  a.agent.stop();
});

test("only friends may invite — a pending request cannot", async () => {
  const a = await mkAgent("in");
  const built = await a.invite({ app: "g1", url: ROOM, label: "come" });
  await a.deliver(built.outer);
  assert.deepEqual(a.invites, []);
  a.agent.stop();
});

test("a blocked sender's invite never reaches the event", async () => {
  // HONEST LIMIT, recorded because a mutation run proved it: this passes even
  // with route-envelope's isBlocked check deleted. block() removes the record
  // as well as tombstoning it, so the friends-only rule already refuses the
  // invite, and applyFriendEnvelope carries its OWN isBlocked check for the
  // freq/facc/fsync paths. route-envelope's check is defence in depth with no
  // reachable case of its own, and nothing here or anywhere else in this repo
  // can distinguish its presence from its absence. The outcome is what is
  // pinned; the mechanism is not.
  const a = await mkAgent("friend");
  a.store.block(idB.publicKeyB64);
  const built = await a.invite({ app: "g1", url: ROOM, label: "come" });
  await a.deliver(built.outer);
  assert.deepEqual(a.invites, []);
  assert.equal(a.store.isBlocked(idB.publicKeyB64), true);
  assert.deepEqual(a.store.list(), [], "block() removes the record, which is what actually refuses it");
  a.agent.stop();
});

test("the invite URL must be a suite host over https — it is a capability", async () => {
  const a = await mkAgent("friend");
  for (const url of [
    "https://evil.example/#abc",
    "http://game1.ardegazu.ro/#abc",
    "https://ardegazu.ro.evil.example/#abc",
    "javascript:alert(1)",
    42,
    undefined,
  ]) {
    const built = await a.invite({ app: "g1", url, label: "come" });
    await a.deliver(built.outer);
  }
  assert.deepEqual(a.invites, [], "not one of those is a suite room link");
  // and the good one still lands, so the loop above was not vacuous
  const ok = await a.invite({ app: "g1", url: "https://board.ardegazu.ro/#x", label: "come" });
  await a.deliver(ok.outer);
  assert.equal(a.invites.length, 1);
  a.agent.stop();
});

test("an invite with no msg code yields msg undefined, not a partial object", async () => {
  const a = await mkAgent("friend");
  const built = await a.invite({ app: "g1", url: ROOM, label: "legacy prose" });
  await a.deliver(built.outer);
  assert.equal(a.invites.length, 1);
  assert.equal(a.invites[0].msg, undefined);
  assert.equal(a.invites[0].label, "legacy prose", "old receivers' prose rides through");
  a.agent.stop();
});

test("an expired invite does not open at all", async () => {
  const a = await mkAgent("friend");
  const built = await a.invite({ app: "g1", url: ROOM, label: "come" }, -1000);
  await a.deliver(built.outer);
  assert.deepEqual(a.invites, []);
  a.agent.stop();
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/social-kit.git