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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:34:14 +00:00
parent ebcbf4c05f
commit d63f7b3bf1
No known key found for this signature in database
9 changed files with 1004 additions and 1 deletions

View File

@ -166,8 +166,12 @@ SMTP_URL=
EMAIL_FROM= EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console. # Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR= 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_PUBLIC_KEY=
VAPID_PRIVATE_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). # Structured logs to stdout (json in production, pretty in development unless overridden).
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT= LOG_FORMAT=

View File

@ -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<void>;
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<ReturnType<typeof newUser>>, 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) };
}

View File

@ -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);
});
});

View File

@ -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<string, string>();
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");
});
});

View File

@ -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);
});
});

View File

@ -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 ( ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK (
("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL)
OR ( 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 "quietHoursEnd" BETWEEN 0 AND 1439
AND "quietHoursStart" <> "quietHoursEnd" AND "quietHoursStart" <> "quietHoursEnd"
) )

View File

@ -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<void>;
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);
});
});

View File

@ -106,9 +106,11 @@ async function main() {
"packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/bot-secrets.test.ts",
"packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/delegation.postgres.test.ts",
"packages/testkit/src/user-profile.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/space-membership.postgres.test.ts",
"packages/db/src/main-agent.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts",
"packages/db/src/user-profile.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/db/src/messaging.postgres.test.ts",
"packages/memory/src/commit.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts",
"packages/adapters/src/wakeup.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/cloud-agent.postgres.test.ts",
"packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts",
"packages/adapters/src/profile-consolidation.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 // Each app reconciles all durable work in its database, including intentionally
// unfinished fixture runs. Clone the pristine migrated schema so one suite // unfinished fixture runs. Clone the pristine migrated schema so one suite

View File

@ -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<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
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<NotificationSettings>(app, owner, "notifications/settings/get"),
).resolves.toEqual({ quietHours: null, nudges: true });
await expect(
rpc<NotificationSettings>(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<NotificationSettings>(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<NotificationSettings>(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<WebPushConfig>(app, cookie, "notifications/webPush/config");
expect(first.available).toBe(true);
expect(first.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/);
await expect(rpc<WebPushConfig>(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<T>(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;
}