diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts new file mode 100644 index 000000000..44160d2ec --- /dev/null +++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + fitbitConnection: { delete: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + safeJson: async (req: NextRequest) => { + try { + return { data: await req.json(), error: null }; + } catch { + return { data: null, error: { status: 400 } }; + } + }, +})); + +import { PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; + +const userUpdate = prisma.user.update as ReturnType; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/fitbit/credentials", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("/api/fitbit/credentials", () => { + beforeEach(() => vi.clearAllMocks()); + + it("PUT 422s on missing fields", async () => { + const res = (await ( + PUT as unknown as (r: NextRequest) => Promise<{ status: number }> + )(req({ clientId: "" }))) as { status: number }; + expect(res.status).toBe(422); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("DELETE clears credentials, audits the teardown, and parks the ledger (04-L1 parity)", async () => { + userUpdate.mockResolvedValue({}); + ( + prisma.fitbitConnection.delete as ReturnType + ).mockResolvedValue({}); + const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)()) + .data as { deleted: boolean }; + expect(res.deleted).toBe(true); + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { + fitbitClientIdEncrypted: null, + fitbitClientSecretEncrypted: null, + }, + }); + expect(auditLog).toHaveBeenCalledWith("fitbit.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "fitbit"); + }); +}); diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index 1a57ae5fe..9ecb39a6e 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -1,8 +1,10 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; +import { markDisconnected } from "@/lib/integrations/status"; import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -60,6 +62,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { /** * Delete Fitbit credentials and the active connection. + * + * Audits the teardown and parks the integration ledger at `disconnected` for + * parity with the Polar / Oura credential-DELETE so a sensitive op leaves a + * uniform audit trail and the status snapshot does not linger at a stale state. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); @@ -76,6 +82,8 @@ export const DELETE = apiHandler(async () => { fitbitClientSecretEncrypted: null, }, }); + await auditLog("fitbit.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "fitbit"); return apiSuccess({ deleted: true }); }); diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts index 0816df18c..ec79e0cd1 100644 --- a/src/app/api/whoop/credentials/__tests__/route.test.ts +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), @@ -30,6 +32,8 @@ vi.mock("@/lib/api-response", () => ({ import { GET, PUT, DELETE } from "../route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; const userFind = prisma.user.findUnique as ReturnType; const userUpdate = prisma.user.update as ReturnType; @@ -91,4 +95,16 @@ describe("/api/whoop/credentials", () => { }, }); }); + + it("DELETE audits the teardown and parks the ledger (04-L1 parity)", async () => { + userUpdate.mockResolvedValue({}); + (prisma.whoopConnection.delete as ReturnType).mockResolvedValue( + {}, + ); + await (DELETE as unknown as () => Promise)(); + expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop"); + }); }); diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index 618d87405..6675fa11e 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -1,8 +1,10 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; +import { markDisconnected } from "@/lib/integrations/status"; import { whoopCredentialsSchema } from "@/lib/validations/whoop"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -58,6 +60,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { /** * Delete WHOOP credentials and the active connection. + * + * Audits the teardown and parks the integration ledger at `disconnected` for + * parity with the Polar / Oura credential-DELETE so a sensitive op leaves a + * uniform audit trail and the status snapshot does not linger at a stale state. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); @@ -74,6 +80,8 @@ export const DELETE = apiHandler(async () => { whoopClientSecretEncrypted: null, }, }); + await auditLog("whoop.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "whoop"); return apiSuccess({ deleted: true }); });