rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1import { test } from "node:test";
2import assert from "node:assert/strict";
3import { generateKeyPairSync } from "node:crypto";
4import { readFileSync } from "node:fs";
5import { runInThisContext } from "node:vm";
6
7globalThis.self = globalThis;
8globalThis.location = { origin: "https://app.example" };
9// Load the helper the way a worker does: importScripts evaluates a
10// classic script in the global scope. vm.runInThisContext is that.
11globalThis.importScripts = (path) => runInThisContext(readFileSync(new URL(path, import.meta.url), "utf8"), { filename: path });
12importScripts("./aviso-sw.js");
13const { AvisoSW } = globalThis;
14
15// Real P-256 points: a browser's subscribe() rejects anything else.
16function realPoint() {
17 const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
18 const jwk = publicKey.export({ format: "jwk" });
19 return Uint8Array.from([4, ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url")]);
20}
21const KEY_BYTES = realPoint();
22const KEY = Buffer.from(KEY_BYTES).toString("base64url");
23const OLD_KEY_BYTES = realPoint();
24const asBuffer = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
25
26function fakeSub(endpoint, keyBytes) {
27 return {
28 endpoint,
29 options: { applicationServerKey: asBuffer(keyBytes) },
30 unsubscribed: false,
31 toJSON() { return { endpoint: this.endpoint, expirationTime: null, keys: { p256dh: "P", auth: "A" } }; },
32 async unsubscribe() { this.unsubscribed = true; return true; },
33 };
34}
35
36// The platform refuses options it cannot store: a function inside
37// data is a DataCloneError in every browser.
38function cloneable(v) {
39 if (typeof v === "function") return false;
40 if (v && typeof v === "object") return Object.values(v).every(cloneable);
41 return true;
42}
43
44function rig() {
45 const shown = [], opened = [], subscribed = [];
46 globalThis.registration = {
47 async showNotification(title, options) {
48 if (!cloneable(options)) throw new Error("DataCloneError");
49 shown.push({ title, options });
50 },
51 pushManager: {
52 async subscribe(opts) {
53 assert.equal(opts.userVisibleOnly, true);
54 assert.deepEqual(Array.from(opts.applicationServerKey), Array.from(KEY_BYTES));
55 const s = fakeSub("https://push.example/renewed", KEY_BYTES);
56 subscribed.push(s);
57 return s;
58 },
59 },
60 };
61 globalThis.clients = {
62 windows: [],
63 async matchAll(opts) {
64 // A worker that forgets includeUncontrolled misses windows it
65 // does not yet control and opens duplicates; the fake refuses.
66 assert.deepEqual(opts, { type: "window", includeUncontrolled: true });
67 return this.windows;
68 },
69 async openWindow(u) { opened.push(u); },
70 };
71 return { shown, opened, subscribed };
72}
73
74function pushEvent(payload) {
75 return { data: payload === undefined ? null : { json() { return JSON.parse(payload); } } };
76}
77
78test("validateURL admits only root-relative same-origin paths, absolute refused even on-origin", () => {
79 const o = "https://app.example";
80 assert.equal(AvisoSW.validateURL("/inbox?x=1", o), "https://app.example/inbox?x=1");
81 for (const bad of ["", "inbox", "//evil.example/x", "https://evil.example/x", "https://app.example/inbox", "/a\\b", "javascript:alert(1)", "https://app.example.evil/x", 42, null]) {
82 assert.equal(AvisoSW.validateURL(bad, o), null, String(bad));
83 }
84});
85
86test("handlePush shows the default payload and validates url", async () => {
87 const r = rig();
88 await AvisoSW.handlePush(pushEvent('{"title":"Hi","body":"b","url":"/inbox","tag":"t"}'), { fallback: () => ({ title: "fb" }) });
89 assert.equal(r.shown.length, 1);
90 assert.equal(r.shown[0].title, "Hi");
91 assert.equal(r.shown[0].options.body, "b");
92 assert.equal(r.shown[0].options.tag, "t");
93 assert.equal(r.shown[0].options.data.url, "https://app.example/inbox");
94});
95
96test("handlePush drops an off-origin or absolute url but still shows", async () => {
97 const r = rig();
98 await AvisoSW.handlePush(pushEvent('{"title":"Hi","url":"https://evil.example/"}'), { fallback: () => ({ title: "fb" }) });
99 await AvisoSW.handlePush(pushEvent('{"title":"Hi2","url":"https://app.example/inbox"}'), { fallback: () => ({ title: "fb" }) });
100 assert.equal(r.shown[0].title, "Hi");
101 assert.equal("url" in r.shown[0].options.data, false);
102 assert.equal(r.shown[1].title, "Hi2");
103 assert.equal("url" in r.shown[1].options.data, false);
104});
105
106test("handlePush falls back on malformed or missing payload", async () => {
107 const r = rig();
108 await AvisoSW.handlePush(pushEvent('{"nope":1}'), { fallback: () => ({ title: "fb", options: { data: { url: "/" } } }) });
109 await AvisoSW.handlePush(pushEvent(undefined), { fallback: () => ({ title: "fb2" }) });
110 await AvisoSW.handlePush({ data: { json() { throw new Error("not json"); } } }, { fallback: () => ({ title: "fb3" }) });
111 assert.deepEqual(r.shown.map((s) => s.title), ["fb", "fb2", "fb3"]);
112 assert.equal(r.shown[0].options.data.url, "https://app.example/");
113});
114
115test("handlePush falls back when the platform refuses the decoded options", async () => {
116 const r = rig();
117 await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
118 decode: async () => ({ title: "Custom", options: { data: { url: "/x", fn() {} } } }),
119 fallback: () => ({ title: "fb" }),
120 });
121 assert.deepEqual(r.shown.map((s) => s.title), ["fb"]);
122 // A fallback the platform refuses is the app's bug: it surfaces.
123 await assert.rejects(AvisoSW.handlePush(pushEvent('{"nope":1}'), { fallback: () => ({ title: "fb", options: { data: { fn() {} } } }) }), /DataCloneError/);
124});
125
126test("handlePush requires a fallback", async () => {
127 rig();
128 await assert.rejects(AvisoSW.handlePush(pushEvent('{"title":"fine"}'), {}), /fallback is required/);
129});
130
131test("handlePush uses a custom decoder and validates its url; a throwing decoder falls back", async () => {
132 const r = rig();
133 await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
134 decode: async () => ({ title: "Custom", options: { data: { url: "//evil.example/" } } }),
135 fallback: () => ({ title: "fb" }),
136 });
137 assert.equal(r.shown[0].title, "Custom");
138 assert.equal("url" in r.shown[0].options.data, false);
139 await AvisoSW.handlePush(pushEvent('{"blob":"..."}'), {
140 decode: async () => { throw new Error("cannot decrypt"); },
141 fallback: () => ({ title: "fb" }),
142 });
143 assert.equal(r.shown[1].title, "fb");
144});
145
146test("handleClick focuses a matching window, else opens, else fallback, never off-origin", async () => {
147 const r = rig();
148 let focusedURL = null;
149 globalThis.clients.windows = [{ url: "https://app.example/inbox", async focus() { focusedURL = this.url; } }];
150 let closed = 0;
151 const ev = (url) => ({ notification: { close() { closed++; }, data: url === undefined ? {} : { url } } });
152 await AvisoSW.handleClick(ev("https://app.example/inbox"), {});
153 assert.equal(focusedURL, "https://app.example/inbox");
154 await AvisoSW.handleClick(ev("https://app.example/other"), {});
155 assert.deepEqual(r.opened, ["https://app.example/other"]);
156 await AvisoSW.handleClick(ev(undefined), { fallbackURL: "/" });
157 assert.deepEqual(r.opened, ["https://app.example/other", "https://app.example/"]);
158 await AvisoSW.handleClick(ev("https://evil.example/"), {});
159 await AvisoSW.handleClick(ev("https://app.example.evil/"), {});
160 await AvisoSW.handleClick(ev("https://evil.example/"), { fallbackURL: "//evil.example/" });
161 await AvisoSW.handleClick(ev(undefined), { fallbackURL: "https://app.example/abs" }); // fallbackURL is external input: relative only
162 assert.equal(r.opened.length, 2);
163 assert.equal(closed, 7);
164});
165
166test("handleSubscriptionChange saves a new subscription under the current key", async () => {
167 rig();
168 const saved = [];
169 const sub = fakeSub("https://push.example/new", KEY_BYTES);
170 const ok = await AvisoSW.handleSubscriptionChange(
171 { newSubscription: sub, oldSubscription: { endpoint: "https://push.example/old" } },
172 { publicKey: async () => KEY, save: async (b) => { saved.push(b); return { ok: true }; } },
173 );
174 assert.equal(ok, true);
175 assert.deepEqual(saved[0], {
176 subscription: { endpoint: "https://push.example/new", keys: { p256dh: "P", auth: "A" } },
177 publicKey: KEY,
178 previousEndpoint: "https://push.example/old",
179 });
180});
181
182test("handleSubscriptionChange replaces a renewal made under a rotated key", async () => {
183 const r = rig();
184 const saved = [];
185 const stale = fakeSub("https://push.example/stale", OLD_KEY_BYTES);
186 const ok = await AvisoSW.handleSubscriptionChange(
187 { newSubscription: stale, oldSubscription: { endpoint: "https://push.example/old" } },
188 { publicKey: async () => KEY, save: async (b) => { saved.push(b); } },
189 );
190 assert.equal(ok, true);
191 assert.equal(stale.unsubscribed, true);
192 assert.equal(r.subscribed.length, 1);
193 assert.equal(saved[0].subscription.endpoint, "https://push.example/renewed");
194 assert.equal(saved[0].publicKey, KEY);
195 assert.equal(saved[0].previousEndpoint, "https://push.example/old");
196});
197
198test("handleSubscriptionChange renews itself when the event carries no subscription", async () => {
199 const r = rig();
200 const saved = [];
201 const ok = await AvisoSW.handleSubscriptionChange({}, { publicKey: async () => KEY, save: async (b) => { saved.push(b); } });
202 assert.equal(ok, true);
203 assert.equal(r.subscribed.length, 1);
204 assert.equal(saved[0].subscription.endpoint, "https://push.example/renewed");
205 assert.equal("previousEndpoint" in saved[0], false);
206});
207
208test("handleSubscriptionChange fails silently on an expired session, a failed key fetch, or a throw", async () => {
209 rig();
210 const sub = fakeSub("e", KEY_BYTES);
211 assert.equal(await AvisoSW.handleSubscriptionChange({ newSubscription: sub }, { publicKey: async () => KEY, save: async () => ({ ok: false, status: 401 }) }), false);
212 assert.equal(await AvisoSW.handleSubscriptionChange({ newSubscription: sub }, { publicKey: async () => { throw new Error("offline"); }, save: async () => ({ ok: true }) }), false);
213 assert.equal(await AvisoSW.handleSubscriptionChange({ newSubscription: sub }, { publicKey: async () => KEY, save: async () => { throw new Error("net"); } }), false);
214 await assert.rejects(AvisoSW.handleSubscriptionChange({ newSubscription: sub }, {}), /publicKey\(\) and save\(\) are required/);
215});
216
217// The app's sw.js, as SKILL.md shows it: listeners that hand the
218// helper's promise to event.waitUntil synchronously. A worker that
219// awaited before calling waitUntil would be terminated mid-flight.
220test("an app worker wires the helper through event.waitUntil synchronously", async () => {
221 const r = rig();
222 const listeners = {};
223 globalThis.addEventListener = (type, fn) => { listeners[type] = fn; };
224 runInThisContext(`
225 self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, {
226 fallback: () => ({ title: "New activity", options: { data: { url: "/" } } }),
227 })));
228 self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" })));
229 `, { filename: "sw.js" });
230 let waited = null;
231 const ev = Object.assign(pushEvent('{"title":"Hi","url":"/inbox"}'), { waitUntil(p) { waited = p; } });
232 listeners.push(ev);
233 assert.ok(waited && typeof waited.then === "function", "waitUntil not called synchronously with a promise");
234 await waited;
235 assert.equal(r.shown[0].title, "Hi");
236 waited = null;
237 listeners.notificationclick({ notification: { close() {}, data: { url: "https://app.example/inbox" } }, waitUntil(p) { waited = p; } });
238 assert.ok(waited);
239 await waited;
240 assert.deepEqual(r.opened, ["https://app.example/inbox"]);
241});
242