import { createECDH, randomBytes } from "node:crypto"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import webPush from "web-push"; import type { PushPayload } from "./push-notifications.js"; import { EncryptedSecretStore } from "./secrets.js"; import { VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js"; const browserKey = createECDH("prime256v1"); browserKey.generateKeys(); const device = { endpoint: "https://push.example.test/device-1", p256dh: browserKey.getPublicKey().toString("base64url"), auth: randomBytes(16).toString("base64url"), }; const publicHost = async () => [{ address: "93.184.216.34", family: 4 }]; const payload: PushPayload = { kind: "completion", title: "Chief finished", body: "Done", botId: "bot-1", threadId: "thread-1", url: "/app/g/group-1", }; function settingsTable() { const rows = new Map(); const prisma = { integrationProviderConfig: { findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.has(where.id) ? { ciphertext: rows.get(where.id)! } : null, ), createMany: vi.fn(async ({ data }: { data: Array<{ id: string; ciphertext: string }> }) => { for (const row of data) if (!rows.has(row.id)) rows.set(row.id, row.ciphertext); return { count: data.length }; }), }, } as unknown as PrismaClient; return { prisma, rows }; } const fixedKeys = { subject: "mailto:ops@example.test", ...webPush.generateVAPIDKeys() }; describe("VAPID keys", () => { it("uses the configured key pair", async () => { const { prisma, rows } = settingsTable(); const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), fixedKeys); await expect(keys.details()).resolves.toEqual(fixedKeys); expect(rows.size).toBe(0); }); it("generates one key pair, stores it encrypted and shares it across processes", async () => { const { prisma, rows } = settingsTable(); const secrets = new EncryptedSecretStore("test-key"); const api = new VapidKeys(prisma, secrets, { publicKey: fixedKeys.publicKey }); const worker = new VapidKeys(prisma, secrets); const [fromApi, fromWorker] = await Promise.all([api.details(), worker.details()]); expect(fromWorker).toEqual(fromApi); expect(fromApi.publicKey).not.toBe(fixedKeys.publicKey); expect(fromApi.subject).toBe("mailto:admin@localhost"); expect(rows.size).toBe(1); const [ciphertext] = [...rows.values()]; expect(ciphertext).not.toContain(fromApi.privateKey); await expect(new VapidKeys(prisma, secrets).publicKey()).resolves.toBe(fromApi.publicKey); }); it("reports no public key when the configured keys are invalid", async () => { const { prisma } = settingsTable(); const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), { ...fixedKeys, privateKey: "not-a-key", }); await expect(keys.publicKey()).resolves.toBeNull(); }); it("reads the environment and uses an https web origin as the contact", () => { expect( vapidEnvFromProcess({ VAPID_PUBLIC_KEY: " pub ", VAPID_PRIVATE_KEY: "priv", WEB_ORIGIN: "https://app.example.test", }), ).toEqual({ publicKey: "pub", privateKey: "priv", subject: "https://app.example.test" }); expect(vapidEnvFromProcess({ WEB_ORIGIN: "http://127.0.0.1:5173" }).subject).toBeUndefined(); expect( vapidEnvFromProcess({ VAPID_SUBJECT: "mailto:a@example.test", WEB_ORIGIN: "https://x.test" }) .subject, ).toBe("mailto:a@example.test"); }); }); describe("web push sender", () => { const vapid = { details: async () => fixedKeys }; it("posts an encrypted, VAPID-signed message to the endpoint", async () => { const fetchMock = vi.fn(async () => new Response(null, { status: 201 })); const sender = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); await expect(sender.send(device, payload)).resolves.toBe("sent"); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe(device.endpoint); expect(init.method).toBe("POST"); expect(init.redirect).toBe("manual"); const headers = new Headers(init.headers); expect(headers.get("content-encoding")).toBe("aes128gcm"); expect(headers.get("ttl")).toBe("86400"); expect(headers.get("authorization")).toMatch(/^vapid t=.+, k=/); expect(headers.has("content-length")).toBe(false); // The body is ciphertext: the notification text never travels in the clear. expect(Buffer.from(init.body as Uint8Array).toString("utf8")).not.toContain("Chief finished"); }); it("reports an expired subscription as gone", async () => { for (const status of [404, 410]) { const sender = new WebPushSender(vapid, { fetch: async () => new Response(null, { status }), resolveHostname: publicHost, }); await expect(sender.send(device, payload)).resolves.toBe("gone"); } }); it("throws on other failures without naming the endpoint", async () => { const sender = new WebPushSender(vapid, { fetch: async () => new Response("quota exceeded", { status: 429 }), resolveHostname: publicHost, }); const error = await sender.send(device, payload).catch((caught: Error) => caught); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("Web push failed (429)"); expect((error as Error).message).not.toContain("push.example.test"); }); it("never connects to a private or non-https endpoint", async () => { const fetchMock = vi.fn(); const privateHost = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: async () => [{ address: "10.0.0.5", family: 4 }], }); await expect(privateHost.send(device, payload)).rejects.toThrow("private address"); const plainHttp = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); await expect( plainHttp.send({ ...device, endpoint: "http://push.example.test/device-1" }, payload), ).rejects.toThrow("https"); const loopback = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); await expect( loopback.send({ ...device, endpoint: "https://127.0.0.1/device-1" }, payload), ).rejects.toThrow("private or internal host"); expect(fetchMock).not.toHaveBeenCalled(); }); it("treats a subscription without keys as gone", async () => { const sender = new WebPushSender(vapid, { fetch: vi.fn(), resolveHostname: publicHost }); await expect(sender.send({ ...device, p256dh: null }, payload)).resolves.toBe("gone"); }); });