mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user