Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_legalreach.mjs

30.9 KiB, 1 run

created by r2519314175:503, 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// verify_legalreach.mjs — the two promises the legal pages make about the app.
2//
3// PROMISE ONE: the documents exist where the user is. Daimond published its
4// Terms and its Privacy Policy at daimond.app and then shipped the app on
5// daimond.oxedyne.com, where both paths 404. The app itself never mentioned
6// them. A beta consent line had nowhere to point, and "by using Daimond you
7// agree to these Terms" was addressed to somebody who could not open them.
8//
9// PROMISE TWO: Terms §13 and Privacy §9 both say, of stored file data,
10// "You will be told IN THE APP before it happens"; and Terms §7 sells a
11// five-year licence without the app ever saying when the five years are up.
12//
13// Six properties are worth asserting and the rest is decoration.
14//
15// 1. THE DOCUMENT OPENS INSIDE DAIMOND. Not "a link exists" — the Web panel
16// is on screen, its frame is showing a page of THIS origin, and the page
17// in it has the document's own heading and the document's own words. A
18// link out to daimond.app satisfies "reachable" and fails this.
19//
20// 2. NOTHING IN IT LEAVES THE ORIGIN. Every anchor in both documents is
21// resolved in the frame and required to be same-origin. This is asserted
22// at the DOM of the rendered page and not by reading the source, because
23// what matters is where a click would go.
24//
25// 3. THE APP LEADS TO THEM WITHOUT BEING TOLD THEY EXIST. About — where the
26// small print already lives — carries both, and clicking one shows it.
27//
28// 4. THE IN-APP COPY IS THE PUBLISHED COPY. The pages are generated from
29// landing/; the generator's own --check must pass, and the check is
30// itself proved to have teeth by feeding it a mutated source.
31//
32// 5. THE NOTICE IS DRIVEN BY THE FACT, AND NAMES THE RIGHT DAY. In grace: a
33// notice, naming grace_start + grace_secs and not grace_start. Not in
34// grace: nothing. Both halves, because a notice that is always up is not
35// a notice, and a notice with the wrong date is worse than none.
36//
37// 6. IT SAYS WHAT THE TERMS SAY AND NOTHING MORE GENEROUS. The wording is
38// checked against the clause it is quoting: "is deleted", never "may be
39// deleted" (the Terms say, in terms, 'We say "is", not "may be"'); the
40// licence notice must carry what does NOT stop, or it overstates the loss.
41// A × on either lasts a day and no longer.
42//
43// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
44// deliberately damaged copy of a real file to the real page, and the run is
45// expected to FAIL. A break whose anchor does not appear exactly once aborts:
46// a check proved against code that was never broken is not proved at all.
47//
48// node dev/verify_legalreach.mjs --break linkout # 1 fails: it opens daimond.app
49// node dev/verify_legalreach.mjs --break external # 2 fails: an anchor off-origin
50// node dev/verify_legalreach.mjs --break norow # 3 fails: About says nothing
51// node dev/verify_legalreach.mjs --break unreached # 5 fails: nothing in the app calls it
52// node dev/verify_legalreach.mjs --break nograce # 5 fails: the fields are ignored
53// node dev/verify_legalreach.mjs --break gracelen # 5 fails: the date is the wrong day
54// node dev/verify_legalreach.mjs --break always # 5 fails: a notice with no lapse
55// node dev/verify_legalreach.mjs --break halfquiet # 5 fails: silent on a half-answer
56// node dev/verify_legalreach.mjs --break maybe # 6 fails: "may be deleted"
57// node dev/verify_legalreach.mjs --break expires # 6 fails: the gateway is overruled
58// node dev/verify_legalreach.mjs --break lead # 6 fails: four years' warning
59// node dev/verify_legalreach.mjs --break hushforever # 6 fails: dismissed for good
60// node dev/verify_legalreach.mjs --break topup # 6 fails: Top up does nothing
61// node dev/verify_legalreach.mjs # and then, clean
62//
63// eval "$(bash dev/world.sh 12 --up)"
64// node dev/verify_legalreach.mjs
65//
66// Needs dev/serve.mjs only. No gateway on :9002: every /api route is stubbed
67// here, and everything below the stub is the real code.
68import fs from 'node:fs';
69import path from 'node:path';
70import { execFileSync } from 'node:child_process';
71import { fileURLToPath } from 'node:url';
72import { open, signInAs, scratch } from './harness.mjs';
73
74const HERE = path.dirname(fileURLToPath(import.meta.url));
75const ROOT = path.join(HERE, '..');
76const WWW = path.join(ROOT, 'www');
77
78const BREAK = (() => {
79 const i = process.argv.indexOf('--break');
80 return i > 0 ? String(process.argv[i + 1] || '') : '';
81})();
82
83const PROFILE = scratch('pw', 'legalreach' + (BREAK ? '-' + BREAK : ''));
84fs.rmSync(PROFILE, { recursive: true, force: true });
85
86const ok = [], bad = [];
87const check = (name, pass, detail) => {
88 (pass ? ok : bad).push(name);
89 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
90};
91
92// ── The seam that is not applied yet ─────────────────────────────────
93//
94// js/legal.js, js/lapse.js and css/lapse.css are new files, and nothing loads a
95// file in this app except index.html — which this lane does not own. Until those
96// three lines land, the behaviour below cannot be exercised at all.
97//
98// So the document is patched in flight WITH THE EXACT LINES the seam asks for,
99// and the fact that it had to be is reported as a failing check of its own. The
100// run therefore stays red until the seam is applied, and goes green the moment
101// it is, with no change here: `wire()` finds the tags already present and
102// patches nothing.
103
104const SEAM = [
105 {
106 after: '<link rel="stylesheet" href="css/terminal.css">',
107 add: '<link rel="stylesheet" href="css/lapse.css">',
108 },
109 {
110 after: '<script src="js/handmode.js"></script>',
111 add: '<script src="js/legal.js"></script>\n<script src="js/lapse.js"></script>',
112 },
113];
114
115/// index.html as it should be served. Returns `[html, patched]`.
116function wire() {
117 let html = fs.readFileSync(path.join(WWW, 'index.html'), 'utf8');
118 let patched = false;
119 for (const s of SEAM) {
120 if (html.includes(s.add)) continue;
121 if (!html.includes(s.after)) {
122 console.error(`the seam anchor is gone from index.html: ${s.after}`);
123 process.exit(2);
124 }
125 html = html.replace(s.after, s.after + '\n' + s.add);
126 patched = true;
127 }
128 return [html, patched];
129}
130
131// ── The breaks ───────────────────────────────────────────────────────
132// Each is a real edit to a real file, served in place of it.
133const BREAKS = {
134 // The world before this work: the app hands the reader to another website.
135 // `open()` still answers true, so a caller cannot tell — only the panel can.
136 linkout: [{
137 file: 'js/legal.js',
138 find: '\t\tif (window.DaimondWeb && DaimondWeb.guide) {\n'
139 + '\t\t\tDaimondWeb.guide(sub);\n\t\t\treturn true;\n\t\t}',
140 with: '\t\tif (window.DaimondWeb && DaimondWeb.guide) {\n'
141 + '\t\t\twindow.open(\'https://daimond.app/\' + which + \'.html\', \'_blank\');\n'
142 + '\t\t\treturn true;\n\t\t}',
143 }],
144 // Exactly what the generator's `unlink` takes out, put back: one anchor in
145 // §1 pointing at the product website. The document still renders, still
146 // reads correctly, and now has a door out of the app in it.
147 //
148 // This is the one break that serves a DOCUMENT rather than a script, and
149 // Chrome treats a fulfilled document as public address space — so the
150 // stylesheets it then asks of the loopback server are refused, and the
151 // "nothing threw" check fails alongside the real one. Both failures are in
152 // a run that is meant to fail; the link check is the one being proved.
153 external: [{
154 file: 'guide/legal/terms.html',
155 find: 'daimond.app. In these Terms,',
156 with: '<a href="https://daimond.app">daimond.app</a>. In these Terms,',
157 }],
158 // The documents exist and open, and nothing in the app mentions them.
159 norow: [{
160 file: 'js/legal.js',
161 find: '\tfunction decorate(card) {\n\t\tvar body = card.querySelector(\'.about-body\');',
162 with: '\tfunction decorate(card) {\n\t\tif (card) return;\n\t\tvar body = card.querySelector(\'.about-body\');',
163 }],
164 // Everything works, and nothing in the shipped app ever runs it: the module
165 // is loaded, its functions are correct, and only a test ever calls one. This
166 // is the defect class this tree keeps shipping, so it gets its own break.
167 unreached: [{
168 file: 'js/lapse.js',
169 find: '\twindow.addEventListener(\'daimond:authed\', start);',
170 with: '\t// window.addEventListener(\'daimond:authed\', start);',
171 }],
172 // The gateway answers and the client throws the answer away — which is the
173 // state this file was written to end.
174 nograce: [{
175 file: 'js/lapse.js',
176 find: '\t\t\t\tif (typeof bal.storage_grace_start === \'number\') {',
177 with: '\t\t\t\tif (false && typeof bal.storage_grace_start === \'number\') {',
178 }],
179 // A notice on the right subject naming the wrong day: the day the grace
180 // BEGAN, not the day the data goes. The reader tops up six months late.
181 gracelen: [{
182 file: 'js/lapse.js',
183 find: '\t\t\t\t\tstate.storageAt = (start > 0 && len > 0) ? (start + len) * 1000 : 0;',
184 with: '\t\t\t\t\tstate.storageAt = (start > 0 && len > 0) ? start * 1000 : 0;',
185 }],
186 // The notice is furniture: up whether or not anything is lapsing.
187 always: [{
188 file: 'js/lapse.js',
189 find: '\tfunction storageSpec() {\n\t\tif (!state.storageOn) return null;',
190 with: '\tfunction storageSpec() {\n\t\tif (false) return null;',
191 }],
192 // The half-answer silences it: an account in grace, a gateway that did not
193 // say how long, and an app that therefore says nothing at all.
194 halfquiet: [{
195 file: 'js/lapse.js',
196 find: '\t\t\t\t\tstate.storageOn = start > 0;',
197 with: '\t\t\t\t\tstate.storageOn = start > 0 && Number(bal.storage_grace_secs) > 0;',
198 }],
199 // The hedge the Terms explicitly refuse: "We say 'is', not 'may be'".
200 maybe: [{
201 file: 'js/lapse.js',
202 find: '\t\t\t\t+ \'allowance is deleted. Files on this device are untouched.\'),',
203 with: '\t\t\t\t+ \'allowance may be deleted. Files on this device are untouched.\'),',
204 }],
205 // The client computes its own expiry and ignores the one the gateway is
206 // enforcing. Both dates look plausible; only one is the one that bites.
207 expires: [{
208 file: 'js/lapse.js',
209 find: '\t\tif (typeof j.expires_ts === \'number\' && j.expires_ts > 0) return j.expires_ts * 1000;',
210 with: '\t\tif (false) return 0;',
211 }],
212 // Four years and eleven months of being told the licence is ending.
213 lead: [{
214 file: 'js/lapse.js',
215 find: '\t\tif (state.licenceAt - now > lead) return null;',
216 with: '\t\tif (false) return null;',
217 }],
218 // The × silences a deletion notice for ever.
219 hushforever: [{
220 file: 'js/lapse.js',
221 find: '\t\treturn !!h && (Date.now() - h) < HUSH_MS;',
222 with: '\t\treturn !!h;',
223 }],
224 // The button is drawn, and pressing it does nothing at all. This is the
225 // defect class the app keeps shipping, so it gets its own break.
226 topup: [{
227 file: 'js/lapse.js',
228 find: '\t\t\tacts.push(act(t(\'lapse.top_up\', \'Top up credits\'), function () {\n'
229 + '\t\t\t\tDaimondAdmin.credits(t(\'lapse.credits_pitch\',\n'
230 + '\t\t\t\t\t\'Topping up stops the stored data above the free allowance being deleted.\'));\n'
231 + '\t\t\t}, true));',
232 with: '\t\t\tacts.push(act(t(\'lapse.top_up\', \'Top up credits\'), function () {\n'
233 + '\t\t\t}, true));',
234 }],
235};
236
237if (BREAK && !BREAKS[BREAK]) {
238 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
239 process.exit(2);
240}
241
242/// `src` with `spec` applied, or a hard stop. Nothing is served that was not
243/// verified to differ from what it was given.
244function damaged(src, spec) {
245 const n = src.split(spec.find).length - 1;
246 if (n !== 1) {
247 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
248 + 'so nothing was broken and the run below would prove nothing.');
249 process.exit(2);
250 }
251 return src.replace(spec.find, spec.with);
252}
253
254/// The damaged files, ONE BODY PER FILE.
255///
256/// Every edit a break names for a file goes into the SAME body, in order, and
257/// that one body is what the route serves. A `page.route` per edit spec does not
258/// work and does not say so: Playwright hands a request to the LAST route
259/// registered for its URL, so a two-edit break shipped only its second edit --
260/// and still went red, for half the reason it claims, with nothing to notice it.
261function damagedFiles() {
262 const byFile = new Map();
263 for (const spec of (BREAKS[BREAK] || [])) {
264 const src = byFile.has(spec.file) ? byFile.get(spec.file)
265 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
266 byFile.set(spec.file, damaged(src, spec));
267 }
268 return byFile;
269}
270
271const MIME = { '.js': 'application/javascript', '.html': 'text/html; charset=utf-8' };
272
273// ── The stubbed gateway ──────────────────────────────────────────────
274
275const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
276const json = (body, status = 200) => ({
277 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
278});
279
280const DAY = 86400;
281const now = () => Math.floor(Date.now() / 1000);
282
283// What the gateway is currently saying. Each phase below sets these and then
284// asks the app to re-read, exactly as the six-hourly poll would.
285//
286// It opens IN GRACE, because the first thing to prove is that the app asks on
287// its own — no test called anything, the notice is simply there a few seconds
288// after the session is.
289const GRACE_STARTED = now() - 10 * DAY;
290const GRACE_SECS = 180 * DAY;
291let BALANCE = {
292 ok: true, credits_minor: 0, currency: 'usd', entries: [],
293 storage_grace_start: GRACE_STARTED,
294 storage_grace_secs: GRACE_SECS,
295 storage_paid_bytes: 240 * 1024 * 1024,
296};
297let LICENCE = { ok: true, licence: null, held: false, currency: 'usd', pro_price_minor: 4500 };
298
299// Every balance read that LEFT the page, so "it asked" can be told from "it
300// happened to have the answer already".
301let balanceReads = 0;
302
303async function stub(page) {
304 const [html, patched] = wire();
305 await page.route((u) => u.pathname === '/' || u.pathname === '/index.html',
306 (r) => r.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: html }));
307 global.__patched = patched;
308
309 if (BREAK) {
310 for (const [file, body] of damagedFiles()) {
311 const type = MIME[path.extname(file)] || 'text/plain';
312 await page.route('**/' + file,
313 (r) => r.fulfill({ status: 200, contentType: type, body }));
314 }
315 }
316
317 // Everything the app asks of a gateway that is not here, answered rather
318 // than left to 502 so the console stays readable. FIRST, because Playwright
319 // gives the last matching route the request — so every stub that means
320 // something is registered after this one and wins.
321 await page.route('**/api/**', (r) => r.fulfill(json({ ok: true })));
322
323 await page.route('**/api/account', (r) => r.fulfill(json({ ok: true })));
324 await page.route('**/api/auth/challenge', (r) => r.fulfill(json({ ok: true, challenge: 'chal-lr', challenge_id: 'cid-lr' })));
325 await page.route('**/api/auth/verify', (r) => r.fulfill(json({ ok: true })));
326 await page.route('**/api/balance', (r) => { balanceReads++; return r.fulfill(json(BALANCE)); });
327 await page.route('**/api/licence', (r) => r.fulfill(json(LICENCE)));
328}
329
330// ── Driving ──────────────────────────────────────────────────────────
331
332const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
333
334/// Ask the app to re-read the gateway, through the same function its own timer
335/// calls, and let it redraw.
336async function reread(page) {
337 await page.evaluate(() => window.DaimondLapse.check());
338 await sleep(300);
339}
340
341/// The notice of a kind, as the reader sees it: heading, body and the words on
342/// its controls. Null when there is none.
343function notice(page, kind) {
344 return page.evaluate((k) => {
345 const el = document.querySelector('.lapse-note.lapse-' + k);
346 if (!el) return null;
347 return {
348 head: (el.querySelector('.lapse-head') || {}).textContent || '',
349 body: [...el.querySelectorAll('.lapse-body')].map((p) => p.textContent).join(' '),
350 acts: [...el.querySelectorAll('.lapse-acts > *')].map((a) => a.textContent),
351 shown: el.getBoundingClientRect().width > 0,
352 };
353 }, kind);
354}
355
356/// A date as the app writes it, from a timestamp the TEST worked out. The
357/// formatting is shared with the app on purpose — the oracle here is the
358/// arithmetic (when the data goes), not the spelling of the month.
359function asDate(page, ms) {
360 return page.evaluate((t) => new Date(t).toLocaleDateString(
361 (window.DaimondI18n && DaimondI18n.locale()) || undefined,
362 { day: 'numeric', month: 'long', year: 'numeric' }), ms);
363}
364
365const s = await open({ name: 'legalreach', profile: PROFILE, signIn: false, connect: false, route: stub });
366const { page } = s;
367
368await signInAs(s, 'legalreach');
369await page.waitForTimeout(2500);
370
371try {
372 // ── 0. The seam ──────────────────────────────────────────────
373 check('index.html loads js/legal.js, js/lapse.js and css/lapse.css', !global.__patched,
374 global.__patched ? 'not yet — the three lines were injected for this run, '
375 + 'so everything below is proved against code the shipped page does not load' : null);
376
377 const up = await page.evaluate(() => [!!window.DaimondLegal, !!window.DaimondLapse]);
378 check('the two modules are up', up[0] && up[1], JSON.stringify(up));
379
380 // ── 0b. Something in production asks ─────────────────────────
381 //
382 // THE CHECK THIS APP MOST NEEDS. A surface that only a test calls is a
383 // surface no user ever sees, and this tree has shipped several. Nothing has
384 // been evaluated in the page at this point: the app signed in, raised its own
385 // `daimond:authed`, and the notice arrived because the shipped code asked the
386 // gateway of its own accord.
387 let byItself = true;
388 await page.waitForSelector('.lapse-note.lapse-storage', { timeout: 25000 })
389 .catch(() => { byItself = false; });
390 check('the app asks by itself once there is a session, and says so unprompted', byItself);
391
392 // ── 1. The document opens inside Daimond ─────────────────────
393 await page.evaluate(() => window.DaimondLegal.open('terms'));
394 await sleep(1200);
395
396 const panelUp = await page.evaluate(() => {
397 const el = document.getElementById('panel-web');
398 return !!el && el.offsetParent !== null;
399 });
400 check('opening the Terms puts the Web panel on screen', panelUp);
401
402 const here = new URL(page.url()).origin;
403 let doc = page.frames().find((f) => /guide\/legal\/terms\.html/.test(f.url()));
404 check('and the frame is showing a page of this origin, not another site',
405 !!doc && new URL(doc.url()).origin === here,
406 doc ? doc.url() : page.frames().map((f) => f.url()).join(' | '));
407
408 if (doc) {
409 const h1 = (await doc.textContent('h1').catch(() => '')) || '';
410 check('the page in the frame is the Terms themselves', /Terms of Service/.test(h1), h1);
411
412 // The clause this whole verifier is about, in the document the app shows.
413 const said = await doc.evaluate(() => document.body.innerText);
414 check('and it carries the clause that promises the notice',
415 /You will be told in the app before it happens/.test(said));
416 check('and the five-year section',
417 /What happens when the five years end/.test(said)
418 && /cloud storage and Daimond Email switch off/.test(said));
419 }
420
421 // ── 2. Nothing in either document leaves the origin ──────────
422 for (const which of ['terms', 'privacy']) {
423 await page.evaluate((w) => window.DaimondLegal.open(w), which);
424 await sleep(900);
425 const f = page.frames().find((fr) => new RegExp(`guide/legal/${which}\\.html`).test(fr.url()));
426 if (!f) { check(`the ${which} document opens in the frame`, false); continue; }
427 const off = await f.evaluate(() => {
428 const out = [];
429 document.querySelectorAll('a[href]').forEach((a) => {
430 let u;
431 try { u = new URL(a.getAttribute('href'), document.baseURI); }
432 catch (e) { out.push(a.getAttribute('href')); return; }
433 if (u.origin !== location.origin) out.push(u.href);
434 });
435 return out;
436 });
437 const n = await f.evaluate(() => document.querySelectorAll('a[href]').length);
438 check(`every link in the ${which} document stays in the app`,
439 off.length === 0 && n > 3, off.length ? off.join(', ') : `${n} links`);
440 }
441
442 // ── 3. The app leads to them ─────────────────────────────────
443 await page.evaluate(() => window.DaimondPanels.hide('web'));
444 await page.click('#about-btn');
445 await sleep(600);
446 const row = await page.evaluate(() => {
447 const r = document.querySelector('.about-legal');
448 if (!r) return null;
449 return [...r.querySelectorAll('a')].map((a) => a.textContent.trim());
450 });
451 check('About offers both documents', !!row && row.length === 2
452 && /Terms/.test(row[0]) && /Privacy/.test(row[1]), row ? row.join(' / ') : 'no row');
453
454 // And the link in it does what a link in it should: the document, in the
455 // panel. Asserted from the About dialog, because that is the path a person
456 // actually takes.
457 if (row) {
458 await page.evaluate(() => {
459 const a = document.querySelector('.about-legal a');
460 a.click();
461 });
462 await sleep(1200);
463 const f = page.frames().find((fr) => /guide\/legal\/terms\.html/.test(fr.url()));
464 check('and clicking one shows it in the panel', !!f && new URL(f.url()).origin === here,
465 f ? f.url() : 'no frame');
466 await page.evaluate(() => {
467 const x = document.querySelector('.about-card .ui-close, .about-card .tile-dlg-done');
468 if (x) x.click();
469 });
470 await sleep(300);
471 }
472
473 // ── 4. The in-app copy is the published copy ─────────────────
474 {
475 let current = true, why = '';
476 try {
477 execFileSync('node', [path.join(HERE, 'legal-pages.mjs'), '--check'],
478 { cwd: ROOT, stdio: 'pipe' });
479 } catch (e) {
480 current = false;
481 why = String(e.stdout || e.message).split('\n').filter(Boolean).slice(-2).join(' | ');
482 }
483 check('the in-app documents are current with landing/', current, why || null);
484
485 // …and the check that says so can tell. A source with one word moved must
486 // produce a different page, or "current" means nothing.
487 const gen = await import('./legal-pages.mjs');
488 const real = gen.build({ file: 'terms.html', title: 'Terms of Service' });
489 const src = path.join(ROOT, 'landing', 'terms.html');
490 const keep = fs.readFileSync(src, 'utf8');
491 const spot = 'You will be told in the app before it happens';
492 if (keep.indexOf(spot) < 0) {
493 check('the drift check has teeth', false, 'the sentence it mutates is not in the source');
494 } else {
495 fs.writeFileSync(src, keep.replace(spot, 'You may be told in the app'));
496 let moved = false;
497 try {
498 moved = gen.build({ file: 'terms.html', title: 'Terms of Service' }) !== real;
499 } finally {
500 fs.writeFileSync(src, keep);
501 }
502 check('and a word changed in landing/ would be caught', moved);
503 }
504 }
505
506 // ── 5. The notice is driven by the fact ──────────────────────
507 //
508 // Not in grace first, so the notice's absence later cannot be mistaken for
509 // a notice that never appears at all.
510 const inGrace = BALANCE;
511 BALANCE = {
512 ok: true, credits_minor: 500, currency: 'usd', entries: [],
513 storage_grace_start: 0, storage_grace_secs: GRACE_SECS, storage_paid_bytes: 0,
514 };
515 await reread(page);
516 check('an account that is not in grace is told nothing', !(await notice(page, 'storage')));
517
518 const started = GRACE_STARTED;
519 const ends = (started + GRACE_SECS) * 1000;
520 BALANCE = inGrace;
521 await reread(page);
522
523 const st = await notice(page, 'storage');
524 check('an account in grace is told, on screen', !!st && st.shown);
525 await page.screenshot({ path: path.join(HERE, 'shots', 'legalreach-storage.png') });
526
527 if (st) {
528 const wantEnd = await asDate(page, ends);
529 const wantStart = await asDate(page, started * 1000);
530 check('and the day named is the day the data goes, not the day grace began',
531 st.head.includes(wantEnd) && !st.head.includes(wantStart),
532 `said "${st.head}", wanted ${wantEnd}`);
533 check('and it says how much is at stake', /240\.0 MB/.test(st.body), st.body.slice(0, 120));
534
535 // ── 6. What it says, and what it must not ────────────────
536 check('it says the data IS deleted, as the Terms do',
537 /is deleted/.test(st.body) && !/may be deleted/.test(st.body), st.body);
538 check('it says the meter has paused and nothing is back-charged',
539 /back-charged/.test(st.body) && /still\s+read/.test(st.body));
540 check('it says this device is untouched',
541 /(on this device are untouched|device are untouched)/i.test(st.body));
542
543 // The two controls, and both do what they say.
544 check('it offers a way to top up and a way to read the clause',
545 st.acts.length === 2 && /Top up/.test(st.acts[0]) && /Terms/.test(st.acts[1]),
546 st.acts.join(' / '));
547
548 await page.evaluate(() => document.querySelector('.lapse-storage .lapse-act-primary').click());
549 await sleep(700);
550 const credits = await page.evaluate(() => {
551 const v = document.getElementById('admin-credits');
552 const n = document.getElementById('credits-note');
553 return { shown: !!v && v.style.display !== 'none', note: n ? n.textContent : '' };
554 });
555 check('Top up credits reaches the Credits view, saying why it was opened',
556 credits.shown && /Topping up/.test(credits.note),
557 JSON.stringify(credits));
558
559 await page.evaluate(() => document.querySelector('.lapse-storage .lapse-act:not(.lapse-act-primary)').click());
560 await sleep(1200);
561 const clause = page.frames().find((f) => /guide\/legal\/terms\.html#storage-lapse/.test(f.url()));
562 check('and "What the Terms say" opens that clause, in the app',
563 !!clause && new URL(clause.url()).origin === here,
564 clause ? clause.url() : page.frames().map((f) => f.url()).join(' | '));
565 if (clause) {
566 const target = await clause.evaluate(() => {
567 const el = document.getElementById('storage-lapse');
568 return el ? el.textContent.slice(0, 80) : '';
569 });
570 check('and the clause it lands on is the storage one',
571 /Cloud storage above the free allowance is metered/.test(target), target);
572 }
573 await page.evaluate(() => window.DaimondPanels.hide('web'));
574
575 // A × lasts a day. Not for ever: the notice this silences is a deletion.
576 await page.evaluate(() => document.querySelector('.lapse-storage .lapse-x').click());
577 await sleep(300);
578 check('dismissing it takes it down', !(await notice(page, 'storage')));
579 await page.evaluate(() => window.DaimondLapse.render());
580 await sleep(200);
581 check('and it stays down for the rest of the day', !(await notice(page, 'storage')));
582
583 await page.evaluate(() => {
584 const k = 'daimond-lapse-hushed';
585 const h = JSON.parse(localStorage.getItem(k) || '{}');
586 Object.keys(h).forEach((x) => { h[x] = Date.now() - 25 * 3600 * 1000; });
587 localStorage.setItem(k, JSON.stringify(h));
588 window.DaimondLapse.render();
589 });
590 await sleep(200);
591 check('and it is back tomorrow', !!(await notice(page, 'storage')));
592
593 // A gateway that says an account is in grace but not how long it runs
594 // must not silence the warning. No date is invented; the sentence says
595 // what is known.
596 BALANCE = { ok: true, credits_minor: 0, currency: 'usd', entries: [],
597 storage_grace_start: started, storage_paid_bytes: 240 * 1024 * 1024 };
598 await page.evaluate(() => localStorage.removeItem('daimond-lapse-hushed'));
599 await reread(page);
600 const half = await notice(page, 'storage');
601 check('a grace with no length still warns, and names no day it does not know',
602 !!half && /when the grace period ends/.test(half.head)
603 && !/\d{4}/.test(half.head), half ? half.head : 'nothing said');
604 }
605
606 // ── 6b. The licence, and its five years ──────────────────────
607 BALANCE = { ok: true, credits_minor: 500, currency: 'usd', entries: [],
608 storage_grace_start: 0, storage_grace_secs: GRACE_SECS };
609
610 // Bought a year ago: four years to run, and nothing to say about it.
611 LICENCE = { ok: true, held: true, currency: 'usd', pro_price_minor: 4500,
612 licence: { licence_id: 'l1', product: 'pro', issued_ts: now() - 365 * DAY } };
613 await reread(page);
614 check('a licence with years to run says nothing', !(await notice(page, 'licence')));
615
616 // Bought four years and eleven months ago: a month left.
617 const issued = now() - (5 * 365 - 20) * DAY;
618 LICENCE = { ok: true, held: true, currency: 'usd', pro_price_minor: 4500,
619 licence: { licence_id: 'l1', product: 'pro', issued_ts: issued } };
620 await reread(page);
621 const lic = await notice(page, 'licence');
622 check('a licence within a month of its five years does say so', !!lic && lic.shown);
623
624 if (lic) {
625 const d = new Date(issued * 1000);
626 d.setFullYear(d.getFullYear() + 5);
627 check('and it names five years from the purchase',
628 lic.head.includes(await asDate(page, d.getTime())), lic.head);
629
630 check('it says which three services stop',
631 /sync/i.test(lic.body) && /cloud storage/i.test(lic.body) && /Email/i.test(lic.body));
632 check('and, in the same breath, what does not',
633 /[Nn]othing is deleted/.test(lic.body) && /nothing is locked/.test(lic.body)
634 && /never stops/.test(lic.body) && /credits are unaffected/.test(lic.body),
635 lic.body);
636 // The app does not fall behind a paywall, and the notice must not hint
637 // that it does.
638 check('it does not say the app stops',
639 !/(stop working|no longer be able to use|locked out|read-only)/i.test(lic.body), lic.body);
640 // There is no Buy again, because /api/checkout/pro answers 409 while a
641 // licence exists. A button that refuses is worse than no button.
642 check('it draws no control that would refuse',
643 lic.acts.length === 1 && /Terms/.test(lic.acts[0]), lic.acts.join(' / '));
644 }
645
646 // The gateway is the authority on when a licence ends.
647 const soon = now() + 5 * DAY;
648 LICENCE = { ok: true, held: true, currency: 'usd', pro_price_minor: 4500, expires_ts: soon,
649 licence: { licence_id: 'l1', product: 'pro', issued_ts: issued } };
650 await page.evaluate(() => {
651 // Yesterday's dismissal must not hide today's different date.
652 localStorage.removeItem('daimond-lapse-hushed');
653 });
654 await reread(page);
655 const lic2 = await notice(page, 'licence');
656 check('an expiry the gateway states overrules the one computed from the term',
657 !!lic2 && lic2.head.includes(await asDate(page, soon * 1000)),
658 lic2 ? lic2.head : 'no notice');
659
660 // And it asked, rather than being handed the answer by something else.
661 check('it read the gateway to learn all this', balanceReads > 0, `${balanceReads} reads`);
662
663 await page.screenshot({ path: path.join(HERE, 'shots', 'legalreach.png') });
664
665 const noisy = s.errs.filter((e) => !/502|Failed to load resource|favicon/.test(e));
666 check('nothing threw', noisy.length === 0, noisy.slice(0, 3).join(' | '));
667} finally {
668 await s.close();
669}
670
671console.log(`\n${ok.length} ok, ${bad.length} failed`);
672if (BREAK) {
673 console.log(bad.length
674 ? `break '${BREAK}': FAILED as it should — the checks above have teeth.`
675 : `break '${BREAK}': everything still passed, so the checks prove nothing.`);
676 process.exit(bad.length ? 0 : 1);
677}
678process.exit(bad.length ? 1 : 0);