import { test } from "node:test"; import assert from "node:assert/strict"; import { generateKeyPairSync } from "node:crypto"; import { capabilities, status, enable, reconcile, disable } from "./push.mjs"; // Real P-256 points, as a browser would require of applicationServerKey // (an invalid point is an InvalidAccessError there, not a byte // mismatch). The browser hands the key back as an ArrayBuffer, so the // fakes do too. function realPoint() { const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); const jwk = publicKey.export({ format: "jwk" }); return Uint8Array.from([4, ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url")]); } const KEY_BYTES = realPoint(); const KEY = Buffer.from(KEY_BYTES).toString("base64url"); const OLD_KEY_BYTES = realPoint(); const asBuffer = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); function fakeSub(endpoint, keyBytes) { return { endpoint, options: { applicationServerKey: asBuffer(keyBytes) }, unsubscribed: false, toJSON() { return { endpoint, expirationTime: null, keys: { p256dh: "P", auth: "A" } }; }, async unsubscribe() { this.unsubscribed = true; return true; }, }; } function fakeRegistration(existing) { const reg = { subscribed: [], pushManager: { async getSubscription() { return existing; }, async subscribe(opts) { assert.equal(opts.userVisibleOnly, true); assert.deepEqual(Array.from(opts.applicationServerKey), Array.from(KEY_BYTES)); const s = fakeSub("https://push.example/new", opts.applicationServerKey); reg.subscribed.push(s); return s; }, }, }; return reg; } function env(permission, requested = permission) { const calls = []; return { calls, Notification: { permission, async requestPermission() { calls.push("prompt"); return requested; }, }, navigator: { serviceWorker: {} }, PushManager: function () {}, }; } const okSave = () => { const saved = []; const save = async (b) => { saved.push(b); return { ok: true, status: 204 }; }; return { saved, save }; }; test("capabilities reports the four booleans", () => { const c = capabilities({ navigator: { serviceWorker: {}, standalone: true }, PushManager: function () {}, Notification: {} }); assert.deepEqual(c, { serviceWorker: true, push: true, notifications: true, standalone: true }); assert.deepEqual(capabilities({}), { serviceWorker: false, push: false, notifications: false, standalone: false }); }); test("enable prompts synchronously inside the gesture, subscribes and saves a projected body", async () => { const e = env("default", "granted"); const reg = fakeRegistration(null); const { saved, save } = okSave(); const pending = enable({ registration: reg, publicKey: KEY, save }, e); // Before any await resolves: a prompt after an await is outside the // user gesture and browsers deny it. assert.deepEqual(e.calls, ["prompt"]); const sub = await pending; assert.ok(sub); assert.deepEqual(e.calls, ["prompt"]); assert.equal(saved.length, 1); assert.deepEqual(saved[0], { subscription: { endpoint: "https://push.example/new", keys: { p256dh: "P", auth: "A" } }, publicKey: KEY, }); }); test("enable resolves null when denied and never subscribes", async () => { const e = env("default", "denied"); const reg = fakeRegistration(null); const { saved, save } = okSave(); assert.equal(await enable({ registration: reg, publicKey: KEY, save }, e), null); assert.equal(reg.subscribed.length, 0); assert.equal(saved.length, 0); }); test("enable re-saves a matching subscription instead of replacing it", async () => { const e = env("granted"); const existing = fakeSub("https://push.example/old", KEY_BYTES); const reg = fakeRegistration(existing); const { saved, save } = okSave(); const sub = await enable({ registration: reg, publicKey: KEY, save }, e); assert.equal(sub, existing); assert.equal(reg.subscribed.length, 0); assert.equal(saved.length, 1); }); test("enable rejects when save reports failure, and accepts a bare resolve", async () => { const e = env("granted"); await assert.rejects( enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => ({ ok: false, status: 500 }) }, e), /save rejected: 500/, ); await assert.rejects( enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => { throw new Error("net"); } }, e), /net/, ); const sub = await enable({ registration: fakeRegistration(null), publicKey: KEY, save: async () => undefined }, e); assert.ok(sub); }); test("reconcile never prompts and re-saves a matching subscription", async () => { const e = env("granted"); const existing = fakeSub("https://push.example/old", KEY_BYTES); const reg = fakeRegistration(existing); const { saved, save } = okSave(); const sub = await reconcile({ registration: reg, publicKey: KEY, save }, e); assert.equal(sub, existing); assert.deepEqual(e.calls, []); assert.equal(saved.length, 1); assert.equal(reg.subscribed.length, 0); assert.equal("previousEndpoint" in saved[0], false); }); test("reconcile re-subscribes under a new key and names the old endpoint", async () => { const e = env("granted"); const existing = fakeSub("https://push.example/old", OLD_KEY_BYTES); const reg = fakeRegistration(existing); const { saved, save } = okSave(); await reconcile({ registration: reg, publicKey: KEY, save }, e); assert.equal(existing.unsubscribed, true); assert.equal(reg.subscribed.length, 1); assert.equal(saved[0].previousEndpoint, "https://push.example/old"); assert.equal(saved[0].subscription.endpoint, "https://push.example/new"); }); test("reconcile never creates a subscription: enable, disable, reconcile stays disabled", async () => { const e = env("granted"); let current = null; const reg = { pushManager: { async getSubscription() { return current; }, async subscribe(opts) { current = fakeSub("https://push.example/new", new Uint8Array(opts.applicationServerKey)); return current; }, }, }; const { saved, save } = okSave(); const removed = []; const remove = async (b) => { removed.push(b.endpoint); return { ok: true }; }; assert.ok(await enable({ registration: reg, publicKey: KEY, save }, e)); current.unsubscribe = async () => { current = null; return true; }; await disable({ registration: reg, remove }); assert.deepEqual(removed, ["https://push.example/new"]); // Permission is still "granted"; the next page load must not re-enrol. assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null); assert.equal(current, null); assert.equal(saved.length, 1, "reconcile saved after disable"); }); test("reconcile does nothing without permission", async () => { for (const perm of ["default", "denied"]) { const e = env(perm); const reg = fakeRegistration(null); const { saved, save } = okSave(); assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null); assert.equal(saved.length, 0); assert.deepEqual(e.calls, []); } }); test("status reads permission and subscription", async () => { const existing = fakeSub("https://push.example/x", KEY_BYTES); const s = await status(fakeRegistration(existing), env("granted")); assert.equal(s.permission, "granted"); assert.equal(s.subscription, existing); }); test("disable removes server-side first, then the browser subscription", async () => { const existing = fakeSub("https://push.example/x", KEY_BYTES); const order = []; const remove = async (b) => { order.push("remove:" + b.endpoint); return { ok: true }; }; existing.unsubscribe = async () => { order.push("unsubscribe"); return true; }; await disable({ registration: fakeRegistration(existing), remove }); assert.deepEqual(order, ["remove:https://push.example/x", "unsubscribe"]); }); test("disable keeps the browser subscription when remove fails", async () => { const existing = fakeSub("https://push.example/x", KEY_BYTES); await assert.rejects( disable({ registration: fakeRegistration(existing), remove: async () => ({ ok: false, status: 500 }) }), /remove rejected/, ); assert.equal(existing.unsubscribed, false); }); test("disable is a no-op without a subscription", async () => { let called = false; await disable({ registration: fakeRegistration(null), remove: async () => { called = true; } }); assert.equal(called, false); });