cautpartener / client / e2e / media.e2e.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
365
366
367
368
369
370
371
372
373
// M3b media e2e — image + voice messages over the MAILBOX only (never
// co-online): A sends a photo (canvas re-encode must strip metadata — the
// fixture carries an eXIf + tEXt canary) and a voice message (MediaRecorder is
// monkeypatched to emit fixed bytes; real mics don't exist headless) while B
// is offline; B boots and must render the thumb from its decrypted blob, show
// the voice bubble with a duration label, round-trip the voice bytes
// byte-exactly through the E2E blob path, and open the full-screen viewer.
// Run: node e2e/media.e2e.mjs        (HEADED=1 to watch)
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import { deflateSync } from "node:zlib";
import {
  acceptInvite,
  confirmJoinedInvite,
  createHarness,
  createIdentity as createIdentityFlow,
  createInvite,
  fail,
  ok,
  roomIdOf,
  sleep,
  waitAllRoomsDrained,
  waitChatReady,
  waitContactsReady,
} from "./helpers.mjs";

const PORT = 5187;
const STUB_PORT = 8794;
const CANARY = "EXIFCANARY9000"; // ASCII marker that must NOT survive re-encode

const harness = createHarness({ port: PORT, stubPort: STUB_PORT });
const BASE = harness.base;
const createIdentity = (page) => createIdentityFlow(page, BASE);

// ---- fixtures ----------------------------------------------------------------

/** Deterministic voice bytes, mirrored inside the page's MediaRecorder mock. */
const VOICE_LEN = 2048;
const voiceBytes = Buffer.from(Array.from({ length: VOICE_LEN }, (_, i) => (i * 7 + 13) & 0xff));

/** 64×64 PNG with eXIf + tEXt chunks carrying the canary (metadata to strip). */
function canaryPNG() {
  const S = 64;
  const crcT = [...Array(256)].map((_, n) => {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    return c >>> 0;
  });
  const crc = (b) => {
    let c = 0xffffffff;
    for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  };
  const chunk = (t, d) => {
    const len = Buffer.alloc(4);
    len.writeUInt32BE(d.length);
    const td = Buffer.concat([Buffer.from(t), d]);
    const cc = Buffer.alloc(4);
    cc.writeUInt32BE(crc(td));
    return Buffer.concat([len, td, cc]);
  };
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(S, 0);
  ihdr.writeUInt32BE(S, 4);
  ihdr[8] = 8;
  ihdr[9] = 2; // RGB
  const raw = Buffer.alloc(S * (S * 3 + 1));
  for (let y = 0; y < S; y++) {
    raw[y * (S * 3 + 1)] = 0;
    for (let x = 0; x < S; x++) raw.writeUIntBE(0x2266cc, y * (S * 3 + 1) + 1 + x * 3, 3);
  }
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk("IHDR", ihdr),
    chunk("eXIf", Buffer.concat([Buffer.from("Exif\0\0"), Buffer.from(CANARY)])), // EXIF marker
    chunk("tEXt", Buffer.concat([Buffer.from("Comment"), Buffer.from([0]), Buffer.from(CANARY)])),
    chunk("IDAT", deflateSync(raw)),
    chunk("IEND", Buffer.alloc(0)),
  ]);
}
const fixturePNG = canaryPNG();
if (!fixturePNG.includes(CANARY)) fail("fixture PNG does not contain the canary — fixture bug");

/** Page-side MediaRecorder + getUserMedia mock (headless has no mic). */
const RECORDER_MOCK = `
(() => {
  const LEN = ${VOICE_LEN};
  const bytes = new Uint8Array(LEN);
  for (let i = 0; i < LEN; i++) bytes[i] = (i * 7 + 13) & 0xff;
  const fakeStream = () => {
    try { return new MediaStream(); } catch { return { getTracks: () => [] }; }
  };
  const md = navigator.mediaDevices ?? {};
  const fakeGUM = async () => fakeStream();
  try { md.getUserMedia = fakeGUM; } catch {}
  try { Object.defineProperty(md, "getUserMedia", { value: fakeGUM, configurable: true }); } catch {}
  if (!navigator.mediaDevices) {
    try { Object.defineProperty(navigator, "mediaDevices", { value: md, configurable: true }); } catch {}
  }
  class FakeMediaRecorder {
    static isTypeSupported(t) { return typeof t === "string" && t.startsWith("audio/webm"); }
    constructor(_stream, opts) {
      this.mimeType = (opts && opts.mimeType) || "audio/webm";
      this.state = "inactive";
      this.ondataavailable = null;
      this.onstop = null;
    }
    start() { this.state = "recording"; }
    stop() {
      if (this.state === "inactive") return;
      this.state = "inactive";
      const data = new Blob([bytes], { type: this.mimeType });
      if (this.ondataavailable) this.ondataavailable({ data });
      if (this.onstop) this.onstop();
    }
  }
  window.MediaRecorder = FakeMediaRecorder;
})();
`;

