Browse Source

RHL-009 feat(auth): implement change password API with validation and tests

Code_Uwe 5 days ago
parent
commit
e5ecf9d554
2 changed files with 381 additions and 0 deletions
  1. 104 0
      app/api/auth/change-password/route.js
  2. 277 0
      app/api/auth/change-password/route.test.js

+ 104 - 0
app/api/auth/change-password/route.js

@@ -0,0 +1,104 @@
+import bcrypt from "bcryptjs";
+
+import User from "@/models/user";
+import { getDb } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { validateNewPassword } from "@/lib/auth/passwordPolicy";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+} from "@/lib/api/errors";
+
+export const dynamic = "force-dynamic";
+
+const BCRYPT_SALT_ROUNDS = 12;
+
+export const POST = withErrorHandling(
+	async function POST(request) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		let body;
+		try {
+			body = await request.json();
+		} catch {
+			throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
+		}
+
+		if (!body || typeof body !== "object") {
+			throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
+		}
+
+		const { currentPassword, newPassword } = body;
+
+		const missing = [];
+		if (typeof currentPassword !== "string" || !currentPassword.trim()) {
+			missing.push("currentPassword");
+		}
+		if (typeof newPassword !== "string" || !newPassword.trim()) {
+			missing.push("newPassword");
+		}
+
+		if (missing.length > 0) {
+			throw badRequest(
+				"VALIDATION_MISSING_FIELD",
+				"Missing currentPassword or newPassword",
+				{ fields: missing },
+			);
+		}
+
+		await getDb();
+
+		const user = await User.findById(session.userId).exec();
+
+		// Treat missing users like an invalid session (do not leak anything).
+		if (!user) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		// Defensive: if hash is missing, treat as invalid credentials.
+		if (typeof user.passwordHash !== "string" || !user.passwordHash) {
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
+		}
+
+		const currentMatches = await bcrypt.compare(
+			currentPassword,
+			user.passwordHash,
+		);
+
+		if (!currentMatches) {
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
+		}
+
+		const policyCheck = validateNewPassword({
+			newPassword,
+			currentPassword,
+		});
+
+		if (!policyCheck.ok) {
+			throw badRequest("VALIDATION_WEAK_PASSWORD", "Weak password", {
+				...policyCheck.policy,
+				reasons: policyCheck.reasons,
+			});
+		}
+
+		const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
+
+		user.passwordHash = newHash;
+		user.mustChangePassword = false;
+
+		// Defense-in-depth: invalidate any reset token when password changes.
+		user.passwordResetToken = null;
+		user.passwordResetExpiresAt = null;
+
+		await user.save();
+
+		return json({ ok: true }, 200);
+	},
+	{ logPrefix: "[api/auth/change-password]" },
+);

+ 277 - 0
app/api/auth/change-password/route.test.js

@@ -0,0 +1,277 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+	getDb: vi.fn(),
+}));
+
+vi.mock("@/models/user", () => ({
+	default: {
+		findById: vi.fn(),
+	},
+}));
+
+vi.mock("bcryptjs", () => {
+	const compare = vi.fn();
+	const hash = vi.fn();
+	return {
+		default: { compare, hash },
+		compare,
+		hash,
+	};
+});
+
+import { getSession } from "@/lib/auth/session";
+import { getDb } from "@/lib/db";
+import User from "@/models/user";
+import { compare as bcryptCompare, hash as bcryptHash } from "bcryptjs";
+
+import { POST, dynamic } from "./route.js";
+
+function createRequestStub(body) {
+	return {
+		async json() {
+			return body;
+		},
+	};
+}
+
+describe("POST /api/auth/change-password", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+	});
+
+	it('exports dynamic="force-dynamic"', () => {
+		expect(dynamic).toBe("force-dynamic");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await POST(createRequestStub({}));
+		expect(res.status).toBe(401);
+
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 400 when JSON parsing fails", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const req = {
+			json: vi.fn().mockRejectedValue(new Error("invalid json")),
+		};
+
+		const res = await POST(req);
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_JSON",
+			},
+		});
+	});
+
+	it("returns 400 when body is not an object", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const res = await POST(createRequestStub("nope"));
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_BODY",
+			},
+		});
+	});
+
+	it("returns 400 when fields are missing", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const res = await POST(createRequestStub({ currentPassword: "x" }));
+		expect(res.status).toBe(400);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing currentPassword or newPassword",
+				code: "VALIDATION_MISSING_FIELD",
+				details: { fields: ["newPassword"] },
+			},
+		});
+
+		expect(User.findById).not.toHaveBeenCalled();
+	});
+
+	it("returns 401 when user is not found (treat as invalid session)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(null),
+		});
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "OldPassword123",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 401 when current password is wrong", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			passwordHash: "hash",
+			mustChangePassword: true,
+			passwordResetToken: "tok",
+			passwordResetExpiresAt: new Date(),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(false);
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "wrong",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid credentials",
+				code: "AUTH_INVALID_CREDENTIALS",
+			},
+		});
+
+		expect(bcryptHash).not.toHaveBeenCalled();
+		expect(user.save).not.toHaveBeenCalled();
+	});
+
+	it("returns 400 when new password is weak", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			passwordHash: "hash",
+			mustChangePassword: true,
+			passwordResetToken: "tok",
+			passwordResetExpiresAt: new Date(),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(true);
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "OldPassword123",
+				newPassword: "short",
+			}),
+		);
+
+		expect(res.status).toBe(400);
+
+		const body = await res.json();
+		expect(body.error.code).toBe("VALIDATION_WEAK_PASSWORD");
+		expect(body.error.details).toMatchObject({
+			minLength: 8,
+			requireLetter: true,
+			requireNumber: true,
+		});
+		expect(Array.isArray(body.error.details.reasons)).toBe(true);
+
+		expect(bcryptHash).not.toHaveBeenCalled();
+		expect(user.save).not.toHaveBeenCalled();
+	});
+
+	it("returns 200 and updates passwordHash + clears flags on success", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			passwordHash: "old-hash",
+			mustChangePassword: true,
+			passwordResetToken: "tok",
+			passwordResetExpiresAt: new Date("2030-01-01"),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(true);
+		bcryptHash.mockResolvedValue("new-hash");
+
+		const res = await POST(
+			createRequestStub({
+				currentPassword: "OldPassword123",
+				newPassword: "StrongPassword123",
+			}),
+		);
+
+		expect(res.status).toBe(200);
+		expect(await res.json()).toEqual({ ok: true });
+
+		expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12);
+
+		expect(user.passwordHash).toBe("new-hash");
+		expect(user.mustChangePassword).toBe(false);
+		expect(user.passwordResetToken).toBe(null);
+		expect(user.passwordResetExpiresAt).toBe(null);
+
+		expect(user.save).toHaveBeenCalledTimes(1);
+	});
+});