fix(devices): auto-upsert APNS NotificationChannel on registration

The dispatcher's APNS branch only fires when a NotificationChannel of
type APNS exists for the user, but no production code path created one.
Every iOS install needed a manual settings toggle that doesn't exist,
making the v1.4.23 W3 APNs cascade unreachable in real traffic.

When `POST /api/devices` accepts an apnsToken, eagerly upsert the
channel row with an encrypted empty config — the per-device token and
environment live on the Device row by design, so the channel only
exists to flip the dispatcher's gate. Mirrors the legacy Telegram
on-first-dispatch auto-migration but eager so the very first reminder
after registration routes through APNs.

Adds an integration test that registers a device with apnsToken and
asserts the channel row exists, registers without one and asserts
absence, and re-registers the same apnsToken and asserts idempotency
(channel count stays at one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 02:12:02 +02:00
1 parent 54865075a5
commit 13977bcc70
2 files changed
+171

No files matched your search

+23
View File
@@ -28,6 +28,7 @@ import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context"; import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit"; import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
const deviceSchema = z const deviceSchema = z
.object({ .object({
@@ -157,6 +158,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
id = created.id; 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", { await auditLog("devices.register", {
userId: user.id, userId: user.id,
details: { details: {
+148
View File
@@ -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<string, unknown>) {
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<Response>)(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<Response>)(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<Response>)(
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<Response>)(
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);
});
});