social-kit / test / consumers.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
/**
 * Consumer-shaped RUNTIME rename-safety suite against the committed dist.
 * Mirrors the exact externally-constructed option objects the real apps pass:
 *  - game1 main.ts: new GameLb({g,roomId,ns,identity,net,myScore}) + a full
 *    lbq/lbs/lbr co-sign round over a fake wire + hostMatchEnd;
 *  - home social.ts: new FriendStore(ns), new SocialAgent({...}) with store,
 *    profileSync{get,apply}, events{presence,invite,request,accepted,synced};
 *  - lb-ui.ts: ReceiptSet/aggregate/fetchWeekly/isoWeek/startLbGossip;
 *  - ./net: createSocialNode option-object reads (Proxy-instrumented; the
 *    network dial itself is expected to fail offline).
 * Every option object is wrapped in a recording Proxy: any :advanced-renamed
 * property read would show up as an unexpected key (and read undefined).
 */
import test from "node:test";
import assert from "node:assert/strict";
import { installFakeDom } from "./helpers/fake-dom.mjs";

const env = installFakeDom();

const reads = new Map(); // label -> Set of keys read
const rec = (label, obj) =>
  new Proxy(obj, {
    get(t, k) {
      if (typeof k === "string") {
        if (!reads.has(label)) reads.set(label, new Set());
        reads.get(label).add(k);
      }
      return Reflect.get(t, k);
    },
  });
const assertReads = (label, allowed, mustInclude) => {
  const got = [...(reads.get(label) ?? [])].filter((k) => !["then", "constructor", "toJSON", "Symbol(Symbol.toPrimitive)"].includes(k));
  const unexpected = got.filter((k) => !allowed.includes(k));
  assert.deepEqual(unexpected, [], `${label}: unexpected (renamed?) option reads`);
  for (const k of mustInclude) assert.ok(got.includes(k), `${label}: expected read of "${k}" (got: ${got.join(",")})`);
};

const { GameLb, SocialAgent, FriendStore, ReceiptSet, aggregate, verifyReceipt, startLbGossip, isoWeek, canon } =
  await import("../dist/index.js");
const { Identity } = await import("ardegazu-id-kit");

const idHost = await Identity.fromSeed(Identity.newSeed());
const idPlayer = await Identity.fromSeed(Identity.newSeed());

// window.setTimeout is used for the co-sign collection window; shrink it.
const realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (fn, ms, ...a) => realSetTimeout(fn, ms > 1000 ? 60 : ms, ...a);
globalThis.window.setTimeout = globalThis.setTimeout;

test("game1 shape: full lbq/lbs/lbr co-sign round across two GameLb instances", async () => {
  const frames = [];
  const mkNet = (myId, other) => ({
    myId,
    identityOf: (peer) => (peer === "P" ? { pub: idPlayer.publicKeyB64 } : peer === "H" ? { pub: idHost.publicKeyB64 } : null),
    broadcast: (f) => frames.push([myId, "*", f]),
    send: (to, f) => frames.push([myId, to, f]),
  });
  const received = [];
  const netH = rec("gamelb.net", mkNet("H", "P"));
  const lbH = new GameLb(
    rec("gamelb.opts", {
      g: "game1-test-salt",
      roomId: "room-1",
      ns: "t-h",
      identity: idHost,
      net: netH,
      myScore: () => 42,
      onReceipt: (r) => received.push(r),
    }),
  );
  const lbP = new GameLb({
    g: "game1-test-salt",
    roomId: "room-1",
    ns: "t-p",
    identity: idPlayer,
    net: mkNet("P", "H"),
    myScore: () => 17,
  });

  // non-lb frames pass through
  assert.equal(lbH.onFrame("P", { t: "move", x: 1 }), false);
  assert.equal(lbH.onFrame("P", null), false);
  assert.equal(lbH.onFrame("P", { t: "lbs", p: "x", s: "y" }), true, "lbs consumed even with no pending");

  await lbH.hostMatchEnd([
    ["H", "hostnick", 42],
    ["P", "playernick-that-is-way-over-32-characters-long", 17],
    ["ghost", "no-identity", 99],
  ]);
  const lbq = frames.find(([f, t, fr]) => fr.t === "lbq");
  assert.ok(lbq, "host broadcast lbq");
  assert.equal(lbq[2].base.sc.length, 2, "identity-less player dropped");
  assert.equal(lbq[2].base.sc[1].nm.length, 32, "nick clamped to 32");
  assert.equal(lbq[2].base.n, 3, "n counts everyone present");

  // player co-signs (score matches belief)
  assert.equal(lbP.onFrame("H", lbq[2]), true);
  await new Promise((r) => realSetTimeout(r, 10));
  const lbs = frames.find(([, , fr]) => fr.t === "lbs");
  assert.ok(lbs, "player sent lbs signature");
  assert.equal(lbs[1], "H", "lbs sent to host");

  // deliver to host, wait for the (shrunk) collection window
  assert.equal(lbH.onFrame("P", lbs[2]), true);
  await new Promise((r) => realSetTimeout(r, 120));
  const lbr = frames.find(([, , fr]) => fr.t === "lbr");
  assert.ok(lbr, "host finalized + broadcast lbr");
  const v = await verifyReceipt(lbr[2].rec, {});
  assert.ok(v, "finalized receipt verifies independently");
  assert.equal(received.length, 1, "onReceipt fired once");
  assert.equal(lbH.receipts.all().length, 1, "host stored its receipt");

  // player accepts the lbr
  assert.equal(lbP.onFrame("H", lbr[2]), true);
  await new Promise((r) => realSetTimeout(r, 30));
  assert.equal(lbP.receipts.all().length, 1, "player stored the receipt too");

  assertReads("gamelb.opts", ["g", "roomId", "ns", "identity", "net", "myScore", "mode", "onReceipt"], ["g", "roomId", "ns", "identity", "net"]);
  assertReads("gamelb.net", ["myId", "identityOf", "broadcast", "send"], ["myId", "broadcast", "identityOf"]);
});

