Files
HealthLog/src/lib/auth/__tests__/audit.test.ts
T
Marc-André Bombeck 4f09c3f862 chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
2026-05-15 22:59:10 +02:00

284 lines
8.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: {
create: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
lookupIpAsn: vi.fn(),
}));
import { auditLog } from "../audit";
import { prisma } from "@/lib/db";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
const ENTRY = { id: "audit-1" };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue(ENTRY as never);
vi.mocked(prisma.auditLog.update).mockResolvedValue(ENTRY as never);
// Default: no ASN row resolves. Tests that exercise carrier
// resolution override this with `mockReturnValueOnce`.
vi.mocked(lookupIpAsn).mockReturnValue(null);
});
afterEach(() => {
vi.useRealTimers();
});
/**
* Wait for any micro/macrotask `Promise.race` resolutions to flush.
* Real timers are used because the Promise wrapping `setTimeout` resolves
* via the event loop — vi.useFakeTimers() would block the geo-lookup
* Promise from resolving in tests that need it.
*/
async function flush(): Promise<void> {
// Two `setImmediate`-equivalent cycles are enough: one for `Promise.race`
// to resolve and one for the `.then(...)` continuation.
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("auditLog", () => {
it("creates an audit-log row with action, userId, JSON-stringified details, ipAddress and null location", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
await auditLog("auth.login", {
userId: "user-7",
details: { reason: "passkey", browser: "firefox" },
ipAddress: "203.0.113.5",
});
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.auditLog.create).mock.calls[0][0];
expect(args.data).toEqual({
action: "auth.login",
userId: "user-7",
details: JSON.stringify({ reason: "passkey", browser: "firefox" }),
ipAddress: "203.0.113.5",
});
// Location is intentionally not set on insert — it's filled later by the
// geo lookup. The schema column itself is not in the create payload.
expect(args.data).not.toHaveProperty("location");
});
it("persists null userId / null ipAddress / null details when none supplied", async () => {
await auditLog("auth.bearer.failure");
const args = vi.mocked(prisma.auditLog.create).mock.calls[0][0];
expect(args.data).toEqual({
action: "auth.bearer.failure",
userId: null,
details: null,
ipAddress: null,
});
// No IP → no geo lookup enqueued.
expect(lookupIpLocation).not.toHaveBeenCalled();
});
it("for auth.* actions with an ip address, runs geo lookup and updates the row with the resolved location", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
await auditLog("auth.login", {
userId: "user-8",
ipAddress: "8.8.8.8",
});
await flush();
expect(lookupIpLocation).toHaveBeenCalledWith("8.8.8.8");
expect(prisma.auditLog.update).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: { location: "Berlin, DE" },
});
});
it("does NOT update the row when geo lookup resolves to null", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
await auditLog("auth.login", {
userId: "user-9",
ipAddress: "1.2.3.4",
});
await flush();
expect(lookupIpLocation).toHaveBeenCalledOnce();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("does NOT update the row when geo lookup outruns the 3s race timeout", async () => {
// Lookup that never resolves within 3 s — must lose the Promise.race.
let neverResolve: (value: string | null) => void;
vi.mocked(lookupIpLocation).mockImplementationOnce(
() =>
new Promise<string | null>((resolve) => {
neverResolve = resolve;
}),
);
vi.useFakeTimers();
const promise = auditLog("auth.login", {
userId: "user-10",
ipAddress: "1.2.3.4",
});
await promise; // create resolves immediately; race continues in background
// Advance past the 3 s timeout — race resolves to null path.
await vi.advanceTimersByTimeAsync(3001);
vi.useRealTimers();
await flush();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
// Caller stays unblocked — the awaited promise above already resolved.
// Resolve the dangling lookup so the test process exits cleanly.
neverResolve!("Tokyo, JP");
await flush();
});
it("non-auth actions never enqueue a geo lookup, even with an IP", async () => {
await auditLog("medication.create", {
userId: "user-11",
ipAddress: "203.0.113.99",
});
await flush();
expect(prisma.auditLog.create).toHaveBeenCalledOnce();
expect(lookupIpLocation).not.toHaveBeenCalled();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("silently swallows geo-lookup throws — caller stays unblocked, no update emitted", async () => {
vi.mocked(lookupIpLocation).mockRejectedValueOnce(
new Error("upstream 503"),
);
// The auditLog promise itself must NOT reject.
await expect(
auditLog("auth.login", {
userId: "user-12",
ipAddress: "8.8.8.8",
}),
).resolves.toBeUndefined();
await flush();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("silently swallows update() throws (e.g. row deleted between create + update)", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Paris, FR");
vi.mocked(prisma.auditLog.update).mockRejectedValueOnce(
new Error("row gone"),
);
await expect(
auditLog("auth.login", {
userId: "user-13",
ipAddress: "8.8.4.4",
}),
).resolves.toBeUndefined();
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledOnce();
});
// ── v1.4.27 B3 — ASN + carrier resolution ────────────────────────
it("writes asn + carrier alongside location when both resolvers fire", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3320,
carrier: "Deutsche Telekom AG",
});
await auditLog("auth.login.password", {
userId: "user-14",
ipAddress: "84.131.0.1",
});
await flush();
expect(lookupIpAsn).toHaveBeenCalledWith("84.131.0.1");
expect(prisma.auditLog.update).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: {
location: "Berlin, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
},
});
});
it("writes asn + carrier even when the location lookup returns null", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3209,
carrier: "Vodafone GmbH",
});
await auditLog("auth.login.passkey", {
userId: "user-15",
ipAddress: "139.7.0.1",
});
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: {
asn: 3209,
carrier: "Vodafone GmbH",
},
});
});
it("writes location only when the ASN lookup misses", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
await auditLog("auth.login.password", {
userId: "user-16",
ipAddress: "192.0.2.1",
});
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: { location: "Berlin, DE" },
});
});
it("does NOT update when both resolvers miss", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
await auditLog("auth.login.password", {
userId: "user-17",
ipAddress: "192.0.2.2",
});
await flush();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("carries a null carrier through when the ASN row has no organisation", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce({ asn: 64500, carrier: null });
await auditLog("auth.login.password", {
userId: "user-18",
ipAddress: "192.0.2.3",
});
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: { asn: 64500, carrier: null },
});
});
});