// ---- page helpers ------------------------------------------------------------

const openChats = async (page) => {
  await page.click('[data-testid="tab-chats"]');
  await page.waitForSelector('[data-testid="chats-panel"]', { timeout: 60000 });
};

const openConversation = async (page, convId) => {
  await page.evaluate((h) => {
    location.hash = h;
  }, `#/c/${convId}`);
  await page.waitForSelector('[data-testid="chat-input"]', { timeout: 60000 });
};

/** Decrypted blob bytes of a media message, via the page's own decode path. */
const blobBytesOf = (page, convId, cid) =>
  page.evaluate(
    async ([conv, c]) => {
      const room = window.__cp.chat.getOpen(conv);
      if (!room?.handle) throw new Error("room not open");
      const bytes = await room.handle.log.getImage(c);
      return bytes ? [...bytes] : null;
    },
    [convId, cid],
  );

const mediaOps = (page, convId) =>
  page.evaluate((conv) => {
    const room = window.__cp.chat.getOpen(conv);
    return room.messages
      .filter((m) => m.kind !== "text")
      .map((m) => ({ kind: m.kind, img: m.img ?? null, voice: m.voice ?? null, from: m.from }));
  }, convId);

const hasSub = (haystack, needle) => {
  outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
    for (let j = 0; j < needle.length; j++) if (haystack[i + j] !== needle[j]) continue outer;
    return true;
  }
  return false;
};

// ---- scenario ----------------------------------------------------------------