test("player refuses to co-sign a base that lies about their score / room", async () => {
  const frames = [];
  const lbP = new GameLb({
    g: "g", roomId: "room-1", ns: "t-p2", identity: idPlayer,
    net: { myId: "P", identityOf: () => null, broadcast: () => {}, send: (to, f) => frames.push(f) },
    myScore: () => 17,
  });
  const { roomHash, signReceipt } = await import("../dist/index.js");
  const r = await roomHash("room-1");
  const mkBase = (s) => ({ v: 1, g: "g", m: "match", r, ts: Date.now(), n: 2, sc: [{ p: idPlayer.publicKeyB64, nm: "p", s }, { p: idHost.publicKeyB64, nm: "h", s: 1 }] });
  lbP.onFrame("H", { t: "lbq", base: mkBase(9999) }); // wrong score
  await new Promise((res) => realSetTimeout(res, 20));
  assert.equal(frames.length, 0, "refused: wrong score about me");
  lbP.onFrame("H", { t: "lbq", base: { ...mkBase(17), r: "other-room-hash-aaaaaa" } });
  await new Promise((res) => realSetTimeout(res, 20));
  assert.equal(frames.length, 0, "refused: different room");
  lbP.onFrame("H", { t: "lbq", base: mkBase(17) });
  await new Promise((res) => realSetTimeout(res, 20));
  assert.equal(frames.length, 1, "signed the honest base");
});

test("home shape: SocialAgent with external options object + FriendStore + profileSync + events", async () => {
  const store = new FriendStore("t-home-ns");
  const events = rec("agent.events", {
    presence: () => {},
    invite: () => {},
    request: () => {},
    accepted: () => {},
    synced: () => {},
  });
  const profileSync = rec("agent.profileSync", {
    get: () => ({ name: "ana", hue: 12, glyph: null, lang: "ro", ts: 5 }),
    apply: () => {},
  });
  const opts = rec("agent.opts", {
    ns: "t-home-ns",
    app: "ardegazu.ro",
    identity: idHost,
    seed: Identity.newSeed(),
    myName: () => "ana",
    store,
    pubsub: null,
    profileSync,
    events,
  });
  const agent = new SocialAgent(opts);
  assert.equal(agent.myPub, idHost.publicKeyB64, "myPub getter");
  await agent.start();
  await new Promise((r) => realSetTimeout(r, 400));
  assertReads("agent.opts",
    ["ns", "app", "identity", "seed", "myName", "store", "pubsub", "profileSync", "events", "roomLink", "t", "appState", "appsSync", "brains"],
    ["ns", "identity", "seed"]);
  const evReads = [...(reads.get("agent.events") ?? [])];
  assert.ok(evReads.every((k) => ["presence", "invite", "request", "accepted", "synced"].includes(k)), `events keys sane: ${evReads}`);
  agent.stop();
});

