mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
/**
|
|
* 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";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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);
|
|
|
|
// v1.30.32 — the cookie carries the session secret, not the row id, so
|
|
// bind them through the stored hash instead. Strictly stronger than the
|
|
// old identity check: it proves the cookie actually authenticates the
|
|
// surviving row, and that the row id never leaves the server.
|
|
const rotatedCookie = cookieJar.get("healthlog_session")!;
|
|
expect(rotatedCookie).not.toBe(remainingSessions[0].id);
|
|
expect(remainingSessions[0].tokenHash).toBe(hashToken(rotatedCookie));
|
|
|
|
// 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("audits a wrong current password and shares the re-proof ceiling", async () => {
|
|
const { userId } = await seedUserWithSession();
|
|
const prisma = getPrismaClient();
|
|
const { POST } = await import("@/app/api/auth/password/route");
|
|
|
|
const statuses: number[] = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const res = await POST(
|
|
makeRequest({
|
|
currentPassword: `WrongPasswordValue!${i}`,
|
|
newPassword: STRONG_NEW,
|
|
confirmPassword: STRONG_NEW,
|
|
}),
|
|
);
|
|
statuses.push(res.status);
|
|
}
|
|
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
|
expect(
|
|
await prisma.auditLog.count({
|
|
where: { userId, action: "auth.mfa.failed" },
|
|
}),
|
|
).toBe(5);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|