|
@@ -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);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|