From d63f7b3bf16e42b07aec178fa578ca678cfb0ca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:34:14 +0000 Subject: [PATCH] test(notifications): cover quiet hours, delivery, nudges and the notification RPCs The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped yet, so it is corrected in place. - Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders. - PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and notify_user against a real main conversation, and every notification RPC through the API including cross-user isolation and endpoint validation. - .env.example documents the optional VAPID keys and VAPID_SUBJECT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .env.example | 4 + .../adapters/src/notify-user.postgres.test.ts | 216 ++++++++++++++++++ .../adapters/src/push-notifications.test.ts | 179 +++++++++++++++ packages/adapters/src/web-push.test.ts | 155 +++++++++++++ packages/core/src/quiet-hours.test.ts | 62 +++++ .../migration.sql | 4 +- .../notification-settings.postgres.test.ts | 184 +++++++++++++++ packages/testkit/src/cli/harness.ts | 3 + .../src/notifications.postgres.test.ts | 198 ++++++++++++++++ 9 files changed, 1004 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/src/notify-user.postgres.test.ts create mode 100644 packages/adapters/src/push-notifications.test.ts create mode 100644 packages/adapters/src/web-push.test.ts create mode 100644 packages/core/src/quiet-hours.test.ts create mode 100644 packages/db/src/notification-settings.postgres.test.ts create mode 100644 packages/testkit/src/notifications.postgres.test.ts diff --git a/.env.example b/.env.example index 5d588c8..9c4b48f 100644 --- a/.env.example +++ b/.env.example @@ -166,8 +166,12 @@ SMTP_URL= EMAIL_FROM= # Local development only: capture password-reset email and print its link to the API console. EMAIL_EMULATOR= +# Optional web push keys (set both). Unset: generated once and stored encrypted in the database. +# Browsers only allow push on https (or localhost). VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https. +VAPID_SUBJECT= # Structured logs to stdout (json in production, pretty in development unless overridden). LOG_LEVEL=info LOG_FORMAT= diff --git a/packages/adapters/src/notify-user.postgres.test.ts b/packages/adapters/src/notify-user.postgres.test.ts new file mode 100644 index 0000000..f0105f5 --- /dev/null +++ b/packages/adapters/src/notify-user.postgres.test.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import type { PrismaClient } from "@rakazo/db"; +import { + bootstrapUserSpace, + createDb, + ensureMainConversation, + savePushSubscription, + updateNotificationSettings, +} from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { notifyUserFromTool } from "./notify-user-tool.js"; +import { PushNotificationProvider, type PushSender } from "./push-notifications.js"; +import type { PushPayload } from "./web-push.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describePostgres("push delivery and notify_user (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser() { + const id = `nu${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + const conversation = await ensureMainConversation(prisma, id); + const main = await prisma.bot.findUniqueOrThrow({ + where: { id: conversation.leadBotId }, + select: { id: true, name: true, kind: true }, + }); + return { userId: id, conversation, main }; + } + + function recordingSender(outcome: "sent" | "gone" = "sent") { + const sent: PushPayload[] = []; + const sender: PushSender = { + send: vi.fn(async (_target, payload: PushPayload) => { + sent.push(payload); + return outcome; + }), + }; + return { sender, sent }; + } + + function toolContext(user: Awaited>, bot = user.main) { + return { + userId: user.userId, + spaceId: user.conversation.spaceId, + runId: `run-${randomUUID()}`, + bot, + signal: new AbortController().signal, + }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("delivers to each device, links the conversation and drops gone devices", async () => { + const user = await newUser(); + const browser = `https://push.example.test/${randomUUID()}`; + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: browser, + ...keys, + }); + await savePushSubscription(prisma, user.userId, { + kind: "expo", + endpoint: `ExponentPushToken[${user.userId}]`, + }); + const web = recordingSender("sent"); + const expo = recordingSender("gone"); + await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( + { + kind: "completion", + title: "Chief finished", + body: "Done", + botId: user.main.id, + threadId: user.conversation.threadId, + }, + { + operationId: "notify", + traceId: "notify", + spaceId: user.conversation.spaceId, + userId: user.userId, + signal: new AbortController().signal, + }, + ); + expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`); + const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) }); + }); + + it("lets only the main bot nudge, within the user's settings and the daily cap", async () => { + const user = await newUser(); + const web = recordingSender(); + const deps = { + prisma, + notifications: new PushNotificationProvider(prisma, { webpush: web.sender }), + }; + + await expect( + notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), { + body: "Hi", + }), + ).resolves.toEqual({ error: "Only the user's main bot can notify the user." }); + await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({ + error: expect.stringContaining("body is required"), + }); + await expect( + notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }), + ).resolves.toMatchObject({ sent: false }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "The user has no device with notifications turned on.", + }); + + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: `https://push.example.test/${randomUUID()}`, + ...keys, + }); + await updateNotificationSettings(prisma, user.userId, { nudges: false }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "The user turned nudges off.", + }); + + // Quiet hours around the current time. + await updateNotificationSettings(prisma, user.userId, { + nudges: true, + quietHours: quietAroundNow(), + }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "It is the user's quiet hours.", + }); + expect(web.sent).toHaveLength(0); + + await updateNotificationSettings(prisma, user.userId, { quietHours: null }); + for (let index = 0; index < 3; index += 1) { + await expect( + notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }), + ).resolves.toEqual({ sent: true }); + } + await expect( + notifyUserFromTool(deps, toolContext(user), { body: "One too many" }), + ).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." }); + + expect(web.sent).toHaveLength(3); + expect(web.sent[0]).toMatchObject({ + kind: "nudge", + title: user.main.name, + body: "Check-in 0", + threadId: user.conversation.threadId, + url: `/app/g/${user.conversation.groupId}`, + }); + }); + + it("reports a nudge no device accepted", async () => { + const user = await newUser(); + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: `https://push.example.test/${randomUUID()}`, + ...keys, + }); + const failing: PushSender = { + send: async () => { + throw new Error("Web push failed (500)"); + }, + }; + await expect( + notifyUserFromTool( + { prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) }, + toolContext(user), + { body: "Hi", title: "Evening check-in" }, + ), + ).resolves.toEqual({ sent: false, reason: "No device accepted the notification." }); + }); +}); + +/** A UTC range that covers the current minute (users without a time zone use UTC). */ +function quietAroundNow() { + const now = new Date(); + const minute = now.getUTCHours() * 60 + now.getUTCMinutes(); + const clock = (value: number) => { + const wrapped = (value + 1440) % 1440; + return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`; + }; + return { start: clock(minute - 60), end: clock(minute + 60) }; +} diff --git a/packages/adapters/src/push-notifications.test.ts b/packages/adapters/src/push-notifications.test.ts new file mode 100644 index 0000000..c305f94 --- /dev/null +++ b/packages/adapters/src/push-notifications.test.ts @@ -0,0 +1,179 @@ +import type { NotificationMessage } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import { PushNotificationProvider, type PushSender } from "./push-notifications.js"; +import type { PushOutcome, PushPayload } from "./web-push.js"; + +const context = { + operationId: "notify", + traceId: "t", + spaceId: "space-1", + userId: "user-1", + signal: new AbortController().signal, +}; +const message: NotificationMessage = { + kind: "completion", + title: "Chief finished", + body: "Booked the table for Friday.", + botId: "bot-1", + threadId: "thread-1", +}; + +function fakeDb(options: { + devices: Array<{ id: string; kind: "webpush" | "expo" }>; + quietHours?: { start: number; end: number }; + timeZone?: string; + groupId?: string | null; +}) { + const forgotten: string[] = []; + const used: string[] = []; + const prisma = { + userNotificationSettings: { + findUnique: async () => + options.quietHours + ? { + quietHoursStart: options.quietHours.start, + quietHoursEnd: options.quietHours.end, + nudges: true, + } + : null, + }, + userProfile: { findUnique: async () => ({ timeZone: options.timeZone ?? null }) }, + pushSubscription: { + findMany: async () => + options.devices.map((device) => ({ + ...device, + endpoint: `endpoint-${device.id}`, + p256dh: null, + auth: null, + })), + deleteMany: async ({ where }: { where: { id: string } }) => { + forgotten.push(where.id); + return { count: 1 }; + }, + updateMany: async ({ where }: { where: { id: string } }) => { + used.push(where.id); + return { count: 1 }; + }, + }, + thread: { findUnique: async () => ({ groupId: options.groupId ?? null }) }, + } as unknown as PrismaClient; + return { prisma, forgotten, used }; +} + +function fakeSender(outcome: PushOutcome | Error) { + const sent: PushPayload[] = []; + const sender: PushSender = { + send: vi.fn(async (_target, payload) => { + sent.push(payload); + if (outcome instanceof Error) throw outcome; + return outcome; + }), + }; + return { sender, sent }; +} + +// 23:30 in Vienna (UTC+2 in summer). +const lateEvening = () => new Date("2026-07-01T21:30:00Z"); + +describe("push notification fan-out", () => { + it("sends to every device with a link to the conversation", async () => { + const { prisma, used } = fakeDb({ + devices: [ + { id: "browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + groupId: "group-1", + }); + const web = fakeSender("sent"); + const expo = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( + message, + context, + ); + expect(web.sent).toEqual([{ ...message, url: "/app/g/group-1" }]); + expect(expo.sent).toHaveLength(1); + expect(used.sort()).toEqual(["browser", "phone"]); + }); + + it("links a direct thread to its bot and shortens long bodies", async () => { + const { prisma } = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); + const web = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender }).send( + { ...message, body: "x".repeat(1_000) }, + context, + ); + expect(web.sent[0]?.url).toBe("/app/bot-1"); + expect(web.sent[0]?.body.length).toBeLessThanOrEqual(240); + }); + + it("holds every push back during quiet hours in the user's time zone", async () => { + const quiet = fakeDb({ + devices: [{ id: "browser", kind: "webpush" }], + quietHours: { start: 22 * 60, end: 7 * 60 }, + timeZone: "Europe/Vienna", + }); + const web = fakeSender("sent"); + await new PushNotificationProvider(quiet.prisma, { webpush: web.sender }, lateEvening).send( + message, + context, + ); + expect(web.sent).toHaveLength(0); + + // The same moment is mid-afternoon in Los Angeles. + const awake = fakeDb({ + devices: [{ id: "browser", kind: "webpush" }], + quietHours: { start: 22 * 60, end: 7 * 60 }, + timeZone: "America/Los_Angeles", + }); + await new PushNotificationProvider(awake.prisma, { webpush: web.sender }, lateEvening).send( + message, + context, + ); + expect(web.sent).toHaveLength(1); + }); + + it("drops devices the push service no longer knows", async () => { + const { prisma, forgotten, used } = fakeDb({ + devices: [ + { id: "old-browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + }); + await new PushNotificationProvider(prisma, { + webpush: fakeSender("gone").sender, + expo: fakeSender("sent").sender, + }).send(message, context); + expect(forgotten).toEqual(["old-browser"]); + expect(used).toEqual(["phone"]); + }); + + it("succeeds when one device works and fails only when all of them fail", async () => { + const mixed = fakeDb({ + devices: [ + { id: "browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + }); + await expect( + new PushNotificationProvider(mixed.prisma, { + webpush: fakeSender(new Error("Web push failed (500)")).sender, + expo: fakeSender("sent").sender, + }).send(message, context), + ).resolves.toBeUndefined(); + + const broken = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); + await expect( + new PushNotificationProvider(broken.prisma, { + webpush: fakeSender(new Error("Web push failed (500)")).sender, + }).send(message, context), + ).rejects.toThrow("Push delivery failed"); + }); + + it("does nothing without devices or senders", async () => { + const { prisma } = fakeDb({ devices: [{ id: "phone", kind: "expo" }] }); + const web = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender }).send(message, context); + expect(web.sent).toHaveLength(0); + }); +}); diff --git a/packages/adapters/src/web-push.test.ts b/packages/adapters/src/web-push.test.ts new file mode 100644 index 0000000..5199553 --- /dev/null +++ b/packages/adapters/src/web-push.test.ts @@ -0,0 +1,155 @@ +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 { EncryptedSecretStore } from "./secrets.js"; +import { type PushPayload, 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"); + }); +}); diff --git a/packages/core/src/quiet-hours.test.ts b/packages/core/src/quiet-hours.test.ts new file mode 100644 index 0000000..183ee65 --- /dev/null +++ b/packages/core/src/quiet-hours.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { isQuietMinute, isQuietTime, localDateAndMinute } from "./quiet-hours.js"; + +const minute = (clock: string) => { + const [hour, min] = clock.split(":").map(Number); + return hour! * 60 + min!; +}; + +describe("quiet hours", () => { + it("covers a same-day range with an inclusive start and an exclusive end", () => { + const lunch = { start: minute("12:00"), end: minute("13:30") }; + expect(isQuietMinute(lunch, minute("11:59"))).toBe(false); + expect(isQuietMinute(lunch, minute("12:00"))).toBe(true); + expect(isQuietMinute(lunch, minute("13:29"))).toBe(true); + expect(isQuietMinute(lunch, minute("13:30"))).toBe(false); + }); + + it("covers a range that crosses midnight", () => { + const night = { start: minute("22:00"), end: minute("07:00") }; + expect(isQuietMinute(night, minute("21:59"))).toBe(false); + expect(isQuietMinute(night, minute("22:00"))).toBe(true); + expect(isQuietMinute(night, minute("23:59"))).toBe(true); + expect(isQuietMinute(night, minute("00:00"))).toBe(true); + expect(isQuietMinute(night, minute("06:59"))).toBe(true); + expect(isQuietMinute(night, minute("07:00"))).toBe(false); + expect(isQuietMinute(night, minute("12:00"))).toBe(false); + }); + + it("is never quiet without a range or with an empty one", () => { + expect(isQuietMinute(null, minute("03:00"))).toBe(false); + expect(isQuietMinute({ start: 300, end: 300 }, 300)).toBe(false); + }); + + it("reads the wall clock in the user's time zone", () => { + // 21:30 UTC is 23:30 in Vienna (CEST, UTC+2) and 14:30 in Los Angeles (PDT, UTC-7). + const now = new Date("2026-07-01T21:30:00Z"); + const night = { start: minute("22:00"), end: minute("07:00") }; + expect(isQuietTime(night, "Europe/Vienna", now)).toBe(true); + expect(isQuietTime(night, "America/Los_Angeles", now)).toBe(false); + expect(isQuietTime(night, null, now)).toBe(false); + }); + + it("falls back to UTC for an unknown zone and reports the local date", () => { + const now = new Date("2026-01-31T23:15:00Z"); + expect(localDateAndMinute(now, "Not/AZone")).toEqual({ + date: "2026-01-31", + minute: minute("23:15"), + }); + // Already the next day in Tokyo (UTC+9). + expect(localDateAndMinute(now, "Asia/Tokyo")).toEqual({ + date: "2026-02-01", + minute: minute("08:15"), + }); + }); + + it("follows daylight saving changes", () => { + const night = { start: minute("22:00"), end: minute("07:00") }; + // 05:30 UTC is 06:30 in Vienna in winter (CET) and 07:30 in summer (CEST). + expect(isQuietTime(night, "Europe/Vienna", new Date("2026-01-15T05:30:00Z"))).toBe(true); + expect(isQuietTime(night, "Europe/Vienna", new Date("2026-07-15T05:30:00Z"))).toBe(false); + }); +}); diff --git a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql index c684498..cd83c49 100644 --- a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql +++ b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql @@ -44,7 +44,9 @@ ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_webpush_keys ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK ( ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) OR ( - "quietHoursStart" BETWEEN 0 AND 1439 + "quietHoursStart" IS NOT NULL + AND "quietHoursEnd" IS NOT NULL + AND "quietHoursStart" BETWEEN 0 AND 1439 AND "quietHoursEnd" BETWEEN 0 AND 1439 AND "quietHoursStart" <> "quietHoursEnd" ) diff --git a/packages/db/src/notification-settings.postgres.test.ts b/packages/db/src/notification-settings.postgres.test.ts new file mode 100644 index 0000000..624f9dc --- /dev/null +++ b/packages/db/src/notification-settings.postgres.test.ts @@ -0,0 +1,184 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + claimDailyNudge, + getNotificationSettings, + loadNotificationPolicy, + updateNotificationSettings, +} from "./notification-settings.js"; +import { + listPushSubscriptions, + PUSH_SUBSCRIPTION_LIMIT, + removePushSubscription, + removePushSubscriptionsOfKind, + savePushSubscription, +} from "./push-subscriptions.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describePostgres("notification settings and push subscriptions (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser() { + const id = `ns${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + return id; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("starts with no quiet hours and nudges on, and applies partial updates", async () => { + const userId = await newUser(); + await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ + quietHours: null, + nudges: true, + }); + + await expect( + updateNotificationSettings(prisma, userId, { quietHours: { start: "22:30", end: "07:00" } }), + ).resolves.toEqual({ quietHours: { start: "22:30", end: "07:00" }, nudges: true }); + const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); + expect([row.quietHoursStart, row.quietHoursEnd]).toEqual([22 * 60 + 30, 7 * 60]); + + await expect(updateNotificationSettings(prisma, userId, { nudges: false })).resolves.toEqual({ + quietHours: { start: "22:30", end: "07:00" }, + nudges: false, + }); + await expect(updateNotificationSettings(prisma, userId, { quietHours: null })).resolves.toEqual( + { quietHours: null, nudges: false }, + ); + }); + + it("rejects an empty quiet-hours range in the repository and in the database", async () => { + const userId = await newUser(); + await expect( + updateNotificationSettings(prisma, userId, { quietHours: { start: "08:00", end: "08:00" } }), + ).rejects.toThrow(); + await expect( + prisma.userNotificationSettings.create({ + data: { userId, quietHoursStart: 60, quietHoursEnd: null }, + }), + ).rejects.toThrow(); + await expect( + prisma.userNotificationSettings.create({ + data: { userId, quietHoursStart: 1440, quietHoursEnd: 60 }, + }), + ).rejects.toThrow(); + }); + + it("judges quiet hours in the profile time zone", async () => { + const userId = await newUser(); + await updateNotificationSettings(prisma, userId, { + quietHours: { start: "22:00", end: "07:00" }, + }); + const now = new Date("2026-07-01T21:30:00Z"); + await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toMatchObject({ + quiet: false, + localDate: "2026-07-01", + }); + await prisma.userProfile.create({ data: { userId, timeZone: "Europe/Vienna" } }); + await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toEqual({ + quiet: true, + nudges: true, + localDate: "2026-07-01", + }); + }); + + it("caps nudges per local day, also under concurrency", async () => { + const userId = await newUser(); + const claims = await Promise.all( + Array.from({ length: 6 }, () => claimDailyNudge(prisma, userId, "2026-07-01")), + ); + expect(claims.filter(Boolean)).toHaveLength(3); + await expect(claimDailyNudge(prisma, userId, "2026-07-01")).resolves.toBe(false); + await expect(claimDailyNudge(prisma, userId, "2026-07-02")).resolves.toBe(true); + const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); + expect(row.nudgeCount).toBe(1); + // Claiming never changes the user's settings. + await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ + quietHours: null, + nudges: true, + }); + }); + + it("keeps devices per user, moves a re-registered endpoint and caps the list", async () => { + const owner = await newUser(); + const other = await newUser(); + const endpoint = `https://push.example.test/${randomUUID()}`; + await savePushSubscription(prisma, owner, { kind: "webpush", endpoint, ...keys }); + await savePushSubscription(prisma, owner, { kind: "expo", endpoint: "ExponentPushToken[a]" }); + expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual([ + "webpush", + "expo", + ]); + + // Another user cannot remove the owner's device by naming its endpoint. + await removePushSubscription(prisma, other, endpoint); + expect(await listPushSubscriptions(prisma, owner)).toHaveLength(2); + + // The same browser signed into another account now delivers to that account. + await savePushSubscription(prisma, other, { kind: "webpush", endpoint, ...keys }); + expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual(["expo"]); + expect((await listPushSubscriptions(prisma, other)).map((row) => row.endpoint)).toEqual([ + endpoint, + ]); + + await removePushSubscriptionsOfKind(prisma, owner, "expo"); + expect(await listPushSubscriptions(prisma, owner)).toEqual([]); + + for (let index = 0; index <= PUSH_SUBSCRIPTION_LIMIT; index += 1) { + await savePushSubscription(prisma, owner, { + kind: "expo", + endpoint: `ExponentPushToken[${owner}-${index}]`, + }); + } + const kept = await listPushSubscriptions(prisma, owner); + expect(kept).toHaveLength(PUSH_SUBSCRIPTION_LIMIT); + expect(kept.map((row) => row.endpoint)).not.toContain(`ExponentPushToken[${owner}-0]`); + }); + + it("requires keys for web push and removes everything with the account", async () => { + const userId = await newUser(); + await expect( + prisma.pushSubscription.create({ + data: { userId, kind: "webpush", endpoint: `https://push.example.test/${userId}` }, + }), + ).rejects.toThrow(); + await expect( + prisma.pushSubscription.create({ + data: { userId, kind: "sms", endpoint: `sms-${userId}` }, + }), + ).rejects.toThrow(); + await savePushSubscription(prisma, userId, { + kind: "expo", + endpoint: `ExponentPushToken[${userId}]`, + }); + await updateNotificationSettings(prisma, userId, { nudges: false }); + + await prisma.user.delete({ where: { id: userId } }); + expect(await prisma.pushSubscription.count({ where: { userId } })).toBe(0); + expect(await prisma.userNotificationSettings.count({ where: { userId } })).toBe(0); + }); +}); diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..dbd9559 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,9 +106,11 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/notifications.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", + "packages/db/src/notification-settings.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", @@ -117,6 +119,7 @@ async function main() { "packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts", + "packages/adapters/src/notify-user.postgres.test.ts", ]; // Each app reconciles all durable work in its database, including intentionally // unfinished fixture runs. Clone the pristine migrated schema so one suite diff --git a/packages/testkit/src/notifications.postgres.test.ts b/packages/testkit/src/notifications.postgres.test.ts new file mode 100644 index 0000000..9668046 --- /dev/null +++ b/packages/testkit/src/notifications.postgres.test.ts @@ -0,0 +1,198 @@ +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; +}