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:
parent
ebcbf4c05f
commit
d63f7b3bf1
@ -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=
|
||||||
|
|||||||
216
packages/adapters/src/notify-user.postgres.test.ts
Normal file
216
packages/adapters/src/notify-user.postgres.test.ts
Normal 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) };
|
||||||
|
}
|
||||||
179
packages/adapters/src/push-notifications.test.ts
Normal file
179
packages/adapters/src/push-notifications.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
packages/adapters/src/web-push.test.ts
Normal file
155
packages/adapters/src/web-push.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
62
packages/core/src/quiet-hours.test.ts
Normal file
62
packages/core/src/quiet-hours.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
184
packages/db/src/notification-settings.postgres.test.ts
Normal file
184
packages/db/src/notification-settings.postgres.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
|||||||
198
packages/testkit/src/notifications.postgres.test.ts
Normal file
198
packages/testkit/src/notifications.postgres.test.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user