mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
54865075a5
commit
13977bcc70
2 files changed
+171
No files matched your search
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user