Skip to content

Commit 6e03bbc

Browse files
committed
test: consent gate + NIP-98 token freshness/exact-origin coverage
Drive background.js through its captured chrome.runtime.onMessage listener (it exports nothing and wires handlers at import time), mirroring the real content-script / approve-popup message contract. consent-approval.test.js (11): approve resolves the pending request; deny and APPROVE_SIGNING{approved:false} (popup beforeunload) reject; the 60s timeout auto-denies (and does not fire early); a late response after timeout is a no-op; trusted+autoSign auto-signs kind-27235 Solid auth with no prompt while other kinds and autoSign-off still prompt; approving an untrusted origin trusts it for next time. nip98-token.test.js (16): per-token random 16-byte nonce yields distinct event ids/sigs for the same (url,method) within one second (no replayable duplicate); payload tag carries the sha-256 of a string body and prefers a page-computed bodyHash; exact-origin Solid auto-trust accepts inrupt.net and real subdomains but rejects inrupt.net.evil.com / evilinrupt.net / non-Solid hosts; autoSign-off and no-keypair return null instead of a header. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent ae8a1f3 commit 6e03bbc

2 files changed

Lines changed: 451 additions & 0 deletions

File tree

test/consent-approval.test.js

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* Tests for the per-origin consent gate / signing-approval flow.
3+
*
4+
* background.js exports nothing and wires its handlers onto chrome.runtime at
5+
* import time, so these tests drive it exactly the way the real extension does:
6+
* mock `chrome`, import the module to capture the onMessage listener, then send
7+
* the same messages the content script and approve popup would send.
8+
*
9+
* Covered (consent contract, security/signing-approval-ui #20):
10+
* - approve resolves the pending request with the result
11+
* - deny rejects the request ("User denied ...")
12+
* - the 60s timeout auto-denies
13+
* - APPROVE_SIGNING with approved:false (popup beforeunload = deny) rejects
14+
* - trusted-origin + autoSign auto-path: kind 27235 Solid auth signs with NO
15+
* prompt; any other kind still prompts even when trusted+autoSign
16+
*/
17+
18+
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
19+
import assert from 'node:assert/strict';
20+
21+
// --- chrome mock (session + local areas, like storage.test.js) ----------------
22+
function makeArea (store) {
23+
return {
24+
get: async (keys) => {
25+
const result = {};
26+
if (Array.isArray(keys)) {
27+
keys.forEach((k) => { result[k] = store[k]; });
28+
} else if (keys) {
29+
Object.keys(keys).forEach((k) => { result[k] = store[k] ?? keys[k]; });
30+
} else {
31+
return { ...store };
32+
}
33+
return result;
34+
},
35+
set: async (items) => { Object.assign(store, items); },
36+
remove: async (keys) => {
37+
(Array.isArray(keys) ? keys : [keys]).forEach((k) => delete store[k]);
38+
}
39+
};
40+
}
41+
42+
const stores = { local: {}, session: {} };
43+
const windowsCreated = [];
44+
const captured = { onMessage: null };
45+
46+
global.chrome = {
47+
runtime: {
48+
onInstalled: { addListener: () => {} },
49+
onMessage: { addListener: (fn) => { captured.onMessage = fn; } },
50+
lastError: null
51+
},
52+
windows: {
53+
create: (opts) => { windowsCreated.push(opts); return Promise.resolve({ id: windowsCreated.length }); }
54+
},
55+
storage: {
56+
local: makeArea(stores.local),
57+
session: makeArea(stores.session)
58+
}
59+
};
60+
61+
// Import background once (captures the onMessage listener). Storage helpers are
62+
// imported separately for seeding; they share the same chrome mock instance.
63+
await import('../src/background.js');
64+
const { generateKeypair } = await import('../src/crypto.js');
65+
const { storeKeypair, addTrustedOrigin, setAutoSign } = await import('../src/storage.js');
66+
67+
const onMessage = captured.onMessage;
68+
69+
/**
70+
* Drain the microtask queue. handleMessage awaits several storage reads before
71+
* it reaches showPermissionPrompt / chrome.windows.create; one setImmediate
72+
* turn flushes that chain deterministically (and is not affected by the faked
73+
* setTimeout used in the timeout tests).
74+
*/
75+
const flush = () => new Promise((resolve) => setImmediate(resolve));
76+
77+
/** Send a message to the background and return a promise of sendResponse(). */
78+
function send (message) {
79+
return new Promise((resolve) => { onMessage(message, {}, resolve); });
80+
}
81+
82+
/** Read the requestId out of the most recently opened approval popup URL. */
83+
function lastRequestId () {
84+
const last = windowsCreated[windowsCreated.length - 1];
85+
// url is `popup/approve.html?id=...&origin=...` — parse against a dummy base
86+
return new URL('https://x/' + last.url).searchParams.get('id');
87+
}
88+
89+
/** Resolve a pending prompt by sending the popup's APPROVE_SIGNING message. */
90+
function respond (requestId, approved) {
91+
onMessage({ type: 'APPROVE_SIGNING', requestId, approved }, {}, () => {});
92+
}
93+
94+
const SOLID_EVENT = { kind: 27235, created_at: 1700000000, tags: [['u', 'https://pod.test/']], content: '' };
95+
const NOTE_EVENT = { kind: 1, created_at: 1700000000, tags: [], content: 'gm' };
96+
97+
describe('consent gate / approval flow', () => {
98+
let keypair;
99+
100+
beforeEach(async () => {
101+
// Reset storage and the record of opened windows before every test.
102+
for (const k of Object.keys(stores.local)) delete stores.local[k];
103+
for (const k of Object.keys(stores.session)) delete stores.session[k];
104+
windowsCreated.length = 0;
105+
keypair = await generateKeypair();
106+
await storeKeypair(keypair.privateKey, keypair.publicKey);
107+
});
108+
109+
afterEach(() => { mock.timers.reset(); });
110+
111+
it('opens an approval popup for an untrusted origin (does not auto-resolve)', async () => {
112+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://untrusted.test' });
113+
// Let the handler reach showPermissionPrompt / chrome.windows.create.
114+
await flush();
115+
assert.equal(windowsCreated.length, 1, 'exactly one approval popup should open');
116+
const url = windowsCreated[0].url;
117+
assert.match(url, /^popup\/approve\.html\?/);
118+
const params = new URL('https://x/' + url).searchParams;
119+
assert.equal(params.get('origin'), 'https://untrusted.test');
120+
assert.equal(params.get('action'), 'read your public key');
121+
assert.ok(params.get('id'), 'a requestId must be present');
122+
// Clean up the dangling promise so the test runner doesn't hang.
123+
respond(params.get('id'), true);
124+
await pending;
125+
});
126+
127+
it('approve resolves the pending request with the public key', async () => {
128+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
129+
await flush();
130+
respond(lastRequestId(), true);
131+
const result = await pending;
132+
assert.equal(result, keypair.publicKey);
133+
});
134+
135+
it('deny rejects the request with "User denied permission"', async () => {
136+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
137+
await flush();
138+
respond(lastRequestId(), false);
139+
const result = await pending;
140+
assert.deepEqual(result, { error: 'User denied permission' });
141+
});
142+
143+
it('APPROVE_SIGNING approved:false (popup beforeunload = deny) rejects signing', async () => {
144+
const pending = send({ type: 'SIGN_EVENT', event: NOTE_EVENT, origin: 'https://app.test' });
145+
await flush();
146+
// The approve popup sends {approved:false} on window beforeunload (closing
147+
// the window without choosing = deny). Same wire message, approved:false.
148+
respond(lastRequestId(), false);
149+
const result = await pending;
150+
assert.deepEqual(result, { error: 'User denied signing' });
151+
});
152+
153+
it('the 60s timeout auto-denies when the popup never responds', async () => {
154+
mock.timers.enable({ apis: ['setTimeout'] });
155+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://slow.test' });
156+
await flush();
157+
assert.equal(windowsCreated.length, 1, 'popup opened, awaiting a decision');
158+
// No APPROVE_SIGNING arrives. Advance just past the 60s auto-deny.
159+
mock.timers.tick(60001);
160+
const result = await pending;
161+
assert.deepEqual(result, { error: 'User denied permission' });
162+
});
163+
164+
it('does NOT auto-deny before 60s elapse', async () => {
165+
mock.timers.enable({ apis: ['setTimeout'] });
166+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
167+
await flush();
168+
mock.timers.tick(59999); // one ms short of the deadline
169+
// Still pending — a late approval must win the race.
170+
respond(lastRequestId(), true);
171+
const result = await pending;
172+
assert.equal(result, keypair.publicKey);
173+
});
174+
175+
it('a late APPROVE_SIGNING after timeout is ignored (entry already deleted)', async () => {
176+
mock.timers.enable({ apis: ['setTimeout'] });
177+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
178+
await flush();
179+
const id = lastRequestId();
180+
mock.timers.tick(60001);
181+
const result = await pending;
182+
assert.deepEqual(result, { error: 'User denied permission' });
183+
// Late response for the same id must be a harmless no-op (no throw, no
184+
// second resolution). pendingApprovals no longer holds the id.
185+
assert.doesNotThrow(() => respond(id, true));
186+
});
187+
});
188+
189+
describe('trusted-origin auto-path (autoSign)', () => {
190+
let keypair;
191+
192+
beforeEach(async () => {
193+
for (const k of Object.keys(stores.local)) delete stores.local[k];
194+
for (const k of Object.keys(stores.session)) delete stores.session[k];
195+
windowsCreated.length = 0;
196+
keypair = await generateKeypair();
197+
await storeKeypair(keypair.privateKey, keypair.publicKey);
198+
});
199+
200+
it('signs a kind-27235 Solid event with NO prompt when trusted + autoSign', async () => {
201+
await addTrustedOrigin('https://pod.test');
202+
await setAutoSign(true);
203+
const result = await send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://pod.test' });
204+
assert.equal(windowsCreated.length, 0, 'auto-sign path must not open a popup');
205+
assert.equal(result.kind, 27235);
206+
assert.equal(result.pubkey, keypair.publicKey);
207+
assert.equal(result.sig.length, 128);
208+
});
209+
210+
it('still PROMPTS for a non-Solid kind even when trusted + autoSign', async () => {
211+
await addTrustedOrigin('https://pod.test');
212+
await setAutoSign(true);
213+
const pending = send({ type: 'SIGN_EVENT', event: NOTE_EVENT, origin: 'https://pod.test' });
214+
await flush();
215+
assert.equal(windowsCreated.length, 1, 'non-Solid kind must still require approval');
216+
respond(lastRequestId(), true);
217+
const result = await pending;
218+
assert.equal(result.kind, 1);
219+
});
220+
221+
it('PROMPTS for a Solid event when autoSign is OFF (even if trusted)', async () => {
222+
await addTrustedOrigin('https://pod.test');
223+
await setAutoSign(false);
224+
const pending = send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://pod.test' });
225+
await flush();
226+
assert.equal(windowsCreated.length, 1, 'autoSign off must require explicit approval');
227+
respond(lastRequestId(), true);
228+
const result = await pending;
229+
assert.equal(result.kind, 27235);
230+
});
231+
232+
it('approving an untrusted signing request trusts the origin (no re-prompt)', async () => {
233+
await setAutoSign(true);
234+
// First request: untrusted -> prompt -> approve.
235+
const first = send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://new.test' });
236+
await flush();
237+
assert.equal(windowsCreated.length, 1);
238+
respond(lastRequestId(), true);
239+
await first;
240+
// Second Solid request from the now-trusted origin: auto-signs, no popup.
241+
const second = await send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://new.test' });
242+
assert.equal(windowsCreated.length, 1, 'origin became trusted; no second popup');
243+
assert.equal(second.kind, 27235);
244+
});
245+
});

0 commit comments

Comments
 (0)