/* @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", () => { const USER_ROLES = Object.freeze({ BRANCH: "branch", ADMIN: "admin", SUPERADMIN: "superadmin", DEV: "dev", }); return { default: { find: vi.fn(), findOne: vi.fn(), create: vi.fn(), }, USER_ROLES, }; }); vi.mock("bcryptjs", () => { const hash = vi.fn(); return { default: { hash }, hash, }; }); import { getSession } from "@/lib/auth/session"; import { getDb } from "@/lib/db"; import User from "@/models/user"; import { hash as bcryptHash } from "bcryptjs"; import { GET, POST, dynamic } from "./route.js"; function buildCursor(lastId) { return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString( "base64url", ); } function buildOffsetCursor(sort, offset) { return Buffer.from(JSON.stringify({ v: 2, sort, offset }), "utf8").toString( "base64url", ); } function createRequestStub(body) { return { async json() { return body; }, }; } describe("GET /api/admin/users", () => { 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 GET(new Request("http://localhost/api/admin/users")); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" }, }); }); it("returns 403 when authenticated but not allowed (admin)", async () => { getSession.mockResolvedValue({ userId: "u1", role: "admin", branchId: null, email: "admin@example.com", }); const res = await GET(new Request("http://localhost/api/admin/users")); expect(res.status).toBe(403); expect(await res.json()).toEqual({ error: { message: "Forbidden", code: "AUTH_FORBIDDEN_USER_MANAGEMENT", }, }); expect(User.find).not.toHaveBeenCalled(); }); it("returns 200 with items and nextCursor (superadmin, limit + cursor)", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const d1 = { _id: "507f1f77bcf86cd799439013", username: "u3", email: "u3@example.com", role: "admin", branchId: null, mustChangePassword: false, createdAt: new Date("2026-02-01T10:00:00.000Z"), updatedAt: new Date("2026-02-02T10:00:00.000Z"), }; const d2 = { _id: "507f1f77bcf86cd799439012", username: "u2", email: "u2@example.com", role: "branch", branchId: "NL01", mustChangePassword: true, createdAt: new Date("2026-02-01T09:00:00.000Z"), updatedAt: new Date("2026-02-02T09:00:00.000Z"), }; const d3 = { _id: "507f1f77bcf86cd799439011", username: "u1", email: "u1@example.com", role: "dev", branchId: null, mustChangePassword: false, createdAt: new Date("2026-02-01T08:00:00.000Z"), updatedAt: new Date("2026-02-02T08:00:00.000Z"), }; const chain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue([d1, d2, d3]), }; User.find.mockReturnValue(chain); const res = await GET( new Request("http://localhost/api/admin/users?limit=2"), ); expect(res.status).toBe(200); expect(chain.sort).toHaveBeenCalledWith({ _id: -1 }); expect(chain.limit).toHaveBeenCalledWith(3); // limit + 1 const body = await res.json(); expect(body.items).toHaveLength(2); expect(body.items[0]).toMatchObject({ id: "507f1f77bcf86cd799439013", username: "u3", email: "u3@example.com", role: "admin", branchId: null, mustChangePassword: false, }); expect(body.items[1]).toMatchObject({ id: "507f1f77bcf86cd799439012", username: "u2", email: "u2@example.com", role: "branch", branchId: "NL01", mustChangePassword: true, }); expect(body.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012")); }); it("returns 400 for invalid sort parameter", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const res = await GET( new Request("http://localhost/api/admin/users?sort=unknown"), ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: { message: "Invalid sort", code: "VALIDATION_INVALID_FIELD", details: { field: "sort", value: "unknown", allowed: ["default", "role_rights", "branch_asc"], }, }, }); }); it("returns sorted users for sort=role_rights with stable nextCursor", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const docs = [ { _id: "507f1f77bcf86cd799439011", username: "branchb", email: "branchb@example.com", role: "branch", branchId: "NL10", mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439012", username: "admin", email: "admin@example.com", role: "admin", branchId: null, mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439013", username: "dev", email: "dev@example.com", role: "dev", branchId: null, mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439014", username: "super", email: "super@example.com", role: "superadmin", branchId: null, mustChangePassword: false, }, ]; const chain = { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(docs), }; User.find.mockReturnValue(chain); const res = await GET( new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"), ); expect(res.status).toBe(200); expect(chain.select).toHaveBeenCalledTimes(1); expect(chain.exec).toHaveBeenCalledTimes(1); const body = await res.json(); expect(body.items.map((x) => x.role)).toEqual(["superadmin", "dev"]); expect(body.nextCursor).toBe(buildOffsetCursor("role_rights", 2)); }); it("returns sorted users for sort=branch_asc with null branches at the end", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const docs = [ { _id: "507f1f77bcf86cd799439011", username: "branch10", email: "branch10@example.com", role: "branch", branchId: "NL10", mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439012", username: "admin", email: "admin@example.com", role: "admin", branchId: null, mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439013", username: "branch2", email: "branch2@example.com", role: "branch", branchId: "NL2", mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439014", username: "branch1", email: "branch1@example.com", role: "branch", branchId: "NL01", mustChangePassword: false, }, ]; const chain = { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(docs), }; User.find.mockReturnValue(chain); const res = await GET( new Request("http://localhost/api/admin/users?sort=branch_asc&limit=10"), ); expect(res.status).toBe(200); const body = await res.json(); expect(body.items.map((x) => x.branchId)).toEqual([ "NL01", "NL2", "NL10", null, ]); expect(body.nextCursor).toBe(null); }); it("returns 400 when cursor sort context does not match sort parameter", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const cursor = buildOffsetCursor("role_rights", 2); const res = await GET( new Request( `http://localhost/api/admin/users?sort=branch_asc&cursor=${encodeURIComponent(cursor)}`, ), ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: { message: "Invalid cursor", code: "VALIDATION_INVALID_FIELD", details: { field: "cursor" }, }, }); }); it("keeps pagination stable within the same custom sort mode", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const docs = [ { _id: "507f1f77bcf86cd799439011", username: "branchb", email: "branchb@example.com", role: "branch", branchId: "NL10", mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439012", username: "admin", email: "admin@example.com", role: "admin", branchId: null, mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439013", username: "dev", email: "dev@example.com", role: "dev", branchId: null, mustChangePassword: false, }, { _id: "507f1f77bcf86cd799439014", username: "super", email: "super@example.com", role: "superadmin", branchId: null, mustChangePassword: false, }, ]; const chain = { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(docs), }; User.find.mockReturnValue(chain); const firstRes = await GET( new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"), ); expect(firstRes.status).toBe(200); const firstBody = await firstRes.json(); expect(firstBody.items.map((x) => x.role)).toEqual(["superadmin", "dev"]); expect(firstBody.nextCursor).toBe(buildOffsetCursor("role_rights", 2)); const secondRes = await GET( new Request( `http://localhost/api/admin/users?sort=role_rights&limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`, ), ); expect(secondRes.status).toBe(200); const secondBody = await secondRes.json(); expect(secondBody.items.map((x) => x.role)).toEqual(["admin", "branch"]); expect(secondBody.nextCursor).toBe(null); }); }); describe("POST /api/admin/users", () => { beforeEach(() => { vi.clearAllMocks(); getDb.mockResolvedValue({}); bcryptHash.mockResolvedValue("hashed"); }); 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 403 when authenticated but not allowed (admin)", async () => { getSession.mockResolvedValue({ userId: "u1", role: "admin", branchId: null, email: "admin@example.com", }); const res = await POST(createRequestStub({})); expect(res.status).toBe(403); expect(await res.json()).toEqual({ error: { message: "Forbidden", code: "AUTH_FORBIDDEN_USER_MANAGEMENT", }, }); }); it("returns 400 when JSON parsing fails", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); 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: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); 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: "u2", role: "dev", branchId: null, email: "dev@example.com", }); const res = await POST(createRequestStub({})); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: { message: "Missing required fields", code: "VALIDATION_MISSING_FIELD", details: { fields: ["username", "email", "role", "initialPassword"] }, }, }); }); it("returns 400 for invalid role", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const res = await POST( createRequestStub({ username: "newuser", email: "new@example.com", role: "nope", initialPassword: "StrongPassword123", }), ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error.code).toBe("VALIDATION_INVALID_FIELD"); expect(body.error.details.field).toBe("role"); }); it("returns 400 for invalid branchId when role=branch", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); const res = await POST( createRequestStub({ username: "newuser", email: "new@example.com", role: "branch", branchId: "XX1", initialPassword: "StrongPassword123", }), ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error.code).toBe("VALIDATION_INVALID_FIELD"); expect(body.error.details.field).toBe("branchId"); }); it("returns 400 for weak initialPassword", async () => { getSession.mockResolvedValue({ userId: "u2", role: "dev", branchId: null, email: "dev@example.com", }); const res = await POST( createRequestStub({ username: "newuser", email: "new@example.com", role: "admin", initialPassword: "short1", }), ); 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(User.create).not.toHaveBeenCalled(); }); it("returns 400 when username already exists", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); User.findOne.mockImplementation((query) => { if (query?.username) { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue({ _id: "x" }), }; } if (query?.email) { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null), }; } return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null), }; }); const res = await POST( createRequestStub({ username: "NewUser", email: "new@example.com", role: "admin", initialPassword: "StrongPassword123", }), ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: { message: "Username already exists", code: "VALIDATION_INVALID_FIELD", details: { field: "username" }, }, }); expect(User.create).not.toHaveBeenCalled(); expect(bcryptHash).not.toHaveBeenCalled(); }); it("returns 400 when email already exists", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); User.findOne.mockImplementation((query) => { if (query?.username) { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null), }; } if (query?.email) { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue({ _id: "y" }), }; } return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null), }; }); const res = await POST( createRequestStub({ username: "newuser", email: "NEW@EXAMPLE.COM", role: "admin", initialPassword: "StrongPassword123", }), ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: { message: "Email already exists", code: "VALIDATION_INVALID_FIELD", details: { field: "email" }, }, }); expect(User.create).not.toHaveBeenCalled(); expect(bcryptHash).not.toHaveBeenCalled(); }); it("returns 400 when username AND email already exist", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); User.findOne.mockImplementation((query) => { if (query?.username) { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue({ _id: "x" }), }; } if (query?.email) { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue({ _id: "y" }), }; } return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null), }; }); const res = await POST( createRequestStub({ username: "newuser", email: "new@example.com", role: "admin", initialPassword: "StrongPassword123", }), ); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: { message: "Username and email already exist", code: "VALIDATION_INVALID_FIELD", details: { fields: ["username", "email"] }, }, }); expect(User.create).not.toHaveBeenCalled(); expect(bcryptHash).not.toHaveBeenCalled(); }); it("returns 200 and creates user with hashed password + mustChangePassword=true", async () => { getSession.mockResolvedValue({ userId: "u2", role: "superadmin", branchId: null, email: "superadmin@example.com", }); // No duplicates User.findOne.mockImplementation(() => { return { select: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null), }; }); User.create.mockResolvedValue({ _id: "507f1f77bcf86cd799439099", username: "newuser", email: "new@example.com", role: "branch", branchId: "NL01", mustChangePassword: true, createdAt: new Date("2026-02-06T10:00:00.000Z"), updatedAt: new Date("2026-02-06T10:00:00.000Z"), }); const res = await POST( createRequestStub({ username: "NewUser", email: "NEW@EXAMPLE.COM", role: "branch", branchId: "nl01", initialPassword: "StrongPassword123", }), ); expect(res.status).toBe(200); expect(getDb).toHaveBeenCalledTimes(1); expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12); expect(User.findOne).toHaveBeenCalledWith({ username: "newuser" }); expect(User.findOne).toHaveBeenCalledWith({ email: "new@example.com" }); expect(User.create).toHaveBeenCalledWith( expect.objectContaining({ username: "newuser", email: "new@example.com", role: "branch", branchId: "NL01", passwordHash: "hashed", mustChangePassword: true, }), ); const body = await res.json(); expect(body).toMatchObject({ ok: true, user: { id: "507f1f77bcf86cd799439099", username: "newuser", email: "new@example.com", role: "branch", branchId: "NL01", mustChangePassword: true, }, }); }); });