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. |
| 68 | import fs from 'node:fs'; |
| 69 | import path from 'node:path'; |
| 70 | import { execFileSync } from 'node:child_process'; |
| 71 | import { fileURLToPath } from 'node:url'; |
| 72 | import { open, signInAs, scratch } from './harness.mjs'; |
| 73 | |
| 74 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 75 | const ROOT = path.join(HERE, '..'); |
| 76 | const WWW = path.join(ROOT, 'www'); |
| 77 | |
| 78 | const BREAK = (() => { |
| 79 | const i = process.argv.indexOf('--break'); |
| 80 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 81 | })(); |
| 82 | |
| 83 | const PROFILE = scratch('pw', 'legalreach' + (BREAK ? '-' + BREAK : '')); |
| 84 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 85 | |
| 86 | const ok = [], bad = []; |
| 87 | const 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 | |
| 104 | const 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]`. |
| 116 | function 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. |
| 133 | const 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 | |
| 237 | if (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. |
| 244 | function 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. |
| 261 | function 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 | |
| 271 | const MIME = { '.js': 'application/javascript', '.html': 'text/html; charset=utf-8' }; |
| 272 | |
| 273 | // ── The stubbed gateway ────────────────────────────────────────────── |
| 274 | |
| 275 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 276 | const json = (body, status = 200) => ({ |
| 277 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 278 | }); |
| 279 | |
| 280 | const DAY = 86400; |
| 281 | const 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. |
| 289 | const GRACE_STARTED = now() - 10 * DAY; |
| 290 | const GRACE_SECS = 180 * DAY; |
| 291 | let 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 | }; |
| 297 | let 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". |
| 301 | let balanceReads = 0; |
| 302 | |
| 303 | async 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 | |
| 332 | const 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. |
| 336 | async 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. |
| 343 | function 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. |
| 359 | function 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 | |
| 365 | const s = await open({ name: 'legalreach', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 366 | const { page } = s; |
| 367 | |
| 368 | await signInAs(s, 'legalreach'); |
| 369 | await page.waitForTimeout(2500); |
| 370 | |
| 371 | try { |
| 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 | |
| 671 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 672 | if (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 | } |
| 678 | process.exit(bad.length ? 1 : 0); |