diff --git a/extension/lib.js b/extension/lib.js index 94447a7..f2b5ddc 100644 --- a/extension/lib.js +++ b/extension/lib.js @@ -111,14 +111,21 @@ async function vapidJwt(privateJwk, audience, sub) { export async function sendWebPush(subscription, vapid, ttl = 60) { const audience = new URL(subscription.endpoint).origin; const jwt = await vapidJwt(vapid.privateJwk, audience); - const r = await fetch(subscription.endpoint, { - method: "POST", - headers: { - "Authorization": `vapid t=${jwt}, k=${vapid.publicB64}`, - "TTL": String(ttl), - }, - }); - return { ok: r.ok, status: r.status }; + let r; + try { + r = await fetch(subscription.endpoint, { + method: "POST", + headers: { + "Authorization": `vapid t=${jwt}, k=${vapid.publicB64}`, + "TTL": String(ttl), + }, + }); + } catch (e) { + return { ok: false, error: `fetch failed: ${e.message || e} (host_permission missing for ${audience}?)` }; + } + if (r.ok) return { ok: true, status: r.status }; + const body = await r.text().catch(() => ""); + return { ok: false, status: r.status, error: `push service ${r.status}: ${body.slice(0, 200) || r.statusText}` }; } // Polls an ntfy topic once. Returns parsed JSON body (the subscription diff --git a/extension/manifest.json b/extension/manifest.json index 6a55297..3a42adc 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -12,6 +12,9 @@ "host_permissions": [ "https://*.youtube.com/*", "https://*.google.com/*", + "https://fcm.googleapis.com/*", + "https://updates.push.services.mozilla.com/*", + "https://web.push.apple.com/*", "http://localhost/*", "http://127.0.0.1/*" ], diff --git a/test/extension-only.spec.ts b/test/extension-only.spec.ts index fbcb780..02cb2da 100644 --- a/test/extension-only.spec.ts +++ b/test/extension-only.spec.ts @@ -137,6 +137,31 @@ test("share-to-phone: ntfy round-trip — extension picks up a posted subscripti await context.close(); }); +test("share-to-phone: test-phone-push reaches FCM (regression for missing fcm.googleapis.com host permission)", async () => { + const { context, extensionId } = await bootContext(); + const dash = await context.newPage(); + await dash.goto(`chrome-extension://${extensionId}/dashboard.html`); + await dash.evaluate(() => chrome.storage.local.set({ + phoneSub: { + // Real FCM origin so the SW's fetch hits the actual endpoint. Path is + // bogus so FCM will return 404 — that's fine; we only care that the + // request *reaches* FCM (no CORS / host_permission failure). + endpoint: "https://fcm.googleapis.com/fcm/send/host-perm-regression-fake-id", + keys: { p256dh: "BHello-test-p256dh-key", auth: "test-auth-secret" }, + }, + })); + await dash.reload(); + await expect(dash.locator("#phoneStatus")).toContainText("phone subscribed", { timeout: 10_000 }); + await dash.click("#testPhone"); + await expect(dash.locator("#phoneStatus")).not.toHaveText("sending…", { timeout: 15_000 }); + const status = await dash.locator("#phoneStatus").innerText(); + // Acceptable: either ✓ sent (impossible with fake) or ✗ push service <2xx-4xx>. + // Forbidden: ✗ fetch failed (means CORS / host_permission missing). + expect(status, "must reach FCM, not CORS-block").not.toMatch(/fetch failed|Failed to fetch/i); + expect(status, "should report a real push-service status").toMatch(/sent|push service \d{3}/); + await context.close(); +}); + test("share-to-phone: paired state shows test/forget; forget clears storage", async () => { const { context, extensionId } = await bootContext(); const dash = await context.newPage();