diff --git a/tests/integration/admin-reset-password.test.ts b/tests/integration/admin-reset-password.test.ts new file mode 100644 index 000000000..94ad34014 --- /dev/null +++ b/tests/integration/admin-reset-password.test.ts @@ -0,0 +1,258 @@ +/** + * v1.4.35 — integration coverage for + * `POST /api/admin/users/[id]/reset-password`. + * + * The admin reset-password path runs through the same `destroyAllSessions` + * helper as the self-serve change route, but acts on a different user's + * credentials. F-1 in the test coverage audit flagged it alongside the + * self-serve route as a critical-path gap. This file pins: + * + * - 403 when the caller is not an admin (and credentials stay intact) + * - 401 when the caller is unauthenticated + * - 404 when the target user does not exist + * - 422 when the new password is weak + * - happy path wipes the target user's sessions, API tokens, refresh + * tokens and writes an `admin.user.reset-password` audit row + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const STRONG_NEW_PW = "Adm1nReset!P@ssw0rd789"; + +async function seedCaller(role: "ADMIN" | "USER"): Promise<{ + callerId: string; + sessionId: string; +}> { + const prisma = getPrismaClient(); + const caller = await prisma.user.create({ + data: { + username: role === "ADMIN" ? "admin-caller" : "regular-caller", + email: `${role.toLowerCase()}-caller@example.test`, + role, + }, + }); + const session = await prisma.session.create({ + data: { + userId: caller.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return { callerId: caller.id, sessionId: session.id }; +} + +async function seedTargetUser(): Promise { + const { hashPassword } = await import("@/lib/auth/password"); + const prisma = getPrismaClient(); + const target = await prisma.user.create({ + data: { + username: "reset-target", + email: "target@example.test", + role: "USER", + passwordHash: await hashPassword("OldTargetPassword!12345"), + }, + }); + return target.id; +} + +function makeRequest(targetId: string, body: unknown): NextRequest { + return new NextRequest( + `http://localhost/api/admin/users/${targetId}/reset-password`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +function paramsFor(id: string) { + return { params: Promise.resolve({ id }) }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => { + it("returns 401 when the caller is unauthenticated", async () => { + const targetId = await seedTargetUser(); + + const { POST } = await import( + "@/app/api/admin/users/[id]/reset-password/route" + ); + const res = await POST( + makeRequest(targetId, { password: STRONG_NEW_PW }), + paramsFor(targetId), + ); + + expect(res.status).toBe(401); + }); + + it("returns 403 when the caller is not an admin", async () => { + await seedCaller("USER"); + const targetId = await seedTargetUser(); + const prisma = getPrismaClient(); + + const targetSession = await prisma.session.create({ + data: { + userId: targetId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + + const { POST } = await import( + "@/app/api/admin/users/[id]/reset-password/route" + ); + const res = await POST( + makeRequest(targetId, { password: STRONG_NEW_PW }), + paramsFor(targetId), + ); + + expect(res.status).toBe(403); + + // Target session must NOT have been wiped on a denied call. + const stillThere = await prisma.session.findUnique({ + where: { id: targetSession.id }, + }); + expect(stillThere).not.toBeNull(); + }); + + it("returns 404 when the target user does not exist", async () => { + await seedCaller("ADMIN"); + + const { POST } = await import( + "@/app/api/admin/users/[id]/reset-password/route" + ); + const res = await POST( + makeRequest("user-does-not-exist", { password: STRONG_NEW_PW }), + paramsFor("user-does-not-exist"), + ); + + expect(res.status).toBe(404); + }); + + it("returns 422 on a weak new password and does not touch the hash", async () => { + await seedCaller("ADMIN"); + const targetId = await seedTargetUser(); + const prisma = getPrismaClient(); + + const before = await prisma.user.findUnique({ where: { id: targetId } }); + + const { POST } = await import( + "@/app/api/admin/users/[id]/reset-password/route" + ); + const res = await POST( + makeRequest(targetId, { password: "password1234" }), + paramsFor(targetId), + ); + + expect(res.status).toBe(422); + + const after = await prisma.user.findUnique({ where: { id: targetId } }); + expect(after!.passwordHash).toBe(before!.passwordHash); + }); + + it("wipes the target user's sessions, API tokens and refresh tokens and writes an audit row", async () => { + const { callerId } = await seedCaller("ADMIN"); + const targetId = await seedTargetUser(); + const prisma = getPrismaClient(); + + const targetSession = await prisma.session.create({ + data: { + userId: targetId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + const targetApiToken = await prisma.apiToken.create({ + data: { + userId: targetId, + name: "iOS", + tokenHash: "hashed-target-token", + revoked: false, + }, + }); + const targetRefresh = await prisma.refreshToken.create({ + data: { + userId: targetId, + tokenHash: "hashed-target-refresh", + expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), + revokedAt: null, + }, + }); + + const { POST } = await import( + "@/app/api/admin/users/[id]/reset-password/route" + ); + const res = await POST( + makeRequest(targetId, { password: STRONG_NEW_PW }), + paramsFor(targetId), + ); + + expect(res.status).toBe(200); + + // Target web session deleted. + const stillThere = await prisma.session.findUnique({ + where: { id: targetSession.id }, + }); + expect(stillThere).toBeNull(); + + // API token revoked in place — audit trail survives. + const refreshedToken = await prisma.apiToken.findUnique({ + where: { id: targetApiToken.id }, + }); + expect(refreshedToken?.revoked).toBe(true); + + // Refresh token revoked in place. + const refreshedRefresh = await prisma.refreshToken.findUnique({ + where: { id: targetRefresh.id }, + }); + expect(refreshedRefresh?.revokedAt).not.toBeNull(); + + // Audit row exists and is attributed to the admin caller. + const audit = await prisma.auditLog.findFirst({ + where: { action: "admin.user.reset-password" }, + }); + expect(audit).not.toBeNull(); + expect(audit?.userId).toBe(callerId); + expect(audit?.details).toContain(targetId); + + // New password verifies against the persisted hash. + const { verifyPassword } = await import("@/lib/auth/password"); + const dbUser = await prisma.user.findUnique({ where: { id: targetId } }); + expect(dbUser?.passwordHash).toBeTruthy(); + expect(await verifyPassword(dbUser!.passwordHash!, STRONG_NEW_PW)).toBe( + true, + ); + }); +}); diff --git a/tests/integration/auth-password-change.test.ts b/tests/integration/auth-password-change.test.ts new file mode 100644 index 000000000..0d8c6c30f --- /dev/null +++ b/tests/integration/auth-password-change.test.ts @@ -0,0 +1,274 @@ +/** + * v1.4.35 — integration coverage for `POST /api/auth/password`. + * + * The v1.4.34.3 token-wipe directive routed every password rotation + * through `destroyAllSessions(userId)`, which now revokes web sessions, + * API tokens, and refresh tokens in a single transaction. The route had + * zero route-level coverage despite that change — F-1 in the test + * coverage audit. This file pins the contract end-to-end against real + * Postgres: + * + * - happy path with a strong new password returns 200, rotates the + * session cookie, and wipes every sibling credential on the user + * - wrong current password returns 401 and leaves credentials intact + * - identical current + new password returns 422 + * - weak new password returns 422 and does not touch the hash + * - a stale session cookie carried by a second request returns 401 + * on a session-protected endpoint after the rotation + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const STRONG_CURRENT = "OldStrongP@ssw0rd!123abc"; +const STRONG_NEW = "NewStrongP@ssw0rd!456xyz"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedUserWithSession(): Promise<{ + userId: string; + sessionId: string; +}> { + const { hashPassword } = await import("@/lib/auth/password"); + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "pw-change", + email: "pw-change@example.test", + role: "USER", + passwordHash: await hashPassword(STRONG_CURRENT), + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return { userId: user.id, sessionId: session.id }; +} + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/auth/password (real Postgres)", () => { + it("rotates the session cookie and wipes every sibling credential", async () => { + const { userId, sessionId } = await seedUserWithSession(); + const prisma = getPrismaClient(); + + // Seed a sibling web session, a long-lived API token, and a refresh + // token — the rotation must invalidate all three. + const siblingSession = await prisma.session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + const apiToken = await prisma.apiToken.create({ + data: { + userId, + name: "iOS app", + tokenHash: "hashed-token-1", + revoked: false, + }, + }); + const refreshToken = await prisma.refreshToken.create({ + data: { + userId, + tokenHash: "hashed-refresh-1", + expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), + revokedAt: null, + }, + }); + + const { POST } = await import("@/app/api/auth/password/route"); + const res = await POST( + makeRequest({ + currentPassword: STRONG_CURRENT, + newPassword: STRONG_NEW, + confirmPassword: STRONG_NEW, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { changed: boolean } }; + expect(body.data.changed).toBe(true); + + // Every prior web session is gone — including the one we logged in + // with — and a brand-new session row exists with the cookie pointing + // at it (the route calls createSession after destroyAllSessions). + const remainingSessions = await prisma.session.findMany({ + where: { userId }, + }); + expect(remainingSessions).toHaveLength(1); + expect(remainingSessions[0].id).not.toBe(sessionId); + expect(remainingSessions[0].id).not.toBe(siblingSession.id); + expect(cookieJar.get("healthlog_session")).toBe(remainingSessions[0].id); + + // The API token is flipped to revoked rather than deleted so the + // audit trail survives. + const refreshedToken = await prisma.apiToken.findUnique({ + where: { id: apiToken.id }, + }); + expect(refreshedToken?.revoked).toBe(true); + + const refreshedRefresh = await prisma.refreshToken.findUnique({ + where: { id: refreshToken.id }, + }); + expect(refreshedRefresh?.revokedAt).not.toBeNull(); + + // The password hash on disk is actually a fresh hash for the new + // value — verifyPassword roundtrips. + const { verifyPassword } = await import("@/lib/auth/password"); + const dbUser = await prisma.user.findUnique({ where: { id: userId } }); + expect(dbUser?.passwordHash).toBeTruthy(); + expect(await verifyPassword(dbUser!.passwordHash!, STRONG_NEW)).toBe(true); + + // An audit row was written for the change action. + const audit = await prisma.auditLog.findFirst({ + where: { userId, action: "auth.password.change" }, + }); + expect(audit).not.toBeNull(); + }); + + it("returns 401 and leaves credentials intact on a wrong current password", async () => { + const { userId } = await seedUserWithSession(); + const prisma = getPrismaClient(); + + const apiToken = await prisma.apiToken.create({ + data: { + userId, + name: "iOS app", + tokenHash: "hashed-token-2", + revoked: false, + }, + }); + + const { POST } = await import("@/app/api/auth/password/route"); + const res = await POST( + makeRequest({ + currentPassword: "WrongPasswordValue!1", + newPassword: STRONG_NEW, + confirmPassword: STRONG_NEW, + }), + ); + + expect(res.status).toBe(401); + + // Sibling credentials are intact. + const stillActive = await prisma.apiToken.findUnique({ + where: { id: apiToken.id }, + }); + expect(stillActive?.revoked).toBe(false); + + // The password hash is unchanged — the old password still verifies. + const { verifyPassword } = await import("@/lib/auth/password"); + const dbUser = await prisma.user.findUnique({ where: { id: userId } }); + expect(await verifyPassword(dbUser!.passwordHash!, STRONG_CURRENT)).toBe( + true, + ); + }); + + it("returns 422 when the new password matches the current password", async () => { + await seedUserWithSession(); + + const { POST } = await import("@/app/api/auth/password/route"); + const res = await POST( + makeRequest({ + currentPassword: STRONG_CURRENT, + newPassword: STRONG_CURRENT, + confirmPassword: STRONG_CURRENT, + }), + ); + + expect(res.status).toBe(422); + }); + + it("returns 422 on a weak new password and does not touch the hash", async () => { + const { userId } = await seedUserWithSession(); + const prisma = getPrismaClient(); + + const before = await prisma.user.findUnique({ where: { id: userId } }); + const beforeHash = before!.passwordHash; + + const { POST } = await import("@/app/api/auth/password/route"); + // 12 chars but trivially weak — zxcvbn scores 0/1. + const res = await POST( + makeRequest({ + currentPassword: STRONG_CURRENT, + newPassword: "password1234", + confirmPassword: "password1234", + }), + ); + + expect(res.status).toBe(422); + + const after = await prisma.user.findUnique({ where: { id: userId } }); + expect(after!.passwordHash).toBe(beforeHash); + }); + + it("stale cookie carried in a second request lands unauthenticated on a protected endpoint", async () => { + const { sessionId } = await seedUserWithSession(); + + const { POST } = await import("@/app/api/auth/password/route"); + const rotateRes = await POST( + makeRequest({ + currentPassword: STRONG_CURRENT, + newPassword: STRONG_NEW, + confirmPassword: STRONG_NEW, + }), + ); + expect(rotateRes.status).toBe(200); + + // Reset the cookie jar to the stolen / stale cookie value the + // attacker would still hold after the legitimate user changed + // their password. The route handler must see no session. + cookieJar.clear(); + cookieJar.set("healthlog_session", sessionId); + + // Hit a session-protected endpoint — `apiHandler` raises HttpError(401) + // which the wrapper converts to a 401 JSON response. + const { GET } = await import("@/app/api/auth/me/route"); + const meRes = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/auth/me")); + expect(meRes.status).toBe(401); + }); +}); diff --git a/tests/integration/cache-invalidation-coverage.test.ts b/tests/integration/cache-invalidation-coverage.test.ts new file mode 100644 index 000000000..51a963c50 --- /dev/null +++ b/tests/integration/cache-invalidation-coverage.test.ts @@ -0,0 +1,267 @@ +/** + * v1.4.35 — invalidation matrix coverage for the IW-G server cache. + * + * The audit (F-6) found 16 of 18 write surfaces had no + * invalidation-verification test. A missed `invalidateUser*` call on + * any write path silently leaks stale dashboard reads for up to the + * TTL. This file grids the contract: for each major write surface we + * prime the matching cache, run the mutation, and assert the cache is + * empty for the user's key. The intent is breadth — one assertion per + * write × cache pair — not depth. + * + * Cross-user isolation is also pinned: a write by user A must not + * evict user B's cache slot for the same cache. + * + * Reference: `src/lib/cache/invalidate.ts` (the helper module that + * encodes the write→cache fan-out). + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +// `ensureDbCompatibility` is intentionally LEFT REAL for this file — +// the admin app-settings PUT touches the `app_settings` table whose +// columns land via that helper rather than the migration set in +// `prisma/migrations/`. Mocking it out (as the other integration tests +// do for speed) would make the upsert reference a `default_locale` +// column the test container doesn't carry. Letting the real helper +// run inside the route handler keeps the schema consistent for the +// admin-settings test case in this file. +// +// `isolate: false` in `vitest.integration.config.mts` shares module +// state across files, so a sibling test file that mocks db-compat +// would leak its mock here. The beforeEach below explicitly imports +// the actual implementation through `vi.importActual` and runs it on +// each test setup to defeat that bleed-through. + +const PRIMARY_USER_ID = "user-cache-invalidation-primary"; +const OTHER_USER_ID = "user-cache-invalidation-other"; + +async function seedUsers() { + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { + id: PRIMARY_USER_ID, + username: "cache-primary", + email: "cache-primary@example.test", + role: "USER", + }, + { + id: OTHER_USER_ID, + username: "cache-other", + email: "cache-other@example.test", + role: "USER", + }, + ], + }); +} + +async function loginAs(userId: string) { + const prisma = getPrismaClient(); + const session = await prisma.session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", session.id); +} + +async function loginAsAdmin() { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "cache-admin", + email: "cache-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { + userId: admin.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", session.id); + return admin.id; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + const { __resetAllCachesForTests } = await import( + "@/lib/cache/server-cache" + ); + __resetAllCachesForTests(); + // Run the real db-compat helper directly so a sibling test file's + // `vi.mock("@/lib/db-compat", ...)` cannot leave the `app_settings` + // table missing columns the admin-settings PUT writes through. + const realDbCompat = + await vi.importActual("@/lib/db-compat"); + await realDbCompat.ensureDbCompatibility(); + await seedUsers(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("v1.4.34 IW-G — invalidation matrix across the major write surfaces", () => { + it("measurement POST evicts the analytics cache bucket for the writer", async () => { + await loginAs(PRIMARY_USER_ID); + + const { caches } = await import("@/lib/cache/server-cache"); + // Seed the analytics bucket and a sibling user's bucket to also + // assert cross-user isolation below. + caches.analytics.set(`${PRIMARY_USER_ID}|all`, { fake: "data" }); + caches.analytics.set(`${OTHER_USER_ID}|all`, { other: "data" }); + expect(caches.analytics.get(`${PRIMARY_USER_ID}|all`)).not.toBeNull(); + + const { POST } = await import("@/app/api/measurements/route"); + const res = await POST( + new NextRequest("http://localhost/api/measurements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "WEIGHT", + value: 82.5, + measuredAt: new Date().toISOString(), + }), + }), + ); + expect([200, 201]).toContain(res.status); + + // The writer's bucket is gone. + expect(caches.analytics.get(`${PRIMARY_USER_ID}|all`)).toBeNull(); + // The sibling user's bucket is untouched (cross-user isolation). + expect(caches.analytics.get(`${OTHER_USER_ID}|all`)).not.toBeNull(); + }); + + it("mood-entry POST evicts the moodAnalytics cache bucket for the writer", async () => { + await loginAs(PRIMARY_USER_ID); + + const { caches } = await import("@/lib/cache/server-cache"); + caches.moodAnalytics.set(PRIMARY_USER_ID, { fake: "mood" }); + caches.moodAnalytics.set(OTHER_USER_ID, { other: "mood" }); + expect(caches.moodAnalytics.get(PRIMARY_USER_ID)).not.toBeNull(); + + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + new NextRequest("http://localhost/api/mood-entries", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + mood: "GUT", + moodLoggedAt: new Date().toISOString(), + }), + }), + ); + expect([200, 201]).toContain(res.status); + + expect(caches.moodAnalytics.get(PRIMARY_USER_ID)).toBeNull(); + expect(caches.moodAnalytics.get(OTHER_USER_ID)).not.toBeNull(); + }); + + it("medication POST evicts the medications cache bucket for the writer", async () => { + await loginAs(PRIMARY_USER_ID); + + const { caches } = await import("@/lib/cache/server-cache"); + caches.medications.set(PRIMARY_USER_ID, [{ fake: "med" }]); + caches.medications.set(OTHER_USER_ID, [{ other: "med" }]); + expect(caches.medications.get(PRIMARY_USER_ID)).not.toBeNull(); + + const { POST } = await import("@/app/api/medications/route"); + const res = await POST( + new NextRequest("http://localhost/api/medications", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Test Med", + dose: "10mg", + schedules: [ + { windowStart: "08:00", windowEnd: "10:00", daysOfWeek: [] }, + ], + }), + }), + ); + expect([200, 201]).toContain(res.status); + + expect(caches.medications.get(PRIMARY_USER_ID)).toBeNull(); + expect(caches.medications.get(OTHER_USER_ID)).not.toBeNull(); + }); + + it("dashboard-widgets PUT evicts the dashboardWidgets cache bucket for the writer", async () => { + await loginAs(PRIMARY_USER_ID); + + const { caches } = await import("@/lib/cache/server-cache"); + caches.dashboardWidgets.set(PRIMARY_USER_ID, { fake: "layout" }); + caches.dashboardWidgets.set(OTHER_USER_ID, { other: "layout" }); + expect(caches.dashboardWidgets.get(PRIMARY_USER_ID)).not.toBeNull(); + + const { DEFAULT_DASHBOARD_LAYOUT } = await import("@/lib/dashboard-layout"); + const { PUT } = await import("@/app/api/dashboard/widgets/route"); + const res = await PUT( + new NextRequest("http://localhost/api/dashboard/widgets", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 1, + widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, + }), + }), + ); + expect(res.status).toBe(200); + + expect(caches.dashboardWidgets.get(PRIMARY_USER_ID)).toBeNull(); + expect(caches.dashboardWidgets.get(OTHER_USER_ID)).not.toBeNull(); + }); + + it("admin app-settings PUT evicts the bugreportStatus singleton cache", async () => { + await loginAsAdmin(); + + const { caches } = await import("@/lib/cache/server-cache"); + caches.bugreportStatus.set("singleton", { fake: "status" }); + expect(caches.bugreportStatus.get("singleton")).not.toBeNull(); + + const { PUT } = await import("@/app/api/admin/settings/route"); + const res = await PUT( + new NextRequest("http://localhost/api/admin/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ bugReportRepo: "owner/repo" }), + }), + ); + expect(res.status).toBe(200); + + // The singleton bucket is gone — the next bugreport.status read + // will rebuild from app_settings. + expect(caches.bugreportStatus.get("singleton")).toBeNull(); + }); +}); diff --git a/tests/integration/passkey-register.test.ts b/tests/integration/passkey-register.test.ts new file mode 100644 index 000000000..6dc99d100 --- /dev/null +++ b/tests/integration/passkey-register.test.ts @@ -0,0 +1,252 @@ +/** + * v1.4.35 — integration coverage for the passkey registration ceremony. + * + * F-2 in the test coverage audit flagged + * `/api/auth/passkey/register-options` and `register-verify` as having + * zero route tests. The ceremony is purely server-side — a regression + * in the challenge-store roundtrip ships silently. This file pins: + * + * - register-options issues a challenge, persists it in + * `auth_challenges`, and returns the RP-ID derived from APP_URL + * - register-verify with a valid (mocked) attestation persists a + * Passkey row and consumes the challenge + * - register-verify with a tampered attestation responds 400 and + * does NOT persist a Passkey + * + * `@simplewebauthn/server` runs cryptographic verification we can't + * fake against real WebAuthn output in a unit-test setting; the helper + * module that wraps it (`src/lib/auth/passkey.ts`) is mocked so the + * test exercises the route handler + DB write path end-to-end. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// `verifyRegistration` runs the upstream attestation verification that +// requires real WebAuthn credentials. Stub the helper so the test owns +// the success / failure branch shape; the route handler's +// challenge-lookup + Passkey insertion still runs against real Postgres. +vi.mock("@/lib/auth/passkey", async () => { + const actual = await vi.importActual( + "@/lib/auth/passkey", + ); + return { + ...actual, + verifyRegistration: vi.fn(), + }; +}); + +const TEST_USER_ID = "user-passkey-register"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + + // The RP-ID derives from APP_URL / NEXT_PUBLIC_APP_URL; pin both so + // the assertion below can compare literal hostnames. + process.env.APP_URL = "http://localhost:3000"; + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "passkey-user", + email: "passkey@example.test", + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Passkey registration (real Postgres)", () => { + it("register-options persists a challenge and returns the RP-ID from APP_URL", async () => { + const { POST } = await import( + "@/app/api/auth/passkey/register-options/route" + ); + // The route handler takes no arguments at the TS level (the wrapper + // ignores the request) — cast to a no-arg callable for clarity. + const res = await (POST as unknown as () => Promise)(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + options: { + challenge: string; + rp: { name: string; id: string }; + }; + challengeId: string; + }; + }; + + expect(body.data.challengeId).toBeTruthy(); + expect(body.data.options.challenge).toBeTruthy(); + expect(body.data.options.rp.id).toBe("localhost"); + expect(body.data.options.rp.name).toBe("HealthLog"); + + // The challenge row landed in `auth_challenges` and is tied to the + // calling user with type=registration and a future expiry. + const prisma = getPrismaClient(); + const stored = await prisma.authChallenge.findUnique({ + where: { id: body.data.challengeId }, + }); + expect(stored).not.toBeNull(); + expect(stored?.userId).toBe(TEST_USER_ID); + expect(stored?.type).toBe("registration"); + expect(stored!.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it("register-verify persists a Passkey row on a verified attestation", async () => { + const { verifyRegistration } = await import("@/lib/auth/passkey"); + const credentialIdRaw = "valid-credential-id"; + vi.mocked(verifyRegistration).mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: credentialIdRaw, + publicKey: new Uint8Array([1, 2, 3, 4, 5]), + counter: 0, + }, + credentialDeviceType: "singleDevice", + credentialBackedUp: false, + }, + } as never); + + const prisma = getPrismaClient(); + // Seed a challenge row so the verify path can resolve `challengeId`. + const challenge = await prisma.authChallenge.create({ + data: { + userId: TEST_USER_ID, + challenge: "fake-server-challenge", + type: "registration", + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }, + }); + + const { POST } = await import( + "@/app/api/auth/passkey/register-verify/route" + ); + const res = await POST( + new NextRequest("http://localhost/api/auth/passkey/register-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + challengeId: challenge.id, + credential: { + id: credentialIdRaw, + response: { transports: ["internal"] }, + }, + }), + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { verified: boolean } }; + expect(body.data.verified).toBe(true); + + // Passkey row exists and is tied to this user. + const passkeys = await prisma.passkey.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(passkeys).toHaveLength(1); + expect(passkeys[0].credentialId).toBe(credentialIdRaw); + expect(passkeys[0].transports).toEqual(["internal"]); + + // Audit row for the register action. + const audit = await prisma.auditLog.findFirst({ + where: { userId: TEST_USER_ID, action: "auth.passkey.register" }, + }); + expect(audit).not.toBeNull(); + }); + + it("register-verify returns 400 on a tampered attestation and writes no Passkey", async () => { + const { verifyRegistration } = await import("@/lib/auth/passkey"); + vi.mocked(verifyRegistration).mockResolvedValue({ + verified: false, + } as never); + + const prisma = getPrismaClient(); + const challenge = await prisma.authChallenge.create({ + data: { + userId: TEST_USER_ID, + challenge: "fake-server-challenge", + type: "registration", + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }, + }); + + const { POST } = await import( + "@/app/api/auth/passkey/register-verify/route" + ); + const res = await POST( + new NextRequest("http://localhost/api/auth/passkey/register-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + challengeId: challenge.id, + credential: { id: "tampered-credential" }, + }), + }), + ); + + expect(res.status).toBe(400); + + const passkeys = await prisma.passkey.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(passkeys).toHaveLength(0); + }); + + it("register-verify returns 422 when challengeId is missing", async () => { + const { POST } = await import( + "@/app/api/auth/passkey/register-verify/route" + ); + const res = await POST( + new NextRequest("http://localhost/api/auth/passkey/register-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential: { id: "x" } }), + }), + ); + + expect(res.status).toBe(422); + }); +}); diff --git a/tests/integration/withings-oauth.test.ts b/tests/integration/withings-oauth.test.ts new file mode 100644 index 000000000..873f5267c --- /dev/null +++ b/tests/integration/withings-oauth.test.ts @@ -0,0 +1,289 @@ +/** + * v1.4.35 — integration coverage for the Withings OAuth handshake. + * + * F-3 in the test coverage audit flagged the OAuth credential lifecycle + * (`connect`, `callback`, `disconnect`) as completely uncovered despite + * being the only thing standing between a working Withings sync and a + * forever-broken integration. This file pins the contract end-to-end: + * + * - GET /api/withings/connect returns a 307 to the upstream authorise + * endpoint with the right query params and stamps the + * `withings_state` cookie + * - GET /api/withings/callback exchanges `code` for tokens against a + * mocked Withings endpoint and persists the encrypted result on + * WithingsConnection + * - POST /api/withings/disconnect deletes the connection and writes a + * `withings.disconnect` audit row + * - GET /api/withings/callback with a replayed / wrong state cookie + * redirects to `?withings=error&reason=state` and does not write + * any tokens + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +// Crypto module reads `ENCRYPTION_KEY` lazily on first encrypt() / decrypt(); +// seed a deterministic test key here so the integration container has +// something to decrypt against. Skipped if the env already carries one. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TEST_USER_ID = "user-withings-oauth"; + +const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2"; +const WITHINGS_OAUTH_URL = + "https://account.withings.com/oauth2_user/authorize2"; +const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + + const { encrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "withings-user", + email: "withings@example.test", + role: "USER", + withingsClientIdEncrypted: encrypt("client-id-test"), + withingsClientSecretEncrypted: encrypt("client-secret-test"), + }, + }); + const session = await prisma.session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + // The OAuth client builds its redirect URI from NEXT_PUBLIC_APP_URL + // unless WITHINGS_REDIRECT_URI overrides it. Pin both so the assertions + // below can compare literal strings. + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Withings OAuth handshake (real Postgres)", () => { + it("connect endpoint redirects to Withings with the expected query params and stamps the state cookie", async () => { + const { GET } = await import("@/app/api/withings/connect/route"); + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/withings/connect"), + ); + + // 307 from NextResponse.redirect — the body is intentionally empty. + expect(res.status).toBe(307); + const location = res.headers.get("location"); + expect(location).not.toBeNull(); + expect(location).toContain(WITHINGS_OAUTH_URL); + + const target = new URL(location!); + expect(target.searchParams.get("response_type")).toBe("code"); + expect(target.searchParams.get("client_id")).toBe("client-id-test"); + expect(target.searchParams.get("redirect_uri")).toBe( + "http://localhost:3000/api/withings/callback", + ); + expect(target.searchParams.get("scope")).toBe("user.metrics,user.activity"); + const state = target.searchParams.get("state"); + expect(state).toMatch(/^user-withings-oauth:[a-f0-9]{32}$/); + + // The Set-Cookie header carries the same state nonce — that cookie + // is what `callback/route.ts` compares against on the second leg. + // Cookie value is URL-encoded for the `:` separator; compare on the + // encoded form so the assertion is byte-stable. + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("withings_state="); + expect(setCookie).toContain(encodeURIComponent(state!)); + }); + + it("callback exchanges code → encrypted tokens on the WithingsConnection row", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input) => { + const url = typeof input === "string" ? input : (input as Request).url; + if (url === WITHINGS_TOKEN_URL) { + return new Response( + JSON.stringify({ + status: 0, + body: { + userid: "withings-user-12345", + access_token: "fresh-access-token", + refresh_token: "fresh-refresh-token", + expires_in: 3600, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url === WITHINGS_NOTIFY_URL) { + // Webhook subscribe is fire-and-forget — return a benign + // success so the spy does not surface an unexpected error. + return new Response(JSON.stringify({ status: 0 }), { status: 200 }); + } + throw new Error(`Unexpected fetch in withings-oauth test: ${url}`); + }); + + const state = `${TEST_USER_ID}:${"a".repeat(32)}`; + const req = new NextRequest( + `http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(state)}`, + { + method: "GET", + headers: { + cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${state}`, + }, + }, + ); + + const { GET } = await import("@/app/api/withings/callback/route"); + const res = await GET(req); + + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain( + "/settings/integrations?withings=connected", + ); + + const prisma = getPrismaClient(); + const conn = await prisma.withingsConnection.findUnique({ + where: { userId: TEST_USER_ID }, + }); + expect(conn).not.toBeNull(); + expect(conn?.withingsUserId).toBe("withings-user-12345"); + expect(conn?.scope).toBe("user.metrics,user.activity"); + + // Tokens persist as encrypted blobs — decrypt to confirm the + // round-trip lands the plaintext we received from the mocked + // Withings response. + const { decrypt } = await import("@/lib/crypto"); + expect(decrypt(conn!.accessToken)).toBe("fresh-access-token"); + expect(decrypt(conn!.refreshToken)).toBe("fresh-refresh-token"); + + // Audit row tagged with the upstream userid. + const audit = await prisma.auditLog.findFirst({ + where: { action: "withings.connect", userId: TEST_USER_ID }, + }); + expect(audit).not.toBeNull(); + + // The token endpoint was hit exactly once for the code exchange. + const tokenCalls = fetchSpy.mock.calls.filter( + ([input]) => + (typeof input === "string" ? input : (input as Request).url) === + WITHINGS_TOKEN_URL, + ); + expect(tokenCalls).toHaveLength(1); + }); + + it("callback with a replayed / wrong state redirects to the error page and writes no tokens", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("must not be called", { status: 500 })); + + const validState = `${TEST_USER_ID}:${"a".repeat(32)}`; + const replayedState = `${TEST_USER_ID}:${"b".repeat(32)}`; + const req = new NextRequest( + `http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(replayedState)}`, + { + method: "GET", + headers: { + cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${validState}`, + }, + }, + ); + + const { GET } = await import("@/app/api/withings/callback/route"); + const res = await GET(req); + + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain( + "withings=error&reason=state", + ); + + // No upstream call was made — the state check short-circuited the + // handler before exchangeCode could fire. + expect(fetchSpy).not.toHaveBeenCalled(); + + // No connection row created. + const prisma = getPrismaClient(); + const conn = await prisma.withingsConnection.findUnique({ + where: { userId: TEST_USER_ID }, + }); + expect(conn).toBeNull(); + }); + + it("disconnect clears the WithingsConnection row and writes an audit entry", async () => { + // Seed an existing connection so disconnect has something to delete. + const { encrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + await prisma.withingsConnection.create({ + data: { + userId: TEST_USER_ID, + withingsUserId: "withings-user-12345", + accessToken: encrypt("old-access"), + refreshToken: encrypt("old-refresh"), + tokenExpiresAt: new Date(Date.now() + 3600_000), + scope: "user.metrics,user.activity", + }, + }); + + // Stub the unsubscribe fetch as a benign success so the handler runs + // its cleanup loop without surfacing an unexpected network error. + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: 0 }), { status: 200 }), + ); + + const { POST } = await import("@/app/api/withings/disconnect/route"); + // disconnect handler takes no arguments at the TS level — cast. + const res = await (POST as unknown as () => Promise)(); + + expect(res.status).toBe(200); + + const conn = await prisma.withingsConnection.findUnique({ + where: { userId: TEST_USER_ID }, + }); + expect(conn).toBeNull(); + + const audit = await prisma.auditLog.findFirst({ + where: { action: "withings.disconnect", userId: TEST_USER_ID }, + }); + expect(audit).not.toBeNull(); + }); + + it("disconnect returns 404 when there is no existing connection", async () => { + const { POST } = await import("@/app/api/withings/disconnect/route"); + const res = await (POST as unknown as () => Promise)(); + + expect(res.status).toBe(404); + }); +});