route.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach } from "vitest";
  3. vi.mock("@/lib/auth/session", () => ({
  4. getSession: vi.fn(),
  5. }));
  6. vi.mock("@/lib/db", () => ({
  7. getDb: vi.fn(),
  8. }));
  9. vi.mock("@/models/user", () => {
  10. const USER_ROLES = Object.freeze({
  11. BRANCH: "branch",
  12. ADMIN: "admin",
  13. SUPERADMIN: "superadmin",
  14. DEV: "dev",
  15. });
  16. return {
  17. default: {
  18. find: vi.fn(),
  19. findOne: vi.fn(),
  20. create: vi.fn(),
  21. },
  22. USER_ROLES,
  23. };
  24. });
  25. vi.mock("bcryptjs", () => {
  26. const hash = vi.fn();
  27. return {
  28. default: { hash },
  29. hash,
  30. };
  31. });
  32. import { getSession } from "@/lib/auth/session";
  33. import { getDb } from "@/lib/db";
  34. import User from "@/models/user";
  35. import { hash as bcryptHash } from "bcryptjs";
  36. import { GET, POST, dynamic } from "./route.js";
  37. function buildCursor(lastId) {
  38. return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
  39. "base64url",
  40. );
  41. }
  42. function createRequestStub(body) {
  43. return {
  44. async json() {
  45. return body;
  46. },
  47. };
  48. }
  49. describe("GET /api/admin/users", () => {
  50. beforeEach(() => {
  51. vi.clearAllMocks();
  52. getDb.mockResolvedValue({});
  53. });
  54. it('exports dynamic="force-dynamic"', () => {
  55. expect(dynamic).toBe("force-dynamic");
  56. });
  57. it("returns 401 when unauthenticated", async () => {
  58. getSession.mockResolvedValue(null);
  59. const res = await GET(new Request("http://localhost/api/admin/users"));
  60. expect(res.status).toBe(401);
  61. expect(await res.json()).toEqual({
  62. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  63. });
  64. });
  65. it("returns 403 when authenticated but not allowed (admin)", async () => {
  66. getSession.mockResolvedValue({
  67. userId: "u1",
  68. role: "admin",
  69. branchId: null,
  70. email: "admin@example.com",
  71. });
  72. const res = await GET(new Request("http://localhost/api/admin/users"));
  73. expect(res.status).toBe(403);
  74. expect(await res.json()).toEqual({
  75. error: {
  76. message: "Forbidden",
  77. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  78. },
  79. });
  80. expect(User.find).not.toHaveBeenCalled();
  81. });
  82. it("returns 200 with items and nextCursor (superadmin, limit + cursor)", async () => {
  83. getSession.mockResolvedValue({
  84. userId: "u2",
  85. role: "superadmin",
  86. branchId: null,
  87. email: "superadmin@example.com",
  88. });
  89. const d1 = {
  90. _id: "507f1f77bcf86cd799439013",
  91. username: "u3",
  92. email: "u3@example.com",
  93. role: "admin",
  94. branchId: null,
  95. mustChangePassword: false,
  96. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  97. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  98. };
  99. const d2 = {
  100. _id: "507f1f77bcf86cd799439012",
  101. username: "u2",
  102. email: "u2@example.com",
  103. role: "branch",
  104. branchId: "NL01",
  105. mustChangePassword: true,
  106. createdAt: new Date("2026-02-01T09:00:00.000Z"),
  107. updatedAt: new Date("2026-02-02T09:00:00.000Z"),
  108. };
  109. const d3 = {
  110. _id: "507f1f77bcf86cd799439011",
  111. username: "u1",
  112. email: "u1@example.com",
  113. role: "dev",
  114. branchId: null,
  115. mustChangePassword: false,
  116. createdAt: new Date("2026-02-01T08:00:00.000Z"),
  117. updatedAt: new Date("2026-02-02T08:00:00.000Z"),
  118. };
  119. const chain = {
  120. sort: vi.fn().mockReturnThis(),
  121. limit: vi.fn().mockReturnThis(),
  122. select: vi.fn().mockReturnThis(),
  123. exec: vi.fn().mockResolvedValue([d1, d2, d3]),
  124. };
  125. User.find.mockReturnValue(chain);
  126. const res = await GET(
  127. new Request("http://localhost/api/admin/users?limit=2"),
  128. );
  129. expect(res.status).toBe(200);
  130. expect(chain.sort).toHaveBeenCalledWith({ _id: -1 });
  131. expect(chain.limit).toHaveBeenCalledWith(3); // limit + 1
  132. const body = await res.json();
  133. expect(body.items).toHaveLength(2);
  134. expect(body.items[0]).toMatchObject({
  135. id: "507f1f77bcf86cd799439013",
  136. username: "u3",
  137. email: "u3@example.com",
  138. role: "admin",
  139. branchId: null,
  140. mustChangePassword: false,
  141. });
  142. expect(body.items[1]).toMatchObject({
  143. id: "507f1f77bcf86cd799439012",
  144. username: "u2",
  145. email: "u2@example.com",
  146. role: "branch",
  147. branchId: "NL01",
  148. mustChangePassword: true,
  149. });
  150. expect(body.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
  151. });
  152. });
  153. describe("POST /api/admin/users", () => {
  154. beforeEach(() => {
  155. vi.clearAllMocks();
  156. getDb.mockResolvedValue({});
  157. bcryptHash.mockResolvedValue("hashed");
  158. });
  159. it("returns 401 when unauthenticated", async () => {
  160. getSession.mockResolvedValue(null);
  161. const res = await POST(createRequestStub({}));
  162. expect(res.status).toBe(401);
  163. expect(await res.json()).toEqual({
  164. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  165. });
  166. });
  167. it("returns 403 when authenticated but not allowed (admin)", async () => {
  168. getSession.mockResolvedValue({
  169. userId: "u1",
  170. role: "admin",
  171. branchId: null,
  172. email: "admin@example.com",
  173. });
  174. const res = await POST(createRequestStub({}));
  175. expect(res.status).toBe(403);
  176. expect(await res.json()).toEqual({
  177. error: {
  178. message: "Forbidden",
  179. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  180. },
  181. });
  182. });
  183. it("returns 400 when JSON parsing fails", async () => {
  184. getSession.mockResolvedValue({
  185. userId: "u2",
  186. role: "superadmin",
  187. branchId: null,
  188. email: "superadmin@example.com",
  189. });
  190. const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
  191. const res = await POST(req);
  192. expect(res.status).toBe(400);
  193. expect(await res.json()).toEqual({
  194. error: {
  195. message: "Invalid request body",
  196. code: "VALIDATION_INVALID_JSON",
  197. },
  198. });
  199. });
  200. it("returns 400 when body is not an object", async () => {
  201. getSession.mockResolvedValue({
  202. userId: "u2",
  203. role: "superadmin",
  204. branchId: null,
  205. email: "superadmin@example.com",
  206. });
  207. const res = await POST(createRequestStub("nope"));
  208. expect(res.status).toBe(400);
  209. expect(await res.json()).toEqual({
  210. error: {
  211. message: "Invalid request body",
  212. code: "VALIDATION_INVALID_BODY",
  213. },
  214. });
  215. });
  216. it("returns 400 when fields are missing", async () => {
  217. getSession.mockResolvedValue({
  218. userId: "u2",
  219. role: "dev",
  220. branchId: null,
  221. email: "dev@example.com",
  222. });
  223. const res = await POST(createRequestStub({}));
  224. expect(res.status).toBe(400);
  225. expect(await res.json()).toEqual({
  226. error: {
  227. message: "Missing required fields",
  228. code: "VALIDATION_MISSING_FIELD",
  229. details: { fields: ["username", "email", "role", "initialPassword"] },
  230. },
  231. });
  232. });
  233. it("returns 400 for invalid role", async () => {
  234. getSession.mockResolvedValue({
  235. userId: "u2",
  236. role: "superadmin",
  237. branchId: null,
  238. email: "superadmin@example.com",
  239. });
  240. const res = await POST(
  241. createRequestStub({
  242. username: "newuser",
  243. email: "new@example.com",
  244. role: "nope",
  245. initialPassword: "StrongPassword123",
  246. }),
  247. );
  248. expect(res.status).toBe(400);
  249. const body = await res.json();
  250. expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
  251. expect(body.error.details.field).toBe("role");
  252. });
  253. it("returns 400 for invalid branchId when role=branch", async () => {
  254. getSession.mockResolvedValue({
  255. userId: "u2",
  256. role: "superadmin",
  257. branchId: null,
  258. email: "superadmin@example.com",
  259. });
  260. const res = await POST(
  261. createRequestStub({
  262. username: "newuser",
  263. email: "new@example.com",
  264. role: "branch",
  265. branchId: "XX1",
  266. initialPassword: "StrongPassword123",
  267. }),
  268. );
  269. expect(res.status).toBe(400);
  270. const body = await res.json();
  271. expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
  272. expect(body.error.details.field).toBe("branchId");
  273. });
  274. it("returns 400 for weak initialPassword", async () => {
  275. getSession.mockResolvedValue({
  276. userId: "u2",
  277. role: "dev",
  278. branchId: null,
  279. email: "dev@example.com",
  280. });
  281. const res = await POST(
  282. createRequestStub({
  283. username: "newuser",
  284. email: "new@example.com",
  285. role: "admin",
  286. initialPassword: "short1",
  287. }),
  288. );
  289. expect(res.status).toBe(400);
  290. const body = await res.json();
  291. expect(body.error.code).toBe("VALIDATION_WEAK_PASSWORD");
  292. expect(body.error.details).toMatchObject({
  293. minLength: 8,
  294. requireLetter: true,
  295. requireNumber: true,
  296. });
  297. expect(Array.isArray(body.error.details.reasons)).toBe(true);
  298. expect(User.create).not.toHaveBeenCalled();
  299. });
  300. it("returns 400 when username already exists", async () => {
  301. getSession.mockResolvedValue({
  302. userId: "u2",
  303. role: "superadmin",
  304. branchId: null,
  305. email: "superadmin@example.com",
  306. });
  307. User.findOne.mockImplementation((query) => {
  308. if (query?.username) {
  309. return {
  310. select: vi.fn().mockReturnThis(),
  311. exec: vi.fn().mockResolvedValue({ _id: "x" }),
  312. };
  313. }
  314. if (query?.email) {
  315. return {
  316. select: vi.fn().mockReturnThis(),
  317. exec: vi.fn().mockResolvedValue(null),
  318. };
  319. }
  320. return {
  321. select: vi.fn().mockReturnThis(),
  322. exec: vi.fn().mockResolvedValue(null),
  323. };
  324. });
  325. const res = await POST(
  326. createRequestStub({
  327. username: "NewUser",
  328. email: "new@example.com",
  329. role: "admin",
  330. initialPassword: "StrongPassword123",
  331. }),
  332. );
  333. expect(res.status).toBe(400);
  334. expect(await res.json()).toEqual({
  335. error: {
  336. message: "Username already exists",
  337. code: "VALIDATION_INVALID_FIELD",
  338. details: { field: "username" },
  339. },
  340. });
  341. expect(User.create).not.toHaveBeenCalled();
  342. expect(bcryptHash).not.toHaveBeenCalled();
  343. });
  344. it("returns 400 when email already exists", async () => {
  345. getSession.mockResolvedValue({
  346. userId: "u2",
  347. role: "superadmin",
  348. branchId: null,
  349. email: "superadmin@example.com",
  350. });
  351. User.findOne.mockImplementation((query) => {
  352. if (query?.username) {
  353. return {
  354. select: vi.fn().mockReturnThis(),
  355. exec: vi.fn().mockResolvedValue(null),
  356. };
  357. }
  358. if (query?.email) {
  359. return {
  360. select: vi.fn().mockReturnThis(),
  361. exec: vi.fn().mockResolvedValue({ _id: "y" }),
  362. };
  363. }
  364. return {
  365. select: vi.fn().mockReturnThis(),
  366. exec: vi.fn().mockResolvedValue(null),
  367. };
  368. });
  369. const res = await POST(
  370. createRequestStub({
  371. username: "newuser",
  372. email: "NEW@EXAMPLE.COM",
  373. role: "admin",
  374. initialPassword: "StrongPassword123",
  375. }),
  376. );
  377. expect(res.status).toBe(400);
  378. expect(await res.json()).toEqual({
  379. error: {
  380. message: "Email already exists",
  381. code: "VALIDATION_INVALID_FIELD",
  382. details: { field: "email" },
  383. },
  384. });
  385. expect(User.create).not.toHaveBeenCalled();
  386. expect(bcryptHash).not.toHaveBeenCalled();
  387. });
  388. it("returns 400 when username AND email already exist", async () => {
  389. getSession.mockResolvedValue({
  390. userId: "u2",
  391. role: "superadmin",
  392. branchId: null,
  393. email: "superadmin@example.com",
  394. });
  395. User.findOne.mockImplementation((query) => {
  396. if (query?.username) {
  397. return {
  398. select: vi.fn().mockReturnThis(),
  399. exec: vi.fn().mockResolvedValue({ _id: "x" }),
  400. };
  401. }
  402. if (query?.email) {
  403. return {
  404. select: vi.fn().mockReturnThis(),
  405. exec: vi.fn().mockResolvedValue({ _id: "y" }),
  406. };
  407. }
  408. return {
  409. select: vi.fn().mockReturnThis(),
  410. exec: vi.fn().mockResolvedValue(null),
  411. };
  412. });
  413. const res = await POST(
  414. createRequestStub({
  415. username: "newuser",
  416. email: "new@example.com",
  417. role: "admin",
  418. initialPassword: "StrongPassword123",
  419. }),
  420. );
  421. expect(res.status).toBe(400);
  422. expect(await res.json()).toEqual({
  423. error: {
  424. message: "Username and email already exist",
  425. code: "VALIDATION_INVALID_FIELD",
  426. details: { fields: ["username", "email"] },
  427. },
  428. });
  429. expect(User.create).not.toHaveBeenCalled();
  430. expect(bcryptHash).not.toHaveBeenCalled();
  431. });
  432. it("returns 200 and creates user with hashed password + mustChangePassword=true", async () => {
  433. getSession.mockResolvedValue({
  434. userId: "u2",
  435. role: "superadmin",
  436. branchId: null,
  437. email: "superadmin@example.com",
  438. });
  439. // No duplicates
  440. User.findOne.mockImplementation(() => {
  441. return {
  442. select: vi.fn().mockReturnThis(),
  443. exec: vi.fn().mockResolvedValue(null),
  444. };
  445. });
  446. User.create.mockResolvedValue({
  447. _id: "507f1f77bcf86cd799439099",
  448. username: "newuser",
  449. email: "new@example.com",
  450. role: "branch",
  451. branchId: "NL01",
  452. mustChangePassword: true,
  453. createdAt: new Date("2026-02-06T10:00:00.000Z"),
  454. updatedAt: new Date("2026-02-06T10:00:00.000Z"),
  455. });
  456. const res = await POST(
  457. createRequestStub({
  458. username: "NewUser",
  459. email: "NEW@EXAMPLE.COM",
  460. role: "branch",
  461. branchId: "nl01",
  462. initialPassword: "StrongPassword123",
  463. }),
  464. );
  465. expect(res.status).toBe(200);
  466. expect(getDb).toHaveBeenCalledTimes(1);
  467. expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12);
  468. expect(User.findOne).toHaveBeenCalledWith({ username: "newuser" });
  469. expect(User.findOne).toHaveBeenCalledWith({ email: "new@example.com" });
  470. expect(User.create).toHaveBeenCalledWith(
  471. expect.objectContaining({
  472. username: "newuser",
  473. email: "new@example.com",
  474. role: "branch",
  475. branchId: "NL01",
  476. passwordHash: "hashed",
  477. mustChangePassword: true,
  478. }),
  479. );
  480. const body = await res.json();
  481. expect(body).toMatchObject({
  482. ok: true,
  483. user: {
  484. id: "507f1f77bcf86cd799439099",
  485. username: "newuser",
  486. email: "new@example.com",
  487. role: "branch",
  488. branchId: "NL01",
  489. mustChangePassword: true,
  490. },
  491. });
  492. });
  493. });