Oregami
Repositories/oxedyne/daimond

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
51import { 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.
60function thrown(s) {
61 return errors(s).filter(e => !/Failed to load resource/.test(e));
62}
63
64let failures = 0;
65function 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}
71function 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.
83async 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.
98async 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).
148async 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()`.
157async 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.
170async 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.
235async 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
257async 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.
354async 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
366async 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
392async 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
422async 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
456async 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
506async 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.
545async 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
558async 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
635async 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
677async 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
698async 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
733const only = process.argv[2] || '';
734const 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];
750for (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}
755console.log(failures ? `\n${failures} failure(s)` : '\nall properties hold');
756process.exit(failures ? 1 : 0);