Oregami
Repositories/oxedyne/daimond

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
19import { open, errors, shot } from './harness.mjs';
20import path from 'node:path';
21import fs from 'node:fs';
22
23const EXT = path.resolve('ext');
24const SHOTS = path.resolve('dev/shots');
25const ok = [], bad = [];
26const check = (name, pass, detail) => {
27 (pass ? ok : bad).push(name);
28 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
29};
30
31const s = await open({ name: 'grant', headed: true, extension: EXT });
32// Give the extension's announce content-script a moment to register.
33await s.page.waitForTimeout(1500);
34
35const sw = s.browser.serviceWorkers()[0];
36check('the broker service worker started', !!sw);
37const 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.
42async 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.
57async 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.
68async 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
75const grant = await askFor('https://example.com');
76
77// ── Surface 1: the grant window ─────────────────────────────────────
78
79check('the grant window opens', !!grant,
80 grant ? '' : 'no grant.html page appeared: ' + s.browser.pages().map((p) => p.url()).join(', '));
81
82if (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.
104if (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
120if (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
127if (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
159if (grant) {
160 await grant.click('#deny');
161}
162const declined = await toolResult();
163check('declining ends the tool call', declined.done && !!declined.err, JSON.stringify(declined));
164check('the tool result says the USER declined',
165 /user (declined|said no)|declined/i.test(declined.err || ''), declined.err);
166check('it names the site that was refused', /example\.com/.test(declined.err || ''), '');
167check('it tells the daimon what to do instead', /web_fetch/.test(declined.err || ''), '');
168check('it tells the daimon not to keep asking', /not (retry|ask)|do not ask/i.test(declined.err || ''), declined.err);
169
170if (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.
177const dismissable = await askFor('https://example.org');
178check('a second site raises its own question', !!dismissable);
179if (dismissable) {
180 await dismissable.close();
181}
182const dismissed = await toolResult();
183check('closing the window unseen ends the tool call', dismissed.done && !!dismissed.err, JSON.stringify(dismissed));
184check('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);
186check('it still says the site is not approved', /not approved/i.test(dismissed.err || ''), '');
187
188// ── The standing popup, with nothing pending ────────────────────────
189
190if (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.
206await shot(s, 'grant-panel-declined');
207const note = await s.page.evaluate(() => (document.getElementById('web-note') || {}).innerText || '');
208console.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.
213const errs = errors(s).filter((e) => !/Failed to load resource.*\b(401|402|502|503)\b/.test(e));
214check('the app logged no console errors', errs.length === 0, errs.join(' | '));
215
216console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
217await s.close();
218process.exit(bad.length ? 1 : 0);