async function main() {
  await harness.start();

  const dirA = harness.newProfileDir("cp-media-a-");
  const dirB = harness.newProfileDir("cp-media-b-");

  // --- A + B become contacts (M2 flow, never co-online) -----------------------
  let A = await harness.openDevice("A", dirA);
  await createIdentity(A.page);
  await waitContactsReady(A.page);
  const pubA = await A.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
  const { token } = await createInvite(A.page, "Ana");
  const inviteRoom = await roomIdOf(A.page, token.split(".")[0]);
  for (let i = 0; i < 40 && (await harness.roomCount(inviteRoom)) < 1; i++) await sleep(500);
  await waitAllRoomsDrained(A.page);
  await A.close();

  let B = await harness.openDevice("B", dirB);
  await createIdentity(B.page);
  await waitContactsReady(B.page);
  const pubB = await B.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
  await acceptInvite(B.page, BASE, token, "Bogdan");
  await waitAllRoomsDrained(B.page);
  await B.close();
  ok("contacts established (A ↔ B, mailbox only)");

  // --- A (B offline): send an image and a voice message -----------------------
  A = await harness.openDevice("A2", dirA);
  await A.ctx.addInitScript(RECORDER_MOCK);
  await A.page.goto(BASE);
  await confirmJoinedInvite(A.page);
  await A.page.waitForFunction((pub) => window.__cp?.contacts?.state.contacts.get(pub)?.status === "active", pubB, {
    timeout: 60000,
  });
  await waitChatReady(A.page);
  await openChats(A.page);
  await openConversation(A.page, pubB);

  // image: file input → re-encode → preview sheet → send
  await A.page.setInputFiles('[data-testid="chat-file"]', {
    name: "photo.png",
    mimeType: "image/png",
    buffer: fixturePNG,
  });
  await A.page.waitForSelector('[data-testid="img-preview"]', { timeout: 30000 });
  await A.page.click('[data-testid="img-send"]');
  await A.page.waitForFunction(
    () => {
      const img = document.querySelector('[data-testid="msg-img"]');
      return !!img && img.src.startsWith("blob:") && img.naturalWidth > 0;
    },
    undefined,
    { timeout: 60000 },
  );
  ok("A: image re-encoded, previewed, sent — own thumb renders from the sealed blob");

  // voice: mic → recording state with countdown → stop = send
  await A.page.click('[data-testid="chat-mic"]');
  await A.page.waitForSelector('[data-testid="rec-bar"]:not([hidden])', { timeout: 15000 });
  const recText = (await A.page.textContent('[data-testid="rec-elapsed"]')).trim();
  if (!/\/ 1:00$/.test(recText)) fail(`recording countdown reads "${recText}" — expected the 1:00 cap`);
  await sleep(1300); // ~1 s of "recording"
  await A.page.click('[data-testid="rec-stop"]');
  await A.page.waitForSelector('[data-testid="voice-play"]', { timeout: 30000 });
  const durLabelA = (await A.page.textContent('[data-testid="voice-dur"]')).trim();
  if (!/^\d:\d\d$/.test(durLabelA)) fail(`A voice duration label reads "${durLabelA}"`);
  ok(`A: voice message recorded (${durLabelA}) via MediaRecorder path and sent`);

  // both messages (entry + all blob chunks) must reach the mailbox
  await waitAllRoomsDrained(A.page);
  const aOps = await mediaOps(A.page, pubB);
  const aImg = aOps.find((o) => o.kind === "img");
  const aVoice = aOps.find((o) => o.kind === "voice");
  if (!aImg || !aVoice) fail("A's fold is missing the img/voice ops");
  if (!["image/webp", "image/jpeg"].includes(aImg.img.mime)) fail(`img op mime is ${aImg.img.mime}`);
  if (aVoice.voice.mime !== "audio/webm") fail(`voice op mime is ${aVoice.voice.mime}`);
  if (!(aVoice.voice.dur >= 1 && aVoice.voice.dur <= 3)) fail(`voice op dur is ${aVoice.voice.dur}`);
  await A.close();
  ok("A: img + voice ops well-formed, blobs deposited; device closed (B was offline)");

  // --- B boots: both media messages must arrive through the mailbox -----------
  B = await harness.openDevice("B2", dirB);
  await B.page.goto(BASE);
  await waitChatReady(B.page);
  await openChats(B.page);
  await B.page.waitForSelector(`[data-testid="conv-row"][data-conv="${pubA}"] [data-testid="conv-unread"]`, {
    timeout: 90000,
  });
  await B.page.click(`[data-testid="conv-row"][data-conv="${pubA}"]`);
  await B.page.waitForSelector('[data-testid="chat-input"]', { timeout: 60000 });

  // image thumb renders from the decrypted blob (blob: URL + real bitmap)
  await B.page.waitForFunction(
    () => {
      const img = document.querySelector('[data-testid="msg-img"]');
      return !!img && img.src.startsWith("blob:") && img.naturalWidth > 0;
    },
    undefined,
    { timeout: 90000 },
  );
  const dims = await B.page.$eval('[data-testid="msg-img"]', (el) => ({ w: el.naturalWidth, h: el.naturalHeight }));
  if (dims.w < 1 || dims.h < 1) fail("B: thumb has no bitmap");
  ok(`B: image thumb delivered offline and renders (${dims.w}×${dims.h} thumb bitmap)`);

  const bOps = await mediaOps(B.page, pubA);
  const bImg = bOps.find((o) => o.kind === "img");
  const bVoice = bOps.find((o) => o.kind === "voice");
  if (!bImg || !bVoice) fail(`B folded ${bOps.length} media ops — expected img + voice`);
  if (bImg.from !== pubA || bVoice.from !== pubA) fail("media ops not attributed to A's verified identity");

  // EXIF/metadata stripping: the DISPLAY bytes must not contain the canary and
  // must be a re-encoded webp/jpeg, not the original PNG container
  {
    const display = await blobBytesOf(B.page, pubA, bImg.img.full);
    if (!display) fail("B could not fetch/decrypt the display blob");
    const canaryBytes = [...Buffer.from(CANARY)];
    if (hasSub(display, canaryBytes)) fail("metadata canary SURVIVED the re-encode — EXIF stripping broken");
    const isWebp = display[0] === 0x52 && display[1] === 0x49 && display[2] === 0x46 && display[3] === 0x46;
    const isJpeg = display[0] === 0xff && display[1] === 0xd8;
    const isPng = display[0] === 0x89 && display[1] === 0x50;
    if (isPng || (!isWebp && !isJpeg)) fail("display blob is not a re-encoded webp/jpeg");
    const thumb = await blobBytesOf(B.page, pubA, bImg.img.thumb);
    if (!thumb) fail("B could not fetch/decrypt the thumb blob");
    if (hasSub(thumb, canaryBytes)) fail("metadata canary survived in the THUMB");
    ok(`B: metadata canary stripped structurally (display is ${isWebp ? "webp" : "jpeg"}, ${display.length} B)`);
  }

  // voice: duration label + play control render; bytes round-trip EXACTLY
  const durLabelB = (await B.page.textContent('[data-testid="voice-dur"]')).trim();
  if (durLabelB !== durLabelA) fail(`B voice duration label "${durLabelB}" ≠ A's "${durLabelA}"`);
  if (!(await B.page.$('[data-testid="voice-play"]'))) fail("B has no voice play button");
  {
    const got = await blobBytesOf(B.page, pubA, bVoice.voice.blob);
    if (!got) fail("B could not fetch/decrypt the voice blob");
    if (got.length !== voiceBytes.length) fail(`voice blob length ${got.length} ≠ ${voiceBytes.length}`);
    for (let i = 0; i < got.length; i++) {
      if (got[i] !== voiceBytes[i]) fail(`voice blob differs at byte ${i}`);
    }
  }
  ok(`B: voice message delivered offline — ${durLabelB} label, play control, bytes round-trip byte-exact`);

  // full-screen viewer: tap thumb → display blob renders; close works
  await B.page.click('[data-testid="msg-img"]');
  await B.page.waitForSelector('[data-testid="img-viewer"]', { timeout: 15000 });
  await B.page.waitForFunction(
    () => {
      const img = document.querySelector('[data-testid="viewer-img"]');
      return !!img && img.src.startsWith("blob:") && img.naturalWidth > 0;
    },
    undefined,
    { timeout: 60000 },
  );
  await B.page.click('[data-testid="viewer-close"]');
  await B.page.waitForFunction(() => !document.querySelector('[data-testid="img-viewer"]'), undefined, {
    timeout: 10000,
  });
  ok("B: full-screen viewer opens on tap (display blob) and closes");

  // --- decode bomb: an op claiming tiny w/h but carrying a blob that decodes --
  //     to 5000×5000 (> the 4096² cap) must be REFUSED, not decoded to ~400 MB
  //     RGBA. Fabricated in-page: render a 5000² solid, encode webp, push it
  //     raw through the blob path, append an img op with forged 64×64 dims.
  const bombCid = await B.page.evaluate(async (conv) => {
    const room = window.__cp.chat.getOpen(conv);
    const canvas = document.createElement("canvas");
    canvas.width = 5000;
    canvas.height = 5000;
    const ctx = canvas.getContext("2d");
    ctx.fillStyle = "#2266cc";
    ctx.fillRect(0, 0, 5000, 5000);
    const blob = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.5));
    const bytes = new Uint8Array(await blob.arrayBuffer());
    const { cid } = await room.handle.log.putImage(bytes);
    await room.handle.append({ t: "img", ts: Date.now(), thumb: cid, full: cid, w: 64, h: 64, mime: "image/webp" });
    return cid;
  }, pubA);
  // the bomb bubble must render REFUSED (honest blank + unavailable state), no bitmap
  await B.page.waitForFunction(
    (cid) => {
      const img = document.querySelector(`[data-testid="msg-img"][data-cid="${cid}"]`);
      return !!img && img.dataset.rejected === "1" && !img.src && img.naturalWidth === 0;
    },
    bombCid,
    { timeout: 60000 },
  );
  // and the legitimate thumb is untouched — still a real bitmap, never refused
  {
    const okImg = await B.page.$eval(`[data-testid="msg-img"][data-cid="${bImg.img.full}"]`, (el) => ({
      rejected: el.dataset.rejected ?? null,
      natural: el.naturalWidth,
    }));
    if (okImg.rejected) fail("the legitimate image was wrongly refused by the decode-bomb guard");
    if (!(okImg.natural > 0)) fail("the legitimate image lost its bitmap");
  }
  ok("B: decode-bomb image (5000×5000 blob behind a forged 64×64 op) refused; legitimate image unaffected");

  await B.close();
}

let exitCode = 0;
try {
  await main();
  console.log("\n🎉 media (image + voice over mailbox, EXIF strip, round-trip) scenarios passed");
} catch (err) {
  console.error("❌ " + (err?.message ?? err));
  exitCode = 1;
} finally {
  await harness.shutdown();
}
process.exit(exitCode);

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git