import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator } from "@rakazo/adapters"; import type { NotificationSettings, WebPushConfig } from "@rakazo/contracts"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { createApp } from "../../../apps/api/src/app.ts"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string, init?: RequestInit) => Response | Promise }; type AppHandles = Awaited>; process.env.WAKEUP_DRIVER = "memory"; process.env.SANDBOX_PROVIDER = "fake"; process.env.AGENT_RUNTIME = "scripted"; const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); const describeWithDatabase = hasDb ? describe : describe.skip; const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; describeWithDatabase("notifications through the API", () => { let handles: AppHandles; let app: App; const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-notifications-")); beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", wakeupDriver: "memory", signupsEnabled: "true", composio: new ComposioEmulator(), }); app = handles.app; }); afterAll(async () => { await handles?.stop(); rmSync(dataDir, { recursive: true, force: true }); }); async function devices(cookie: string) { const me = await rpc<{ userId: string }>(app, cookie, "me"); return handles.prisma.pushSubscription.findMany({ where: { userId: me.userId }, orderBy: { createdAt: "asc" }, select: { kind: true, endpoint: true, userAgent: true }, }); } it("reads and updates the caller's own settings", async () => { const owner = await signup(app, `settings-owner-${stamp}@rakazo.test`, "Settings Owner"); const other = await signup(app, `settings-other-${stamp}@rakazo.test`, "Settings Other"); await expect( rpc(app, owner, "notifications/settings/get"), ).resolves.toEqual({ quietHours: null, nudges: true }); await expect( rpc(app, owner, "notifications/settings/update", { quietHours: { start: "21:00", end: "06:30" }, nudges: false, }), ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false }); await expect( rpc(app, other, "notifications/settings/get"), ).resolves.toEqual({ quietHours: null, nudges: true }); for (const invalid of [ { quietHours: { start: "9:00", end: "17:00" } }, { quietHours: { start: "24:00", end: "06:00" } }, { quietHours: { start: "08:00", end: "08:00" } }, ]) { const response = await raw(app, owner, "notifications/settings/update", invalid); expect(response.status, JSON.stringify(invalid)).toBe(400); } await expect( rpc(app, owner, "notifications/settings/get"), ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false }); }); it("hands browsers one stable public key", async () => { const cookie = await signup(app, `push-config-${stamp}@rakazo.test`, "Push Config"); const first = await rpc(app, cookie, "notifications/webPush/config"); expect(first.available).toBe(true); expect(first.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/); await expect(rpc(app, cookie, "notifications/webPush/config")).resolves.toEqual( first, ); const stored = await handles.prisma.integrationProviderConfig.findUniqueOrThrow({ where: { id: "web-push-vapid" }, }); expect(stored.ciphertext).not.toContain(first.publicKey!); }); it("registers browsers and phones per user, and only the owner removes them", async () => { const owner = await signup(app, `push-owner-${stamp}@rakazo.test`, "Push Owner"); const intruder = await signup(app, `push-intruder-${stamp}@rakazo.test`, "Push Intruder"); const endpoint = `https://push.example.test/${stamp}`; await rpc(app, owner, "notifications/webPush/subscribe", { endpoint, keys }); await rpc(app, owner, "notifications/registerPush", { token: `ExponentPushToken[${stamp}]` }); expect(await devices(owner)).toEqual([ { kind: "webpush", endpoint, userAgent: "" }, { kind: "expo", endpoint: `ExponentPushToken[${stamp}]`, userAgent: "" }, ]); await rpc(app, intruder, "notifications/webPush/unsubscribe", { endpoint }); await rpc(app, intruder, "notifications/unregisterPush"); expect(await devices(owner)).toHaveLength(2); await rpc(app, owner, "notifications/unregisterPush"); expect(await devices(owner)).toEqual([{ kind: "webpush", endpoint, userAgent: "" }]); await rpc(app, owner, "notifications/webPush/unsubscribe", { endpoint }); expect(await devices(owner)).toEqual([]); }); it("rejects endpoints that are not public https push services", async () => { const cookie = await signup(app, `push-invalid-${stamp}@rakazo.test`, "Push Invalid"); for (const endpoint of [ "http://push.example.test/device", "https://127.0.0.1/device", "https://localhost/device", "https://10.0.0.8/device", "https://user:secret@push.example.test/device", "not a url", ]) { const response = await raw(app, cookie, "notifications/webPush/subscribe", { endpoint, keys, }); expect(response.status, endpoint).toBe(400); } const badKeys = await raw(app, cookie, "notifications/webPush/subscribe", { endpoint: `https://push.example.test/${stamp}-keys`, keys: { p256dh: "short", auth: keys.auth }, }); expect(badKeys.status).toBe(400); const badToken = await raw(app, cookie, "notifications/registerPush", { token: "https://push.example.test/not-an-expo-token", }); expect(badToken.status).toBe(400); expect(await devices(cookie)).toEqual([]); }); it("refuses every notification procedure without a session", async () => { for (const [procedure, body] of [ ["notifications/settings/get", {}], ["notifications/settings/update", { nudges: false }], ["notifications/webPush/config", {}], ["notifications/webPush/subscribe", { endpoint: `https://push.example.test/${stamp}`, keys }], ["notifications/webPush/unsubscribe", { endpoint: `https://push.example.test/${stamp}` }], ["notifications/registerPush", { token: "ExponentPushToken[anon]" }], ["notifications/unregisterPush", {}], ] as const) { const response = await raw(app, "", procedure, body); expect(response.status, procedure).toBe(401); } }); }); async function signup(app: App, email: string, name: string) { const response = await app.request("/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, body: JSON.stringify({ email, password: "password12", name }), }); if (response.status >= 400) { throw new Error(`signup failed ${response.status}: ${await response.text()}`); } return sessionCookieHeader(response); } async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { return app.request(`/rpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}), origin: "http://127.0.0.1:5173", }, body: JSON.stringify({ json: body }), }); } async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { const response = await raw(app, cookie, procedure, body); const text = await response.text(); const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; if (response.status >= 400 || payload.error) { throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); } return payload.json as T; }