test("home lb-ui shape: startLbGossip full want/answer/fold flow on a fake pubsub", async () => {
  const listeners = new Set();
  const published = [];
  const subs = new Set();
  const pubsub = {
    subscribe: (t) => subs.add(t),
    unsubscribe: (t) => subs.delete(t),
    addEventListener: (ev, fn) => listeners.add(fn),
    removeEventListener: (ev, fn) => listeners.delete(fn),
    publish: async (topic, data) => { published.push([topic, new TextDecoder().decode(data)]); },
  };
  const receipts = new ReceiptSet("t-gossip");
  // seed one real verified receipt into the carried set
  const { roomHash, signReceipt } = await import("../dist/index.js");
  const base = { v: 1, g: "gA", m: "match", r: await roomHash("r1"), ts: Date.now() - 1000, n: 2,
    sc: [{ p: idHost.publicKeyB64, nm: "h", s: 9 }, { p: idPlayer.publicKeyB64, nm: "p", s: 3 }] };
  const rec1 = { ...base, sig: [await signReceipt(idHost, base), await signReceipt(idPlayer, base)] };
  assert.ok(await verifyReceipt(rec1, {}), "seed receipt valid");
  await receipts.add(rec1);
  const news = [];
  const g = await startLbGossip(
    rec("gossip.opts", { pubsub, receipts, games: ["gA"], onNew: (r) => news.push(r) }),
  );
  assert.equal(subs.size, 1, "subscribed to the lb topic");
  const topic = [...subs][0];
  assert.match(topic, /^lb\/1\//);
  // a peer asks: we must answer with lb-recs after cooldown check
  const fire = (obj) => { for (const fn of listeners) fn({ detail: { topic, data: new TextEncoder().encode(JSON.stringify(obj)) } }); };
  fire({ k: "lb-want", g: "gA" });
  await new Promise((r) => realSetTimeout(r, 50));
  const answer = published.find(([, d]) => d.includes("lb-recs"));
  assert.ok(answer, "answered lb-want with lb-recs");
  assert.ok(JSON.parse(answer[1]).recs.length === 1, "carried receipt in the answer");
  // a peer floods garbage: ignored
  fire({ k: "lb-recs", g: "WRONG" });
  fire(null);
  // a peer sends a foreign valid receipt: folded + onNew
  const base2 = { ...base, r: await roomHash("r2"), ts: Date.now() - 5000 };
  const rec2 = { ...base2, sig: [await signReceipt(idHost, base2), await signReceipt(idPlayer, base2)] };
  fire({ k: "lb-recs", g: "gA", recs: [rec2] });
  await new Promise((r) => realSetTimeout(r, 80));
  assert.equal(news.length, 1, "foreign receipt folded via onNew");
  assert.equal(receipts.all().length, 2, "carried set grew");
  g.stop();
  assert.equal(subs.size, 0, "unsubscribed on stop");
  // faithful-to-TS quirk: neither impl removes the message listener on stop
  assert.equal(listeners.size, 1, "listener deliberately NOT removed (TS parity)");
  assertReads("gossip.opts", ["pubsub", "receipts", "games", "verify", "onNew"], ["pubsub", "receipts", "games"]);
  const rows = aggregate([
    { v: 1, g: "g", m: "m", r: "r", ts: 1, n: 2, sc: [{ p: "A", nm: "ana", s: 5 }], sig: [] },
  ]);
  assert.equal(rows[0].pub, "A");
  assert.equal(rows[0].best, 5);
  assert.equal(rows[0].name, "ana");
  assert.equal(typeof isoWeek(Date.now()), "string");
});

test("./net: createSocialNode reads its documented option keys (dial may fail offline)", async () => {
  // libp2p keeps live handles even after a failed dial, so this runs in a
  // child process that hard-exits once the option-read audit is done.
  const { execFileSync } = await import("node:child_process");
  const script = `
    const reads = new Set();
    const cfg = new Proxy({
      descriptorUrl: "https://127.0.0.1:1/.well-known/ap2p",
      turnUrl: "https://127.0.0.1:1/turn",
      relayFallback: { peerId: "12D3KooWQYVQusM6BSDcVA6DXaXHUEysG9n7QjJUFyLFcM6dWKWm", multiaddrs: [] },
    }, { get(t, k) { if (typeof k === "string") reads.add(k); return Reflect.get(t, k); } });
    const { createSocialNode } = await import("./dist/net.js");
    try { const n = await createSocialNode(cfg); await n?.stop?.(); } catch {}
    console.log("READS:" + [...reads].filter((k) => k !== "then").join(","));
    process.exit(0);
  `;
  const out = execFileSync(process.execPath, ["--input-type=module", "-e", script], {
    cwd: new URL("..", import.meta.url).pathname,
    encoding: "utf8",
    timeout: 30000,
  });
  const m = out.match(/READS:(.*)/);
  assert.ok(m, "child reported option reads");
  const got = m[1].split(",").filter(Boolean);
  const allowed = ["descriptorUrl", "turnUrl", "relayFallback"];
  assert.deepEqual(got.filter((k) => !allowed.includes(k)), [], "unexpected (renamed?) option reads");
  assert.ok(got.includes("descriptorUrl"), "descriptorUrl read");
});

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