Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_group.mjs

91.3 KiB, 1 run

created by r2519314175:451, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// dev/verify_group.mjs -- groups: a membership list with no group key.
2//
3// EVERY SECTION BELOW RUNS WITH NO GATEWAY IN THE PATH AT ALL. Three browsers,
4// three profiles, three identities; the bytes are carried between them by this
5// file, which is what a relay does and nothing more. That is the shape a group
6// needs and it is how the two-party seal was proved (dev/verify_post.mjs §1) --
7// if a group only works when a server is in the middle, the server is part of
8// the cryptography and the whole design is a lie.
9//
10// The properties, each one a thing that could break silently:
11//
12// 0. THE SEAM IS IN THE APP. `<script src="js/group.js">` is in index.html and
13// post.js calls into it. Nothing here injects either.
14// 1. ONE ENVELOPE, N SLOTS, THREE IDENTITIES. A roster A composes opens on B
15// and on C and NOT on D. The envelope carries one slot per member and no
16// recipient tag, so it says how many and never who.
17// 2. THE ID IS THE AUTHORISATION. A roster is obeyed only where the id
18// recomputed from its OWN author and salt is the `to` its signature covers.
19// B cannot mint a roster for A's group; the negative is checked with B
20// holding every key and every module A holds.
21// 3. JOINING SHOWS NOTHING EARLIER, and the sentence is on the screen BEFORE
22// the press. C, added after a message was sent, cannot open that message --
23// and is refused for the right reason.
24// 4. REMOVING RETRACTS NOTHING, and that sentence is on the screen too. After
25// A drops C: C still holds every message; C's own record says left; and a
26// message C writes to the group is REFUSED BY B, because there is no group
27// key to rotate and every reader is where a removal is enforced.
28// 5. A PERSON CANNOT WRITE A ROSTER. The marker line is refused at the one
29// door a person's own text comes through.
30// 6. THE FAN-OUT ARITHMETIC IS AT THE FAN-OUT, in a comment, with the number
31// this build actually has rather than the one the plan assumed.
32// 7. A KEY THIS DEVICE DOES NOT STAND BEHIND GETS NO SLOT, and the sender is
33// told who was left out. Counted in the envelope, not in a sentence.
34// 8. ONE GROUP MESSAGE IS ONE EXPIRY NOTICE. The relay writes one per box; a
35// dozen identical rows would read as a dozen lost messages.
36// 9. THE MERGE CONVERGES. A roster and a decision travel on separate clocks
37// with one writer each, so two parcels give one record in either order.
38// 10. THE CREATOR'S PATH IS THE SHIPPED ONE, pressed rather than called.
39// 11. A KEY THAT COULD NOT GO IN IS NAMED, and a duplicate is not the same fault.
40// 12. A REFUSED DELIVERY IS DRAWN, for a message and for a roster.
41// 13. A GROUP CAN BE CLOSED, ONCE, AND NO READER WILL REOPEN IT. Closing is the
42// creator writing the roster that names NOBODY -- because a group IS its
43// membership list. It travels the road every roster travels, it takes one
44// confirmation dialogue, it destroys nothing, and it cannot be undone: a
45// later roster from the creator's own key is refused by `consume` on every
46// device that already holds the empty one.
47//
48// ── WHY 10 EXISTS, WHICH IS THE ONLY INTERESTING THING IN THIS FILE ──────────
49//
50// This suite reported 88 assertions green over a build in which THE CREATOR OF A
51// GROUP COULD NOT WRITE TO IT. `create` applied its own roster through `consume`,
52// which filed an unknown group as `invited`, and `sealTo` then refused the creator
53// with "Join this group before writing to it." Nothing in the app ever joined
54// them: the panel's Make branch says how many people were told and stops.
55//
56// It passed because sections 3, 4 and 7 each called `DaimondGroup` `join()` on the
57// creator's own page immediately after `create()` -- AND THAT IS A LINE THE
58// APPLICATION DOES NOT HAVE. The test wrote the missing behaviour itself and then
59// measured its own repair. Every assertion after it was true of a device no user
60// can produce.
61//
62// So two rules hold here now, and the first is checked by section 10 against this
63// file's own source rather than left as an instruction in a comment:
64//
65// * NOTHING IN THIS FILE MAY CALL A METHOD TO PUT A DEVICE IN A STATE THE APP
66// PUTS IT IN ITSELF. A creator is joined by `create`, or the build is broken.
67// A member joins by PRESSING JOIN, through the same document listener the
68// panel's own control goes through.
69// * The press is dispatched on the real control rather than hit-tested, because
70// the Social panel is closed in a fresh profile and the group section is
71// therefore zero-sized. What is under test is group.js's own wiring, which the
72// dispatch drives in full; whether improve.js has the panel open is a
73// different file's property.
74//
75// node dev/verify_group.mjs # every section
76// node dev/verify_group.mjs 10 11 # by number, for proving one red
77//
78// ── PROVING THESE RED, MEASURED RATHER THAN REASONED ─────────────────────────
79//
80// Each break below was applied to `www/js/group.js`, run, and the sections it
81// moved written down. Two of the answers were not the expected ones and both are
82// kept here, because a break that reddens LESS than it should is a finding about
83// the check and not about the code.
84//
85// * `sealTo`, delete the `isClosed` branch → 4 red, all in 13: the sender is
86// told "you are no longer in this group" about a group they closed.
87// * `consume`, delete `if (isClosed(rec)) return false;` → 8 red, all in 13,
88// nothing in 0-12. This is the one that makes "it cannot be undone" a
89// property: without it a later roster from the creator revives the group on
90// every device that had already closed it.
91// * `roster`, delete its own `isClosed` guard → 1 red. NOT the outcome check:
92// `setMembers` still fails, because `consume` refuses the read-back and
93// `roster` will not send a roster it could not apply. So that guard buys the
94// SENTENCE and not the refusal, which is written at the guard itself.
95// * `parseOp`, put `|| !j.members.length` back → 10 red in 13 and NOTHING in
96// 0-12, so relaxing it is invisible to every other roster. It also aborted
97// the section on `posts[0]`, which is why `closingEnv` exists below.
98// * `askClose`, ignore the answer and return true → 4 red: the dialogue is
99// asked, dismissed, and the group closes anyway.
100// * `draw`, delete the settle loop → 2 red: a record left saying `joined` over
101// an empty roster keeps offering a destination in `post.js`'s picker.
102// * `draw`, partition on `state` alone → 4 red: a closed group is drawn by
103// `drawLeft`, so it says "you are no longer in this group" instead of what
104// happened, and carries no mark a stylesheet could reach.
105// * `roster`, count `bad.length + missing.length` in `group.err_no_card` again
106// → 1 red, and only in the fixture that supplies ONE OF EACH fault. The
107// one-fault fixtures cannot see it: with no bad key the arithmetic is right
108// by accident.
109// * `roster`, send `bad` through `group.err_no_card` instead of
110// `group.err_bad_keys` → 5 red in 11.
111
112import fs from 'node:fs';
113import path from 'node:path';
114import { fileURLToPath } from 'node:url';
115import { open, errors } from './harness.mjs';
116
117const HERE = path.dirname(fileURLToPath(import.meta.url));
118const APP = path.dirname(HERE);
119
120let failures = 0;
121function ok(cond, what, detail) {
122 if (cond) { console.log(` ok ${what}`); return true; }
123 failures++;
124 console.log(` FAIL ${what}${detail !== undefined ? ` -- ${JSON.stringify(detail)}` : ''}`);
125 return false;
126}
127function eq(got, want, what) {
128 return ok(JSON.stringify(got) === JSON.stringify(want), what, { got, want });
129}
130
131/// Wait for the page the browser assembled. Nothing is injected: a missing
132/// script tag is a failure to report, never a file to load from disk.
133async function ready(s) {
134 await s.page.waitForFunction(
135 () => !!window.DaimondPost && !!window.DaimondTrust && !!window.DaimondGroup
136 && !!document.querySelector('#social-messages-list'),
137 null, { timeout: 15000 }
138 ).catch(() => { throw new Error(
139 'the page did not assemble: post.js, trust.js, group.js or '
140 + '#social-messages-list is missing from www/index.html.'); });
141}
142
143/// An identity with a sealing key and a card, exactly as a person's first
144/// unlock makes one.
145const card = (s) => s.page.evaluate(async () => {
146 await window.DaimondIdentity.ensureSealingKey();
147 await window.DaimondIdentity.mintCard();
148 const raw = await window.DaimondIdentity.publicKeyRaw();
149 let hex = '';
150 for (const b of raw) hex += ('0' + b.toString(16)).slice(-2);
151 return {
152 text: window.DaimondTrust.cardText(),
153 pub: window.DaimondIdentity.publicKeyB64url(),
154 key: hex,
155 };
156});
157
158/// Hand one card to one device, the way a paste does, through trust.js.
159const take = (s, text) => s.page.evaluate(async (t) => {
160 const c = window.DaimondTrust.parse(t);
161 if (!c) return false;
162 await window.DaimondTrust.record(c, window.DaimondTrust.ROUTE.PASTE);
163 await window.DaimondPost.refreshPeople();
164 return true;
165}, text);
166
167/// Hand one device one row, exactly as the relay hands one over.
168///
169/// THROUGH `DaimondPost.take`, which is the function a real `collect` calls for
170/// every row it fetches. Nothing here opens an envelope itself or writes a
171/// record itself: a helper that did would still pass on a build where `collect`
172/// had stopped calling `take` at all, which is a check measuring less than the
173/// run it stands in for.
174let SEQ = 0;
175const deliver = (s, env, addr) => s.page.evaluate(async ([e, a, seq]) => {
176 const r = await window.DaimondPost.take({
177 kind: 'post', addr: a, envelope: e, seq,
178 ts: Math.floor(Date.now() / 1000), tray: false, expired: false, from_pub: '',
179 });
180 const rows = window.DaimondPost.list().concat(window.DaimondPost.tray());
181 const held = rows.filter(m => m.addr === a).pop() || null;
182 return {
183 ok: r.got > 0 || r.notes > 0,
184 op: r.notes > 0,
185 moved: r.notes > 0,
186 body: held && !held.bad ? held.body : '',
187 gid: held ? (held.gid || '') : '',
188 tray: held ? !!held.tray : false,
189 why: (held && held.bad) || r.why || '',
190 };
191}, [env, addr, ++SEQ]);
192
193/// Draw the group section, and wait for it: the roster is read out from under the
194/// passphrase, so `render` returns before there is anything in the host.
195const panel = (s) => s.page.evaluate(async () => {
196 await window.DaimondPost.read();
197 window.DaimondPost.render();
198 const host = document.querySelector('#post-groups');
199 for (let i = 0; i < 60 && host && !host.childElementCount; i++) {
200 await new Promise(r => setTimeout(r, 25));
201 }
202 return { there: !!host, filled: !!host && host.childElementCount > 0,
203 text: host ? host.textContent : '' };
204});
205
206/// The state this device holds for one group, or 'none'.
207const stateOf = (s, gid) => s.page.evaluate(async (g) =>
208 (await window.DaimondGroup.get(g) || {}).state || 'none', gid);
209
210/// Press one control in the group section, and wait for the state it is meant to
211/// produce. The handler is fire-and-forget -- a click returns before the record is
212/// written -- so a press that did not settle is reported as a press, never as the
213/// property it was standing in for.
214async function press(s, sel, until) {
215 const hit = await s.page.evaluate((q) => {
216 const b = document.querySelector(q);
217 if (!b) return false;
218 b.click();
219 return true;
220 }, sel);
221 if (!hit) return { hit: false, why: `no control matched ${sel}` };
222 for (let i = 0; i < 80; i++) {
223 if (await s.page.evaluate(until)) return { hit: true, settled: true };
224 await new Promise(r => setTimeout(r, 50));
225 }
226 return { hit: true, settled: false };
227}
228
229/// JOIN A GROUP THE WAY A PERSON DOES: press Join on the invitation.
230///
231/// The one repair this file is allowed to perform on a device, because it is the
232/// one the panel performs. See the header: calling the module's own `join` was how
233/// a creator who could not write to their own group passed 88 assertions.
234async function joinByPress(s, gid) {
235 await panel(s);
236 const r = await press(s,
237 `#post-groups [data-gid="${gid}"] [data-act="group-join"]`,
238 () => true);
239 if (!r.hit) return r;
240 for (let i = 0; i < 80; i++) {
241 if (await stateOf(s, gid) === 'joined') return { hit: true, settled: true };
242 await new Promise(x => setTimeout(x, 50));
243 }
244 return { hit: true, settled: false, state: await stateOf(s, gid) };
245}
246
247/// Press a control, read the confirmation dialogue it opens, and answer it.
248///
249/// THROUGH THE APP'S OWN MODAL, `daimond.js`'s one dialog frame, driven by
250/// clicking its own buttons. Stubbing `DaimondCore.confirm` would be quicker and
251/// would pass on a build whose dialogue never opens at all -- which is half of
252/// what "one confirmation dialogue" is asked to mean, and the half a stub cannot
253/// see. `share.js` stubs it in `verify_share.mjs` for a case that is about the
254/// ANSWER; this one is about the asking.
255///
256/// `answer` true presses the accepting button, false the way out. The card's own
257/// text comes back so a caller asserts the WORDS a reader is shown.
258async function pressAndAnswer(s, sel, answer) {
259 const hit = await s.page.evaluate((q) => {
260 const b = document.querySelector(q);
261 if (!b) return false;
262 b.click();
263 return true;
264 }, sel);
265 if (!hit) return { hit: false, why: `no control matched ${sel}` };
266 let card = null;
267 for (let i = 0; i < 80; i++) {
268 card = await s.page.evaluate(() => {
269 const back = document.querySelector('.modal.dlg');
270 if (!back) return null;
271 const ok = back.querySelector('.dlg-ok');
272 return {
273 title: (back.querySelector('h2') || {}).textContent || '',
274 text: back.textContent || '',
275 ok: ok ? ok.textContent : '',
276 danger: !!ok && ok.classList.contains('danger'),
277 cancel: !!back.querySelector('.dlg-cancel'),
278 };
279 });
280 if (card) break;
281 await new Promise(r => setTimeout(r, 50));
282 }
283 if (!card) return { hit: true, asked: false };
284 await s.page.evaluate((yes) => {
285 const back = document.querySelector('.modal.dlg');
286 const b = back && back.querySelector(yes ? '.dlg-ok' : '.dlg-cancel');
287 if (b) b.click();
288 }, !!answer);
289 // AND IT GOES AWAY. A dialogue that answered and stayed would leave the next
290 // press finding two, and the second press in this section would drive the first
291 // one's card.
292 for (let i = 0; i < 40; i++) {
293 if (!await s.page.evaluate(() => !!document.querySelector('.modal.dlg'))) break;
294 await new Promise(r => setTimeout(r, 25));
295 }
296 return { hit: true, asked: true, card };
297}
298
299/// `sealGroup`'s answer carries the composed envelope under `made`; these two keep
300/// the reach into it in one place.
301const made2env = (r) => (r && r.made && r.made.envelope) || '';
302const made2addr = (r) => (r && r.made && r.made.addr) || '';
303
304/// THE RELAY, PLAYED BY A FUNCTION, for the two sections that need a delivery to
305/// either land or be refused.
306///
307/// Every section in this file runs with no gateway in the path, and this does not
308/// change that: it is the least a relay can be and still be one. It records what
309/// it was handed -- so a press can be followed by carrying the bytes on -- and it
310/// answers PER RECIPIENT, which is the only way a partial fan-out exists to be
311/// reported at all. A stub that refused everybody would leave `ok:true` beside a
312/// list of refusals untested, and that is precisely the case nothing drew.
313///
314/// `fullFor` is the base64url key whose box answers 507. '' is a relay that takes
315/// everything, which is the negative control.
316const stubRelay = (s, fullFor) => s.page.evaluate((full) => {
317 window.__posts = [];
318 if (!window.__realFetch) window.__realFetch = window.DaimondGateway.gwFetch;
319 window.DaimondGateway.gwFetch = async (q, opts) => {
320 let body = null;
321 try { body = JSON.parse(opts && opts.body); } catch (e) { body = null; }
322 if (!body || !body.envelope) return await window.__realFetch(q, opts);
323 window.__posts.push({ to: String(body.to), addr: String(body.addr),
324 envelope: String(body.envelope) });
325 // 507 is a full box: the relay's answer about a member who has not
326 // collected their mail, and the one that must not silence a group.
327 const status = (full && String(body.to) === full) ? 507 : 200;
328 return { status, json: async () => (status === 200 ? { ok: true } : { ok: false }) };
329 };
330}, fullFor || '');
331
332/// What the stub was handed, oldest first.
333const handed = (s) => s.page.evaluate(() => (window.__posts || []).slice());
334
335/// Put the real one back, so a section cannot leak a relay into the next.
336const realRelay = (s) => s.page.evaluate(() => {
337 if (window.__realFetch) window.DaimondGateway.gwFetch = window.__realFetch;
338});
339
340/// MAKE A GROUP THE WAY A PERSON DOES: the name box, the picker, the Make button,
341/// and then whatever the panel's own status line says about it. Nothing else --
342/// no `create`, and above all nothing afterwards.
343async function makeByPress(s, name, keys) {
344 await panel(s);
345 return await s.page.evaluate(async ([nm, ks]) => {
346 const box = document.querySelector('#group-make');
347 if (!box) return { err: 'the Make box is not drawn' };
348 const field = document.querySelector('#group-name');
349 const pick = document.querySelector('#group-members');
350 if (!field || !pick) return { err: 'the Make box has no name or no picker' };
351 field.value = nm;
352 const offered = [...pick.options].map(o => o.value);
353 [...pick.options].forEach((o) => { o.selected = ks.indexOf(o.value) >= 0; });
354 const chose = [...pick.selectedOptions].map(o => o.value);
355 const btn = box.querySelector('[data-act="group-make"]');
356 if (!btn) return { err: 'the Make box has no Make control' };
357 btn.click();
358 let note = '';
359 for (let i = 0; i < 100; i++) {
360 note = (document.querySelector('#group-note') || {}).textContent || '';
361 if (note && !/^Making/.test(note)) break;
362 await new Promise(r => setTimeout(r, 50));
363 }
364 const gs = await window.DaimondGroup.list();
365 return { note, offered, chose,
366 groups: gs.map(g => ({ gid: g.gid, state: g.state, n: g.members.length })) };
367 }, [String(name), keys]);
368}
369
370// ── 0. The seam is in the app ────────────────────────────────
371
372async function seamIsReal() {
373 console.log('\n0. the seam is in the app, not in this file');
374 const s = await open({ name: 'group-seam', connect: false });
375 try {
376 const seen = await s.page.evaluate(async () => {
377 const html = await (await fetch('/index.html')).text();
378 return {
379 tag: /<script[^>]+src=["']js\/group\.js["']/.test(html),
380 global: !!window.DaimondGroup,
381 // post.js must actually reach into it, or group.js is a module
382 // with no production caller -- which is what three lanes shipped
383 // this week and called done.
384 mounts: typeof window.DaimondGroup?.mount === 'function',
385 seals: typeof window.DaimondPost?.sealGroup === 'function',
386 absorb: typeof window.DaimondPost?.absorbRoster === 'function',
387 store: typeof window.DaimondPost?.groups === 'function',
388 };
389 });
390 ok(seen.tag, '<script src="js/group.js"> is in www/index.html');
391 ok(seen.global, 'window.DaimondGroup is up');
392 ok(seen.mounts && seen.seals && seen.absorb && seen.store,
393 'post.js reaches group.js through four published seams', seen);
394
395 // And the region is drawn by post.js's own render, not by this file. The
396 // draw is asynchronous -- the roster is read out from under the
397 // passphrase -- so this waits for it rather than reading the frame
398 // `render` returned in.
399 const drawn = await s.page.evaluate(async () => {
400 await window.DaimondIdentity.ensureSealingKey();
401 await window.DaimondPost.read();
402 window.DaimondPost.render();
403 const host = document.querySelector('#post-groups');
404 for (let i = 0; i < 40 && host && !host.childElementCount; i++) {
405 await new Promise(r => setTimeout(r, 25));
406 }
407 return { there: !!host, filled: !!host && host.childElementCount > 0,
408 text: host ? host.textContent.slice(0, 120) : '' };
409 });
410 ok(drawn.there && drawn.filled,
411 'post.js renders the group section inside its own region', drawn);
412 } finally { await s.close(); }
413}
414
415// ── 1. One envelope, N slots, three identities ───────────────
416
417async function threeIdentities() {
418 console.log('\n1. one roster, three identities, no server in the path');
419 const a = await open({ name: 'group-a', connect: false });
420 const b = await open({ name: 'group-b', connect: false });
421 const c = await open({ name: 'group-c', connect: false });
422 const d = await open({ name: 'group-d', connect: false });
423 try {
424 for (const s of [a, b, c, d]) await ready(s);
425 const A = await card(a), B = await card(b), C = await card(c), D = await card(d);
426 ok(await take(a, B.text) && await take(a, C.text),
427 'A holds cards for B and C');
428
429 const made = await a.page.evaluate(async ([kb, kc]) =>
430 window.DaimondGroup.create('The file view', [kb, kc]), [B.key, C.key]);
431 ok(made.ok, 'A made a group', made);
432 eq(made.members, 3, 'the roster names three people, A included');
433
434 // The envelope's own shape. One slot per member, and A's own Sent slot is
435 // not a fourth: A is already in the roster.
436 const shape = await a.page.evaluate((env) => {
437 const bin = atob(env);
438 const b = new Uint8Array(bin.length);
439 for (let i = 0; i < bin.length; i++) b[i] = bin.charCodeAt(i);
440 return { magic: String.fromCharCode(b[0], b[1], b[2], b[3]), n: b[36],
441 len: b.length };
442 }, made.envelope);
443 eq(shape.magic, 'DPS1', 'it is one sealed envelope and not three');
444 eq(shape.n, 3, 'it carries one slot per member and no more');
445 // 4 magic + 32 epk + 1 count + 3x60 slot + 12 iv, then the body. The
446 // arithmetic is asserted rather than described, because the comment at the
447 // fan-out is only worth having if the number in it is this one.
448 ok(shape.len > 4 + 32 + 1 + 3 * 60 + 12,
449 'and 60 bytes of slot each, which is the number the fan-out comment uses',
450 shape);
451
452 // B and C each open the SAME bytes. A group message is one envelope.
453 const gotB = await deliver(b, made.envelope, made.addr);
454 const gotC = await deliver(c, made.envelope, made.addr);
455 ok(gotB.ok && gotB.op, 'B opened the roster', gotB);
456 ok(gotC.ok && gotC.op, 'C opened the same bytes', gotC);
457
458 const listB = await b.page.evaluate(() => window.DaimondGroup.list());
459 eq(listB.length, 1, 'B holds one group');
460 eq(listB[0] && listB[0].state, 'invited',
461 'and it is an INVITATION, not a group B has been put in without asking');
462 eq(listB[0] && listB[0].gid, made.gid, 'at the id A derived');
463
464 // The negative that makes the positive mean something. D holds a sealing
465 // key, a full bridge and the group module -- so a refusal about any of
466 // those would be this assertion passing by accident.
467 const gotD = await deliver(d, made.envelope, made.addr);
468 ok(!gotD.ok, 'a fourth identity cannot open it', gotD);
469 ok(/not sealed to any key/i.test(gotD.why || ''),
470 'and is refused because no slot is theirs, not because it could not try',
471 gotD.why);
472 eq((await d.page.evaluate(() => window.DaimondGroup.list())).length, 0,
473 'and holds no group as a result of having been sent one');
474 return { A, B, C, D, gid: made.gid };
475 } finally { await Promise.all([a.close(), b.close(), c.close(), d.close()]); }
476}
477
478// ── 2. The id is the authorisation ───────────────────────────
479
480async function idIsTheAuthorisation() {
481 console.log('\n2. only the creator can write a roster, and it is an identity');
482 const a = await open({ name: 'group-auth-a', connect: false });
483 const b = await open({ name: 'group-auth-b', connect: false });
484 const c = await open({ name: 'group-auth-c', connect: false });
485 try {
486 for (const s of [a, b, c]) await ready(s);
487 const A = await card(a), B = await card(b), C = await card(c);
488 for (const [who, texts] of [[a, [B.text, C.text]], [b, [A.text, C.text]],
489 [c, [A.text, B.text]]]) {
490 for (const t of texts) await take(who, t);
491 }
492
493 const made = await a.page.evaluate(async ([kb, kc]) =>
494 window.DaimondGroup.create('A group of A\'s', [kb, kc]), [B.key, C.key]);
495 ok(made.ok, 'A made a group');
496 await deliver(b, made.envelope, made.addr);
497 await deliver(c, made.envelope, made.addr);
498
499 // The derivation is what the whole model rests on, so it is checked
500 // directly: A's id comes out of A's key and nobody else's.
501 const derived = await b.page.evaluate(async ([creator, gid]) => {
502 const rec = (await window.DaimondGroup.get(gid));
503 return {
504 fromA: await window.DaimondGroup.deriveId(creator, rec.salt),
505 fromB: await window.DaimondGroup.deriveId(
506 (await (async () => {
507 const raw = await window.DaimondIdentity.publicKeyRaw();
508 let h = ''; for (const x of raw) h += ('0' + x.toString(16)).slice(-2);
509 return h;
510 })()), rec.salt),
511 salt: rec.salt,
512 };
513 }, [A.key, made.gid]);
514 eq(derived.fromA, made.gid, 'the id recomputes from A\'s key and the salt');
515 ok(derived.fromB !== made.gid,
516 'and does not recompute from B\'s key with the same salt', derived);
517
518 // B forges: same salt, same members, B's own signature, A's group id in
519 // the signed `to`. B holds every key and every module A holds.
520 const forged = await b.page.evaluate(async ([gid, salt, ka, kb, kc]) => {
521 const unhex = (s) => {
522 const u = new Uint8Array(s.length >> 1);
523 for (let i = 0; i < u.length; i++) u[i] = parseInt(s.substr(i * 2, 2), 16);
524 return u;
525 };
526 const hex = (u) => { let h = ''; for (const x of u) h += ('0' + x.toString(16)).slice(-2); return h; };
527 const people = await window.DaimondTrust.people();
528 const encOf = (k) => (people.find(p => p.key === k) || {}).enc;
529 const mineEnc = hex(window.DaimondIdentity.sealingKeyRaw());
530 const members = [
531 { k: ka, e: encOf(ka), n: '' },
532 { k: kb, e: mineEnc, n: '' },
533 { k: kc, e: encOf(kc), n: '' },
534 ];
535 const body = window.DaimondGroup.MARK + '\n'
536 + JSON.stringify({ op: 'roster', salt, name: 'B\'s takeover', members });
537 const made = await window.DaimondPost.compose({
538 body,
539 group: { id: unhex(gid), enc: [encOf(ka), encOf(kc)].map(unhex) },
540 });
541 return { addr: made.addr, envelope: made.envelope };
542 }, [made.gid, derived.salt, A.key, B.key, C.key]);
543
544 const seen = await deliver(c, forged.envelope, forged.addr);
545 ok(!seen.ok, 'C refuses a roster B signed for A\'s group', seen);
546 ok(/addressed to a different key/i.test(seen.why || ''),
547 'and refuses it as an address that is not C\'s to open', seen.why);
548
549 // AND NOTHING MOVED. A refusal that still applied the roster would be a
550 // refusal in the log and a takeover in the record.
551 const after = await c.page.evaluate(async (gid) => {
552 const rec = await window.DaimondGroup.get(gid);
553 return { name: rec.name, members: rec.members.length };
554 }, made.gid);
555 ok(after.name !== 'B\'s takeover',
556 'and C\'s roster is still the one A wrote', after);
557 } finally { await Promise.all([a.close(), b.close(), c.close()]); }
558}
559
560// ── 3. Joining shows nothing earlier ─────────────────────────
561
562async function joiningShowsNothing() {
563 console.log('\n3. joining shows nothing earlier, and the screen says so first');
564 const a = await open({ name: 'group-join-a', connect: false });
565 const b = await open({ name: 'group-join-b', connect: false });
566 const c = await open({ name: 'group-join-c', connect: false });
567 try {
568 for (const s of [a, b, c]) await ready(s);
569 const A = await card(a), B = await card(b), C = await card(c);
570 await take(a, B.text); await take(a, C.text);
571
572 // A group of two: A and B. C is not in it yet.
573 const first = await a.page.evaluate(async (kb) =>
574 window.DaimondGroup.create('Just us', [kb]), [B.key]);
575 ok(first.ok, 'A made a group of two');
576 // A IS IN IT ALREADY, and nothing here puts them there. This used to be
577 // `join()` on A's own page, which is the line that made the whole suite
578 // green over a creator who could not write to their own group.
579 eq(await stateOf(a, first.gid), 'joined',
580 'and is in it by having made it, with nothing else called');
581 await deliver(b, first.envelope, first.addr);
582 ok((await joinByPress(b, first.gid)).settled, 'B pressed Join');
583
584 // A message to the two of them.
585 const EARLY = 'Said before anybody else was here.';
586 const early = await a.page.evaluate(async ([gid, body]) =>
587 window.DaimondPost.sealGroup(gid, { body }), [first.gid, EARLY]);
588 ok(early.ok, 'A sealed a message to the group of two', early.why);
589 eq((await deliver(b, early.made.envelope, early.made.addr)).body, EARLY,
590 'B reads it');
591
592 // Now C is added.
593 const second = await a.page.evaluate(async ([gid, kb, kc]) =>
594 window.DaimondGroup.setMembers(gid, null, [kb, kc]), [first.gid, B.key, C.key]);
595 ok(second.ok, 'A added C', second);
596 const invite = await deliver(c, second.envelope, second.addr);
597 ok(invite.ok && invite.op && invite.moved,
598 'C was sent the roster that adds them', invite);
599
600 // THE SENTENCE IS ON THE SCREEN BEFORE THE PRESS, which is the whole of
601 // why it is asserted here and not three lines further down. After the
602 // press it would be an explanation; before it, it is a fact somebody can
603 // act on. Read off the rendered DOM and never off the function that makes
604 // it: a sentence a module can produce and never draws is on nobody's
605 // screen, and that is a form this suite has been bitten by before.
606 const before = await c.page.evaluate(async () => {
607 await window.DaimondPost.read();
608 window.DaimondPost.render();
609 const host = document.querySelector('#post-groups');
610 for (let i = 0; i < 40 && host && !host.childElementCount; i++) {
611 await new Promise(r => setTimeout(r, 25));
612 }
613 return { text: host ? host.textContent : '',
614 want: window.DaimondGroup.joiningSentence(),
615 joins: !!host && !!host.querySelector('[data-act="group-join"]') };
616 });
617 ok(before.joins, 'the invitation is drawn with a Join control');
618 ok(before.text.includes(before.want),
619 'and the words "joining shows you nothing that was sent before" are on it, '
620 + 'BEFORE the press', before.want);
621
622 ok((await joinByPress(c, first.gid)).settled, 'C pressed the control they were shown');
623 eq(await stateOf(c, first.gid), 'joined', 'C joined');
624
625 // THE PROPERTY. The earlier envelope was never sealed to C's key, so no
626 // device can open it for them -- not the relay's fault, not a policy, and
627 // not something a build could decide to relax.
628 const late = await deliver(c, early.made.envelope, early.made.addr);
629 ok(!late.ok, 'C cannot open what was sent before they joined', late);
630 ok(/not sealed to any key/i.test(late.why || ''),
631 'and the reason is that there was never a slot for them', late.why);
632
633 return true;
634 } finally { await Promise.all([a.close(), b.close(), c.close()]); }
635}
636
637// ── 4. Removing retracts nothing ─────────────────────────────
638
639async function removingRetractsNothing() {
640 console.log('\n4. removing retracts nothing, and it is enforced at the readers');
641 const a = await open({ name: 'group-drop-a', connect: false });
642 const b = await open({ name: 'group-drop-b', connect: false });
643 const c = await open({ name: 'group-drop-c', connect: false });
644 try {
645 for (const s of [a, b, c]) await ready(s);
646 const A = await card(a), B = await card(b), C = await card(c);
647 for (const [who, texts] of [[a, [B.text, C.text]], [b, [A.text, C.text]],
648 [c, [A.text, B.text]]]) {
649 for (const t of texts) await take(who, t);
650 }
651
652 const made = await a.page.evaluate(async ([kb, kc]) =>
653 window.DaimondGroup.create('Three of us', [kb, kc]), [B.key, C.key]);
654 ok(made.ok, 'A made a group of three', made);
655 for (const s of [b, c]) {
656 ok((await deliver(s, made.envelope, made.addr)).moved,
657 'the roster reached a member');
658 }
659 // A IS IN IT BY HAVING MADE IT; B and C press the control they were sent.
660 eq(await stateOf(a, made.gid), 'joined', 'A is in the group A made');
661 for (const s of [b, c]) {
662 ok((await joinByPress(s, made.gid)).settled, 'a member pressed Join');
663 eq(await stateOf(s, made.gid), 'joined', 'and they joined');
664 }
665
666 // Something C receives while they are still in it.
667 const KEPT = 'This one is already on C\'s device.';
668 const kept = await a.page.evaluate(async ([gid, body]) =>
669 window.DaimondPost.sealGroup(gid, { body }), [made.gid, KEPT]);
670 const held = await deliver(c, kept.made.envelope, kept.made.addr);
671 eq(held.body, KEPT, 'C received a message while they were in the group');
672
673 // A drops C. The roster goes to B and TO C, so C is told rather than left
674 // composing into a room that will refuse them.
675 const after = await a.page.evaluate(async ([gid, kb]) =>
676 window.DaimondGroup.setMembers(gid, null, [kb]), [made.gid, B.key]);
677 ok(after.ok, 'A took C out', after);
678 await deliver(b, after.envelope, after.addr);
679 const toC = await deliver(c, after.envelope, after.addr);
680 ok(toC.ok && toC.op, 'C was sent the roster that does not name them', toC);
681 eq((await c.page.evaluate(async (g) =>
682 (await window.DaimondGroup.get(g) || {}).state || 'none', made.gid)),
683 'left', 'C\'s own record says they are out');
684
685 // NOTHING WAS TAKEN BACK. The message C already had is still there, byte
686 // for byte, and no code path exists that could remove it.
687 const still = await c.page.evaluate(async (body) => {
688 await window.DaimondPost.read();
689 return window.DaimondPost.list().some(m => m.body === body)
690 || window.DaimondPost.tray().some(m => m.body === body);
691 }, KEPT);
692 ok(still, 'the message C already held is still on C\'s device');
693
694 // AND THE SENTENCE IS ON A's SCREEN, beside the control, before the press.
695 const shown = await a.page.evaluate(async () => {
696 await window.DaimondPost.read();
697 window.DaimondPost.render();
698 await new Promise(r => setTimeout(r, 250));
699 const host = document.querySelector('#post-groups');
700 const btns = [...(host ? host.querySelectorAll('[data-act="group-drop"]') : [])];
701 return {
702 text: host ? host.textContent : '',
703 want: window.DaimondGroup.removingSentence(),
704 labels: btns.map(x => x.textContent),
705 leave: !!host && !!host.querySelector('[data-act="group-leave"]'),
706 };
707 });
708 ok(shown.text.includes(shown.want),
709 'the words "taking somebody out takes nothing back" are drawn', shown.want);
710 ok(shown.labels.length > 0 && shown.labels.every(l => !/remove/i.test(l)),
711 'and the control says "stop sending to", never "remove"', shown.labels);
712
713 // AND THE CREATOR IS NOT OFFERED LEAVE, because `left` is a state their own
714 // next roster contradicts: `roster` names them in everything it writes and
715 // `consume` reads authorship, so the press would appear to work and the next
716 // membership change would silently undo it. Both halves are checked -- the
717 // absence of the control AND the refusal at the door -- because the panel is
718 // one caller and the second half is what makes the invariant hold for the
719 // others.
720 ok(!shown.leave, 'the creator is not offered Leave on a group they made',
721 { leave: shown.leave, labels: shown.labels });
722 const stuck = await a.page.evaluate(async (gid) => {
723 const answer = await window.DaimondGroup.leave(gid);
724 return { answer, state: (await window.DaimondGroup.get(gid) || {}).state };
725 }, made.gid);
726 eq(stuck.answer, false, 'and `leave` refuses them rather than pretending');
727 eq(stuck.state, 'joined', 'so the creator is still in the group they made');
728 // THE NEGATIVE CONTROL for the two above. B is in the same group, drawn by
729 // the same function, and IS offered Leave -- so the absence above is about
730 // authorship and not about a control this build stopped drawing at all.
731 await panel(b);
732 const bLeave = await b.page.evaluate(() => {
733 const host = document.querySelector('#post-groups');
734 return !!host && !!host.querySelector('[data-act="group-leave"]');
735 });
736 ok(bLeave, 'a member who did not make it IS offered Leave', { bLeave });
737
738 // THE REMOVAL IS ENFORCED AT THE READERS, because there is nowhere else it
739 // could be: no group key to rotate, and a relay that knows nothing about
740 // groups. C composes anyway -- C's own record still holds the roster and
741 // the sealing keys -- and B refuses it.
742 const anyway = await c.page.evaluate(async (gid) => {
743 const r = await window.DaimondPost.sealGroup(gid, { body: 'Still here.' });
744 if (r.ok) return { composed: true, env: r.made.envelope, addr: r.made.addr };
745 // Refused on C's own side, which is also a correct outcome -- but it
746 // is a WEAKER one, so it is reported rather than counted as the same
747 // thing: it would leave B's refusal untested.
748 return { composed: false, why: r.why };
749 }, made.gid);
750 if (anyway.composed) {
751 const atB = await deliver(b, anyway.env, anyway.addr);
752 ok(!atB.ok, 'B refuses a message from somebody A took out', atB);
753 ok(/addressed to a different key/i.test(atB.why || ''),
754 'and refuses it because the author is not in the roster B holds', atB.why);
755 } else {
756 ok(true, 'C\'s own client refuses to compose to a group it has left',
757 anyway.why);
758 // The reader-side refusal still has to hold, so it is driven with an
759 // envelope C composes while its own record has been put back. Without
760 // this the section would pass having tested only the sender's half.
761 const forced = await c.page.evaluate(async (gid) => {
762 const rec = await window.DaimondGroup.get(gid);
763 rec.state = 'joined';
764 await window.DaimondPost.putGroup(gid, rec);
765 const r = await window.DaimondPost.sealGroup(gid, { body: 'Still here.' });
766 return r.ok ? { env: r.made.envelope, addr: r.made.addr } : { why: r.why };
767 }, made.gid);
768 ok(!!forced.env, 'C, believing itself still in, composes to the group', forced);
769 if (forced.env) {
770 const atB = await deliver(b, forced.env, forced.addr);
771 ok(!atB.ok, 'and B refuses it, which is where a removal is enforced', atB);
772 }
773 }
774 } finally { await Promise.all([a.close(), b.close(), c.close()]); }
775}
776
777// ── 5. A person cannot write a roster ────────────────────────
778
779async function markerIsRefused() {
780 console.log('\n5. a person\'s own words cannot be applied as a membership list');
781 const a = await open({ name: 'group-mark-a', connect: false });
782 const b = await open({ name: 'group-mark-b', connect: false });
783 try {
784 for (const s of [a, b]) await ready(s);
785 const B = await card(b);
786 await card(a);
787 await take(a, B.text);
788
789 // COUNTED, BECAUSE `ok:false` PROVES NOTHING HERE. There is no relay in
790 // this suite, so every send fails and an assertion that the marker was
791 // refused would pass on a build with the check deleted. What separates
792 // the two is WHERE it was refused: the marker must be turned away at the
793 // door, with nothing leaving the browser at all.
794 let posts = 0;
795 const count = (r) => { if (/\/api\/post/.test(r.url())) posts++; };
796 a.page.on('request', count);
797
798 const tried = await a.page.evaluate(async (to) => {
799 const body = window.DaimondGroup.MARK + '\n{"op":"roster"}';
800 return await window.DaimondPost.send({ body, to });
801 }, B.pub);
802 // `ok:false` is deliberately NOT asserted: with no relay it is true
803 // whatever this build does, so a line asserting it would be a line that
804 // cannot fail.
805 ok(/membership list/i.test(tried.why || ''),
806 'and the refusal names the reason, in words a person can act on', tried.why);
807 eq(posts, 0, 'and NOTHING left the browser: it was refused at the door');
808
809 // THE POSITIVE CONTROL, and it is what makes the count above mean
810 // something. The same text one character further in is an ordinary
811 // message: it composes, it reaches the relay, and it fails there instead
812 // -- a different refusal, in different words, after a request.
813 const fine = await a.page.evaluate(async (to) => {
814 const body = '> ' + window.DaimondGroup.MARK + '\nnot a roster';
815 return await window.DaimondPost.send({ body, to });
816 }, B.pub);
817 ok(!/membership list/i.test(fine.why || ''),
818 'the same text one character in is not taken for a roster', fine.why);
819 ok(posts > 0, 'and it got as far as the relay, which the marker never did',
820 { posts, why: fine.why });
821 a.page.off('request', count);
822 } finally { await Promise.all([a.close(), b.close()]); }
823}
824
825// ── 7. A key this device does not stand behind gets no slot ──
826
827async function aChangedKeyGetsNoSlot() {
828 console.log('\n7. a member whose key this device does not stand behind gets no slot');
829 const a = await open({ name: 'group-key-a', connect: false });
830 const b = await open({ name: 'group-key-b', connect: false });
831 const c = await open({ name: 'group-key-c', connect: false });
832 try {
833 for (const s of [a, b, c]) await ready(s);
834 const A = await card(a), B = await card(b), C = await card(c);
835 await take(a, B.text); await take(a, C.text);
836
837 const made = await a.page.evaluate(async ([kb, kc]) =>
838 window.DaimondGroup.create('Everybody', [kb, kc]), [B.key, C.key]);
839 eq(await stateOf(a, made.gid), 'joined',
840 'A can seal to the group A made, having done nothing else to it');
841
842 // The baseline, so the counting below measures the skip and not the
843 // arithmetic. Three members means two slots plus A's own.
844 const whole = await a.page.evaluate(async ([gid]) =>
845 window.DaimondPost.sealGroup(gid, { body: 'to everybody' }), [made.gid]);
846 const slots = (s, env) => s.page.evaluate((e) => {
847 const bin = atob(e);
848 return bin.charCodeAt(36);
849 }, env);
850 eq(await slots(a, whole.made.envelope), 3, 'three members, three slots');
851
852 // A BLOCKED KEY. The block is this account's own act, so offering to seal
853 // to them anyway would be the interface arguing with the user.
854 await a.page.evaluate((k) => window.DaimondTrust.setBlocked(k, true), C.key);
855 await a.page.evaluate(() => window.DaimondPost.refreshPeople());
856 const blocked = await a.page.evaluate(async ([gid]) =>
857 window.DaimondGroup.sealTo(gid), [made.gid]);
858 ok(blocked.ok, 'the message still goes to the rest of the group', blocked.why);
859 eq((blocked.skipped || []).length, 1, 'and exactly one person is left out');
860 ok(/blocked/i.test((blocked.skipped[0] || {}).why || ''),
861 'named, with the reason, so the sender can act on it', blocked.skipped);
862 const short = await a.page.evaluate(async ([gid]) =>
863 window.DaimondPost.sealGroup(gid, { body: 'to everybody' }), [made.gid]);
864 eq(await slots(a, short.made.envelope), 2,
865 'AND THE ENVELOPE REALLY HAS ONE SLOT FEWER: a skip that only changed '
866 + 'a sentence would still have sealed the message to them');
867 await a.page.evaluate((k) => window.DaimondTrust.setBlocked(k, false), C.key);
868 await a.page.evaluate(() => window.DaimondPost.refreshPeople());
869
870 // A ROSTER THAT DISAGREES WITH A HELD CARD. This is a key change arriving
871 // by the group's own road: the creator asserts a sealing key for somebody
872 // and this device holds a card saying otherwise. The card wins.
873 //
874 // The fixture IS the disagreement, and it is written through the app's own
875 // published door rather than into storage, so the branch under test is
876 // reached by the state a real disagreement produces.
877 const wrong = await a.page.evaluate(async ([gid, kc]) => {
878 const rec = await window.DaimondGroup.get(gid);
879 rec.members = rec.members.map(m => m.k === kc
880 ? { ...m, e: '00'.repeat(32) } : m);
881 await window.DaimondPost.putGroup(gid, rec);
882 return await window.DaimondGroup.sealTo(gid);
883 }, [made.gid, C.key]);
884 ok(wrong.ok, 'the message still goes to the rest', wrong.why);
885 eq((wrong.skipped || []).length, 1, 'and the disagreement leaves exactly one out');
886 ok(/not the one you hold/i.test((wrong.skipped[0] || {}).why || ''),
887 'named as a key this device does not stand behind', wrong.skipped);
888 } finally { await Promise.all([a.close(), b.close(), c.close()]); }
889}
890
891// ── 8. One group message, one expiry notice ──────────────────
892
893async function oneMessageIsOneNotice() {
894 console.log('\n8. a group message that expires is ONE notice, not one per member');
895 const s = await open({ name: 'group-notice', connect: false });
896 try {
897 await ready(s);
898 // The relay writes the sender one expiry notice PER BOX, because it has no
899 // notion of a group and each copy expires in its own box
900 // (gateway/src/schema.rs, `Store::expire_post`). Twelve rows all naming
901 // one address is what one uncollected group message looks like coming
902 // back, and it is fed in here exactly as `collect` would take it.
903 const seen = await s.page.evaluate(async () => {
904 await window.DaimondPost.read();
905 for (let i = 1; i <= 12; i++) {
906 await window.DaimondPost.take({ kind: 'post', addr: 'post1group',
907 envelope: '', seq: i, ts: 1786000000, tray: false, expired: true,
908 from_pub: '' });
909 }
910 // And one ordinary message of its own, uncollected by its one
911 // recipient. It must NOT be folded into the group's row.
912 await window.DaimondPost.take({ kind: 'post', addr: 'post1alone',
913 envelope: '', seq: 13, ts: 1786000001, tray: false, expired: true,
914 from_pub: '' });
915 window.DaimondPost.render();
916 const rows = window.DaimondPost.notices();
917 return {
918 rows: rows.map(r => ({ addr: r.addr, copies: r.copies })),
919 drawn: [...document.querySelectorAll('#post-notices .post-notice')]
920 .map(x => x.textContent),
921 };
922 });
923 eq(seen.rows.length, 2, 'twelve copies and one single make two rows', seen.rows);
924 const group = seen.rows.find(r => r.addr === 'post1group');
925 const alone = seen.rows.find(r => r.addr === 'post1alone');
926 eq(group && group.copies, 12, 'the group\'s row knows it was twelve');
927 eq(alone && alone.copies, 1, 'and the one-to-one message is not folded into it');
928 eq(seen.drawn.length, 2, 'and two rows are what the panel draws', seen.drawn);
929 ok(seen.drawn.some(t => /12 of the people/.test(t)),
930 'the group row says how many copies expired', seen.drawn);
931 ok(seen.drawn.some(t => /never collected and the relay has let it go/.test(t)),
932 'and the one-to-one row keeps its own wording', seen.drawn);
933 } finally { await s.close(); }
934}
935
936// ── 9. Two clocks, one writer each, and it converges ─────────
937
938async function theMergeConverges() {
939 console.log('\n9. a roster and a decision merge on separate clocks, in any order');
940 const a = await open({ name: 'group-merge', connect: false });
941 try {
942 await ready(a);
943 const A = await card(a);
944 const made = await a.page.evaluate(async () =>
945 window.DaimondGroup.create('First name', []));
946 ok(made.ok, 'a group exists to merge into', made);
947 eq(await stateOf(a, made.gid), 'joined', 'and its creator is in it');
948
949 // The two parcels another device might send, built off this one's own
950 // snapshot so every field is a real one. Each moves ONE clock.
951 const out = await a.page.evaluate(async (gid) => {
952 const base = window.DaimondPost.snapshot();
953 const clone = () => JSON.parse(JSON.stringify(base));
954
955 // A later roster from the creator: the roster half moves, and the
956 // local half must not.
957 const later = clone();
958 later.groups[gid].at = base.groups[gid].at + 1000;
959 later.groups[gid].addr = 'zzzz';
960 later.groups[gid].name = 'Second name';
961 later.groups[gid].members = base.groups[gid].members.concat(
962 [{ k: 'aa'.repeat(32), e: 'bb'.repeat(32), n: 'Late' }]);
963 later.groups[gid].state = 'left'; // must be ignored: older stateAt
964
965 // This account's own later decision, from another of its devices: the
966 // local half moves, and the roster half must not.
967 const decided = clone();
968 // NOT `| 0`: this fixture wrote `(x | 0) + 1000` and that truncation
969 // made the stamp SMALLER than the one it was meant to beat, so the
970 // assertion below failed for a reason that had nothing to do with the
971 // merge. The bug it uncovered was real and is fixed in post.js; the
972 // lesson kept here is that a fixture's own arithmetic is part of what
973 // a check measures.
974 decided.groups[gid].stateAt = base.groups[gid].stateAt + 1000;
975 decided.groups[gid].state = 'left';
976 decided.groups[gid].name = 'Never this'; // must be ignored: older at
977
978 // And a record whose roster is not a list at all. `at` is a REAL
979 // millisecond stamp: an earlier draft of this used 1e12, which `| 0`
980 // truncated to a negative number, so the merge never reached the
981 // guard and the assertion below was true whatever the guard did.
982 const broken = clone();
983 broken.groups[gid] = { gid, members: 'not a list',
984 at: base.groups[gid].at + 5000, addr: 'zzzz' };
985
986 const after = () => {
987 const g = window.DaimondPost.snapshot().groups[gid];
988 return { name: g.name, members: g.members.length, state: g.state,
989 at: g.at, stateAt: g.stateAt };
990 };
991 const reset = async () => {
992 const r = await window.DaimondGroup.get(gid);
993 r.name = base.groups[gid].name;
994 r.at = base.groups[gid].at;
995 r.addr = base.groups[gid].addr;
996 r.members = base.groups[gid].members;
997 r.state = base.groups[gid].state;
998 r.stateAt = base.groups[gid].stateAt;
999 await window.DaimondPost.putGroup(gid, r);
1000 };
1001
1002 window.DaimondPost.adopt(later);
1003 const rosterOnly = after();
1004 await reset();
1005 window.DaimondPost.adopt(decided);
1006 const localOnly = after();
1007
1008 // ORDER INDEPENDENCE, which is the whole claim. Same two parcels, both
1009 // orders, same answer.
1010 await reset();
1011 window.DaimondPost.adopt(later);
1012 window.DaimondPost.adopt(decided);
1013 const forwards = after();
1014 await reset();
1015 window.DaimondPost.adopt(decided);
1016 window.DaimondPost.adopt(later);
1017 const backwards = after();
1018
1019 // An older roster moves nothing at all.
1020 await reset();
1021 window.DaimondPost.adopt(later);
1022 const older = clone();
1023 older.groups[gid].at = base.groups[gid].at - 1000;
1024 older.groups[gid].name = 'Stale';
1025 window.DaimondPost.adopt(older);
1026 const stale = after();
1027
1028 await reset();
1029 let threw = '';
1030 try { window.DaimondPost.adopt(broken); }
1031 catch (e) { threw = String(e && e.message || e); }
1032 const guarded = after();
1033
1034 // The stamps themselves, so a truncation shows up as the wrong number
1035 // rather than as ordering that happens to still work this month.
1036 const whole = { at: base.groups[gid].at, past32: base.groups[gid].at > 2 ** 31 };
1037 return { rosterOnly, localOnly, forwards, backwards, stale, guarded, threw, whole };
1038 }, made.gid);
1039
1040 eq(out.rosterOnly.name, 'Second name', 'a later roster brings the new name');
1041 eq(out.rosterOnly.members, 2, 'and the person it adds');
1042 eq(out.rosterOnly.state, 'joined',
1043 'and does NOT carry the creator\'s idea of whether this device is in it');
1044
1045 eq(out.localOnly.state, 'left', 'a later decision from another device lands');
1046 eq(out.localOnly.name, 'First name',
1047 'and does NOT drag an older roster along with it');
1048
1049 eq(out.forwards, out.backwards,
1050 'and the two arriving in either order give the same record');
1051 eq(out.forwards.name, 'Second name', 'with the later roster');
1052 eq(out.forwards.state, 'left', 'and the later decision');
1053
1054 eq(out.stale.name, 'Second name', 'an older roster moves nothing');
1055
1056 ok(out.whole.past32,
1057 'the stamps this merges on are milliseconds, past what 32 bits hold',
1058 out.whole);
1059 eq(out.rosterOnly.at, out.whole.at + 1000,
1060 'and a merged stamp comes through whole rather than wrapped');
1061 eq(out.guarded.members, 1, 'a record whose roster is not a list is refused');
1062 eq(out.threw, '', 'and refused without throwing, which would jam the sync');
1063 } finally { await a.close(); }
1064}
1065
1066// ── 10. The creator's path is the shipped one ────────────────
1067
1068async function theShippedPath() {
1069 console.log('\n10. a group is made by pressing Make, and its maker can write to it');
1070
1071 // FIRST, THIS FILE'S OWN SOURCE. The bug that got past 88 assertions was a
1072 // line in the test, not a line in the app, so the rule against it is checked
1073 // where it was broken. The needle is built rather than written out, or this
1074 // assertion would match itself.
1075 const self = fs.readFileSync(path.join(HERE, 'verify_group.mjs'), 'utf8');
1076 const needle = 'DaimondGroup' + '.join(';
1077 ok(self.indexOf(needle) < 0,
1078 'nothing in this file joins a device the way the application cannot',
1079 self.split('\n').map((l, i) => l.indexOf(needle) >= 0 ? i + 1 : 0).filter(Boolean));
1080
1081 const a = await open({ name: 'group-ship-a', connect: false });
1082 const b = await open({ name: 'group-ship-b', connect: false });
1083 try {
1084 for (const s of [a, b]) await ready(s);
1085 const B = await card(b);
1086 await card(a);
1087 ok(await take(a, B.text), 'A holds a card for B');
1088 await stubRelay(a, ''); // a relay that takes what it is given
1089
1090 // THE WHOLE OF THE PATH A PERSON TAKES: fill the box, choose somebody,
1091 // press Make. Nothing after it.
1092 const made = await makeByPress(a, 'The shipped one', [B.key]);
1093 ok(!made.err, 'the Make box is drawn with a name field, a picker and a control',
1094 made.err);
1095 eq(made.chose, [B.key], 'B is chosen in the picker', made.offered);
1096 ok(/have been told/.test(made.note || ''),
1097 'and the panel reports the group was made', made.note);
1098 eq(made.groups.length, 1, 'one group is held afterwards', made.groups);
1099 const gid = (made.groups[0] || {}).gid || '';
1100
1101 // THE PROPERTY, and the one this suite could not see. A creator is a member
1102 // of their own group BY CONSTRUCTION -- not because a test joined them.
1103 eq((made.groups[0] || {}).state, 'joined',
1104 'and its maker is IN it, with no join in the path at all');
1105 eq((made.groups[0] || {}).n, 2, 'the roster names A and B');
1106
1107 // Which is only worth saying because of what it lets them do. `sealGroup`
1108 // is the half of a send with no relay in it, so this is the refusal the
1109 // creator used to get, asked directly.
1110 const write = await a.page.evaluate(async (g) =>
1111 window.DaimondPost.sealGroup(g, { body: 'The first word in my own group.' }), gid);
1112 ok(write.ok, 'A can write to the group A just made', write.why);
1113 ok(!/[Jj]oin this group before writing/.test(write.why || ''),
1114 'and is not told to join a group they made', write.why);
1115
1116 // AND IT OPENS ON SOMEBODY ELSE'S DEVICE, so "can write" means an envelope
1117 // that reaches a reader and not merely a function that returned true. The
1118 // roster is the one the press sent -- taken off the relay stub rather than
1119 // composed a second time -- and B answers the invitation by pressing Join.
1120 const rows = await handed(a);
1121 eq(rows.length, 1, 'the press sent the roster to B', rows.map(r => r.to));
1122 const gotRoster = await deliver(b, rows[0].envelope, rows[0].addr);
1123 ok(gotRoster.op, 'B opened the roster the press sent', gotRoster);
1124 ok((await joinByPress(b, gid)).settled, 'B pressed Join');
1125 const seen = await deliver(b, made2env(write), made2addr(write));
1126 eq(seen.body, 'The first word in my own group.',
1127 'and B reads what the group\'s maker wrote in it');
1128
1129 // THE PANEL DREW IT AS A GROUP, not as an invitation. A creator filed as
1130 // `invited` appeared under Group invitations with a Join control on it,
1131 // which is what the bug looked like on screen.
1132 const drawn = await panel(a);
1133 const where = await a.page.evaluate((g) => {
1134 const inv = document.querySelector('#group-invites');
1135 const list = document.querySelector('#group-list');
1136 const sel = `[data-gid="${g}"]`;
1137 return {
1138 invited: !!inv && !!inv.querySelector(sel),
1139 listed: !!list && !!list.querySelector(sel),
1140 joins: !!document.querySelector(`#post-groups ${sel} [data-act="group-join"]`),
1141 };
1142 }, gid);
1143 ok(drawn.filled, 'the section is drawn', drawn);
1144 ok(where.listed && !where.invited,
1145 'the group is under Groups and NOT under Group invitations', where);
1146 ok(!where.joins, 'and carries no Join control, because there is nothing to answer',
1147 where);
1148 } finally {
1149 await realRelay(a);
1150 await Promise.all([a.close(), b.close()]);
1151 }
1152}
1153
1154// ── 11. A key that could not go in is named ──────────────────
1155
1156async function aRejectedKeyIsNamed() {
1157 console.log('\n11. a key that is not a key is named, and a duplicate is not the same fault');
1158 const a = await open({ name: 'group-bad-a', connect: false });
1159 const b = await open({ name: 'group-bad-b', connect: false });
1160 const c = await open({ name: 'group-bad-c', connect: false });
1161 try {
1162 for (const s of [a, b, c]) await ready(s);
1163 const B = await card(b), C = await card(c);
1164 await card(a);
1165 await take(a, B.text); // a card for B, and deliberately none for C
1166
1167 // A MALFORMED KEY. This read `if (!isHex(k, 32) || seen[k]) continue;` and
1168 // did not add it to `missing`, so the answer was `ok:true, members:1,
1169 // sent:0` -- A GROUP OF ONE, MADE SILENTLY, which is how the first caller
1170 // to reach this by hand made one and could not tell.
1171 const typo = B.key.toUpperCase() + 'zz';
1172 const bad = await a.page.evaluate(async ([kb, junk]) =>
1173 window.DaimondGroup.create('Typed wrong', [kb, junk]), [B.key, typo]);
1174 ok(!bad.ok, 'a key with the wrong spelling refuses the group', bad);
1175 ok(Array.isArray(bad.bad) && bad.bad.length === 1,
1176 'and exactly one key is reported as not being one', bad.bad);
1177 ok((bad.bad || []).some(x => x === typo.toLowerCase()),
1178 'named by its own spelling, which is the thing that can be corrected',
1179 bad.bad);
1180 ok(!/^ok/.test(String(bad.members)) && bad.members === undefined,
1181 'and no count of members comes back, because none were made', bad.members);
1182 // AND NOTHING WAS MADE. `ok:false` with a group in the record would be the
1183 // same fault wearing a refusal.
1184 eq((await a.page.evaluate(() => window.DaimondGroup.list())).length, 0,
1185 'nothing at all is in the record: not a group of one, not a group of two');
1186
1187 // AND THE SENTENCE IS PLURAL, BECAUSE THE VALUE IS AN ARRAY. `group.err_bad_key`
1188 // was singular with one `{k}`, so it could not describe the case it was written
1189 // for, and the code was left on `group.err_no_card`'s count rather than
1190 // misusing it: the fault was reported precisely in the value and only
1191 // approximately in the words. `group.err_bad_keys` carries a joined list in
1192 // `{who}`, in the register `post.group_refused` already uses.
1193 const two = await a.page.evaluate(async ([kb, j1, j2]) =>
1194 window.DaimondGroup.create('Two wrong', [kb, j1, j2]),
1195 [B.key, 'not-a-key', 'ABC' + 'zz']);
1196 ok(!two.ok, 'two keys that are not keys refuse the group', two);
1197 eq((two.bad || []).length, 2, 'and both are reported', two.bad);
1198 ok(/These are not keys/.test(two.why || ''),
1199 'in a sentence that is plural, because the value it describes is a list',
1200 two.why);
1201 ok(/not-a-key/.test(two.why || '') && /abczz/.test(two.why || ''),
1202 'AND IT NAMES BOTH SPELLINGS, which is the thing that can be corrected -- '
1203 + 'a count cannot be', two.why);
1204 ok(!/sealing key for/.test(two.why || ''),
1205 'and does not send the reader to scan a code for a typing mistake', two.why);
1206 // The key it replaced is gone, asked of the LIVE CATALOGUE rather than of the
1207 // file: a key present in `en.js` and unreachable at runtime is the same dead
1208 // weight, and this is the end that a reader meets.
1209 const keys = await a.page.evaluate(() => ({
1210 plural: window.DaimondI18n.t('group.err_bad_keys'),
1211 singular: window.DaimondI18n.t('group.err_bad_key'),
1212 }));
1213 ok(keys.plural !== 'group.err_bad_keys' && /\{who\}/.test(keys.plural),
1214 'the plural key is in the catalogue and carries a list', keys);
1215 eq(keys.singular, 'group.err_bad_key',
1216 'and the singular one it replaced is retired: nothing names it, so it is '
1217 + 'not carried in eight languages');
1218
1219 // A KEY WITH NO CARD is the other half, and it is told apart. Both refuse,
1220 // and the two arrays are what says which fault it was.
1221 const noCard = await a.page.evaluate(async ([kb, kc]) =>
1222 window.DaimondGroup.create('No card for C', [kb, kc]), [B.key, C.key]);
1223 ok(!noCard.ok, 'somebody whose code has not been scanned refuses it too', noCard);
1224 eq((noCard.missing || []).length, 1, 'and is reported as missing, not as malformed');
1225 eq((noCard.bad || []).length, 0, 'with nothing in the malformed list', noCard.bad);
1226 ok(/sealing key for 1 of the people/.test(noCard.why || ''),
1227 'and the count in the sentence is the number of people it is about',
1228 noCard.why);
1229
1230 // BOTH FAULTS AT ONCE, TOLD APART IN THE WORDS as well as in the arrays.
1231 // This is where the old single sentence was FALSE rather than merely vague:
1232 // `{n}` was `bad.length + missing.length`, so one typo beside one uncarded
1233 // person read as "no sealing key for 2 of the people chosen" -- a sentence
1234 // naming a repair for a fault that was not there.
1235 const both = await a.page.evaluate(async ([kc, junk]) =>
1236 window.DaimondGroup.create('One of each', [kc, junk]), [C.key, 'nope']);
1237 ok(!both.ok, 'one bad spelling and one missing card refuse the group', both);
1238 eq((both.bad || []).length, 1, 'one is a spelling');
1239 eq((both.missing || []).length, 1, 'and one is a person with no card');
1240 ok(/These are not keys/.test(both.why || ''),
1241 'both sentences are drawn: the spelling first', both.why);
1242 ok(/sealing key for 1 of the people/.test(both.why || ''),
1243 'AND THE COUNT IS 1, NOT 2 -- it counts the people it is about and not '
1244 + 'the typing mistake as well', both.why);
1245
1246 // A DUPLICATE IS NOT A FAULT. Naming somebody twice, or naming yourself,
1247 // asks for a roster this one already is: the caller gets the membership they
1248 // asked for, so it is counted and reported and refuses nothing. Reporting it
1249 // as a fault would refuse a group over a request that was granted.
1250 const mine = await a.page.evaluate(async () => {
1251 const raw = await window.DaimondIdentity.publicKeyRaw();
1252 let h = ''; for (const x of raw) h += ('0' + x.toString(16)).slice(-2);
1253 return h;
1254 });
1255 const twice = await a.page.evaluate(async ([kb, ka]) =>
1256 window.DaimondGroup.create('Named twice', [kb, kb, ka]), [B.key, mine]);
1257 ok(twice.ok, 'naming somebody twice makes the group anyway', twice.why);
1258 eq(twice.members, 2, 'with each person in it once');
1259 eq(twice.dupes, 2, 'and the repeats counted, said rather than swallowed');
1260 eq((twice.bad || []).length, 0, 'and not reported as a key that is not one');
1261
1262 // THE POSITIVE CONTROL, which is what makes the three refusals above mean
1263 // something: the same call with every key well spelled and carded works.
1264 const fine = await a.page.evaluate(async (kb) =>
1265 window.DaimondGroup.create('All well', [kb]), B.key);
1266 ok(fine.ok, 'and a well-spelled key with a card makes a group', fine.why);
1267 eq(fine.members, 2, 'of two');
1268
1269 // AND THE SAME FAULT ONE FUNCTION FURTHER DOWN. `create` reads its own
1270 // roster back through `consume`, which is what makes ONE path turn a roster
1271 // into a record -- and the answer to that read used to be a log line, so a
1272 // read-back that failed left the caller holding `ok:true` for a group in
1273 // nobody's record and a fan-out announcing it. The store is made to refuse
1274 // the write, which is what a device whose identity locked between the
1275 // compose and the record does.
1276 await stubRelay(a, '');
1277 const unapplied = await a.page.evaluate(async (kb) => {
1278 const real = window.DaimondPost.putGroup;
1279 window.DaimondPost.putGroup = async () => false;
1280 let r;
1281 try { r = await window.DaimondGroup.create('Never stored', [kb]); }
1282 finally { window.DaimondPost.putGroup = real; }
1283 const gs = await window.DaimondGroup.list();
1284 return { ok: r.ok, why: r.why || '', names: gs.map(g => g.name) };
1285 }, B.key);
1286 ok(!unapplied.ok, 'a roster this device could not store refuses the whole call',
1287 unapplied);
1288 ok(/could not apply|was not sent/.test(unapplied.why),
1289 'saying so rather than answering ok with nothing behind it', unapplied.why);
1290 ok(!unapplied.names.some(n => n === 'Never stored'),
1291 'and no such group is held', unapplied.names);
1292 eq((await handed(a)).length, 0,
1293 'AND NOTHING WAS SENT: a roster announced to people this device does not '
1294 + 'itself hold would refuse every message sent to the group it announced');
1295 await realRelay(a);
1296 } finally { await Promise.all([a.close(), b.close(), c.close()]); }
1297}
1298
1299// ── 12. A refused delivery is drawn ──────────────────────────
1300
1301async function aRefusalIsDrawn() {
1302 console.log('\n12. a delivery the relay would not take is NAMED, not counted');
1303
1304 const a = await open({ name: 'group-refuse-a', connect: false });
1305 const b = await open({ name: 'group-refuse-b', connect: false });
1306 const c = await open({ name: 'group-refuse-c', connect: false });
1307 try {
1308 for (const s of [a, b, c]) await ready(s);
1309 const B = await card(b), C = await card(c);
1310 await card(a);
1311 await take(a, B.text); await take(a, C.text);
1312
1313 // C's box is full. B's is not.
1314 await stubRelay(a, C.pub);
1315
1316 // ── A ROSTER, first, because a person who never got it does not know the
1317 // group exists at all. This said "Made, and 5 people have been told" over a
1318 // fan-out that reached one.
1319 const made = await makeByPress(a, 'Half of them', [B.key, C.key]);
1320 ok(!made.err, 'the group was made through the panel', made.err);
1321 const gid = (made.groups[0] || {}).gid || '';
1322 const posts = await handed(a);
1323 eq(posts.length, 2, 'the roster was offered to both members',
1324 posts.map(p => p.to));
1325 // THE COUNT ITSELF, and not merely the shape of the sentence: `/told/`
1326 // matches "have been told" whatever number is in front of it, so it would
1327 // pass on the build that claimed both members had it.
1328 ok(/and 1 people have been told/.test(made.note || ''),
1329 'the panel says ONE was told, which is how many were', made.note);
1330 ok(/would not take it for/.test(made.note || ''),
1331 'AND NAMES THE ONE WHO WAS NOT: a roster that reached one of two used to '
1332 + 'say two people had been told and nothing else', made.note);
1333 ok(/mailbox is full/.test(made.note || ''),
1334 'with the relay\'s reason as a clause, in the register a list needs',
1335 made.note);
1336 // The two halves are ONE assertion deliberately. `!/status_/` alone is true
1337 // of a build that draws nothing at all, which is the build this is here to
1338 // fail.
1339 ok(/would not take it for/.test(made.note || '') && !/status_/.test(made.note || ''),
1340 'and no machine text where the reason goes', made.note);
1341
1342 // ── AND A MESSAGE, through the panel's own Send control, which is where the
1343 // hole was found: `sendGroup` answers `{ok:true, sent, refused}` and the
1344 // branch drew `sent` and dropped `refused`.
1345 await a.page.evaluate(() => { window.__posts = []; });
1346 const sent = await a.page.evaluate(async (g) => {
1347 const r = await window.DaimondPost.send({ group: g, body: 'To whoever can have it.' });
1348 return { ok: r.ok, sent: r.sent, refused: (r.refused || []).length,
1349 words: window.DaimondPost.shortfall(r), why: r.why || '' };
1350 }, gid);
1351 ok(sent.ok, 'the message goes to the rest of the group', sent);
1352 eq(sent.sent, 1, 'one member had it taken for them');
1353 eq(sent.refused, 1, 'and one did not');
1354 ok(/would not take it for/.test(sent.words || ''),
1355 'and the sentence the panel draws names them', sent.words);
1356 ok(/mailbox is full/.test(sent.words || ''),
1357 'with the reason the relay gave', sent.words);
1358
1359 // ── BOTH FAULTS AT ONCE, TOLD APART. A key this device would not seal to is
1360 // a refusal HERE and the reader can lift it; a delivery the relay would not
1361 // take is a refusal ELSEWHERE and they can only wait. One list would be true
1362 // and would leave them to work out which of the two is theirs, which is the
1363 // distinction the eight translations were paid for. Also the `ok:false` half
1364 // of the hole: with nobody sealable and nobody reachable the panel used to
1365 // print `r.why` alone.
1366 await a.page.evaluate((k) => window.DaimondTrust.setBlocked(k, true), B.key);
1367 await a.page.evaluate(() => window.DaimondPost.refreshPeople());
1368 const both = await a.page.evaluate(async (g) => {
1369 const r = await window.DaimondPost.send({ group: g, body: 'To nobody, then.' });
1370 return { ok: r.ok, sent: r.sent | 0, why: r.why || '',
1371 skipped: (r.skipped || []).length, refused: (r.refused || []).length,
1372 words: window.DaimondPost.shortfall(r) };
1373 }, gid);
1374 eq(both.sent, 0, 'with one blocked and one full, the message reaches nobody');
1375 ok(!both.ok, 'which is a failure and is reported as one', both);
1376 ok(/reached nobody/.test(both.why), 'in its own words', both.why);
1377 ok(/Not sealed to/.test(both.words),
1378 'the person this device would not seal to is named as that', both.words);
1379 ok(/you blocked this key/.test(both.words),
1380 'with the reason being one the reader can lift', both.words);
1381 ok(/would not take it for/.test(both.words),
1382 'AND the relay\'s refusal is a SECOND sentence, not folded into the first',
1383 both.words);
1384 ok(both.words.indexOf('Not sealed to') < both.words.indexOf('would not take it for'),
1385 'this device\'s own refusal first, because it is the one they can act on',
1386 both.words);
1387 await a.page.evaluate((k) => window.DaimondTrust.setBlocked(k, false), B.key);
1388 await a.page.evaluate(() => window.DaimondPost.refreshPeople());
1389
1390 // THE NEGATIVE CONTROL. Nobody's box is full, so the same send says nothing
1391 // about anybody -- or the sentence above is one this build always draws.
1392 await stubRelay(a, '');
1393 const clean = await a.page.evaluate(async (g) => {
1394 const r = await window.DaimondPost.send({ group: g, body: 'To everybody.' });
1395 return { ok: r.ok, sent: r.sent, words: window.DaimondPost.shortfall(r) };
1396 }, gid);
1397 ok(clean.ok && clean.sent === 2, 'with no full box, both members have it taken',
1398 clean);
1399 eq(clean.words, '', 'and nothing is drawn about anybody being left out');
1400
1401 // AND THE STATUS TABLE IS ONE TABLE. The fan-out invented `status_507`
1402 // because the words lived inside the one-to-one branch; they are read from
1403 // one place now, so a status has the same words either way.
1404 const words = await a.page.evaluate(() => ({
1405 full: window.DaimondPost.whyRefused(507),
1406 gone: window.DaimondPost.whyRefused(404),
1407 off: window.DaimondPost.whyRefused(0),
1408 other: window.DaimondPost.whyRefused(500),
1409 }));
1410 ok(/mailbox is full/.test(words.full), 'a full box has words', words.full);
1411 ok(/No account holds that key/.test(words.gone), 'so has a key nobody holds', words.gone);
1412 ok(/could not reach the relay/.test(words.off), 'so has an unreachable relay', words.off);
1413 ok(/would not take/.test(words.other), 'and so has anything else', words.other);
1414 ok(new Set(Object.values(words)).size === 4,
1415 'and the four are four different sentences', words);
1416
1417 // AND EACH HAS A SHORT FORM, because a whole sentence per member is what
1418 // makes a list of ten unreadable. Both registers are checked: the clause is
1419 // SHORTER and it is NOT the sentence, or the second argument does nothing.
1420 const clauses = await a.page.evaluate(() => ({
1421 full: window.DaimondPost.whyRefused(507, true),
1422 gone: window.DaimondPost.whyRefused(404, true),
1423 off: window.DaimondPost.whyRefused(0, true),
1424 big: window.DaimondPost.whyRefused(413, true),
1425 other: window.DaimondPost.whyRefused(500, true),
1426 }));
1427 ok(new Set(Object.values(clauses)).size === 5,
1428 'five statuses, five clauses', clauses);
1429 ok(Object.keys(clauses).every(k => !words[k] || clauses[k] !== words[k]),
1430 'and a clause is never the whole sentence', clauses);
1431 ok(clauses.full.length < words.full.length,
1432 'the clause is the shorter of the two, which is its whole purpose',
1433 { clause: clauses.full, sentence: words.full });
1434 ok(Object.values(clauses).every(c => !/^[A-Z]/.test(c) && !/\.$/.test(c)),
1435 'and reads as a clause: no capital, no full stop, because it sits in '
1436 + 'brackets after a name', clauses);
1437 } finally {
1438 await realRelay(a);
1439 await Promise.all([a.close(), b.close(), c.close()]);
1440 }
1441}
1442
1443// ── 13. A group can be closed, and closing is final ──────────
1444
1445async function closingIsFinal() {
1446 console.log('\n13. a creator can close a group, once, and no reader will reopen it');
1447 const a = await open({ name: 'group-close-a', connect: false });
1448 const b = await open({ name: 'group-close-b', connect: false });
1449 const c = await open({ name: 'group-close-c', connect: false });
1450 try {
1451 for (const s of [a, b, c]) await ready(s);
1452 const B = await card(b), C = await card(c);
1453 await card(a);
1454 for (const [who, texts] of [[a, [B.text, C.text]], [b, [C.text]], [c, [B.text]]]) {
1455 for (const t of texts) await take(who, t);
1456 }
1457 await take(b, (await card(a)).text);
1458 await take(c, (await card(a)).text);
1459 await stubRelay(a, '');
1460
1461 const made = await a.page.evaluate(async ([kb, kc]) =>
1462 window.DaimondGroup.create('One to end', [kb, kc]), [B.key, C.key]);
1463 ok(made.ok, 'A made a group of three', made);
1464 const gid = made.gid;
1465 for (const s of [b, c]) {
1466 await deliver(s, made.envelope, made.addr);
1467 ok((await joinByPress(s, gid)).settled, 'a member pressed Join');
1468 }
1469
1470 // Something everybody has BEFORE it closes, so "they keep the messages they
1471 // already have" is measured against a real message rather than asserted.
1472 const KEPT = 'Said while the group was open.';
1473 const kept = await a.page.evaluate(async ([g, body]) =>
1474 window.DaimondPost.sealGroup(g, { body }), [gid, KEPT]);
1475 eq((await deliver(b, made2env(kept), made2addr(kept))).body, KEPT,
1476 'B holds a message from before the close');
1477
1478 // AND ONE B COMPOSES BUT DOES NOT SEND, kept back to be delivered AFTER the
1479 // close. It is the in-flight case, and it is checked because the answer is a
1480 // cost rather than a bug: what a member keeps is what they have COLLECTED,
1481 // and an envelope still on the relay when the group closes is refused by
1482 // every reader. Better measured and said than discovered.
1483 const inFlight = await b.page.evaluate(async (g) =>
1484 window.DaimondPost.sealGroup(g, { body: 'Still in the post.' }), gid);
1485 ok(inFlight.ok, 'B composed one that has not been delivered yet', inFlight.why);
1486
1487 // ── THE CONTROL, AND THE SENTENCE ABOVE IT, BEFORE THE PRESS.
1488 const shown = await panel(a).then(() => a.page.evaluate((g) => {
1489 const host = document.querySelector('#post-groups');
1490 const row = host && host.querySelector(`[data-gid="${g}"]`);
1491 return {
1492 there: !!row && !!row.querySelector('[data-act="group-close"]'),
1493 label: row && row.querySelector('[data-act="group-close"]')
1494 ? row.querySelector('[data-act="group-close"]').textContent : '',
1495 text: host ? host.textContent : '',
1496 want: window.DaimondGroup.closingSentence(),
1497 };
1498 }, gid));
1499 ok(shown.there, 'the creator is offered a control that closes the group');
1500 ok(shown.text.includes(shown.want),
1501 'and the words "it cannot be undone" are on the screen BEFORE the press',
1502 shown.want);
1503 ok(!/delete|disband|remove/i.test(shown.label),
1504 'the control says CLOSE and not delete, disband or remove: nothing is '
1505 + 'destroyed by it, and `stop_sending` refuses "remove" for the same reason',
1506 shown.label);
1507 // THE NEGATIVE CONTROL. B is in the same group, drawn by the same function,
1508 // and is NOT offered it -- so the presence above is about authorship and not
1509 // about a control this build draws on every row.
1510 await panel(b);
1511 const atB = await b.page.evaluate((g) => {
1512 const row = document.querySelector(`#post-groups [data-gid="${g}"]`);
1513 return { close: !!row && !!row.querySelector('[data-act="group-close"]'),
1514 leave: !!row && !!row.querySelector('[data-act="group-leave"]') };
1515 }, gid);
1516 ok(!atB.close, 'a member who did not make it is not offered it', atB);
1517 ok(atB.leave, 'and IS offered Leave, so the row itself is being drawn', atB);
1518 // AND THE DOOR REFUSES THEM TOO, because the panel is one caller.
1519 const bTried = await b.page.evaluate(async (g) =>
1520 window.DaimondGroup.close(g), gid);
1521 ok(!bTried.ok, 'and `close` refuses a member rather than pretending', bTried);
1522 ok(/who made a group can close it/.test(bTried.why || ''),
1523 'in its own sentence, not the one about changing who is in it',
1524 bTried.why);
1525
1526 // ── ONE DIALOGUE, AND SAYING NO CLOSES NOTHING.
1527 await a.page.evaluate(() => { window.__posts = []; });
1528 const said = await pressAndAnswer(a,
1529 `#post-groups [data-gid="${gid}"] [data-act="group-close"]`, false);
1530 ok(said.asked, 'pressing it opens the app\'s own confirmation dialogue', said);
1531 ok(said.card && said.card.text.includes(shown.want),
1532 'which says the same sentence again, because a line read on the way past '
1533 + 'is not consent for something irreversible', said.card && said.card.text);
1534 ok(said.card && /One to end/.test(said.card.text),
1535 'and names the group being closed', said.card && said.card.text);
1536 ok(said.card && said.card.danger,
1537 'with the accepting button marked as the dangerous one',
1538 said.card && said.card.ok);
1539 ok(said.card && said.card.cancel, 'and a way out of it', said.card);
1540 const after = await a.page.evaluate(async (g) => {
1541 const rec = await window.DaimondGroup.get(g);
1542 return { members: rec.members.length, state: rec.state,
1543 closed: window.DaimondGroup.isClosed(rec) };
1544 }, gid);
1545 eq(after.members, 3, 'saying no leaves the group exactly as it was');
1546 ok(!after.closed, 'and not closed', after);
1547 eq((await handed(a)).length, 0,
1548 'AND NOTHING LEFT THE BROWSER: a dialogue asked and then ignored would be '
1549 + 'a dialogue for show');
1550
1551 // ── AND SAYING YES CLOSES IT.
1552 const yes = await pressAndAnswer(a,
1553 `#post-groups [data-gid="${gid}"] [data-act="group-close"]`, true);
1554 ok(yes.asked, 'it asks again on the second press', yes);
1555 for (let i = 0; i < 80; i++) {
1556 if (await a.page.evaluate(async (g) =>
1557 window.DaimondGroup.isClosed(await window.DaimondGroup.get(g)), gid)) break;
1558 await new Promise(r => setTimeout(r, 50));
1559 }
1560 const closed = await a.page.evaluate(async (g) => {
1561 const rec = await window.DaimondGroup.get(g);
1562 return { members: rec.members.length, state: rec.state,
1563 closed: window.DaimondGroup.isClosed(rec),
1564 note: (document.querySelector('#group-note') || {}).textContent || '' };
1565 }, gid);
1566 ok(closed.closed, 'the group is closed', closed);
1567 eq(closed.members, 0,
1568 'and CLOSED IS THE ROSTER NAMING NOBODY -- not a flag beside a roster, '
1569 + 'which is why it travels on the half of the record `adopt` already copies');
1570 eq(closed.state, 'left',
1571 'the creator is out of it too, which is what "nobody, you included" means');
1572 ok(/Closed, and 2 people have been told/.test(closed.note),
1573 'and the panel says how many were told', closed.note);
1574
1575 // ── EVERYBODY WHO WAS IN IT WAS SENT IT.
1576 const posts = await handed(a);
1577 eq(posts.length, 2, 'the closing roster was offered to both members',
1578 posts.map(p => p.to));
1579 // AND THE REST OF THE SECTION DOES NOT DEPEND ON THAT HAVING WORKED. A break
1580 // that stopped the close being composed took this section down at
1581 // `posts[0].envelope` and the eight checks after it printed nothing -- the
1582 // exact shape this file's own header warns about, one level in.
1583 const closingEnv = posts.length ? posts[0] : { envelope: '', addr: '' };
1584
1585 // ── THE CREATOR CANNOT WRITE TO IT, AND IS TOLD WHY IT IS CLOSED.
1586 const mine = await a.page.evaluate(async (g) =>
1587 window.DaimondPost.sealGroup(g, { body: 'One more thing.' }), gid);
1588 ok(!mine.ok, 'its own maker cannot write to it again', mine);
1589 ok(/has been closed/.test(mine.why || ''),
1590 'and the reason says it was CLOSED', mine.why);
1591 ok(!/no longer in this group/.test(mine.why || ''),
1592 'not that they are no longer in it -- true of the record and wrong about '
1593 + 'what happened, and wrong in the direction that reads as blame',
1594 mine.why);
1595 ok(!/[Jj]oin this group before writing/.test(mine.why || ''),
1596 'and not the "join first" wording either', mine.why);
1597
1598 // ── AND NO FURTHER ROSTER, so the membership controls cannot walk around it.
1599 const change = await a.page.evaluate(async ([g, kb]) =>
1600 window.DaimondGroup.setMembers(g, null, [kb]), [gid, B.key]);
1601 ok(!change.ok, 'and cannot change who is in it', change);
1602 ok(/has been closed/.test(change.why || ''), 'for the same stated reason',
1603 change.why);
1604
1605 // ── THE MEMBERS CONVERGE ON IT, off the bytes the press sent.
1606 for (const [s, who] of [[b, 'B'], [c, 'C']]) {
1607 const got = await deliver(s, closingEnv.envelope, closingEnv.addr);
1608 ok(got.op, `${who} opened the roster that closes it`, got);
1609 const st = await s.page.evaluate(async (g) => {
1610 const rec = await window.DaimondGroup.get(g);
1611 return { closed: window.DaimondGroup.isClosed(rec), state: rec.state,
1612 why: (await window.DaimondPost.sealGroup(g, { body: 'hello' })).why };
1613 }, gid);
1614 ok(st.closed, `${who} holds it as closed`, st);
1615 eq(st.state, 'left', `and out of it`);
1616 ok(/has been closed/.test(st.why || ''),
1617 `and ${who} is told the group closed, not that they left`, st.why);
1618 }
1619
1620 // ── AND KEEPS EVERY MESSAGE. Nothing is deleted anywhere: this is the whole
1621 // of "people keep the messages they already have".
1622 const still = await b.page.evaluate(async (body) => {
1623 await window.DaimondPost.read();
1624 return window.DaimondPost.list().some(m => m.body === body)
1625 || window.DaimondPost.tray().some(m => m.body === body);
1626 }, KEPT);
1627 ok(still, 'B still holds every message from before it closed');
1628
1629 // ── NOBODY CAN WRITE TO IT AGAIN, AND IT IS THE READERS THAT SAY SO. B's
1630 // envelope was composed while the group was open and is delivered after it
1631 // closed; C refuses it. There is no group key to rotate and the relay knows
1632 // nothing about groups, so the readers are the only place this could be.
1633 // THE COST IS REAL AND IS THE POINT OF MEASURING IT: what a member keeps is
1634 // what they have already collected, and an envelope still on the relay at
1635 // the moment of the close is lost.
1636 const late = await deliver(c, made2env(inFlight), made2addr(inFlight));
1637 ok(!late.ok, 'a message still in the post when it closed is refused', late);
1638 ok(/addressed to a different key/i.test(late.why || ''),
1639 'by the same walk of the roster a removal is enforced by: an empty roster '
1640 + 'contains nobody', late.why);
1641
1642 // ── IT CANNOT BE UNDONE, AND THAT IS A PROPERTY AT EVERY READER RATHER THAN
1643 // A PROMISE ON A DIALOGUE. A forges nothing here: the roster below is signed
1644 // with A's OWN key and carries A's own group id, so it is authorised by the
1645 // derivation that authorises every other roster of theirs. It is refused
1646 // anyway, by `consume`, on a device that is not A's.
1647 const reopen = await a.page.evaluate(async ([g, salt, ka, kb, kc]) => {
1648 const unhex = (s) => {
1649 const u = new Uint8Array(s.length >> 1);
1650 for (let i = 0; i < u.length; i++) u[i] = parseInt(s.substr(i * 2, 2), 16);
1651 return u;
1652 };
1653 const hex = (u) => { let h = ''; for (const x of u) h += ('0' + x.toString(16)).slice(-2); return h; };
1654 const people = await window.DaimondTrust.people();
1655 const encOf = (k) => (people.find(p => p.key === k) || {}).enc;
1656 const members = [
1657 { k: ka, e: hex(window.DaimondIdentity.sealingKeyRaw()), n: '' },
1658 { k: kb, e: encOf(kb), n: '' },
1659 { k: kc, e: encOf(kc), n: '' },
1660 ];
1661 const body = window.DaimondGroup.MARK + '\n'
1662 + JSON.stringify({ op: 'roster', salt, name: 'Back again', members });
1663 const made = await window.DaimondPost.compose({
1664 body, group: { id: unhex(g), enc: [encOf(kb), encOf(kc)].map(unhex) },
1665 });
1666 return { addr: made.addr, envelope: made.envelope };
1667 }, [gid, made.salt || (await a.page.evaluate(async (g) =>
1668 (await window.DaimondGroup.get(g)).salt, gid)),
1669 (await a.page.evaluate(async () => {
1670 const raw = await window.DaimondIdentity.publicKeyRaw();
1671 let h = ''; for (const x of raw) h += ('0' + x.toString(16)).slice(-2);
1672 return h;
1673 })), B.key, C.key]);
1674 const back = await deliver(c, reopen.envelope, reopen.addr);
1675 ok(!back.moved, 'a later roster from the creator does NOT reopen it', back);
1676 const cStill = await c.page.evaluate(async (g) => {
1677 const rec = await window.DaimondGroup.get(g);
1678 return { members: rec.members.length, name: rec.name,
1679 closed: window.DaimondGroup.isClosed(rec) };
1680 }, gid);
1681 ok(cStill.closed, 'and C\'s record is still closed', cStill);
1682 ok(cStill.name !== 'Back again',
1683 'with the roster it was closed with, not the one that tried to revive it',
1684 cStill);
1685 // AND THE OTHER DOOR. `leave` is what a stale Leave control would call, and it
1686 // refuses: there is nothing left to leave, so answering true would report an
1687 // act that did not happen. Asked directly because the panel is one caller --
1688 // section 4 asks the same question of a creator the same way.
1689 const door = await c.page.evaluate(async (g) => ({
1690 leave: await window.DaimondGroup.leave(g),
1691 state: (await window.DaimondGroup.get(g)).state,
1692 }), gid);
1693 eq(door.leave, false, '`leave` refuses a closed group');
1694 eq(door.state, 'left', 'and did not move the record');
1695
1696 // `join` HAS THE SAME GUARD AND THIS DOES NOT DRIVE IT, deliberately, and the
1697 // gap is named rather than papered over. Section 10 forbids this file from
1698 // calling that method at all -- for a good reason, since a setup call to it
1699 // is what made 88 assertions green over a creator who could not write to
1700 // their own group -- and the application has no path to it on a closed row
1701 // either, because the row above carries no Join control. So what is checked
1702 // here is the REACHABLE property (no control) and the source of the guard
1703 // behind it. THE SECOND HALF PROVES THE LINE EXISTS AND NOT THAT IT RUNS.
1704 const gsrc = fs.readFileSync(path.join(APP, 'www/js/group.js'), 'utf8');
1705 const joinAt = gsrc.indexOf('async function join(gid)');
1706 ok(joinAt > 0 && /if \(isClosed\(rec\)\) return false;/
1707 .test(gsrc.slice(joinAt, joinAt + 260)),
1708 '`join` carries the same closed guard, read from the source because '
1709 + 'nothing in the app or in this file may call it', { joinAt });
1710
1711 // ── IT IS DRAWN AS CLOSED, WITH NO CONTROLS AT ALL, AND IT KEEPS ITS PLACE.
1712 // Not removed: `post.js` `drawMsg` puts this record's NAME over every message
1713 // of the group, so deleting the record would leave the transcript the feature
1714 // exists to keep under "A group · 3f2a91c4".
1715 await panel(c);
1716 const drawn = await c.page.evaluate((g) => {
1717 const host = document.querySelector('#post-groups');
1718 const row = host && host.querySelector(`[data-gid="${g}"]`);
1719 const inv = document.querySelector('#group-invites');
1720 return {
1721 listed: !!document.querySelector(`#group-list [data-gid="${g}"]`),
1722 invited: !!inv && !!inv.querySelector(`[data-gid="${g}"]`),
1723 marked: !!row && row.dataset.closed === '1',
1724 acts: row ? [...row.querySelectorAll('[data-act]')].map(x => x.dataset.act) : ['?'],
1725 text: row ? row.textContent : '',
1726 picker: [...document.querySelectorAll('#post-to option')].map(o => o.value),
1727 };
1728 }, gid);
1729 ok(drawn.listed && !drawn.invited,
1730 'a closed group keeps its place on the list and is not an invitation', drawn);
1731 ok(drawn.marked, 'and is marked as closed for a stylesheet to reach', drawn);
1732 eq(drawn.acts, [], 'with NO controls on it: not Join, not Leave, not Close');
1733 ok(/closed by the person who made it/.test(drawn.text),
1734 'and says who closed it and that nothing was taken away', drawn.text);
1735 ok(!drawn.picker.some(v => v === 'g:' + gid),
1736 'AND IT IS OFF THE RECIPIENT PICKER, so nothing offers to write to it',
1737 drawn.picker);
1738
1739 // ── THE ONE CASE THE LOCAL HALF CAN GET WRONG, AND THE REPAIR FOR IT.
1740 // The two halves of a record merge on separate clocks: the roster half moves
1741 // on the higher `at`, the local half on the higher `stateAt`. A member who
1742 // pressed Join on a device whose clock runs ahead of the creator's holds a
1743 // `stateAt` LATER than the closing roster's `at`, so a second device of
1744 // theirs adopts the empty roster and keeps `joined`. `post.js`
1745 // `joinedGroups` builds the picker from `state === 'joined'` alone, so that
1746 // record would offer a destination `sealTo` refuses. `draw` settles it.
1747 const skewed = await c.page.evaluate(async (g) => {
1748 const rec = await window.DaimondGroup.get(g);
1749 rec.state = 'joined';
1750 rec.stateAt = rec.at + 100000; // a clock that runs ahead
1751 await window.DaimondPost.putGroup(g, rec);
1752 const before = (await window.DaimondGroup.get(g)).state;
1753 await window.DaimondPost.read();
1754 window.DaimondPost.render();
1755 for (let i = 0; i < 60; i++) {
1756 if ((await window.DaimondGroup.get(g)).state === 'left') break;
1757 await new Promise(r => setTimeout(r, 25));
1758 }
1759 // A SECOND RENDER, because the picker is drawn in the same pass as the
1760 // settle: the record goes right on this render and the picker on the next.
1761 window.DaimondPost.render();
1762 await new Promise(r => setTimeout(r, 150));
1763 return { before, after: (await window.DaimondGroup.get(g)).state,
1764 picker: [...document.querySelectorAll('#post-to option')].map(o => o.value) };
1765 }, gid);
1766 eq(skewed.before, 'joined', 'a record can be left saying joined over an empty roster');
1767 eq(skewed.after, 'left', 'and the panel settles it rather than drawing round it');
1768 ok(!skewed.picker.some(v => v === 'g:' + gid),
1769 'so the picker does not offer a closed group even then', skewed.picker);
1770
1771 // ── A GROUP OF ONE CAN BE CLOSED TOO, and it is here because the fan-out
1772 // reaches NOBODY: the closing roster has an empty slot list and an empty
1773 // reach, and `compose` allows that deliberately (see its own note on a group
1774 // of one). It is the case a guard written as "there must be somebody to tell"
1775 // would refuse, leaving a group nothing can be sent to and nothing can close.
1776 const alone = await a.page.evaluate(async () =>
1777 window.DaimondGroup.create('Only me', []));
1778 ok(alone.ok, 'a group of one exists', alone);
1779 const shut = await a.page.evaluate(async (g) => {
1780 const r = await window.DaimondGroup.close(g);
1781 const rec = await window.DaimondGroup.get(g);
1782 return { ok: r.ok, why: r.why || '', sent: r.sent,
1783 closed: window.DaimondGroup.isClosed(rec), state: rec.state };
1784 }, alone.gid);
1785 ok(shut.ok, 'and closing it succeeds with nobody to tell', shut);
1786 eq(shut.sent, 0, 'having told nobody, which is how many there were');
1787 ok(shut.closed && shut.state === 'left', 'and it is closed', shut);
1788 } finally {
1789 await realRelay(a);
1790 await Promise.all([a.close(), b.close(), c.close()]);
1791 }
1792}
1793
1794// ── 6. The arithmetic is at the fan-out ──────────────────────
1795
1796function arithmeticIsInTheCode() {
1797 console.log('\n6. the size arithmetic is in a comment at the fan-out');
1798 const src = fs.readFileSync(path.join(APP, 'www/js/post.js'), 'utf8');
1799 const at = src.indexOf('THE FAN-OUT, AND WHERE IT ACTUALLY STOPS');
1800 ok(at > 0, 'the comment is in post.js');
1801 const near = src.slice(at, at + 2200);
1802 ok(/60 bytes/.test(near), 'and it names 60 bytes a slot, which is what SLOT is');
1803 ok(/255/.test(near), 'and 255 as the hard stop, because the slot count is one byte');
1804 // The comment must be AT the fan-out and not in the file's header, or it is a
1805 // document nobody reads next to the code.
1806 const compose = src.indexOf('async function compose(opts)');
1807 ok(compose > at && compose - at < 2600,
1808 'and it sits immediately above the function that builds the envelope',
1809 { commentAt: at, composeAt: compose });
1810 // The numbers the comment claims are the numbers the code has.
1811 ok(/var SLOT = IV \+ 32 \+ 16;/.test(src), 'SLOT really is 12 + 32 + 16');
1812 ok(/var SLOTS_MAX = 255;/.test(src), 'and SLOTS_MAX really is 255');
1813}
1814
1815// ── Run ──────────────────────────────────────────────────────
1816
1817console.log('verify_group -- a membership list with no group key');
1818
1819// Sections by number, so that proving one RED by mutating the implementation
1820// does not cost a whole run each time. With no argument every section runs, and
1821// that is what the suite means.
1822const SECTIONS = {
1823 0: seamIsReal,
1824 1: threeIdentities,
1825 2: idIsTheAuthorisation,
1826 3: joiningShowsNothing,
1827 4: removingRetractsNothing,
1828 5: markerIsRefused,
1829 6: async () => arithmeticIsInTheCode(),
1830 7: aChangedKeyGetsNoSlot,
1831 8: oneMessageIsOneNotice,
1832 9: theMergeConverges,
1833 10: theShippedPath,
1834 11: aRejectedKeyIsNamed,
1835 12: aRefusalIsDrawn,
1836 13: closingIsFinal,
1837};
1838const want = process.argv.slice(2).filter(a => /^\d+$/.test(a));
1839for (const n of (want.length ? want : Object.keys(SECTIONS))) {
1840 // A SECTION THAT THROWS IS ONE FAILURE, not the end of the run. It used to be
1841 // the end of it: a fixture reading `early.made.envelope` after the send it
1842 // depended on had failed took the process down with it, and every section
1843 // after that one printed nothing at all. Which mattered the first time this
1844 // suite was deliberately broken to see what turned red -- the answer was two
1845 // lines out of twelve sections, because the other ten never ran, and a
1846 // red-proof that cannot see its own reds is not one.
1847 try { await SECTIONS[n](); }
1848 catch (e) {
1849 failures++;
1850 console.log(` FAIL section ${n} threw and the rest of it did not run`
1851 + ` -- ${String(e && e.message || e)}`);
1852 }
1853}
1854
1855console.log(failures ? `\n${failures} failure(s)` : '\nall properties hold');
1856process.exit(failures ? 1 : 0);