oxedyne/daimond/dev/verify_grant.mjs
10.6 KiB, 1 run
created by r2519314175:443, 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_grant.mjs — the Daimond Hands grant flow, driven under the real |
| 2 | // extension (xvfb, headed). |
| 3 | // |
| 4 | // Approving a site touches three surfaces, and a user meets them in this order: |
| 5 | // |
| 6 | // 1. the grant WINDOW the extension raises — what is being asked, what |
| 7 | // granting covers, and that Chrome will ask once more; |
| 8 | // 2. the TOOLBAR — the icon and its popup, the standing surface, which must |
| 9 | // say a question is waiting and offer the way back to it if the window |
| 10 | // was lost behind something; |
| 11 | // 3. the TOOL RESULT the daimon reads when the answer is no — which has to |
| 12 | // distinguish "the user declined" from "nobody answered", because the |
| 13 | // first means stop asking and the second means ask again. |
| 14 | // |
| 15 | // We cannot click Chrome's own permission bubble from a test — that is the known |
| 16 | // coverage gap, and it is the step AFTER Allow — but everything up to it, and |
| 17 | // every refusal path, is driven here for real. Run with: |
| 18 | // xvfb-run -a -s "-screen 0 1400x900x24" node dev/verify_grant.mjs |
| 19 | import { open, errors, shot } from './harness.mjs'; |
| 20 | import path from 'node:path'; |
| 21 | import fs from 'node:fs'; |
| 22 | |
| 23 | const EXT = path.resolve('ext'); |
| 24 | const SHOTS = path.resolve('dev/shots'); |
| 25 | const ok = [], bad = []; |
| 26 | const check = (name, pass, detail) => { |
| 27 | (pass ? ok : bad).push(name); |
| 28 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 29 | }; |
| 30 | |
| 31 | const s = await open({ name: 'grant', headed: true, extension: EXT }); |
| 32 | // Give the extension's announce content-script a moment to register. |
| 33 | await s.page.waitForTimeout(1500); |
| 34 | |
| 35 | const sw = s.browser.serviceWorkers()[0]; |
| 36 | check('the broker service worker started', !!sw); |
| 37 | const extId = sw ? new URL(sw.url()).host : ''; |
| 38 | |
| 39 | /// Ask the page to open a site, without awaiting it: the call BLOCKS until the |
| 40 | /// human has answered. What it finally settles to is the tool result the daimon |
| 41 | /// reads, so it is stashed on the window for us to collect afterwards. |
| 42 | async function askFor(url) { |
| 43 | await s.page.evaluate((u) => { |
| 44 | window.__grant = { done: false, err: null, res: null }; |
| 45 | window.DaimondWeb.open(u) |
| 46 | .then((r) => { window.__grant = { done: true, res: r, err: null }; }) |
| 47 | .catch((e) => { window.__grant = { done: true, res: null, err: String((e && e.message) || e) }; }); |
| 48 | }, url); |
| 49 | for (let i = 0; i < 40; i++) { |
| 50 | await s.page.waitForTimeout(250); |
| 51 | for (const p of s.browser.pages()) if (/grant\.html/.test(p.url())) return p; |
| 52 | } |
| 53 | return null; |
| 54 | } |
| 55 | |
| 56 | /// What the tool call finally answered. |
| 57 | async function toolResult() { |
| 58 | for (let i = 0; i < 80; i++) { |
| 59 | const g = await s.page.evaluate(() => window.__grant); |
| 60 | if (g && g.done) return g; |
| 61 | await s.page.waitForTimeout(250); |
| 62 | } |
| 63 | return { done: false }; |
| 64 | } |
| 65 | |
| 66 | /// The extension's own popup, opened as a page. It is the standing surface: what |
| 67 | /// mode the extension is in, what it is waiting for, and what may be revoked. |
| 68 | async function popup() { |
| 69 | const p = await s.browser.newPage(); |
| 70 | await p.goto(`chrome-extension://${extId}/popup.html`); |
| 71 | await p.waitForTimeout(700); |
| 72 | return p; |
| 73 | } |
| 74 | |
| 75 | const grant = await askFor('https://example.com'); |
| 76 | |
| 77 | // ── Surface 1: the grant window ───────────────────────────────────── |
| 78 | |
| 79 | check('the grant window opens', !!grant, |
| 80 | grant ? '' : 'no grant.html page appeared: ' + s.browser.pages().map((p) => p.url()).join(', ')); |
| 81 | |
| 82 | if (grant) { |
| 83 | await grant.waitForLoadState('domcontentloaded'); |
| 84 | await grant.waitForTimeout(400); |
| 85 | const info = await grant.evaluate(() => { |
| 86 | const txt = (id) => ((document.getElementById(id) || {}).textContent || ''); |
| 87 | return { head: txt('head'), host: txt('host'), scope: txt('scope'), body: txt('body'), fine: txt('fine'), allow: txt('allow'), deny: txt('deny') }; |
| 88 | }); |
| 89 | check('it names the site it is asking about', /example\.com/.test(info.host), info.host); |
| 90 | check('it sets the expectation that Chrome asks next', |
| 91 | /Chrome/.test(info.fine) && /confirm in Chrome/i.test(info.allow), info.allow); |
| 92 | // The grant is `*://*.host/*` — the site AND its subdomains, both schemes. A |
| 93 | // window that shows the bare host alone is asking for more than it says. |
| 94 | check('it says the approval covers subdomains', |
| 95 | /subdomain/i.test(info.scope + info.body + info.fine), JSON.stringify(info.scope)); |
| 96 | check('refusing is offered in plain words', /not now|no/i.test(info.deny), info.deny); |
| 97 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 98 | await grant.screenshot({ path: path.join(SHOTS, 'grant-window.png') }).catch(() => {}); |
| 99 | } |
| 100 | |
| 101 | // The centring of that window is the whole reason it is seen at all, and it is |
| 102 | // best-effort in a try/catch — so a misspelled API fails silently and for ever. |
| 103 | // Check the source against the real API surface rather than the symptom. |
| 104 | if (sw) { |
| 105 | const missing = await sw.evaluate(async () => { |
| 106 | const src = await (await fetch(chrome.runtime.getURL('background.js'))).text(); |
| 107 | const out = []; |
| 108 | for (const m of src.matchAll(/chrome\.(\w+)\.(\w+)\s*\(/g)) { |
| 109 | const ns = chrome[m[1]]; |
| 110 | if (!ns) { out.push(m[1]); continue; } |
| 111 | if (typeof ns[m[2]] !== 'function') out.push(m[1] + '.' + m[2]); |
| 112 | } |
| 113 | return [...new Set(out)]; |
| 114 | }); |
| 115 | check('the broker calls no browser API that does not exist', missing.length === 0, missing.join(', ')); |
| 116 | } |
| 117 | |
| 118 | // ── Surface 2: the toolbar, while the question is pending ─────────── |
| 119 | |
| 120 | if (sw) { |
| 121 | const badge = await sw.evaluate(() => chrome.action.getBadgeText({})); |
| 122 | check('the toolbar icon marks that a question is waiting', !!badge.trim(), JSON.stringify(badge)); |
| 123 | const title = await sw.evaluate(() => chrome.action.getTitle({})); |
| 124 | check('the icon tooltip names the site being asked about', /example\.com/.test(title), title); |
| 125 | } |
| 126 | |
| 127 | if (extId) { |
| 128 | const p = await popup(); |
| 129 | const reply = await p.evaluate(() => chrome.runtime.sendMessage({ type: 'panel' }).then((r) => r, (e) => ({ ok: false, threw: String(e) }))); |
| 130 | check('the popup gets an answer from the broker', !!(reply && reply.ok), JSON.stringify(reply)); |
| 131 | const text = await p.evaluate(() => document.body.innerText); |
| 132 | check('the popup says a site is waiting to be approved', /example\.com/.test(text), text.replace(/\n/g, ' / ')); |
| 133 | const raise = await p.evaluate(() => { |
| 134 | const b = document.getElementById('raise'); |
| 135 | return b && !b.hidden ? b.textContent : ''; |
| 136 | }); |
| 137 | check('the popup offers a way back to the approval window', !!raise, JSON.stringify(raise)); |
| 138 | // It RAISES the window that is already open. A second window per ask is the |
| 139 | // popup flood the mirror guard exists to prevent. |
| 140 | const before = s.browser.pages().filter((q) => /grant\.html/.test(q.url())).length; |
| 141 | const raised = await p.evaluate(() => chrome.runtime.sendMessage({ type: 'raise' })); |
| 142 | await p.waitForTimeout(400); |
| 143 | const after = s.browser.pages().filter((q) => /grant\.html/.test(q.url())).length; |
| 144 | check('that way back reaches the waiting window', !!(raised && raised.ok), JSON.stringify(raised)); |
| 145 | check('and it opens no second window', after === before, `${before} -> ${after}`); |
| 146 | // One question at a time: the broadest permission Chrome has is not offered |
| 147 | // beside a site question the user has not answered yet. |
| 148 | const mirrorShown = await p.evaluate(() => { |
| 149 | const b = document.getElementById('mirror'); |
| 150 | return !!(b && !b.hidden); |
| 151 | }); |
| 152 | check('the mirror is not offered while a site question is waiting', !mirrorShown); |
| 153 | await p.screenshot({ path: path.join(SHOTS, 'grant-popup-pending.png') }).catch(() => {}); |
| 154 | await p.close(); |
| 155 | } |
| 156 | |
| 157 | // ── Surface 3: the refusal, as the daimon reads it ────────────────── |
| 158 | |
| 159 | if (grant) { |
| 160 | await grant.click('#deny'); |
| 161 | } |
| 162 | const declined = await toolResult(); |
| 163 | check('declining ends the tool call', declined.done && !!declined.err, JSON.stringify(declined)); |
| 164 | check('the tool result says the USER declined', |
| 165 | /user (declined|said no)|declined/i.test(declined.err || ''), declined.err); |
| 166 | check('it names the site that was refused', /example\.com/.test(declined.err || ''), ''); |
| 167 | check('it tells the daimon what to do instead', /web_fetch/.test(declined.err || ''), ''); |
| 168 | check('it tells the daimon not to keep asking', /not (retry|ask)|do not ask/i.test(declined.err || ''), declined.err); |
| 169 | |
| 170 | if (sw) { |
| 171 | const badge = await sw.evaluate(() => chrome.action.getBadgeText({})); |
| 172 | check('the toolbar mark clears once the question is answered', !badge.trim(), JSON.stringify(badge)); |
| 173 | } |
| 174 | |
| 175 | // A window closed without an answer is NOT a refusal: the user may never have |
| 176 | // seen it. The daimon must be able to tell the two apart. |
| 177 | const dismissable = await askFor('https://example.org'); |
| 178 | check('a second site raises its own question', !!dismissable); |
| 179 | if (dismissable) { |
| 180 | await dismissable.close(); |
| 181 | } |
| 182 | const dismissed = await toolResult(); |
| 183 | check('closing the window unseen ends the tool call', dismissed.done && !!dismissed.err, JSON.stringify(dismissed)); |
| 184 | check('the tool result says the window was closed, not that the user refused', |
| 185 | /closed/i.test(dismissed.err || '') && !/user declined/i.test(dismissed.err || ''), dismissed.err); |
| 186 | check('it still says the site is not approved', /not approved/i.test(dismissed.err || ''), ''); |
| 187 | |
| 188 | // ── The standing popup, with nothing pending ──────────────────────── |
| 189 | |
| 190 | if (extId) { |
| 191 | const p = await popup(); |
| 192 | const text = await p.evaluate(() => document.body.innerText); |
| 193 | check('the popup paints its state when nothing is pending', /idle|driving/i.test(text), text.replace(/\n/g, ' / ')); |
| 194 | // Chrome reports the extension's OWN manifest origins among its permissions. |
| 195 | // The user never granted those and cannot revoke them, so a list that shows |
| 196 | // them claims a grant that was never given and offers a button that cannot act. |
| 197 | check('it lists no grant the user did not give', |
| 198 | !/localhost|127\.0\.0\.1|daimond\.oxedyne/.test(text), text.replace(/\n/g, ' / ')); |
| 199 | check('it says no site has been approved yet', /none yet/i.test(text), ''); |
| 200 | check('it is no longer asking about a site', !/example\.com/.test(text), ''); |
| 201 | await p.screenshot({ path: path.join(SHOTS, 'grant-popup-idle.png') }).catch(() => {}); |
| 202 | await p.close(); |
| 203 | } |
| 204 | |
| 205 | // The panel note the human reads, after a refusal. |
| 206 | await shot(s, 'grant-panel-declined'); |
| 207 | const note = await s.page.evaluate(() => (document.getElementById('web-note') || {}).innerText || ''); |
| 208 | console.log('\npanel note after refusal:\n ' + note.replace(/\n/g, '\n ')); |
| 209 | |
| 210 | // The account service is not what this exercises, and it may be absent (502) or |
| 211 | // present but unentitled for a throwaway identity (401, 402). Either is noise |
| 212 | // here; anything else the page logged is not. |
| 213 | const errs = errors(s).filter((e) => !/Failed to load resource.*\b(401|402|502|503)\b/.test(e)); |
| 214 | check('the app logged no console errors', errs.length === 0, errs.join(' | ')); |
| 215 | |
| 216 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 217 | await s.close(); |
| 218 | process.exit(bad.length ? 1 : 0); |