fix(integrations): audit WHOOP and Fitbit credential teardown

The Polar and Oura credential-DELETE handlers emit an auditLog entry and park
the integration ledger at disconnected; the WHOOP and Fitbit handlers cleared
the token row and BYO credentials but logged nothing. Credential teardown is a
sensitive op — add auditLog plus markDisconnected to both so the audit trail
and the ledger state stay uniform across every BYO-key integration.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:34:12 +02:00
1 parent 290f46fcda
commit 38a2c7127e
4 files changed
+111

No files matched your search

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
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<typeof vi.fn>
).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");
});
});
+8
View File
@@ -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 });
});
@@ -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<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
@@ -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<typeof vi.fn>).mockResolvedValue(
{},
);
await (DELETE as unknown as () => Promise<unknown>)();
expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop");
});
});
+8
View File
@@ -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 });
});