route.test.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
  135. });
  136. });
  137. describe("POST /api/admin/users", () => {
  138. beforeEach(() => {
  139. vi.clearAllMocks();
  140. getDb.mockResolvedValue({});
  141. bcryptHash.mockResolvedValue("hashed");
  142. });
  143. it("returns 401 when unauthenticated", async () => {
  144. getSession.mockResolvedValue(null);
  145. const res = await POST(createRequestStub({}));
  146. expect(res.status).toBe(401);
  147. expect(await res.json()).toEqual({
  148. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  149. });
  150. });
  151. it("returns 403 when authenticated but not allowed (admin)", async () => {
  152. getSession.mockResolvedValue({
  153. userId: "u1",
  154. role: "admin",
  155. branchId: null,
  156. email: "admin@example.com",
  157. });
  158. const res = await POST(createRequestStub({}));
  159. expect(res.status).toBe(403);
  160. expect(await res.json()).toEqual({
  161. error: {
  162. message: "Forbidden",
  163. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  164. },
  165. });
  166. });
  167. it("returns 400 when JSON parsing fails", async () => {
  168. getSession.mockResolvedValue({
  169. userId: "u2",
  170. role: "superadmin",
  171. branchId: null,
  172. email: "superadmin@example.com",
  173. });
  174. const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
  175. const res = await POST(req);
  176. expect(res.status).toBe(400);
  177. expect(await res.json()).toEqual({
  178. error: {
  179. message: "Invalid request body",
  180. code: "VALIDATION_INVALID_JSON",
  181. },
  182. });
  183. });
  184. it("returns 400 when body is not an object", async () => {
  185. getSession.mockResolvedValue({
  186. userId: "u2",
  187. role: "superadmin",
  188. branchId: null,
  189. email: "superadmin@example.com",
  190. });
  191. const res = await POST(createRequestStub("nope"));
  192. expect(res.status).toBe(400);
  193. expect(await res.json()).toEqual({
  194. error: {
  195. message: "Invalid request body",
  196. code: "VALIDATION_INVALID_BODY",
  197. },
  198. });
  199. });
  200. it("returns 400 when fields are missing", async () => {
  201. getSession.mockResolvedValue({
  202. userId: "u2",
  203. role: "dev",
  204. branchId: null,
  205. email: "dev@example.com",
  206. });
  207. const res = await POST(createRequestStub({}));
  208. expect(res.status).toBe(400);
  209. expect(await res.json()).toEqual({
  210. error: {
  211. message: "Missing required fields",
  212. code: "VALIDATION_MISSING_FIELD",
  213. details: { fields: ["username", "email", "role", "initialPassword"] },
  214. },
  215. });
  216. });
  217. it("returns 400 for invalid role", async () => {
  218. getSession.mockResolvedValue({
  219. userId: "u2",
  220. role: "superadmin",
  221. branchId: null,
  222. email: "superadmin@example.com",
  223. });
  224. const res = await POST(
  225. createRequestStub({
  226. username: "newuser",
  227. email: "new@example.com",
  228. role: "nope",
  229. initialPassword: "StrongPassword123",
  230. }),
  231. );
  232. expect(res.status).toBe(400);
  233. const body = await res.json();
  234. expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
  235. expect(body.error.details.field).toBe("role");
  236. });
  237. it("returns 400 for invalid branchId when role=branch", async () => {
  238. getSession.mockResolvedValue({
  239. userId: "u2",
  240. role: "superadmin",
  241. branchId: null,
  242. email: "superadmin@example.com",
  243. });
  244. const res = await POST(
  245. createRequestStub({
  246. username: "newuser",
  247. email: "new@example.com",
  248. role: "branch",
  249. branchId: "XX1",
  250. initialPassword: "StrongPassword123",
  251. }),
  252. );
  253. expect(res.status).toBe(400);
  254. const body = await res.json();
  255. expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
  256. expect(body.error.details.field).toBe("branchId");
  257. });
  258. it("returns 400 for weak initialPassword", async () => {
  259. getSession.mockResolvedValue({
  260. userId: "u2",
  261. role: "dev",
  262. branchId: null,
  263. email: "dev@example.com",
  264. });
  265. const res = await POST(
  266. createRequestStub({
  267. username: "newuser",
  268. email: "new@example.com",
  269. role: "admin",
  270. initialPassword: "short1",
  271. }),
  272. );
  273. expect(res.status).toBe(400);
  274. const body = await res.json();
  275. expect(body.error.code).toBe("VALIDATION_WEAK_PASSWORD");
  276. expect(body.error.details).toMatchObject({
  277. minLength: 8,
  278. requireLetter: true,
  279. requireNumber: true,
  280. });
  281. expect(Array.isArray(body.error.details.reasons)).toBe(true);
  282. });
  283. it("returns 200 and creates user with hashed password + mustChangePassword=true", async () => {
  284. getSession.mockResolvedValue({
  285. userId: "u2",
  286. role: "superadmin",
  287. branchId: null,
  288. email: "superadmin@example.com",
  289. });
  290. User.findOne.mockImplementation((query) => {
  291. return {
  292. select: vi.fn().mockReturnThis(),
  293. exec: vi.fn().mockResolvedValue(null),
  294. };
  295. });
  296. User.create.mockResolvedValue({
  297. _id: "507f1f77bcf86cd799439099",
  298. username: "newuser",
  299. email: "new@example.com",
  300. role: "branch",
  301. branchId: "NL01",
  302. mustChangePassword: true,
  303. createdAt: new Date("2026-02-06T10:00:00.000Z"),
  304. updatedAt: new Date("2026-02-06T10:00:00.000Z"),
  305. });
  306. const res = await POST(
  307. createRequestStub({
  308. username: "NewUser",
  309. email: "NEW@EXAMPLE.COM",
  310. role: "branch",
  311. branchId: "nl01",
  312. initialPassword: "StrongPassword123",
  313. }),
  314. );
  315. expect(res.status).toBe(200);
  316. expect(getDb).toHaveBeenCalledTimes(1);
  317. expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12);
  318. expect(User.findOne).toHaveBeenCalledWith({ username: "newuser" });
  319. expect(User.findOne).toHaveBeenCalledWith({ email: "new@example.com" });
  320. expect(User.create).toHaveBeenCalledWith(
  321. expect.objectContaining({
  322. username: "newuser",
  323. email: "new@example.com",
  324. role: "branch",
  325. branchId: "NL01",
  326. passwordHash: "hashed",
  327. mustChangePassword: true,
  328. }),
  329. );
  330. const body = await res.json();
  331. expect(body).toMatchObject({
  332. ok: true,
  333. user: {
  334. id: "507f1f77bcf86cd799439099",
  335. username: "newuser",
  336. email: "new@example.com",
  337. role: "branch",
  338. branchId: "NL01",
  339. mustChangePassword: true,
  340. },
  341. });
  342. });
  343. });