Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_doorbell.mjs

16.5 KiB, 1 run

created by r2519314175:377, 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_doorbell.mjs -- the email doorbell: the switch that three published
2// texts promised and the app did not have.
3//
4// The doorbell defaults ON for a beta account (decision 11,
5// gateway/src/doorbell.rs:333): with push declined it is the only thing a closed
6// tab ever hears. Three surfaces told people they could turn it off --
7// www/guide/legal/privacy.html, landing/privacy.html, and the doorbell email's
8// own body, which names "Settings" -- while `?op=doorbell` had no caller
9// anywhere in www/ and no i18n string existed for the notice decision 11 asks
10// for. Beta accounts were emailed by default, told nothing, and pointed at a
11// switch that was not there.
12//
13// FIVE PROPERTIES, and each one is a way the switch could exist and still be a
14// lie:
15//
16// 1. IT IS REACHABLE. Not "the element is in the DOM": measured with
17// `getBoundingClientRect()` and required to have real area on screen after
18// pressing the cog a person presses. An absent element reports itself to a
19// locator as hidden, so everything here is COUNTED before it is asserted.
20// 2. IT READS BEFORE IT DRAWS. The row must ask `?view=doorbell` and paint the
21// answer -- never paint a guess. A switch that showed "off" while the
22// answer was unknown would tell somebody no mail is being sent when it is.
23// 3. THE REACH IS DRAWN AS WELL AS THE STATE. "On" and "will ring" are
24// different answers, which is why the gateway sends both. An account with
25// no address on file has the second without the first, and a screen showing
26// only the switch would be lying to the one person who could fix it.
27// 4. PRESSING IT WRITES, AND DRAWS WHAT CAME BACK. `?op=doorbell` with the
28// opposite of the state on screen, and the row then shows the SERVER's
29// answer rather than the request -- a write that did not land must not
30// leave the switch showing the state it failed to reach.
31// 5. THE NOTICE COMES FIRST, ONCE, AND SAYS HOW TO STOP IT. Only where the
32// default is in force and a bell could actually ring; never twice; and it
33// changes nothing on its own, because a person who taps past a modal has
34// not decided anything.
35//
36// No gateway: `/api/post` is routed in the browser, so what is measured is the
37// CLIENT -- which request it makes, when, and what it puts on screen. The
38// gateway's own half has its tests in gateway/src/doorbell.rs.
39//
40// node dev/verify_doorbell.mjs
41// node dev/verify_doorbell.mjs --break noread # 2: the row paints a guess
42// node dev/verify_doorbell.mjs --break noreach # 3: the reach is dropped
43// node dev/verify_doorbell.mjs --break twice # 5: the notice repeats
44
45import { open, shot, errors } from './harness.mjs';
46
47const BREAK = (process.argv.indexOf('--break') >= 0)
48 ? process.argv[process.argv.indexOf('--break') + 1] : '';
49
50/// A deliberate damage, applied to the live page rather than to a served file:
51/// each replaces one behaviour of the row with the mistake the check opposite it
52/// is there to catch. A break that produces a GREEN run means that check is
53/// checking nothing, and the run says so and exits 1.
54const BREAKS = {
55 // 2. Paint without asking: the row decides it is off and never reads.
56 noread: () => { window.DaimondPost.doorbell = async () =>
57 ({ ok: true, on: false, set: true, reach: 'declined', why: '' }); },
58 // 3. Drop the reach, keeping the switch. This is the exact shape the audit
59 // warned about: "on" drawn for an account that cannot be rung.
60 noreach: () => {
61 const real = window.DaimondPost.doorbell;
62 window.DaimondPost.doorbell = async () => {
63 const r = await real();
64 if (r && r.ok) { r.reach = 'ready'; r.why = ''; }
65 return r;
66 };
67 },
68 // 5. Never remember that the notice was shown, so it comes back every time.
69 //
70 // The shim goes on the INSTANCE, not on `Storage.prototype`: the app holds
71 // `localStorage` directly. And it swallows only this one key -- a shim that
72 // broke every write would fail the run for reasons that have nothing to do
73 // with the notice, which is a break passing by breaking something else.
74 twice: () => {
75 const real = localStorage.setItem.bind(localStorage);
76 localStorage.setItem = function (k, v) {
77 if (k === 'daimond-doorbell-told') return;
78 return real(k, v);
79 };
80 },
81};
82if (BREAK && !BREAKS[BREAK]) {
83 console.log(`no such break '${BREAK}'; have: ${Object.keys(BREAKS).join(', ')}`);
84 process.exit(2);
85}
86
87const ok = [], bad = [];
88function check(name, pass, detail) {
89 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
90 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
91}
92const sleep = ms => new Promise(r => setTimeout(r, ms));
93
94/// Real area, inside the viewport. Not an ancestry check and not `hidden`:
95/// a row can be unhidden and still be nowhere a person could press it.
96const onScreen = (page, sel) => page.evaluate((s) => {
97 const el = document.querySelector(s);
98 if (!el) return { found: false, w: 0, h: 0 };
99 const r = el.getBoundingClientRect();
100 return {
101 found: true,
102 w: Math.round(r.width), h: Math.round(r.height),
103 inView: r.top < innerHeight && r.bottom > 0 && r.left < innerWidth && r.right > 0,
104 text: (el.textContent || '').trim(),
105 };
106}, sel);
107
108const s = await open({ name: 'doorbell', connect: false });
109const { page } = s;
110
111// ── The relay, in the browser ────────────────────────────────
112//
113// `state` is what the gateway would hold. The route reads it at each request
114// rather than closing over a copy, so a write really changes what the next read
115// answers -- otherwise property 4 would pass against a fixture that cannot move.
116const server = { on: true, set: false, reach: 'ready', writes: [], reads: 0, fail: false };
117
118await page.route('**/api/post**', async (route) => {
119 const req = route.request();
120 const url = new URL(req.url());
121 const answer = () => ({
122 ok: true, on: server.on, set: server.set, reach: server.reach,
123 why: server.reach === 'no_address'
124 ? 'No address on file, so nothing can be sent. This waits until they next open Daimond.'
125 : 'A doorbell will ring for this account.',
126 last_ts: 0, status: '', reason: '', attempts: 0,
127 });
128 if (req.method() === 'GET' && url.searchParams.get('view') === 'doorbell') {
129 server.reads += 1;
130 return route.fulfill({ status: 200, contentType: 'application/json',
131 body: JSON.stringify(answer()) });
132 }
133 if (req.method() === 'POST' && url.searchParams.get('op') === 'doorbell') {
134 const body = JSON.parse(req.postData() || '{}');
135 server.writes.push(body);
136 if (server.fail) {
137 return route.fulfill({ status: 500, contentType: 'application/json',
138 body: JSON.stringify({ ok: false }) });
139 }
140 server.on = !!body.on;
141 server.set = true;
142 return route.fulfill({ status: 200, contentType: 'application/json',
143 body: JSON.stringify(answer()) });
144 }
145 // Anything else on this path is not this file's business.
146 return route.fulfill({ status: 200, contentType: 'application/json',
147 body: JSON.stringify({ ok: true, seq: 0, rows: [], more: false }) });
148});
149
150/// The session the row needs. There is no gateway here, so `authed` is false and
151/// the row would correctly hide itself -- which would make every check below
152/// pass by measuring nothing. Faked at `DaimondGateway.state`, the one door the
153/// row reads, and nowhere deeper.
154async function authed() {
155 await page.evaluate(() => {
156 const st = window.DaimondGateway.state;
157 window.DaimondGateway.state = () => Object.assign({}, st(), { authed: true });
158 });
159}
160
161try {
162
163// ── 5a. The notice, which must come before anything else ─────
164//
165// FIRST, because "before the first email could go" is the whole property and a
166// check that ran it after opening Settings would be measuring a notice the
167// person had already been given the switch for.
168
169await authed();
170if (BREAK) await page.evaluate(BREAKS[BREAK]);
171const toldBefore = await page.evaluate(() => localStorage.getItem('daimond-doorbell-told'));
172check('nothing has been said about the doorbell yet', !toldBefore, String(toldBefore));
173
174await page.evaluate(() => window.dispatchEvent(new Event('daimond:authed')));
175await sleep(900);
176
177const notice = await onScreen(page, '.dlg-card, .dialog-card, [role="dialog"] .dlg-msg, #dlg-msg');
178const noticeAll = await page.evaluate(() => {
179 // COUNTED, not asked whether it is hidden: a dialog that was never built
180 // reports itself to a locator exactly as one that is hidden does.
181 const cards = Array.from(document.querySelectorAll('[role="dialog"], .dlg, .dialog'));
182 const live = cards.filter(c => {
183 const r = c.getBoundingClientRect();
184 return r.width > 40 && r.height > 40;
185 });
186 return { cards: cards.length, live: live.length,
187 text: live.map(c => (c.textContent || '').replace(/\s+/g, ' ').trim()).join(' | ') };
188});
189check('a notice is on screen before any doorbell could ring',
190 noticeAll.live >= 1, JSON.stringify({ cards: noticeAll.cards, live: noticeAll.live }));
191check('it says what will happen: one email, and no sender or subject',
192 /one email/i.test(noticeAll.text) && /no sender/i.test(noticeAll.text),
193 noticeAll.text.slice(0, 160));
194check('and it says how to stop it, naming Settings and the row',
195 /Settings/i.test(noticeAll.text) && /Email doorbell/i.test(noticeAll.text),
196 noticeAll.text.slice(0, 160));
197check('and it changed nothing: the account is still on, still unchosen',
198 server.writes.length === 0 && server.on === true && server.set === false,
199 JSON.stringify({ writes: server.writes, on: server.on, set: server.set }));
200
201// Dismiss it the way a person does.
202await page.evaluate(() => {
203 const btn = Array.from(document.querySelectorAll('[role="dialog"] button, .dlg button'))
204 .filter(b => b.offsetParent !== null)[0];
205 if (btn) btn.click();
206});
207await sleep(400);
208
209// ── 5b. Once. ───────────────────────────────────────────────
210const before = noticeAll.live;
211await page.evaluate(() => window.dispatchEvent(new Event('daimond:authed')));
212await sleep(900);
213const again = await page.evaluate(() => Array.from(
214 document.querySelectorAll('[role="dialog"], .dlg, .dialog'))
215 .filter(c => { const r = c.getBoundingClientRect(); return r.width > 40 && r.height > 40; })
216 .length);
217check('and it is said once, not at every session', before >= 1 && again === 0,
218 JSON.stringify({ before, again }));
219
220// ── 1. The control is reachable, by pressing what a person presses ──
221//
222// THE COG, and nothing else. It opens the admin drawer's home view, which is
223// where the sync switch lives and where daimond.js:8323 says a control has to
224// be if anybody is to find it. A check that reached the row by calling
225// `DaimondAdmin.settings()` would be proving the row exists somewhere in the
226// app, which is a weaker claim than the one the privacy page makes.
227
228await page.evaluate(() => { document.getElementById('settings-btn').click(); });
229await sleep(900);
230
231// COUNTED FIRST. An absent element reports itself to a locator as hidden, so
232// asking "is it visible" before asking "is it there" gets the same answer for a
233// row that is scrolled away and a row that was never built.
234const count = await page.evaluate(() => ({
235 btn: document.querySelectorAll('#doorbell-btn').length,
236 note: document.querySelectorAll('#doorbell-note').length,
237 reach: document.querySelectorAll('#doorbell-reach').length,
238}));
239check('the drawer the cog opens has exactly one doorbell row',
240 count.btn === 1 && count.note === 1 && count.reach === 1, JSON.stringify(count));
241
242// The drawer is long, so the row is brought into view the way a finger would.
243// The property is that it is ON the page the cog opened and can be scrolled to,
244// not that it happened to land in the first screenful.
245await page.evaluate(() => {
246 const el = document.getElementById('doorbell-btn');
247 if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center' });
248});
249await sleep(500);
250
251const btn = await onScreen(page, '#doorbell-btn');
252check('and the switch has real area on screen, where a hand could reach it',
253 btn.found && btn.w > 20 && btn.h > 10 && btn.inView, JSON.stringify(btn));
254check('and it is named for what it is', /doorbell/i.test(btn.text), btn.text);
255
256// ── 2. It read before it drew ───────────────────────────────
257
258check('the row asked the gateway what the state is', server.reads >= 1,
259 'reads: ' + server.reads);
260check('and drew the state the server sent: on, so it offers OFF',
261 /turn the email doorbell off/i.test(btn.text), btn.text);
262
263const note1 = await onScreen(page, '#doorbell-note');
264check('the default is drawn AS a default, not as somebody\'s choice',
265 /default for a beta account/i.test(note1.text), note1.text.slice(0, 200));
266const reach1 = await onScreen(page, '#doorbell-reach');
267check('and with a bell that CAN ring, the reach line says nothing twice',
268 reach1.found && reach1.text === '', JSON.stringify(reach1).slice(0, 160));
269
270// ── 3. The reach, which is not the switch ───────────────────
271//
272// The account loses its address; the switch stays exactly where it was. The
273// switch must not move, and the row must now say a bell cannot ring.
274
275server.reach = 'no_address';
276// Closed and reopened, which is what a person does and what re-reads the state.
277await page.evaluate(() => { document.getElementById('settings-btn').click(); });
278await sleep(300);
279await page.evaluate(() => { window.DaimondPost.doorbell().then(() => {}); });
280await page.evaluate(() => { document.getElementById('settings-btn').click(); });
281await sleep(900);
282await page.evaluate(() => {
283 const el = document.getElementById('doorbell-btn');
284 if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center' });
285});
286await sleep(400);
287
288const reachBtn = await onScreen(page, '#doorbell-btn');
289const reachLine = await onScreen(page, '#doorbell-reach');
290check('the switch has not moved: it is still on, and still offers OFF',
291 /turn the email doorbell off/i.test(reachBtn.text), reachBtn.text);
292check('and the row says a bell CANNOT ring, which the switch alone never would',
293 reachLine.found && /no email address/i.test(reachLine.text),
294 JSON.stringify(reachLine).slice(0, 220));
295
296// ── 4. Pressing it writes, and draws what came back ─────────
297
298server.reach = 'ready';
299const writesBefore = server.writes.length;
300await page.evaluate(() => { const b = document.getElementById('doorbell-btn'); if (b) b.click(); });
301await sleep(900);
302
303check('pressing it made exactly one write', server.writes.length === writesBefore + 1,
304 JSON.stringify(server.writes));
305check('and it asked for the OPPOSITE of what was on screen',
306 server.writes[server.writes.length - 1] &&
307 server.writes[server.writes.length - 1].on === false,
308 JSON.stringify(server.writes[server.writes.length - 1]));
309
310const after = await onScreen(page, '#doorbell-btn');
311check('and the row now offers to turn it back ON, which is the server\'s answer',
312 /turn the email doorbell on/i.test(after.text), after.text);
313const note2 = await onScreen(page, '#doorbell-note');
314check('and says plainly that no email will be sent',
315 /no email will be sent/i.test(note2.text), note2.text.slice(0, 160));
316check('and no longer calls it a default, because it is now a choice',
317 !/default for a beta account/i.test(note2.text), note2.text.slice(0, 160));
318
319// A write that does not land must not leave the switch showing the state it
320// failed to reach. This is the check that a fixture which cannot fail would miss.
321server.fail = true;
322const writesBeforeFail = server.writes.length;
323await page.evaluate(() => { const b = document.getElementById('doorbell-btn'); if (b) b.click(); });
324await sleep(900);
325const afterFail = await onScreen(page, '#doorbell-btn');
326check('a refused write was attempted', server.writes.length === writesBeforeFail + 1,
327 String(server.writes.length));
328check('and the switch still shows OFF, the state the server is actually in',
329 /turn the email doorbell on/i.test(afterFail.text), afterFail.text);
330const note3 = await onScreen(page, '#doorbell-note');
331check('and the row says the save did not take', /did not save/i.test(note3.text),
332 note3.text.slice(0, 160));
333server.fail = false;
334
335} finally {
336 await shot(s, 'doorbell' + (BREAK ? '-' + BREAK : ''));
337 const errs = errors(s).filter(e => !/Failed to load resource|status of 4\d\d/.test(e));
338 check('no console errors along the way', errs.length === 0, errs.slice(0, 3).join(' | '));
339 await s.close();
340}
341
342console.log(`\n${ok.length} ok, ${bad.length} failed`);
343if (bad.length) {
344 console.log('failed: ' + bad.join('; '));
345 process.exit(1);
346}
347if (BREAK) {
348 console.log(`\nbreak '${BREAK}' produced a GREEN run, which means the check it is `
349 + 'aimed at is not checking anything.');
350 process.exit(1);
351}