mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
A transient database fault during logout must still clear the session cookie; record the delete failure on the wide event instead of throwing before the cookie is cleared, so a user can never stay authenticated after logging out. Remove the batch measurement future/past instant refine: cumulative HealthKit rows carry an end-of-day endDate that is legitimately hours ahead for the current day, and a whole-array parse would 422 the entire sync. Note the workout batch's deferred Withings path must write its source directly.
136 lines
4.1 KiB
TypeScript
136 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const cookieState = vi.hoisted(() => ({
|
|
sessionId: undefined as string | undefined,
|
|
delete: vi.fn(),
|
|
set: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
session: {
|
|
findUnique: vi.fn(),
|
|
delete: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
apiToken: {
|
|
updateMany: vi.fn(),
|
|
},
|
|
refreshToken: {
|
|
updateMany: vi.fn(),
|
|
},
|
|
trustedDevice: {
|
|
deleteMany: vi.fn(),
|
|
},
|
|
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
getEvent: () => undefined,
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "healthlog_session" && cookieState.sessionId
|
|
? { value: cookieState.sessionId }
|
|
: undefined,
|
|
set: cookieState.set,
|
|
delete: cookieState.delete,
|
|
})),
|
|
}));
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession, destroyAllSessions, destroySession } from "../session";
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
cookieState.sessionId = "sess-expired";
|
|
});
|
|
|
|
describe("getSession", () => {
|
|
it("swallows expired-session delete races and clears cookies", async () => {
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
id: "sess-expired",
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
user: { id: "user-1" },
|
|
} as never);
|
|
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
|
|
new Error("already deleted") as never,
|
|
);
|
|
|
|
await expect(getSession()).resolves.toBeNull();
|
|
|
|
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
|
where: { id: "sess-expired" },
|
|
});
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
|
});
|
|
});
|
|
|
|
describe("destroySession", () => {
|
|
it("treats an already-deleted session row (P2025) as an idempotent logout", async () => {
|
|
cookieState.sessionId = "sess-gone";
|
|
vi.mocked(prisma.session.delete).mockRejectedValue(
|
|
Object.assign(new Error("record not found"), { code: "P2025" }) as never,
|
|
);
|
|
|
|
await expect(destroySession()).resolves.toBeUndefined();
|
|
|
|
expect(prisma.session.delete).toHaveBeenCalledWith({
|
|
where: { id: "sess-gone" },
|
|
});
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
|
});
|
|
|
|
it("clears the cookie even when the row delete fails on a transient fault", async () => {
|
|
cookieState.sessionId = "sess-live";
|
|
vi.mocked(prisma.session.delete).mockRejectedValue(
|
|
Object.assign(new Error("connection reset"), { code: "P1001" }) as never,
|
|
);
|
|
|
|
// A non-P2025 delete failure is recorded on the wide event, not thrown,
|
|
// so logout never leaves the client authenticated with the cookie intact.
|
|
await expect(destroySession()).resolves.toBeUndefined();
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
|
});
|
|
});
|
|
|
|
describe("destroyAllSessions", () => {
|
|
it("revokes web sessions, API tokens, and refresh tokens for the user", async () => {
|
|
vi.mocked(prisma.session.deleteMany).mockResolvedValue({
|
|
count: 2,
|
|
} as never);
|
|
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({
|
|
count: 3,
|
|
} as never);
|
|
|
|
await destroyAllSessions("user-rotated");
|
|
|
|
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
|
where: { userId: "user-rotated" },
|
|
});
|
|
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith({
|
|
where: { userId: "user-rotated", revoked: false },
|
|
data: { revoked: true },
|
|
});
|
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
|
|
where: { userId: "user-rotated", revokedAt: null },
|
|
data: { revokedAt: expect.any(Date) },
|
|
});
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|