diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 63d2800a7..f2f9a6201 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -9,9 +9,17 @@ vi.mock("@/lib/db", () => ({ create: vi.fn(), update: vi.fn(), }, + notificationChannel: { + upsert: vi.fn(), + }, }, })); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc(${s})`, + decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -56,6 +64,9 @@ beforeEach(() => { vi.mocked(prisma.device.findFirst).mockResolvedValue(null); vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never); vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never); + vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({ + id: "ch-1", + } as never); }); describe("POST /api/devices", () => { @@ -182,6 +193,7 @@ describe("POST /api/devices", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.device.findFirst).mockResolvedValue({ id: "other-dev", + userId: "other-user", } as never); const res = await POST( req({ @@ -195,4 +207,27 @@ describe("POST /api/devices", () => { expect(prisma.device.create).not.toHaveBeenCalled(); expect(prisma.device.update).not.toHaveBeenCalled(); }); + + it("falls through idempotently when the apnsToken already belongs to the same user", async () => { + // v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used + // to filter `NOT: { userId }` so it skipped same-user collisions + // entirely, letting `findMany({ apnsToken })` fan a single push + // out to two Device rows. The guard now reads `userId` and only + // rejects cross-user collisions; same-user collisions fall + // through to the legacy-token upsert path. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.device.findFirst).mockResolvedValue({ + id: "self-dev", + userId: "user-1", + } as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsToken: "deadbeef".repeat(8), + apnsEnvironment: "sandbox", + }), + ); + expect(res.status).toBe(201); + }); }); diff --git a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx index bb896d084..297f16f12 100644 --- a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx @@ -85,4 +85,18 @@ describe("MeasurementList — APPLE_HEALTH badge", () => { const html = render("de"); expect(html).toContain("Apple Health"); }); + + it("paints the source badge in BOTH the desktop table and the mobile card", () => { + // v1.4.23 W6 design review HIGH-1: the badge previously rendered + // only inside the desktop `