user-management.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import { describe, expect, it, vi, beforeEach } from "vitest";
  2. import { appRouter } from "./routers";
  3. import type { TrpcContext } from "./_core/context";
  4. type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
  5. /* ─── Context helpers ─── */
  6. function createContext(role: "admin" | "agent" | "user" | null): TrpcContext {
  7. if (!role) {
  8. return {
  9. user: null,
  10. req: { protocol: "https", headers: {} } as TrpcContext["req"],
  11. res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
  12. };
  13. }
  14. const user: AuthenticatedUser = {
  15. id: role === "admin" ? 1 : role === "agent" ? 2 : 3,
  16. openId: `${role}-openid`,
  17. email: `${role}@homelegance.com`,
  18. name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`,
  19. loginMethod: "manus",
  20. role,
  21. createdAt: new Date(),
  22. updatedAt: new Date(),
  23. lastSignedIn: new Date(),
  24. };
  25. return {
  26. user,
  27. req: { protocol: "https", headers: {} } as TrpcContext["req"],
  28. res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
  29. };
  30. }
  31. /* ─── Mock database ─── */
  32. const mockUsers = [
  33. { id: 1, openId: "admin-openid", name: "Test Admin", email: "admin@homelegance.com", role: "admin", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  34. { id: 2, openId: "agent-openid", name: "Test Agent", email: "agent@homelegance.com", role: "agent", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  35. { id: 3, openId: "user-openid", name: "Test User", email: "user@homelegance.com", role: "user", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  36. ];
  37. const mockInvitations: any[] = [];
  38. const mockAuditLogs: any[] = [];
  39. let inviteIdCounter = 1;
  40. let auditIdCounter = 1;
  41. vi.mock("./db", () => ({
  42. createConversation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date(), updatedAt: new Date() })),
  43. getConversations: vi.fn(async () => []),
  44. getConversationById: vi.fn(async (id: number) => ({ id, sessionId: "test", status: "active", createdAt: new Date(), updatedAt: new Date() })),
  45. getConversationBySessionId: vi.fn(async () => null),
  46. updateConversationStatus: vi.fn(async (id: number, status: string) => ({ id, status })),
  47. getConversationStats: vi.fn(async () => ({ total: 5, active: 2, escalated: 1, resolved: 1, closed: 1 })),
  48. addMessage: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  49. getMessagesByConversation: vi.fn(async () => []),
  50. saveWorkflow: vi.fn(async (wid: string, nodes: any[], edges: any[]) => ({ workflowId: wid, nodeCount: nodes.length, edgeCount: edges.length })),
  51. getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })),
  52. getAllUsers: vi.fn(async () => [...mockUsers]),
  53. updateUserRole: vi.fn(async (userId: number, role: string) => {
  54. const u = mockUsers.find(u => u.id === userId);
  55. if (!u) return null;
  56. return { ...u, role };
  57. }),
  58. getUserById: vi.fn(async (userId: number) => mockUsers.find(u => u.id === userId) || null),
  59. getUserByEmail: vi.fn(async (email: string) => mockUsers.find(u => u.email === email) || null),
  60. deleteUser: vi.fn(async (userId: number) => {
  61. const u = mockUsers.find(u => u.id === userId);
  62. return u || null;
  63. }),
  64. createInvitation: vi.fn(async (data: any) => {
  65. const inv = { id: inviteIdCounter++, ...data, createdAt: new Date() };
  66. mockInvitations.push(inv);
  67. return inv;
  68. }),
  69. getAllInvitations: vi.fn(async () => [...mockInvitations]),
  70. getInvitationByToken: vi.fn(async (token: string) => mockInvitations.find(i => i.token === token) || null),
  71. getInvitationByEmail: vi.fn(async (email: string) => mockInvitations.filter(i => i.email === email)),
  72. updateInvitationStatus: vi.fn(async (id: number, status: string, acceptedById?: number) => {
  73. const inv = mockInvitations.find(i => i.id === id);
  74. if (inv) {
  75. inv.status = status;
  76. if (acceptedById) inv.acceptedById = acceptedById;
  77. inv.acceptedAt = status === "accepted" ? new Date() : null;
  78. }
  79. return inv;
  80. }),
  81. expireOldInvitations: vi.fn(async () => {}),
  82. createAuditLog: vi.fn(async (data: any) => {
  83. const log = { id: auditIdCounter++, ...data, createdAt: new Date() };
  84. mockAuditLogs.push(log);
  85. return log;
  86. }),
  87. getAuditLogs: vi.fn(async (limit?: number) => mockAuditLogs.slice(0, limit || 50)),
  88. upsertUser: vi.fn(),
  89. getUserByOpenId: vi.fn(),
  90. }));
  91. vi.mock("./_core/llm", () => ({
  92. invokeLLM: vi.fn(async () => ({
  93. id: "test",
  94. created: Date.now(),
  95. model: "test",
  96. choices: [{ index: 0, message: { role: "assistant", content: "Test response" }, finish_reason: "stop" }],
  97. })),
  98. }));
  99. vi.mock("./_core/notification", () => ({
  100. notifyOwner: vi.fn(async () => true),
  101. }));
  102. /* ═══════════════════════════════════════════════════════════════
  103. INVITATION SYSTEM
  104. ═══════════════════════════════════════════════════════════════ */
  105. describe("Invitation system", () => {
  106. beforeEach(() => {
  107. mockInvitations.length = 0;
  108. mockAuditLogs.length = 0;
  109. inviteIdCounter = 1;
  110. auditIdCounter = 1;
  111. });
  112. describe("Send invitation", () => {
  113. it("admin can send an invitation", async () => {
  114. const caller = appRouter.createCaller(createContext("admin"));
  115. const result = await caller.invitations.send({
  116. email: "newuser@example.com",
  117. role: "agent",
  118. message: "Welcome to the team!",
  119. });
  120. expect(result).toHaveProperty("email", "newuser@example.com");
  121. expect(result).toHaveProperty("role", "agent");
  122. expect(result).toHaveProperty("token");
  123. expect(result).toHaveProperty("status", "pending");
  124. });
  125. it("rejects invitation for existing user email", async () => {
  126. const caller = appRouter.createCaller(createContext("admin"));
  127. await expect(
  128. caller.invitations.send({ email: "agent@homelegance.com", role: "admin" })
  129. ).rejects.toThrow("already exists");
  130. });
  131. it("rejects duplicate pending invitation", async () => {
  132. const caller = appRouter.createCaller(createContext("admin"));
  133. await caller.invitations.send({ email: "dup@example.com", role: "agent" });
  134. await expect(
  135. caller.invitations.send({ email: "dup@example.com", role: "admin" })
  136. ).rejects.toThrow("pending invitation already exists");
  137. });
  138. it("agent cannot send invitations", async () => {
  139. const caller = appRouter.createCaller(createContext("agent"));
  140. await expect(
  141. caller.invitations.send({ email: "test@example.com", role: "agent" })
  142. ).rejects.toThrow();
  143. });
  144. it("regular user cannot send invitations", async () => {
  145. const caller = appRouter.createCaller(createContext("user"));
  146. await expect(
  147. caller.invitations.send({ email: "test@example.com", role: "agent" })
  148. ).rejects.toThrow();
  149. });
  150. });
  151. describe("List invitations", () => {
  152. it("admin can list invitations", async () => {
  153. const caller = appRouter.createCaller(createContext("admin"));
  154. await caller.invitations.send({ email: "list@example.com", role: "agent" });
  155. const result = await caller.invitations.list();
  156. expect(Array.isArray(result)).toBe(true);
  157. expect(result.length).toBeGreaterThan(0);
  158. });
  159. it("agent cannot list invitations", async () => {
  160. const caller = appRouter.createCaller(createContext("agent"));
  161. await expect(caller.invitations.list()).rejects.toThrow();
  162. });
  163. });
  164. describe("Revoke invitation", () => {
  165. it("admin can revoke a pending invitation", async () => {
  166. const caller = appRouter.createCaller(createContext("admin"));
  167. const inv = await caller.invitations.send({ email: "revoke@example.com", role: "agent" });
  168. const result = await caller.invitations.revoke({ invitationId: inv.id });
  169. expect(result).toHaveProperty("status", "revoked");
  170. });
  171. it("cannot revoke a non-pending invitation", async () => {
  172. const caller = appRouter.createCaller(createContext("admin"));
  173. const inv = await caller.invitations.send({ email: "revoke2@example.com", role: "agent" });
  174. await caller.invitations.revoke({ invitationId: inv.id });
  175. await expect(
  176. caller.invitations.revoke({ invitationId: inv.id })
  177. ).rejects.toThrow("Cannot revoke");
  178. });
  179. });
  180. describe("Validate invitation", () => {
  181. it("validates a pending invitation", async () => {
  182. const caller = appRouter.createCaller(createContext("admin"));
  183. const inv = await caller.invitations.send({ email: "validate@example.com", role: "agent" });
  184. const publicCaller = appRouter.createCaller(createContext(null));
  185. const result = await publicCaller.invitations.validate({ token: inv.token });
  186. expect(result.valid).toBe(true);
  187. if (result.valid) {
  188. expect(result.email).toBe("validate@example.com");
  189. expect(result.role).toBe("agent");
  190. }
  191. });
  192. it("returns invalid for non-existent token", async () => {
  193. const publicCaller = appRouter.createCaller(createContext(null));
  194. const result = await publicCaller.invitations.validate({ token: "nonexistent" });
  195. expect(result.valid).toBe(false);
  196. });
  197. it("returns invalid for revoked invitation", async () => {
  198. const caller = appRouter.createCaller(createContext("admin"));
  199. const inv = await caller.invitations.send({ email: "revoked@example.com", role: "agent" });
  200. await caller.invitations.revoke({ invitationId: inv.id });
  201. const publicCaller = appRouter.createCaller(createContext(null));
  202. const result = await publicCaller.invitations.validate({ token: inv.token });
  203. expect(result.valid).toBe(false);
  204. });
  205. });
  206. describe("Accept invitation", () => {
  207. it("authenticated user can accept a valid invitation", async () => {
  208. const adminCaller = appRouter.createCaller(createContext("admin"));
  209. const inv = await adminCaller.invitations.send({ email: "accept@example.com", role: "agent" });
  210. const userCaller = appRouter.createCaller(createContext("user"));
  211. const result = await userCaller.invitations.accept({ token: inv.token });
  212. expect(result.success).toBe(true);
  213. expect(result.role).toBe("agent");
  214. });
  215. it("unauthenticated user cannot accept invitation", async () => {
  216. const adminCaller = appRouter.createCaller(createContext("admin"));
  217. const inv = await adminCaller.invitations.send({ email: "noauth@example.com", role: "agent" });
  218. const publicCaller = appRouter.createCaller(createContext(null));
  219. await expect(
  220. publicCaller.invitations.accept({ token: inv.token })
  221. ).rejects.toThrow();
  222. });
  223. });
  224. describe("Resend invitation", () => {
  225. it("admin can resend a pending invitation", async () => {
  226. const caller = appRouter.createCaller(createContext("admin"));
  227. const inv = await caller.invitations.send({ email: "resend@example.com", role: "agent" });
  228. const result = await caller.invitations.resend({ invitationId: inv.id });
  229. expect(result).toHaveProperty("email", "resend@example.com");
  230. expect(result).toHaveProperty("status", "pending");
  231. expect(result.token).not.toBe(inv.token); // New token
  232. });
  233. });
  234. });
  235. /* ═══════════════════════════════════════════════════════════════
  236. USER DELETION
  237. ═══════════════════════════════════════════════════════════════ */
  238. describe("User deletion", () => {
  239. beforeEach(() => {
  240. mockAuditLogs.length = 0;
  241. auditIdCounter = 1;
  242. });
  243. it("admin can delete a user", async () => {
  244. const caller = appRouter.createCaller(createContext("admin"));
  245. const result = await caller.users.delete({ userId: 2 });
  246. expect(result.success).toBe(true);
  247. });
  248. it("admin cannot delete themselves", async () => {
  249. const caller = appRouter.createCaller(createContext("admin"));
  250. await expect(
  251. caller.users.delete({ userId: 1 })
  252. ).rejects.toThrow("You cannot delete your own account");
  253. });
  254. it("agent cannot delete users", async () => {
  255. const caller = appRouter.createCaller(createContext("agent"));
  256. await expect(
  257. caller.users.delete({ userId: 3 })
  258. ).rejects.toThrow();
  259. });
  260. it("regular user cannot delete users", async () => {
  261. const caller = appRouter.createCaller(createContext("user"));
  262. await expect(
  263. caller.users.delete({ userId: 2 })
  264. ).rejects.toThrow();
  265. });
  266. });
  267. /* ═══════════════════════════════════════════════════════════════
  268. BULK ACTIONS
  269. ═══════════════════════════════════════════════════════════════ */
  270. describe("Bulk actions", () => {
  271. beforeEach(() => {
  272. mockAuditLogs.length = 0;
  273. auditIdCounter = 1;
  274. });
  275. it("admin can bulk update roles", async () => {
  276. const caller = appRouter.createCaller(createContext("admin"));
  277. const results = await caller.users.bulkUpdateRole({ userIds: [2, 3], role: "agent" });
  278. expect(results).toHaveLength(2);
  279. expect(results.every(r => r.success)).toBe(true);
  280. });
  281. it("bulk update skips self", async () => {
  282. const caller = appRouter.createCaller(createContext("admin"));
  283. const results = await caller.users.bulkUpdateRole({ userIds: [1, 2], role: "user" });
  284. expect(results[0].success).toBe(false);
  285. expect(results[0].error).toBe("Cannot change own role");
  286. expect(results[1].success).toBe(true);
  287. });
  288. it("admin can bulk delete users", async () => {
  289. const caller = appRouter.createCaller(createContext("admin"));
  290. const results = await caller.users.bulkDelete({ userIds: [2, 3] });
  291. expect(results).toHaveLength(2);
  292. expect(results.every(r => r.success)).toBe(true);
  293. });
  294. it("bulk delete skips self", async () => {
  295. const caller = appRouter.createCaller(createContext("admin"));
  296. const results = await caller.users.bulkDelete({ userIds: [1, 2] });
  297. expect(results[0].success).toBe(false);
  298. expect(results[0].error).toBe("Cannot delete own account");
  299. expect(results[1].success).toBe(true);
  300. });
  301. it("agent cannot perform bulk actions", async () => {
  302. const caller = appRouter.createCaller(createContext("agent"));
  303. await expect(
  304. caller.users.bulkUpdateRole({ userIds: [3], role: "agent" })
  305. ).rejects.toThrow();
  306. await expect(
  307. caller.users.bulkDelete({ userIds: [3] })
  308. ).rejects.toThrow();
  309. });
  310. });
  311. /* ═══════════════════════════════════════════════════════════════
  312. AUDIT LOGS
  313. ═══════════════════════════════════════════════════════════════ */
  314. describe("Audit logs", () => {
  315. beforeEach(() => {
  316. mockAuditLogs.length = 0;
  317. auditIdCounter = 1;
  318. });
  319. it("admin can view audit logs", async () => {
  320. const caller = appRouter.createCaller(createContext("admin"));
  321. const result = await caller.auditLogs.list({ limit: 10 });
  322. expect(Array.isArray(result)).toBe(true);
  323. });
  324. it("agent cannot view audit logs", async () => {
  325. const caller = appRouter.createCaller(createContext("agent"));
  326. await expect(caller.auditLogs.list({ limit: 10 })).rejects.toThrow();
  327. });
  328. it("audit log is created on role change", async () => {
  329. const caller = appRouter.createCaller(createContext("admin"));
  330. await caller.users.updateRole({ userId: 2, role: "admin" });
  331. expect(mockAuditLogs.length).toBeGreaterThan(0);
  332. expect(mockAuditLogs[0].action).toBe("role_change");
  333. });
  334. it("audit log is created on user deletion", async () => {
  335. const caller = appRouter.createCaller(createContext("admin"));
  336. await caller.users.delete({ userId: 2 });
  337. expect(mockAuditLogs.some(l => l.action === "user_deleted")).toBe(true);
  338. });
  339. it("audit log is created on invitation send", async () => {
  340. mockInvitations.length = 0;
  341. inviteIdCounter = 1;
  342. const caller = appRouter.createCaller(createContext("admin"));
  343. await caller.invitations.send({ email: "auditlog@example.com", role: "agent" });
  344. expect(mockAuditLogs.some(l => l.action === "invitation_sent")).toBe(true);
  345. });
  346. });
  347. /* ═══════════════════════════════════════════════════════════════
  348. CSV EXPORT
  349. ═══════════════════════════════════════════════════════════════ */
  350. describe("CSV export", () => {
  351. it("admin can export users as CSV", async () => {
  352. const caller = appRouter.createCaller(createContext("admin"));
  353. const result = await caller.users.exportCsv();
  354. expect(result).toHaveProperty("csv");
  355. expect(result).toHaveProperty("count", 3);
  356. expect(result.csv).toContain("ID,Name,Email,Role");
  357. expect(result.csv).toContain("admin@homelegance.com");
  358. });
  359. it("agent cannot export CSV", async () => {
  360. const caller = appRouter.createCaller(createContext("agent"));
  361. await expect(caller.users.exportCsv()).rejects.toThrow();
  362. });
  363. });