mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
v1.4.34.5 — audit follow-on: critical-path tests + iOS textarea zoom
Follow-on to v1.4.34.4.
Tests:
24 new integration tests across 5 files closing the highest
critical-path gaps the test-coverage audit flagged:
- auth-password-change.test.ts pins the v1.4.34.3+
destroyAllSessions three-transport revocation contract.
- admin-reset-password.test.ts mirrors the same shape for
admin resets.
- withings-oauth.test.ts covers connect / callback / disconnect
+ state-token replay rejection.
- passkey-register.test.ts covers register-options +
register-verify + tampered-attestation + challenge single-use.
- cache-invalidation-coverage.test.ts grids the IW-G
invalidation matrix across 5 write surfaces.
Integration suite: 197/50 → 222/55. tsc + lint clean.
Mobile:
Five textareas no longer trigger iOS Safari zoom-on-focus. The
Coach composer, bug-report description, admin feedback note,
medication-side-effects notes, and medication-intake JSON
import all shipped text-sm (14 px). iOS Safari zooms whenever
an input renders below 16 px. Switched each to
`text-base sm:text-sm` so mobile clears the floor while desktop
keeps the compact look.
This commit is contained in:
1 parent
f5962497d6
commit
9715831f82
12 files changed
+1394
-6
No files matched your search
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.34.5] — 2026-05-17 — Audit follow-on: critical-path tests + iOS textarea zoom
|
||||
|
||||
Follow-on to v1.4.34.4. Two batches: the missing critical-path
|
||||
integration tests the test-coverage audit flagged, and the textarea
|
||||
viewport-zoom fix the mobile-deep audit flagged as F-5.
|
||||
|
||||
### Tests
|
||||
|
||||
- **24 new integration tests across 5 files** closing the critical
|
||||
gaps from `.planning/round-audit-test-coverage.md`:
|
||||
- `tests/integration/auth-password-change.test.ts` pins the v1.4.34.3+
|
||||
`destroyAllSessions` three-transport revocation contract
|
||||
end-to-end (Session.deleteMany, ApiToken.revoked, RefreshToken.revokedAt).
|
||||
- `tests/integration/admin-reset-password.test.ts` mirrors the same
|
||||
contract for the admin-driven reset.
|
||||
- `tests/integration/withings-oauth.test.ts` covers connect /
|
||||
callback / disconnect plus state-token replay rejection.
|
||||
- `tests/integration/passkey-register.test.ts` covers register-options
|
||||
+ register-verify + tampered-attestation rejection + challenge
|
||||
single-use.
|
||||
- `tests/integration/cache-invalidation-coverage.test.ts` grids the
|
||||
v1.4.34 IW-G invalidation matrix across 5 write surfaces.
|
||||
- Integration suite goes from 197/50 to 222/55. tsc + lint clean.
|
||||
|
||||
### Mobile
|
||||
|
||||
- **Five textareas no longer trigger iOS Safari zoom-on-focus.** The
|
||||
Coach composer, bug-report description, admin feedback note,
|
||||
medication-side-effects notes, and medication-intake JSON import all
|
||||
shipped `text-sm` (14 px). iOS Safari zooms the viewport whenever an
|
||||
input renders below 16 px. Switched each to `text-base sm:text-sm`
|
||||
so the mobile baseline clears the floor while desktop keeps the
|
||||
compact look.
|
||||
|
||||
## [1.4.34.4] — 2026-05-17 — Audit-driven hotfix bundle (security, UX, code-quality, docs)
|
||||
|
||||
A consolidated hotfix landing the findings from a six-axis audit
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.34.4",
|
||||
"version": "1.4.34.5",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -207,7 +207,9 @@ export default function BugReportPage() {
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={8}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ keeps the compact desktop look.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -466,7 +466,9 @@ function IntakeImportDialog({
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ for the compact import textarea.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 font-mono text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
|
||||
@@ -501,7 +501,9 @@ function FeedbackDetailDialog({
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ keeps the compact admin look.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
|
||||
@@ -208,7 +208,12 @@ export function CoachInput({
|
||||
// `max-h-[9.5rem]`, ≈6 lines at the current line-height).
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full resize-none bg-transparent text-sm leading-relaxed outline-none",
|
||||
// `text-base` (16 px) on mobile so iOS Safari does not
|
||||
// zoom-on-focus — the kerned `text-sm` (14 px) trips the
|
||||
// 16 px floor and yanks the viewport on every Coach tap.
|
||||
// Desktop shrinks back to `text-sm` for the compact
|
||||
// composer.
|
||||
"w-full resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||
)}
|
||||
|
||||
@@ -474,7 +474,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="sentences"
|
||||
autoComplete="off"
|
||||
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
|
||||
// text-base on mobile so iOS Safari doesn't
|
||||
// zoom-on-focus; text-sm on sm+ keeps the compact
|
||||
// notes-row visual.
|
||||
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-base sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-[10px]">
|
||||
{notes.length} / {NOTES_MAX}
|
||||
|
||||
@@ -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<string> {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Response>
|
||||
)(new Request("http://localhost/api/auth/me"));
|
||||
expect(meRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/db-compat")>("@/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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/auth/passkey")>(
|
||||
"@/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<Response>)();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Response>)(
|
||||
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<Response>)();
|
||||
|
||||
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<Response>)();
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user