diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index a498b2a97..04af899b5 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -28,6 +28,7 @@ import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { encrypt } from "@/lib/crypto"; const deviceSchema = z .object({ @@ -157,6 +158,28 @@ export const POST = apiHandler(async (request: NextRequest) => { id = created.id; } + // Auto-create the APNS NotificationChannel row when the device + // registers with an apnsToken. Without this row the dispatcher's APNS + // branch (`dispatcher.ts:41-49`) never fires for the user — every + // production iOS install would otherwise need a manual settings + // toggle that doesn't exist. Mirrors the legacy Telegram on-first- + // dispatch auto-migration, but eager so the very first reminder + // after device registration already routes through APNs. Config is + // an encrypted empty record by design — the per-device token + + // environment live on the Device row. + if (apnsToken) { + await prisma.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "APNS" } }, + create: { + userId: user.id, + type: "APNS", + enabled: true, + config: encrypt("{}"), + }, + update: {}, + }); + } + await auditLog("devices.register", { userId: user.id, details: { diff --git a/tests/integration/device-register.test.ts b/tests/integration/device-register.test.ts new file mode 100644 index 000000000..104b40271 --- /dev/null +++ b/tests/integration/device-register.test.ts @@ -0,0 +1,148 @@ +/** + * v1.4.23 W6 follow-up — `POST /api/devices` integration test. + * + * Locks in three guarantees: + * + * 1. Registering a device with an `apnsToken` auto-creates a + * `NotificationChannel { type: "APNS" }` row for the user. Without + * this row the dispatcher's APNS branch never fires (HIGH-1 in the + * v1.4.23 W6 code review). + * 2. Registering without an `apnsToken` does NOT create the channel — + * the iOS client may upsert device metadata before APNs grants a + * token, and we must not enable APNs delivery prematurely. + * 3. Registering a SECOND device with the same `apnsToken` for the + * same user is idempotent — the channel row stays at one. The + * same-user collision path returns 200 (upsert) rather than 409 + * (the cross-user hijack guard). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username, email: `${username}@example.test` }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +function buildRequest(body: Record) { + return new Request("http://localhost/api/devices", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/devices", () => { + it("auto-upserts a NotificationChannel { type: APNS } when apnsToken is supplied", async () => { + const user = await seedSession("device-apns-channel"); + const { POST } = await import("@/app/api/devices/route"); + + const req = buildRequest({ + token: "legacy-token-1", + bundleId: "io.healthlog.ios", + apnsToken: "abcdef0123456789", + apnsEnvironment: "sandbox", + }); + const res = await (POST as (r: import("next/server").NextRequest) => Promise)(req); + expect(res.status).toBe(201); + + const channel = await getPrismaClient().notificationChannel.findFirst({ + where: { userId: user.id, type: "APNS" }, + }); + expect(channel).not.toBeNull(); + expect(channel?.enabled).toBe(true); + }); + + it("does NOT create an APNS channel when apnsToken is omitted", async () => { + const user = await seedSession("device-no-apns"); + const { POST } = await import("@/app/api/devices/route"); + + const req = buildRequest({ + token: "legacy-token-2", + bundleId: "io.healthlog.ios", + }); + const res = await (POST as (r: import("next/server").NextRequest) => Promise)(req); + expect(res.status).toBe(201); + + const channel = await getPrismaClient().notificationChannel.findFirst({ + where: { userId: user.id, type: "APNS" }, + }); + expect(channel).toBeNull(); + }); + + it("re-registering the same apnsToken for the same user keeps the channel row at one", async () => { + const user = await seedSession("device-apns-idempotent"); + const { POST } = await import("@/app/api/devices/route"); + + const first = await (POST as (r: import("next/server").NextRequest) => Promise)( + buildRequest({ + token: "legacy-token-3", + bundleId: "io.healthlog.ios", + apnsToken: "deadbeef", + apnsEnvironment: "sandbox", + }), + ); + expect(first.status).toBe(201); + + const second = await (POST as (r: import("next/server").NextRequest) => Promise)( + buildRequest({ + token: "legacy-token-3", + bundleId: "io.healthlog.ios", + apnsToken: "deadbeef", + apnsEnvironment: "sandbox", + }), + ); + expect(second.status).toBe(201); + + const channels = await getPrismaClient().notificationChannel.findMany({ + where: { userId: user.id, type: "APNS" }, + }); + expect(channels).toHaveLength(1); + }); +});