|
|
@@ -0,0 +1,409 @@
|
|
|
+/* @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 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.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+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);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("returns 200 and creates user with hashed password + mustChangePassword=true", async () => {
|
|
|
+ getSession.mockResolvedValue({
|
|
|
+ userId: "u2",
|
|
|
+ role: "superadmin",
|
|
|
+ branchId: null,
|
|
|
+ email: "superadmin@example.com",
|
|
|
+ });
|
|
|
+
|
|
|
+ User.findOne.mockImplementation((query) => {
|
|
|
+ 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,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|