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 | |
| 112 | import fs from 'node:fs'; |
| 113 | import path from 'node:path'; |
| 114 | import { fileURLToPath } from 'node:url'; |
| 115 | import { open, errors } from './harness.mjs'; |
| 116 | |
| 117 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 118 | const APP = path.dirname(HERE); |
| 119 | |
| 120 | let failures = 0; |
| 121 | function 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 | } |
| 127 | function 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. |
| 133 | async 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. |
| 145 | const 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. |
| 159 | const 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. |
| 174 | let SEQ = 0; |
| 175 | const 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. |
| 195 | const 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'. |
| 207 | const 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. |
| 214 | async 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. |
| 234 | async 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. |
| 258 | async 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. |
| 301 | const made2env = (r) => (r && r.made && r.made.envelope) || ''; |
| 302 | const 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. |
| 316 | const 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. |
| 333 | const 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. |
| 336 | const 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. |
| 343 | async 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 | |
| 372 | async 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 | |
| 417 | async 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 | |
| 480 | async 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 | |
| 562 | async 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 | |
| 639 | async 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 | |
| 779 | async 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 | |
| 827 | async 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 | |
| 893 | async 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 | |
| 938 | async 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 | |
| 1068 | async 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 | |
| 1156 | async 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 | |
| 1301 | async 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 | |
| 1445 | async 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 | |
| 1796 | function 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 | |
| 1817 | console.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. |
| 1822 | const 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 | }; |
| 1838 | const want = process.argv.slice(2).filter(a => /^\d+$/.test(a)); |
| 1839 | for (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 | |
| 1855 | console.log(failures ? `\n${failures} failure(s)` : '\nall properties hold'); |
| 1856 | process.exit(failures ? 1 : 0); |