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 { capabilities, status, enable, reconcile, disable } from "./push.mjs";
5
6// Real P-256 points, as a browser would require of applicationServerKey
7// (an invalid point is an InvalidAccessError there, not a byte
8// mismatch). The browser hands the key back as an ArrayBuffer, so the
9// fakes do too.
10function realPoint() {
11 const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
12 const jwk = publicKey.export({ format: "jwk" });
13 return Uint8Array.from([4, ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url")]);
14}
15const KEY_BYTES = realPoint();
16const KEY = Buffer.from(KEY_BYTES).toString("base64url");
17const OLD_KEY_BYTES = realPoint();
18const asBuffer = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
19
20function fakeSub(endpoint, keyBytes) {
21 return {
22 endpoint,
23 options: { applicationServerKey: asBuffer(keyBytes) },
24 unsubscribed: false,
25 toJSON() { return { endpoint, expirationTime: null, keys: { p256dh: "P", auth: "A" } }; },
26 async unsubscribe() { this.unsubscribed = true; return true; },
27 };
28}
29
30function fakeRegistration(existing) {
31 const reg = {
32 subscribed: [],
33 pushManager: {
34 async getSubscription() { return existing; },
35 async subscribe(opts) {
36 assert.equal(opts.userVisibleOnly, true);
37 assert.deepEqual(Array.from(opts.applicationServerKey), Array.from(KEY_BYTES));
38 const s = fakeSub("https://push.example/new", opts.applicationServerKey);
39 reg.subscribed.push(s);
40 return s;
41 },
42 },
43 };
44 return reg;
45}
46
47function env(permission, requested = permission) {
48 const calls = [];
49 return {
50 calls,
51 Notification: {
52 permission,
53 async requestPermission() { calls.push("prompt"); return requested; },
54 },
55 navigator: { serviceWorker: {} },
56 PushManager: function () {},
57 };
58}
59
60const okSave = () => {
61 const saved = [];
62 const save = async (b) => { saved.push(b); return { ok: true, status: 204 }; };
63 return { saved, save };
64};
65
66test("capabilities reports the four booleans", () => {
67 const c = capabilities({ navigator: { serviceWorker: {}, standalone: true }, PushManager: function () {}, Notification: {} });
68 assert.deepEqual(c, { serviceWorker: true, push: true, notifications: true, standalone: true });
69 assert.deepEqual(capabilities({}), { serviceWorker: false, push: false, notifications: false, standalone: false });
70});
71
72test("enable prompts synchronously inside the gesture, subscribes and saves a projected body", async () => {
73 const e = env("default", "granted");
74 const reg = fakeRegistration(null);
75 const { saved, save } = okSave();
76 const pending = enable({ registration: reg, publicKey: KEY, save }, e);
77 // Before any await resolves: a prompt after an await is outside the
78 // user gesture and browsers deny it.
79 assert.deepEqual(e.calls, ["prompt"]);
80 const sub = await pending;
81 assert.ok(sub);
82 assert.deepEqual(e.calls, ["prompt"]);
83 assert.equal(saved.length, 1);
84 assert.deepEqual(saved[0], {
85 subscription: { endpoint: "https://push.example/new", keys: { p256dh: "P", auth: "A" } },
86 publicKey: KEY,
87 });
88});
89
90test("enable resolves null when denied and never subscribes", async () => {
91 const e = env("default", "denied");
92 const reg = fakeRegistration(null);
93 const { saved, save } = okSave();
94 assert.equal(await enable({ registration: reg, publicKey: KEY, save }, e), null);
95 assert.equal(reg.subscribed.length, 0);
96 assert.equal(saved.length, 0);
97});
98
99test("enable re-saves a matching subscription instead of replacing it", async () => {
100 const e = env("granted");
101 const existing = fakeSub("https://push.example/old", KEY_BYTES);
102 const reg = fakeRegistration(existing);
103 const { saved, save } = okSave();
104 const sub = await enable({ registration: reg, publicKey: KEY, save }, e);
105 assert.equal(sub, existing);
106 assert.equal(reg.subscribed.length, 0);
107 assert.equal(saved.length, 1);
108});
109
110test("enable rejects when save reports failure, and accepts a bare resolve", async () => {
111 const e = env("granted");
112 await assert.rejects(
113 enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => ({ ok: false, status: 500 }) }, e),
114 /save rejected: 500/,
115 );
116 await assert.rejects(
117 enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => { throw new Error("net"); } }, e),
118 /net/,
119 );
120 const sub = await enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => undefined }, e);
121 assert.ok(sub);
122});
123
124test("reconcile never prompts and re-saves a matching subscription", async () => {
125 const e = env("granted");
126 const existing = fakeSub("https://push.example/old", KEY_BYTES);
127 const reg = fakeRegistration(existing);
128 const { saved, save } = okSave();
129 const sub = await reconcile({ registration: reg, publicKey: KEY, save }, e);
130 assert.equal(sub, existing);
131 assert.deepEqual(e.calls, []);
132 assert.equal(saved.length, 1);
133 assert.equal(reg.subscribed.length, 0);
134 assert.equal("previousEndpoint" in saved[0], false);
135});
136
137test("reconcile re-subscribes under a new key and names the old endpoint", async () => {
138 const e = env("granted");
139 const existing = fakeSub("https://push.example/old", OLD_KEY_BYTES);
140 const reg = fakeRegistration(existing);
141 const { saved, save } = okSave();
142 await reconcile({ registration: reg, publicKey: KEY, save }, e);
143 assert.equal(existing.unsubscribed, true);
144 assert.equal(reg.subscribed.length, 1);
145 assert.equal(saved[0].previousEndpoint, "https://push.example/old");
146 assert.equal(saved[0].subscription.endpoint, "https://push.example/new");
147});
148
149test("reconcile never creates a subscription: enable, disable, reconcile stays disabled", async () => {
150 const e = env("granted");
151 let current = null;
152 const reg = {
153 pushManager: {
154 async getSubscription() { return current; },
155 async subscribe(opts) { current = fakeSub("https://push.example/new", new Uint8Array(opts.applicationServerKey)); return current; },
156 },
157 };
158 const { saved, save } = okSave();
159 const removed = [];
160 const remove = async (b) => { removed.push(b.endpoint); return { ok: true }; };
161 assert.ok(await enable({ registration: reg, publicKey: KEY, save }, e));
162 current.unsubscribe = async () => { current = null; return true; };
163 await disable({ registration: reg, remove });
164 assert.deepEqual(removed, ["https://push.example/new"]);
165 // Permission is still "granted"; the next page load must not re-enrol.
166 assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null);
167 assert.equal(current, null);
168 assert.equal(saved.length, 1, "reconcile saved after disable");
169});
170
171test("reconcile does nothing without permission", async () => {
172 for (const perm of ["default", "denied"]) {
173 const e = env(perm);
174 const reg = fakeRegistration(null);
175 const { saved, save } = okSave();
176 assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null);
177 assert.equal(saved.length, 0);
178 assert.deepEqual(e.calls, []);
179 }
180});
181
182test("status reads permission and subscription", async () => {
183 const existing = fakeSub("https://push.example/x", KEY_BYTES);
184 const s = await status(fakeRegistration(existing), env("granted"));
185 assert.equal(s.permission, "granted");
186 assert.equal(s.subscription, existing);
187});
188
189test("disable removes server-side first, then the browser subscription", async () => {
190 const existing = fakeSub("https://push.example/x", KEY_BYTES);
191 const order = [];
192 const remove = async (b) => { order.push("remove:" + b.endpoint); return { ok: true }; };
193 existing.unsubscribe = async () => { order.push("unsubscribe"); return true; };
194 await disable({ registration: fakeRegistration(existing), remove });
195 assert.deepEqual(order, ["remove:https://push.example/x", "unsubscribe"]);
196});
197
198test("disable keeps the browser subscription when remove fails", async () => {
199 const existing = fakeSub("https://push.example/x", KEY_BYTES);
200 await assert.rejects(
201 disable({ registration: fakeRegistration(existing), remove: async () => ({ ok: false, status: 500 }) }),
202 /remove rejected/,
203 );
204 assert.equal(existing.unsubscribed, false);
205});
206
207test("disable is a no-op without a subscription", async () => {
208 let called = false;
209 await disable({ registration: fakeRegistration(null), remove: async () => { called = true; } });
210 assert.equal(called, false);
211});
212