mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Cap and page the unbounded reads that let a multi-year tenant pull its whole measurement history into memory on hot paths. - extractFeatures: read newest-first with a 6000-row cap (mirrors the Coach snapshot) and reverse to ascending, so a 365-400 day window no longer materialises hundreds of thousands of PULSE / glucose rows per Coach turn and nightly briefing. Full-history extremes still come from readAllTimeExtremes, so the cap never relabels an extreme. - Add a (userId, measuredAt) index on measurements (migration 0210) for the type-less range scans that extractFeatures and the consolidation passes drive; the existing (userId, type, measuredAt) cannot serve them as a prefix. - runConsolidation: keyset-paginate the per-(user, type) source scan on (measuredAt, id) instead of one unbounded findMany, removing the giant single result-set allocation that drove the boot storm. Output is identical; covered by a paging unit test. - Stagger the boot-time backfill enqueues with increasing startAfter offsets so the heavy full-history loads no longer all drain in parallel onto one tenant at startup. Non-boot callers keep their immediate send. - Backups: replace load-all-then-stringify with paged narrow-select reads for the weekly and off-host writers, bounding transient heap. Off-host still includes soft-deleted rows. Covered by a serialize/restore round-trip test. - Drop the per-request auth.bearer.success audit insert; the wide event already records auth_method and user_id, so the polling client no longer drives a write + pool checkout on every request. Failure-path audit stays. - Coach conversation detail: cap the message include to the newest 200 so a long thread no longer decrypts every message on each open; response shape unchanged. - Admin audit-log: floor the scan at the retention horizon so the listing and its count stop sweeping open-ended over the substring filters.
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// --- Mocks must be hoisted before importing the module under test. ---
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
apiToken: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/hmac", () => ({
|
|
hashToken: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/transports", () => ({
|
|
emitIfSampled: vi.fn(),
|
|
}));
|
|
|
|
const headersGet = vi.fn<(name: string) => string | null>();
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: headersGet })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
// --- Imports use the mocked modules above. ---
|
|
|
|
import { requireAuth, HttpError, apiHandler } from "../api-handler";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
|
|
const FAKE_HASH = "deadbeefcafef00d";
|
|
const RAW_TOKEN = "hlk_" + "a".repeat(64);
|
|
|
|
const FAKE_USER = {
|
|
id: "user-1",
|
|
role: "USER" as const,
|
|
username: "testuser",
|
|
email: "user@example.com",
|
|
};
|
|
|
|
function setBearerHeader(value: string | null): void {
|
|
headersGet.mockReset();
|
|
headersGet.mockImplementation((name: string) =>
|
|
name.toLowerCase() === "authorization" ? value : null,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
|
|
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
|
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
|
});
|
|
|
|
describe("requireAuth — Bearer token path", () => {
|
|
it("authenticates a valid Bearer token and returns AuthContext", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-1",
|
|
userId: "user-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
|
|
const ctx = await requireAuth();
|
|
|
|
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
|
|
// V3 audit: pin the where-clause shape — a regression that switches
|
|
// back to raw-token comparison (where: { tokenHash: RAW_TOKEN }) would
|
|
// be a CRITICAL leak of stored hashes. Tests must enforce the hash.
|
|
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { tokenHash: FAKE_HASH } }),
|
|
);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
expect(ctx.session.id).toBe("token-1");
|
|
expect(ctx.session.expiresAt).toEqual(expiresAt);
|
|
|
|
// lastUsedAt refresh is fire-and-forget but should still have been triggered.
|
|
expect(prisma.apiToken.update).toHaveBeenCalledWith({
|
|
where: { id: "token-1" },
|
|
data: { lastUsedAt: expect.any(Date) },
|
|
});
|
|
// v1.25 — the success path no longer writes a per-request audit row; the
|
|
// wide event carries `auth_method: "bearer"` + `user_id` instead.
|
|
expect(auditLog).not.toHaveBeenCalledWith(
|
|
"auth.bearer.success",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("rejects a revoked token with 401", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-2",
|
|
userId: "user-1",
|
|
permissions: [],
|
|
revoked: true,
|
|
expiresAt: null,
|
|
} as never);
|
|
|
|
await expect(requireAuth()).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
} satisfies Partial<HttpError>);
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.bearer.failure",
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({ reason: "revoked" }),
|
|
}),
|
|
);
|
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an expired token with 401", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-3",
|
|
userId: "user-1",
|
|
permissions: [],
|
|
revoked: false,
|
|
expiresAt: new Date(Date.now() - 60_000),
|
|
} as never);
|
|
|
|
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.bearer.failure",
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({ reason: "expired" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects an unknown token with 401", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null);
|
|
|
|
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.bearer.failure",
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({ reason: "unknown_token" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects a Bearer token missing the requested permission with 403", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-4",
|
|
userId: "user-1",
|
|
permissions: ["something:else"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
|
|
await expect(requireAuth("medication:ingest")).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
});
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.bearer.failure",
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({
|
|
reason: "insufficient_permissions",
|
|
required: "medication:ingest",
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// v1.4.25 W10 reconcile (code-review H2): a route that does NOT declare
|
|
// a scope is content with any authenticated token. The previous policy
|
|
// 403'd every narrow-scope token here and broke `/api/measurements/by-
|
|
// external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`,
|
|
// and `/api/dashboard/glp1` — all four were called without a scope.
|
|
it("accepts a narrow-scope Bearer token when the route did not declare a scope", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5a",
|
|
userId: "user-1",
|
|
permissions: ["medication:ingest"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
|
|
const ctx = await requireAuth();
|
|
expect(ctx.user.id).toBe("user-1");
|
|
expect(ctx.session.id).toBe("token-5a");
|
|
// v1.25 — no per-request success audit row on the accept path.
|
|
expect(auditLog).not.toHaveBeenCalledWith(
|
|
"auth.bearer.success",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("accepts a wildcard Bearer token when the route did not declare a scope", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5b",
|
|
userId: "user-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
|
|
const ctx = await requireAuth();
|
|
expect(ctx.user.id).toBe("user-1");
|
|
expect(ctx.session.id).toBe("token-5b");
|
|
});
|
|
|
|
it("accepts a narrow-scope Bearer token when the route's required scope matches", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "token-5c",
|
|
userId: "user-1",
|
|
permissions: ["medication:ingest"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
|
|
const ctx = await requireAuth("medication:ingest");
|
|
expect(ctx.user.id).toBe("user-1");
|
|
expect(ctx.session.id).toBe("token-5c");
|
|
});
|
|
|
|
it("returns 401 when neither cookie nor Bearer is present", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
setBearerHeader(null);
|
|
|
|
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
|
|
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
|
|
// Drive the FULL apiHandler path so the request method is in event context —
|
|
// the H1 check reads it to allow safe reads but block writes.
|
|
const route = apiHandler(async (req: Request) => {
|
|
await requireAuth();
|
|
return Response.json({ ok: true, method: req.method });
|
|
});
|
|
|
|
function call(method: string): Promise<Response> {
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
return route(
|
|
new Request("https://health.example/api/measurements/batch", {
|
|
method,
|
|
}) as never,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
|
});
|
|
|
|
function mockHealthReadToken(): void {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "mcp-token",
|
|
userId: "user-1",
|
|
permissions: ["health:read"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
}
|
|
|
|
it("accepts a health:read token on a GET read route", async () => {
|
|
mockHealthReadToken();
|
|
const res = await call("GET");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects a health:read token on POST with 403", async () => {
|
|
mockHealthReadToken();
|
|
const res = await call("POST");
|
|
expect(res.status).toBe(403);
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.bearer.failure",
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({
|
|
reason: "mcp_audience_write_blocked",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects a health:read token on DELETE with 403", async () => {
|
|
mockHealthReadToken();
|
|
const res = await call("DELETE");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "wild",
|
|
userId: "user-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
const res = await call("POST");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => {
|
|
// A write-scoped MCP token is STILL audience-bound: its `health:write`
|
|
// grant admits the confirmed in-process `/mcp` write tools, never a REST
|
|
// write/delete. So it gets the same 403 as a read-only MCP token here.
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "rw",
|
|
userId: "user-1",
|
|
permissions: ["health:read", "health:write"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
const res = await call("POST");
|
|
expect(res.status).toBe(403);
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"auth.bearer.failure",
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({
|
|
reason: "mcp_audience_write_blocked",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts a health:read+write token on a GET read route", async () => {
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "rw",
|
|
userId: "user-1",
|
|
permissions: ["health:read", "health:write"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
const res = await call("GET");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("requireAuth — cookie path remains intact", () => {
|
|
it("returns the session payload without consulting the Bearer header", async () => {
|
|
const cookieSession = {
|
|
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
|
user: { ...FAKE_USER, role: "USER" as const },
|
|
};
|
|
vi.mocked(getSession).mockResolvedValue(cookieSession as never);
|
|
// Even if a Bearer header is present, the cookie wins (existing behaviour).
|
|
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
|
|
|
const ctx = await requireAuth("medication:ingest");
|
|
|
|
expect(ctx).toEqual(cookieSession);
|
|
// Cookie short-circuits before any token logic runs.
|
|
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
expect(hashToken).not.toHaveBeenCalled();
|
|
expect(auditLog).not.toHaveBeenCalled();
|
|
});
|
|
});
|