oxedyne/daimond/dev/verify_post.mjs
35.7 KiB, 3 runs
created by r2519314175:593, 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_post.mjs -- the messaging client: the seal, the five verbs, and the |
| 2 | // ordering that is the whole safety property. |
| 3 | // |
| 4 | // Nine properties, and each one is a thing that could be broken silently: |
| 5 | // |
| 6 | // 1. SEAL AND OPEN BETWEEN TWO IDENTITIES, WITH NO SERVER IN THE PATH AT ALL. |
| 7 | // Two browsers, two profiles, two identities. The bytes are carried between |
| 8 | // them by this file. A third identity must NOT be able to open the same |
| 9 | // envelope, or the seal is decoration. |
| 10 | // 2. ACK AFTER COMMIT. The `?op=ack` request must be made AFTER the sync push |
| 11 | // that carried the message came back 200, and never before. |
| 12 | // 3. NO COMMIT, NO ACK. A push that 409s is not a commit, and nothing may be |
| 13 | // acked on the back of it. |
| 14 | // 4. NOT IN THE PARCEL, NO ACK. Where the parcel does not carry the message |
| 15 | // record, the relay must not be told to let go: the message would be |
| 16 | // dropped from the only place it exists. |
| 17 | // 5. A ROW THE RELAY WROTE IS NEVER DRAWN AS A PERSON. `kind != "post"` goes |
| 18 | // to the notices and never to the message list, in the record AND on screen. |
| 19 | // 6. A FULL BOX IS DRAWN HONESTLY. 507 means the message did not arrive, and |
| 20 | // no Sent copy may be kept. |
| 21 | // 7. A PARK ANSWER WITHOUT `waited` STOPS THE PARKING. A front door that drops |
| 22 | // the query string turns a park into an unthrottled loop. |
| 23 | // 8. THE TRAY'S THREE BUTTONS, pressed the way a person presses them. Accept |
| 24 | // and Block reach the relay; IGNORE REACHES IT IN NO WAY AT ALL, or a |
| 25 | // sender who could tell an ignore from a silence has a presence oracle. |
| 26 | // 9. SENDING THROUGH THE PANEL. The delegated click, the real button, and the |
| 27 | // words nowhere in what left the browser. |
| 28 | // |
| 29 | // FOUR LINES IN OTHER LANES' FILES WERE ONCE SUPPLIED HERE, by a script tag |
| 30 | // injected from disk and a wrapper hung on `collectSync`. All four have landed, |
| 31 | // and the shims have gone with them -- because a shim that outlives its seam |
| 32 | // stops standing in for the line and starts standing in FRONT of it: |
| 33 | // |
| 34 | // * `<script src="js/post.js">` www/index.html:1414 (5c14eea, 9aa963b) |
| 35 | // * `<script src="js/trust.js">` www/index.html:1411 (5c14eea) |
| 36 | // * `DaimondCrypto.postDraft` www/js/daimond.js:201 |
| 37 | // * `state.post = DaimondPost.snapshot()` www/js/sync.js:715 |
| 38 | // |
| 39 | // While `addScriptTag` was loading post.js and trust.js from disk, this suite |
| 40 | // would have passed identically with both script tags deleted from index.html; |
| 41 | // while `wireParcel` was rebuilding the parcel, §2, §3 and §5 onwards proved |
| 42 | // nothing whatever about sync.js. §0 now asserts all four are real, and every |
| 43 | // section below runs on the page as the browser assembles it. |
| 44 | // |
| 45 | // `#social-messages-list` is likewise never built here: the Social panel already |
| 46 | // carries it, and a verifier that built its own region would pass on a panel |
| 47 | // that had none. |
| 48 | // |
| 49 | // node dev/verify_post.mjs |
| 50 | |
| 51 | import { open, errors } from './harness.mjs'; |
| 52 | |
| 53 | // NO PATHS TO post.js OR trust.js. They were here to read the files off disk and |
| 54 | // inject them; a file this suite can reach without the browser reaching it is |
| 55 | // the shape the shims took, and there is nothing left to point at. |
| 56 | |
| 57 | /// Console errors that are the PAGE's fault. A 502 from a gateway this fixture |
| 58 | /// never started is the fixture, not the panel, and counting it would make this |
| 59 | /// assertion fail for a reason that has nothing to do with what is being tested. |
| 60 | function thrown(s) { |
| 61 | return errors(s).filter(e => !/Failed to load resource/.test(e)); |
| 62 | } |
| 63 | |
| 64 | let failures = 0; |
| 65 | function ok(cond, what, detail) { |
| 66 | if (cond) { console.log(` ok ${what}`); return true; } |
| 67 | failures++; |
| 68 | console.log(` FAIL ${what}${detail !== undefined ? ` -- ${JSON.stringify(detail)}` : ''}`); |
| 69 | return false; |
| 70 | } |
| 71 | function eq(got, want, what) { |
| 72 | return ok(JSON.stringify(got) === JSON.stringify(want), what, { got, want }); |
| 73 | } |
| 74 | |
| 75 | // ── The seams, asserted rather than supplied ───────────────── |
| 76 | |
| 77 | /// Wait for the page the browser assembled, and refuse to test a page that is |
| 78 | /// missing a piece rather than quietly building the piece. |
| 79 | /// |
| 80 | /// Nothing is injected here. `waitForFunction` only waits for a tag in |
| 81 | /// `index.html` to have run; if the tag is gone this throws, §0 says which seam, |
| 82 | /// and the section is a failure rather than a pass on an injected copy. |
| 83 | async function ready(s) { |
| 84 | await s.page.waitForFunction( |
| 85 | () => !!window.DaimondPost && !!window.DaimondTrust |
| 86 | && !!document.querySelector('#social-messages-list'), |
| 87 | null, { timeout: 15000 } |
| 88 | ).catch(() => { throw new Error( |
| 89 | 'the page did not assemble: post.js, trust.js or #social-messages-list is ' |
| 90 | + 'missing from www/index.html. Run section 0 for which.'); }); |
| 91 | } |
| 92 | |
| 93 | /// 0. The four seams this file used to supply for itself. |
| 94 | /// |
| 95 | /// Read off the page and off `index.html` itself, because a global can be put |
| 96 | /// there by anything and a script tag cannot. This runs FIRST: every section |
| 97 | /// below is only evidence about the shipped app if these hold. |
| 98 | async function seamsAreReal() { |
| 99 | console.log('\n0. the seams are in the app, not in this file'); |
| 100 | const s = await open({ name: 'post-seams', connect: false }); |
| 101 | try { |
| 102 | const seen = await s.page.evaluate(async () => { |
| 103 | const html = await (await fetch('/index.html')).text(); |
| 104 | // A SCRIPT TAG, not the string: index.html carries comments naming both |
| 105 | // files, and matching those would report a tag present for a build that |
| 106 | // never loaded it. |
| 107 | const tag = n => new RegExp('<script[^>]+src=["\']js/' + n + '\\.js["\']').test(html); |
| 108 | // What `collectParcel` really returns, through sync.js's own door. |
| 109 | // GUARDED, because a missing script tag is exactly what this section |
| 110 | // exists to report: a TypeError here would abort §0 and the run would |
| 111 | // say "seams threw" instead of naming which seam is gone. |
| 112 | let parcel = null; |
| 113 | try { |
| 114 | await window.DaimondIdentity.ensureSealingKey(); |
| 115 | await window.DaimondPost.read(); |
| 116 | parcel = await window.DaimondSync.parcel(); |
| 117 | } catch (e) { parcel = null; } |
| 118 | return { |
| 119 | tagPost: tag('post'), |
| 120 | tagTrust: tag('trust'), |
| 121 | post: !!window.DaimondPost, |
| 122 | trust: !!window.DaimondTrust, |
| 123 | bridge: !!(window.DaimondCrypto |
| 124 | && typeof window.DaimondCrypto.postDraft === 'function'), |
| 125 | host: !!document.querySelector('#social-messages-list'), |
| 126 | carries: !!(parcel && parcel.post && parcel.post.v), |
| 127 | }; |
| 128 | }); |
| 129 | ok(seen.tagPost, 'www/index.html carries a script tag for js/post.js'); |
| 130 | ok(seen.tagTrust, 'www/index.html carries a script tag for js/trust.js'); |
| 131 | ok(seen.post, 'and post.js ran, so DaimondPost is on the page unaided'); |
| 132 | ok(seen.trust, 'and trust.js ran, so DaimondTrust is too'); |
| 133 | ok(seen.bridge, 'daimond.js\'s bridge publishes postDraft'); |
| 134 | ok(seen.host, '#social-messages-list is in the Social panel'); |
| 135 | // The line whose absence §4 and §4b used to be measuring. Read through |
| 136 | // `DaimondSync.parcel()`, which is what `push()` sends, not a reconstruction. |
| 137 | ok(seen.carries, 'sync.js\'s collectParcel carries the message record', seen); |
| 138 | } finally { await s.close(); } |
| 139 | } |
| 140 | |
| 141 | /// Take the message record off the parcel, the way a locked identity does. |
| 142 | /// |
| 143 | /// `snapshot()` answering null is the REAL cause in the field, and sync.js's own |
| 144 | /// `if (pst) state.post = pst` then leaves the section off -- so the strip runs |
| 145 | /// through the shipped line rather than around it. Deleting `state.post` from a |
| 146 | /// wrapper on `DaimondCore.collectSync` would do nothing at all: `collectParcel` |
| 147 | /// calls `collectSync` and adds the section AFTERWARDS (www/js/sync.js:657,715). |
| 148 | async function stripPostFromParcel(s) { |
| 149 | await s.page.evaluate(() => { |
| 150 | if (!window.__postSnapReal) window.__postSnapReal = window.DaimondPost.snapshot; |
| 151 | window.DaimondPost.snapshot = function () { return null; }; |
| 152 | }); |
| 153 | } |
| 154 | |
| 155 | /// Put it back. `delete` would NOT do this: `snapshot` is an own property of the |
| 156 | /// published object, so deleting it leaves sync.js calling `undefined()`. |
| 157 | async function restorePostToParcel(s) { |
| 158 | await s.page.evaluate(() => { |
| 159 | if (window.__postSnapReal) window.DaimondPost.snapshot = window.__postSnapReal; |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | // ── A relay in the test, so the ordering can be watched ────── |
| 164 | // |
| 165 | // The gateway's own half has its own tests in `gateway/src/handlers/post.rs`. |
| 166 | // What is unproven, and what this watches, is the CLIENT's ordering: which |
| 167 | // request it makes, and after what. |
| 168 | |
| 169 | /// Install a mock relay and a mock sync mailbox, and hand back the log. |
| 170 | async function mockServer(s, cfg = {}) { |
| 171 | const log = []; |
| 172 | s.page.on('request', () => {}); |
| 173 | await s.page.route('**/api/post*', async (route) => { |
| 174 | const req = route.request(); |
| 175 | const url = new URL(req.url()); |
| 176 | const op = url.searchParams.get('op') || ''; |
| 177 | const body = req.method() === 'POST' ? JSON.parse(req.postData() || '{}') : null; |
| 178 | log.push({ what: op || (req.method() === 'GET' |
| 179 | ? (url.searchParams.has('above') ? 'park' : 'collect') : 'deliver'), body }); |
| 180 | if (req.method() === 'GET' && url.searchParams.has('above')) { |
| 181 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 182 | body: JSON.stringify(cfg.park || { ok: true, waited: true, seq: 0, changed: false }) }); |
| 183 | } |
| 184 | if (req.method() === 'GET') { |
| 185 | const since = Number(url.searchParams.get('since') || 0); |
| 186 | const rows = (cfg.rows || []).filter(r => r.seq > since); |
| 187 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 188 | body: JSON.stringify({ ok: true, seq: rows.length ? rows[rows.length - 1].seq : since, |
| 189 | rows, more: false }) }); |
| 190 | } |
| 191 | if (op === 'ack') { |
| 192 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 193 | body: JSON.stringify({ ok: true, dropped: 1 }) }); |
| 194 | } |
| 195 | if (op === 'connect') { |
| 196 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 197 | body: JSON.stringify({ ok: true }) }); |
| 198 | } |
| 199 | // A delivery. |
| 200 | if (cfg.deliverStatus && cfg.deliverStatus !== 200) { |
| 201 | return route.fulfill({ status: cfg.deliverStatus, contentType: 'application/json', |
| 202 | body: JSON.stringify({ ok: false, error: 'no' }) }); |
| 203 | } |
| 204 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 205 | body: JSON.stringify({ ok: true, accepted: true }) }); |
| 206 | }); |
| 207 | |
| 208 | let version = 1; |
| 209 | await s.page.route('**/api/sync*', async (route) => { |
| 210 | const req = route.request(); |
| 211 | if (req.method() !== 'POST') { |
| 212 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 213 | body: JSON.stringify({ ok: true, version, blob: '' }) }); |
| 214 | } |
| 215 | log.push({ what: 'sync-push' }); |
| 216 | if (cfg.pushStatus && cfg.pushStatus !== 200) { |
| 217 | return route.fulfill({ status: cfg.pushStatus, contentType: 'application/json', |
| 218 | body: JSON.stringify({ ok: false }) }); |
| 219 | } |
| 220 | version += 1; |
| 221 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 222 | body: JSON.stringify({ ok: true, version }) }); |
| 223 | }); |
| 224 | return log; |
| 225 | } |
| 226 | |
| 227 | /// Put sync.js into the state a signed-in Pro account is in, so `push()` really |
| 228 | /// runs. The harness opens with no gateway, so `ready()` is false and a push |
| 229 | /// returns before it makes a request -- which would make every ordering |
| 230 | /// assertion below pass without a push ever happening. |
| 231 | /// |
| 232 | /// `recheck()` is sync.js's own door for exactly this ("a Pro purchase just |
| 233 | /// landed"), so nothing here reaches inside the module; the only thing faked is |
| 234 | /// the gateway saying there is a session. |
| 235 | async function entitle(s) { |
| 236 | await s.page.evaluate(async () => { |
| 237 | const st = window.DaimondGateway.state; |
| 238 | window.DaimondGateway.state = () => Object.assign({}, st(), { authed: true }); |
| 239 | window.DaimondSync.wakeVia('off'); // no channel noise on the request log |
| 240 | window.DaimondSync.recheck(); |
| 241 | await new Promise(r => setTimeout(r, 300)); |
| 242 | }); |
| 243 | // And prove it took, rather than assuming: a push that cannot run is the one |
| 244 | // way every ordering assertion here passes for the wrong reason. |
| 245 | const live = await s.page.evaluate(async () => { |
| 246 | const before = window.DaimondSync.version(); |
| 247 | await window.DaimondSync.push(); |
| 248 | return { before, after: window.DaimondSync.version() }; |
| 249 | }); |
| 250 | if (!(live.after > live.before)) { |
| 251 | throw new Error(`sync.push() does not reach the wire in this fixture: ${JSON.stringify(live)}`); |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // ── 1. The seal, between two identities, with no server at all ── |
| 256 | |
| 257 | async function sealBetweenTwoIdentities() { |
| 258 | console.log('\n1. seal and open between two identities, no server in the path'); |
| 259 | const a = await open({ name: 'post-a', connect: false }); |
| 260 | const b = await open({ name: 'post-b', connect: false }); |
| 261 | const c = await open({ name: 'post-c', connect: false }); |
| 262 | try { |
| 263 | for (const s of [a, b, c]) await ready(s); |
| 264 | |
| 265 | // Both identities make a sealing key and a card. Nothing crosses a wire: |
| 266 | // the card's bytes are carried by this file, which is what a QR code does. |
| 267 | const card = async (s) => s.page.evaluate(async () => { |
| 268 | await window.DaimondIdentity.ensureSealingKey(); |
| 269 | await window.DaimondIdentity.mintCard(); |
| 270 | return { |
| 271 | card: window.DaimondIdentity.card(), |
| 272 | text: window.DaimondTrust.cardText(), |
| 273 | pub: window.DaimondIdentity.publicKeyB64url(), |
| 274 | }; |
| 275 | }); |
| 276 | const A = await card(a), B = await card(b), C = await card(c); |
| 277 | ok(!!A.card && !!B.card && !!C.card, 'three identities each minted a card'); |
| 278 | |
| 279 | // A reads B's card, exactly as a paste hands it over, through trust.js -- |
| 280 | // the module that owns the log and re-verifies every signature on replay. |
| 281 | const taken = await a.page.evaluate(async (text) => { |
| 282 | const card = window.DaimondTrust.parse(text); |
| 283 | if (!card) return { ok: false, why: 'the card did not parse' }; |
| 284 | await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.PASTE); |
| 285 | await window.DaimondPost.refreshPeople(); |
| 286 | const who = window.DaimondPost.people(); |
| 287 | return { ok: who.length === 1, who }; |
| 288 | }, B.text); |
| 289 | ok(taken.ok, 'A took B\'s card and holds a sealing key for them', taken); |
| 290 | eq(taken.who && taken.who[0] && taken.who[0].pub, B.pub, |
| 291 | 'the card names B\'s own signing key'); |
| 292 | |
| 293 | // A composes, signs and seals. The relay is not involved and is not up. |
| 294 | const TEXT = 'The file view scrolls to the wrong line — twice, on a phone.'; |
| 295 | const made = await a.page.evaluate(async ([to, text]) => { |
| 296 | const m = await window.DaimondPost.compose({ body: text, to }); |
| 297 | return { addr: m.addr, envelope: m.envelope }; |
| 298 | }, [B.pub, TEXT]); |
| 299 | ok(!!made.addr && !!made.envelope, 'A sealed a message', { addr: made.addr }); |
| 300 | |
| 301 | // B opens it. The verification -- envelope, address, signature -- runs |
| 302 | // inside `DaimondCrypto.read`, on B's own device. |
| 303 | const got = await b.page.evaluate(async ([env, addr]) => { |
| 304 | try { return { ok: true, got: await window.DaimondPost.open(env, addr) }; } |
| 305 | catch (e) { return { ok: false, why: String(e && e.message || e) }; } |
| 306 | }, [made.envelope, made.addr]); |
| 307 | ok(got.ok, 'B opened it', got.why); |
| 308 | if (got.ok) { |
| 309 | eq(got.got.post.body, TEXT, 'the text B reads is the text A wrote'); |
| 310 | eq(got.got.address, made.addr, 'the address B computes is the address A did'); |
| 311 | // The author is A's signing key, and the signature over it verified. |
| 312 | const authorPubB64url = await b.page.evaluate((hexKey) => { |
| 313 | const u8 = new Uint8Array(hexKey.length / 2); |
| 314 | for (let i = 0; i < u8.length; i++) u8[i] = parseInt(hexKey.substr(i * 2, 2), 16); |
| 315 | let s = ''; for (const x of u8) s += String.fromCharCode(x); |
| 316 | return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 317 | }, got.got.author); |
| 318 | eq(authorPubB64url, A.pub, 'the signature verifies under A\'s key and nobody else\'s'); |
| 319 | } |
| 320 | |
| 321 | // The negative that makes the positive mean something. |
| 322 | const stranger = await c.page.evaluate(async (env) => { |
| 323 | try { await window.DaimondPost.open(env); return { opened: true }; } |
| 324 | catch (e) { return { opened: false, why: String(e && e.message || e) }; } |
| 325 | }, made.envelope); |
| 326 | ok(!stranger.opened, 'a third identity cannot open the same envelope', stranger); |
| 327 | // FOR THE RIGHT REASON. C holds a sealing key and a full bridge, so a |
| 328 | // refusal about either would be this assertion passing on an accident. |
| 329 | ok(/not sealed to any key/i.test(stranger.why || ''), |
| 330 | 'and is refused because no slot is theirs, not because it could not try', |
| 331 | stranger.why); |
| 332 | |
| 333 | // And a message addressed to B does not become a message to A, however it |
| 334 | // is re-slotted: the payload's `to` is signed. A's own Sent slot DOES open |
| 335 | // the seal -- so the refusal must come from the signed `to` and nowhere |
| 336 | // else, which is what the sentence is checked for. |
| 337 | const backToA = await a.page.evaluate(async (env) => { |
| 338 | try { await window.DaimondPost.open(env); return { opened: true }; } |
| 339 | catch (e) { return { opened: false, why: String(e && e.message || e) }; } |
| 340 | }, made.envelope); |
| 341 | ok(!backToA.opened, 'A\'s own Sent slot opens but does not pass as a message TO A', |
| 342 | backToA); |
| 343 | ok(/addressed to a different key/i.test(backToA.why || ''), |
| 344 | 'and the refusal is the signed `to`, not a seal that would not open', |
| 345 | backToA.why); |
| 346 | } finally { |
| 347 | await a.close(); await b.close(); await c.close(); |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // ── 2-4. The ordering ──────────────────────────────────────── |
| 352 | |
| 353 | /// One collected message, sealed to this session's own identity, as a row. |
| 354 | async function selfRow(s, seq = 1, { kind = 'post', tray = false } = {}) { |
| 355 | return await s.page.evaluate(async ([seq, kind, tray]) => { |
| 356 | await window.DaimondIdentity.ensureSealingKey(); |
| 357 | const to = window.DaimondIdentity.publicKeyB64url(); |
| 358 | const m = await window.DaimondPost.compose({ body: 'a message to myself', to }); |
| 359 | return { |
| 360 | seq, kind, addr: m.addr, from_pub: to, ts: Math.floor(Date.now() / 1000), |
| 361 | bytes: m.envelope.length, tray, expired: false, envelope: m.envelope, |
| 362 | }; |
| 363 | }, [seq, kind, tray]); |
| 364 | } |
| 365 | |
| 366 | async function ackAfterCommit() { |
| 367 | console.log('\n2. the ack is made AFTER the push that carried it committed'); |
| 368 | const s = await open({ name: 'post-ack', connect: false }); |
| 369 | try { |
| 370 | await ready(s); |
| 371 | const row = await selfRow(s, 1); |
| 372 | const cfg = { rows: [row] }; |
| 373 | const log = await mockServer(s, cfg); |
| 374 | await entitle(s); |
| 375 | log.length = 0; // forget the traffic entitling made |
| 376 | |
| 377 | const r = await s.page.evaluate(() => window.DaimondPost.round()); |
| 378 | ok(r.ok && r.got === 1, 'one message was collected', r); |
| 379 | eq(r.acked, 1, 'the relay was told it may let go through sequence 1'); |
| 380 | |
| 381 | const order = log.map(e => e.what); |
| 382 | const iPush = order.lastIndexOf('sync-push'); |
| 383 | const iAck = order.indexOf('ack'); |
| 384 | ok(iAck >= 0, 'an ack was sent', order); |
| 385 | ok(iPush >= 0 && iAck > iPush, |
| 386 | 'the ack came AFTER the parcel push, not before', order); |
| 387 | const ackBody = (log.find(e => e.what === 'ack') || {}).body; |
| 388 | eq(ackBody, { through: 1 }, 'the ack names exactly the sequence that was folded'); |
| 389 | } finally { await s.close(); } |
| 390 | } |
| 391 | |
| 392 | async function noCommitNoAck() { |
| 393 | console.log('\n3. a push that did not commit acks nothing'); |
| 394 | const s = await open({ name: 'post-nocommit', connect: false }); |
| 395 | try { |
| 396 | await ready(s); |
| 397 | const row = await selfRow(s, 1); |
| 398 | const cfg = { rows: [row] }; |
| 399 | const log = await mockServer(s, cfg); |
| 400 | // Entitled while the push still works, so this test cannot pass because a |
| 401 | // push never happened -- which is the trap. THEN the mailbox starts |
| 402 | // answering 409: another device moved it on, and that is not a commit. |
| 403 | await entitle(s); |
| 404 | cfg.pushStatus = 409; |
| 405 | log.length = 0; |
| 406 | |
| 407 | const r = await s.page.evaluate(() => window.DaimondPost.round()); |
| 408 | ok(r.got === 1, 'the message was still collected', r); |
| 409 | eq(r.acked, 0, 'nothing was acked'); |
| 410 | eq(r.why, 'not_committed', 'and the reason given is that the push did not commit'); |
| 411 | ok(!log.some(e => e.what === 'ack'), 'no ack request left the browser at all', |
| 412 | log.map(e => e.what)); |
| 413 | ok(log.some(e => e.what === 'sync-push'), |
| 414 | 'and a push WAS attempted, so this is a refused commit and not an absent one', |
| 415 | log.map(e => e.what)); |
| 416 | const st = await s.page.evaluate(() => window.DaimondPost.state()); |
| 417 | ok(st.through === 1 && st.acked === 0, |
| 418 | 'the message is folded and unacked, so the relay still holds it', st); |
| 419 | } finally { await s.close(); } |
| 420 | } |
| 421 | |
| 422 | async function notInParcelNoAck() { |
| 423 | console.log('\n4. a parcel that does not carry the record acks nothing'); |
| 424 | const s = await open({ name: 'post-noparcel', connect: false }); |
| 425 | try { |
| 426 | await ready(s); |
| 427 | const row = await selfRow(s, 1); |
| 428 | const log = await mockServer(s, { rows: [row] }); |
| 429 | await entitle(s); |
| 430 | // STRIPPED DELIBERATELY, and only now -- after `entitle` has proved a push |
| 431 | // reaches the wire against a parcel that DID carry the record. This section |
| 432 | // used to rely on the record simply not being there, which was true of the |
| 433 | // tree it was written in; when sync.js:715 landed the section stopped |
| 434 | // simulating anything and the ack correctly fired. A test whose premise the |
| 435 | // code has since fixed does not become a bug report, it becomes a stale |
| 436 | // test, and it read as seven failures in post.js for a month. |
| 437 | await stripPostFromParcel(s); |
| 438 | log.length = 0; |
| 439 | |
| 440 | const r = await s.page.evaluate(() => window.DaimondPost.round()); |
| 441 | ok(r.got === 1, 'the message was collected', r); |
| 442 | eq(r.acked, 0, 'nothing was acked'); |
| 443 | eq(r.why, 'not_in_parcel', 'and the reason names the section the parcel is missing'); |
| 444 | ok(!log.some(e => e.what === 'ack'), 'no ack request left the browser at all', |
| 445 | log.map(e => e.what)); |
| 446 | // The control: without the strip, this same fixture DOES ack. Otherwise |
| 447 | // every assertion above would pass on a fixture that never collects. |
| 448 | await restorePostToParcel(s); |
| 449 | const back = await s.page.evaluate(() => window.DaimondPost.ack()); |
| 450 | ok(back.acked === 1 && !back.why, |
| 451 | 'and with the record back on the parcel the very same fixture acks -- ' |
| 452 | + 'so the refusal above is the strip and not a fixture that cannot ack', back); |
| 453 | } finally { await s.close(); } |
| 454 | } |
| 455 | |
| 456 | async function committedButNotCarriedNoAck() { |
| 457 | console.log('\n4b. a push that DID commit, carrying everything but the record, acks nothing'); |
| 458 | const s = await open({ name: 'post-carried', connect: false }); |
| 459 | try { |
| 460 | await ready(s); |
| 461 | // The parcel changes on every collect and commits every time -- but it does |
| 462 | // not carry the message record. Without the parcel read-back this is the |
| 463 | // case that acks messages sitting on no parcel anywhere and loses them: |
| 464 | // the version check alone cannot see it, because the version really did |
| 465 | // move. It is the state a locked identity produces, since `snapshot()` |
| 466 | // answers null and the section is left off. |
| 467 | await s.page.evaluate(() => { |
| 468 | const orig = window.DaimondCore.collectSync; |
| 469 | window.DaimondCore.collectSync = async function () { |
| 470 | const state = await orig.call(window.DaimondCore); |
| 471 | state.__churn = Date.now() + Math.random(); |
| 472 | return state; |
| 473 | }; |
| 474 | }); |
| 475 | const row = await selfRow(s, 1); |
| 476 | const log = await mockServer(s, { rows: [row] }); |
| 477 | await entitle(s); |
| 478 | // The churn alone no longer strips the record, and `delete state.post` |
| 479 | // inside that wrapper would not either: `collectParcel` calls `collectSync` |
| 480 | // and adds the section AFTERWARDS (www/js/sync.js:657, :715), so a wrapper |
| 481 | // underneath it is deleting a key that has not been written yet. The strip |
| 482 | // has to be where sync.js reads from, which is `snapshot()`. |
| 483 | await stripPostFromParcel(s); |
| 484 | log.length = 0; |
| 485 | |
| 486 | const r = await s.page.evaluate(() => window.DaimondPost.round()); |
| 487 | ok(r.got === 1, 'the message was collected', r); |
| 488 | // The push in `entitle` already committed against this same churning |
| 489 | // parcel, so a commit is demonstrably available here and the version has |
| 490 | // demonstrably moved. Remove the parcel read-back and the ack fires on it. |
| 491 | const version = await s.page.evaluate(() => window.DaimondSync.version()); |
| 492 | ok(version > 1, 'a commit against this parcel is available -- the version has moved', |
| 493 | version); |
| 494 | eq(r.acked, 0, 'and still nothing was acked'); |
| 495 | eq(r.why, 'not_in_parcel', 'because the parcel does not carry the record'); |
| 496 | ok(!log.some(e => e.what === 'sync-push'), |
| 497 | 'the refusal came before the push, so no round was spent on it', |
| 498 | log.map(e => e.what)); |
| 499 | ok(!log.some(e => e.what === 'ack'), 'no ack request left the browser at all', |
| 500 | log.map(e => e.what)); |
| 501 | } finally { await s.close(); } |
| 502 | } |
| 503 | |
| 504 | // ── 5. A relay row is never a person ───────────────────────── |
| 505 | |
| 506 | async function relayRowIsNeverAMessage() { |
| 507 | console.log('\n5. a row the relay wrote is never drawn as a message from a person'); |
| 508 | const s = await open({ name: 'post-kind', connect: false }); |
| 509 | try { |
| 510 | await ready(s); |
| 511 | // A row that carries a perfectly good envelope AND a kind the relay writes. |
| 512 | // If `kind` were ignored, this would open and draw as an ordinary message. |
| 513 | const good = await selfRow(s, 1); |
| 514 | const forged = Object.assign({}, good, { seq: 2, kind: 'expiry' }); |
| 515 | await mockServer(s, { rows: [forged] }); |
| 516 | await entitle(s); |
| 517 | |
| 518 | const r = await s.page.evaluate(() => window.DaimondPost.round()); |
| 519 | ok(r.ok, 'the collect ran', r); |
| 520 | const st = await s.page.evaluate(() => ({ |
| 521 | msgs: window.DaimondPost.list().length, |
| 522 | notices: window.DaimondPost.notices().length, |
| 523 | drawnMsgs: document.querySelectorAll('#social-messages-list .post-msg').length, |
| 524 | drawnNotices: document.querySelectorAll('#social-messages-list .post-notice').length, |
| 525 | // The panel's own "not switched on" line, which must be gone once this |
| 526 | // module has drawn: an empty region and an absent one read alike, and |
| 527 | // this is what tells them apart. |
| 528 | offLine: (document.getElementById('social-messages-off') || {}).hidden, |
| 529 | })); |
| 530 | // Counted rather than asserted absent: an empty region and a missing region |
| 531 | // read the same to a locator, so the notice count is what proves the panel |
| 532 | // drew at all. |
| 533 | eq(st.msgs, 0, 'the record holds no message for it'); |
| 534 | eq(st.notices, 1, 'the record holds it as a notice'); |
| 535 | eq(st.drawnMsgs, 0, 'nothing was drawn as a message'); |
| 536 | eq(st.drawnNotices, 1, 'and it WAS drawn, as a notice'); |
| 537 | eq(st.offLine, true, 'and the panel has stopped saying messages are not switched on'); |
| 538 | } finally { await s.close(); } |
| 539 | } |
| 540 | |
| 541 | // ── 8. The panel's own buttons ─────────────────────────────── |
| 542 | |
| 543 | /// One collected message from this session's own key, with a distinct body so |
| 544 | /// two rows are two addresses. |
| 545 | async function selfRowText(s, seq, text, opts = {}) { |
| 546 | return await s.page.evaluate(async ([seq, text, tray]) => { |
| 547 | await window.DaimondIdentity.ensureSealingKey(); |
| 548 | const to = window.DaimondIdentity.publicKeyB64url(); |
| 549 | const m = await window.DaimondPost.compose({ body: text, to }); |
| 550 | return { |
| 551 | seq, kind: 'post', addr: m.addr, from_pub: to, |
| 552 | ts: Math.floor(Date.now() / 1000), bytes: m.envelope.length, |
| 553 | tray, expired: false, envelope: m.envelope, |
| 554 | }; |
| 555 | }, [seq, text, !!opts.tray]); |
| 556 | } |
| 557 | |
| 558 | async function trayButtons() { |
| 559 | console.log('\n8. the tray\'s three buttons, pressed the way a person presses them'); |
| 560 | const s = await open({ name: 'post-tray', connect: false }); |
| 561 | try { |
| 562 | await ready(s); |
| 563 | const r1 = await selfRowText(s, 1, 'first request', { tray: true }); |
| 564 | const r2 = await selfRowText(s, 2, 'second request', { tray: true }); |
| 565 | const cfg = { rows: [r1, r2] }; |
| 566 | const log = await mockServer(s, cfg); |
| 567 | await entitle(s); |
| 568 | log.length = 0; |
| 569 | |
| 570 | // The panel, opened at Messages the way a reference chip opens it. This |
| 571 | // runs `DaimondSocial.show`, which calls every lane's `watch` -- so the |
| 572 | // collect below is triggered by the app and not by the test. |
| 573 | await s.page.evaluate(() => window.DaimondSocial.open('messages')); |
| 574 | await s.page.waitForTimeout(900); |
| 575 | const drawn = await s.page.evaluate(() => ({ |
| 576 | reqs: document.querySelectorAll('#social-messages-list .post-req').length, |
| 577 | msgs: document.querySelectorAll('#social-messages-list .post-msg').length, |
| 578 | })); |
| 579 | eq(drawn.reqs, 2, 'both rows are drawn as requests'); |
| 580 | eq(drawn.msgs, 0, 'and neither is in the message list yet'); |
| 581 | |
| 582 | // ACCEPT: a real click on a real button, through the delegated listener. |
| 583 | await s.page.click('#social-messages-list .post-req [data-act="post-accept"]'); |
| 584 | await s.page.waitForTimeout(400); |
| 585 | const conn = log.filter(e => e.what === 'connect'); |
| 586 | eq(conn.length, 1, 'accepting made exactly one connect request'); |
| 587 | eq(conn[0].body && conn[0].body.action, 'accept', 'and it asked to accept'); |
| 588 | ok(!!(conn[0].body && conn[0].body.peer), 'naming the peer by key', conn[0].body); |
| 589 | |
| 590 | // Accepting is about the PERSON, not the row: both of this sender's |
| 591 | // requests leave the tray, because what was missing was consent to hear |
| 592 | // from them at all. |
| 593 | const accepted = await s.page.evaluate(() => ({ |
| 594 | reqs: document.querySelectorAll('#social-messages-list .post-req').length, |
| 595 | msgs: document.querySelectorAll('#social-messages-list .post-msg').length, |
| 596 | tray: window.DaimondPost.tray().length, |
| 597 | })); |
| 598 | eq(accepted.tray, 0, 'both of that sender\'s requests left the tray'); |
| 599 | eq(accepted.msgs, 2, 'and both are in the message list'); |
| 600 | |
| 601 | // IGNORE: writes nothing, calls nothing, tells nobody. A request here |
| 602 | // would hand the sender a presence oracle, which is why there is no |
| 603 | // `ignore` action on the relay at all. |
| 604 | const r3 = await selfRowText(s, 3, 'third request', { tray: true }); |
| 605 | cfg.rows.push(r3); |
| 606 | await s.page.evaluate(() => window.DaimondPost.collect()); |
| 607 | await s.page.waitForTimeout(500); |
| 608 | eq(await s.page.evaluate(() => |
| 609 | document.querySelectorAll('#social-messages-list .post-req').length), 1, |
| 610 | 'a fresh request is drawn in the tray'); |
| 611 | log.length = 0; |
| 612 | await s.page.click('#social-messages-list .post-req [data-act="post-ignore"]'); |
| 613 | await s.page.waitForTimeout(400); |
| 614 | // NOTHING REACHED THE RELAY. A park is the channel breathing, and a parcel |
| 615 | // push carries the ignore between this account's OWN devices under its own |
| 616 | // key -- neither is a thing the sender can observe. Any relay verb would |
| 617 | // be: a sender who could tell an ignore from a silence has been handed a |
| 618 | // presence oracle, which is why the relay has no `ignore` action at all. |
| 619 | const acts = log.filter(e => e.what !== 'park' && e.what !== 'sync-push'); |
| 620 | eq(acts.map(e => e.what), [], 'ignoring reached the relay in no way at all'); |
| 621 | const after = await s.page.evaluate(() => ({ |
| 622 | reqs: document.querySelectorAll('#social-messages-list .post-req').length, |
| 623 | msgs: document.querySelectorAll('#social-messages-list .post-msg').length, |
| 624 | tray: window.DaimondPost.tray().length, |
| 625 | })); |
| 626 | eq(after.reqs, 0, 'and the tray is empty on screen'); |
| 627 | eq(after.tray, 0, 'and in the record'); |
| 628 | eq(after.msgs, 2, 'and the ignored one is not in the message list either'); |
| 629 | eq(thrown(s), [], 'and the panel threw nothing while doing it'); |
| 630 | } finally { await s.close(); } |
| 631 | } |
| 632 | |
| 633 | // ── 9. Sending, through the panel ──────────────────────────── |
| 634 | |
| 635 | async function sendThroughThePanel() { |
| 636 | console.log('\n9. a message sent by pressing the panel\'s own button'); |
| 637 | const s = await open({ name: 'post-send', connect: false }); |
| 638 | try { |
| 639 | await ready(s); |
| 640 | const log = await mockServer(s, {}); |
| 641 | // Somebody to write to. A's own card into A's own log is the cheapest real |
| 642 | // person there is, and the message is a note to self. |
| 643 | await s.page.evaluate(async () => { |
| 644 | await window.DaimondIdentity.ensureSealingKey(); |
| 645 | await window.DaimondIdentity.mintCard(); |
| 646 | const card = window.DaimondTrust.parse(window.DaimondTrust.cardText()); |
| 647 | await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.PASTE); |
| 648 | }); |
| 649 | await s.page.evaluate(() => window.DaimondSocial.open('messages')); |
| 650 | await s.page.waitForTimeout(900); |
| 651 | |
| 652 | const picker = await s.page.$('#post-to'); |
| 653 | ok(!!picker, 'the box offers somebody to write to'); |
| 654 | await s.page.fill('#post-text', 'Sent by pressing the button.'); |
| 655 | await s.page.click('#social-messages-list [data-act="post-send"]'); |
| 656 | await s.page.waitForTimeout(600); |
| 657 | |
| 658 | const sent = log.filter(e => e.what === 'deliver'); |
| 659 | eq(sent.length, 1, 'one envelope was delivered'); |
| 660 | const body = sent[0].body || {}; |
| 661 | ok(!!body.to && !!body.addr && !!body.envelope, |
| 662 | 'and it carried `to`, `addr` and `envelope`', Object.keys(body)); |
| 663 | ok(!/Sent by pressing/.test(JSON.stringify(body)), |
| 664 | 'and the words are NOWHERE in what left the browser'); |
| 665 | const note = await s.page.evaluate(() => |
| 666 | (document.getElementById('post-note') || {}).textContent || ''); |
| 667 | ok(/sent/i.test(note), 'the panel says it went', note); |
| 668 | const box = await s.page.evaluate(() => |
| 669 | (document.getElementById('post-text') || {}).value); |
| 670 | eq(box, '', 'and the box was cleared'); |
| 671 | eq(thrown(s), [], 'and the panel threw nothing while doing it'); |
| 672 | } finally { await s.close(); } |
| 673 | } |
| 674 | |
| 675 | // ── 6. A full box ──────────────────────────────────────────── |
| 676 | |
| 677 | async function fullBoxIsHonest() { |
| 678 | console.log('\n6. a full box is drawn honestly, and keeps no Sent copy'); |
| 679 | const s = await open({ name: 'post-full', connect: false }); |
| 680 | try { |
| 681 | await ready(s); |
| 682 | await mockServer(s, { deliverStatus: 507 }); |
| 683 | const to = await s.page.evaluate(async () => { |
| 684 | await window.DaimondIdentity.ensureSealingKey(); |
| 685 | return window.DaimondIdentity.publicKeyB64url(); |
| 686 | }); |
| 687 | const r = await s.page.evaluate(async (to) => |
| 688 | await window.DaimondPost.send({ body: 'hello', to }), to); |
| 689 | ok(r.ok === false, 'the send answered that it did not arrive', r); |
| 690 | ok(/full/i.test(r.why || ''), 'and the sentence says the box is full', r.why); |
| 691 | const kept = await s.page.evaluate(() => window.DaimondPost.list().length); |
| 692 | eq(kept, 0, 'no Sent copy was kept for a message that did not arrive'); |
| 693 | } finally { await s.close(); } |
| 694 | } |
| 695 | |
| 696 | // ── 7. A park without `waited` ─────────────────────────────── |
| 697 | |
| 698 | async function parkWithoutWaitedStops() { |
| 699 | console.log('\n7. a park answer without `waited` stops the parking'); |
| 700 | const s = await open({ name: 'post-park', connect: false }); |
| 701 | try { |
| 702 | await ready(s); |
| 703 | // An ordinary collect answer served to a park: what a front door that drops |
| 704 | // the query string produces. It is `ok`, it is 200, and it has no `waited`. |
| 705 | const log = await mockServer(s, { park: { ok: true, seq: 0, rows: [], more: false } }); |
| 706 | // The errand listener (daimond.js `startErrandListener`) already called |
| 707 | // parkStart() on daimond:unlock -- before this fixture's mock was in place -- |
| 708 | // so its first park raced a front door with no gateway behind it and now sits |
| 709 | // in backoff, parking still ON with no reason. Stop that, then start a |
| 710 | // CONTROLLED park against the mock, so this measures the no-`waited` |
| 711 | // detection rather than the listener's pre-mock attempt. (parkStart is a |
| 712 | // no-op while parking is on, which is why the reset is needed first.) |
| 713 | await s.page.evaluate(async () => { |
| 714 | window.DaimondPost.parkStop(); |
| 715 | await new Promise(r => setTimeout(r, 50)); |
| 716 | window.DaimondPost.parkStart(); |
| 717 | }); |
| 718 | await s.page.waitForTimeout(1200); |
| 719 | const first = await s.page.evaluate(() => window.DaimondPost.parking()); |
| 720 | ok(first.on === false, 'parking turned itself off', first); |
| 721 | eq(first.off, 'no_park', 'and named the reason'); |
| 722 | const parksThen = log.filter(e => e.what === 'park').length; |
| 723 | await s.page.waitForTimeout(1500); |
| 724 | const parksNow = log.filter(e => e.what === 'park').length; |
| 725 | eq(parksNow, parksThen, 'and made no further parks'); |
| 726 | ok(parksThen <= 2, 'it stopped on the first such answer, not after a run of them', |
| 727 | parksThen); |
| 728 | } finally { await s.close(); } |
| 729 | } |
| 730 | |
| 731 | // ── Run ────────────────────────────────────────────────────── |
| 732 | |
| 733 | const only = process.argv[2] || ''; |
| 734 | const all = [ |
| 735 | // FIRST. Everything below is evidence about the shipped app only if the app |
| 736 | // is what the browser assembled, so the seams are asked about before anything |
| 737 | // is measured through them. |
| 738 | ['seams', seamsAreReal], |
| 739 | ['seal', sealBetweenTwoIdentities], |
| 740 | ['ack', ackAfterCommit], |
| 741 | ['nocommit', noCommitNoAck], |
| 742 | ['noparcel', notInParcelNoAck], |
| 743 | ['carried', committedButNotCarriedNoAck], |
| 744 | ['kind', relayRowIsNeverAMessage], |
| 745 | ['full', fullBoxIsHonest], |
| 746 | ['park', parkWithoutWaitedStops], |
| 747 | ['tray', trayButtons], |
| 748 | ['panelsend', sendThroughThePanel], |
| 749 | ]; |
| 750 | for (const [name, fn] of all) { |
| 751 | if (only && only !== name) continue; |
| 752 | try { await fn(); } |
| 753 | catch (e) { failures++; console.log(` FAIL ${name} threw: ${e && e.stack || e}`); } |
| 754 | } |
| 755 | console.log(failures ? `\n${failures} failure(s)` : '\nall properties hold'); |
| 756 | process.exit(failures ? 1 : 0); |