| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832 |
- /* @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,
- },
- });
- });
- });
|