rbac.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { describe, expect, it, vi } from "vitest";
  2. import { appRouter } from "./routers";
  3. import type { TrpcContext } from "./_core/context";
  4. type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
  5. /* ─── Context helpers for each role ─── */
  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. vi.mock("./db", () => {
  33. const users = [
  34. { id: 1, openId: "admin-openid", name: "Test Admin", email: "admin@homelegance.com", role: "admin", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  35. { id: 2, openId: "agent-openid", name: "Test Agent", email: "agent@homelegance.com", role: "agent", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  36. { id: 3, openId: "user-openid", name: "Test User", email: "user@homelegance.com", role: "user", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  37. ];
  38. return {
  39. createConversation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date(), updatedAt: new Date() })),
  40. getConversations: vi.fn(async () => []),
  41. getConversationById: vi.fn(async (id: number) => ({ id, sessionId: "test", status: "active", createdAt: new Date(), updatedAt: new Date() })),
  42. getConversationBySessionId: vi.fn(async () => null),
  43. updateConversationStatus: vi.fn(async (id: number, status: string) => ({ id, status })),
  44. getConversationStats: vi.fn(async () => ({ total: 5, active: 2, escalated: 1, resolved: 1, closed: 1 })),
  45. addMessage: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  46. getMessagesByConversation: vi.fn(async () => []),
  47. saveWorkflow: vi.fn(async (wid: string, nodes: any[], edges: any[]) => ({ workflowId: wid, nodeCount: nodes.length, edgeCount: edges.length })),
  48. getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })),
  49. getAllUsers: vi.fn(async () => users),
  50. updateUserRole: vi.fn(async (userId: number, role: string) => {
  51. const u = users.find(u => u.id === userId);
  52. if (!u) return null;
  53. return { ...u, role };
  54. }),
  55. getUserById: vi.fn(async (userId: number) => users.find(u => u.id === userId)),
  56. getUserByEmail: vi.fn(async (email: string) => users.find(u => u.email === email) || null),
  57. deleteUser: vi.fn(async (userId: number) => users.find(u => u.id === userId) || null),
  58. createInvitation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  59. getAllInvitations: vi.fn(async () => []),
  60. getInvitationByToken: vi.fn(async () => null),
  61. getInvitationByEmail: vi.fn(async () => []),
  62. updateInvitationStatus: vi.fn(async (id: number, status: string) => ({ id, status })),
  63. expireOldInvitations: vi.fn(async () => {}),
  64. createAuditLog: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  65. getAuditLogs: vi.fn(async () => []),
  66. getConversationsAdvanced: vi.fn(async () => ({ conversations: [], total: 0, page: 1, pageSize: 20, totalPages: 0 })),
  67. getConversationMessageCounts: vi.fn(async () => ({})),
  68. getAgentUsers: vi.fn(async () => []),
  69. bulkUpdateConversationStatus: vi.fn(async (ids: number[]) => ({ updated: ids.length })),
  70. deleteConversations: vi.fn(async (ids: number[]) => ({ deleted: ids.length })),
  71. getUserByEmailWithPassword: vi.fn(async () => null),
  72. createUserWithPassword: vi.fn(async (data: any) => ({ id: 10, ...data })),
  73. updateUserPassword: vi.fn(async () => {}),
  74. createPasswordResetToken: vi.fn(async (data: any) => ({ id: 1, ...data })),
  75. getPasswordResetToken: vi.fn(async () => null),
  76. markPasswordResetTokenUsed: vi.fn(async () => {}),
  77. upsertUser: vi.fn(),
  78. getUserByOpenId: vi.fn(),
  79. };
  80. });
  81. vi.mock("./_core/llm", () => ({
  82. invokeLLM: vi.fn(async () => ({
  83. id: "test",
  84. created: Date.now(),
  85. model: "test",
  86. choices: [{ index: 0, message: { role: "assistant", content: "Test response" }, finish_reason: "stop" }],
  87. })),
  88. }));
  89. /* ═══════════════════════════════════════════════════════════════
  90. AGENT PROCEDURES — require agent or admin role
  91. ═══════════════════════════════════════════════════════════════ */
  92. describe("Agent procedures — role-based access", () => {
  93. describe("Unauthenticated users", () => {
  94. it("rejects agent.stats for unauthenticated user", async () => {
  95. const caller = appRouter.createCaller(createContext(null));
  96. await expect(caller.agent.stats()).rejects.toThrow();
  97. });
  98. it("rejects agent.conversations for unauthenticated user", async () => {
  99. const caller = appRouter.createCaller(createContext(null));
  100. await expect(caller.agent.conversations({})).rejects.toThrow();
  101. });
  102. it("rejects agent.reply for unauthenticated user", async () => {
  103. const caller = appRouter.createCaller(createContext(null));
  104. await expect(caller.agent.reply({ conversationId: 1, content: "Hi" })).rejects.toThrow();
  105. });
  106. });
  107. describe("Regular users (role=user)", () => {
  108. it("rejects agent.stats for regular user", async () => {
  109. const caller = appRouter.createCaller(createContext("user"));
  110. await expect(caller.agent.stats()).rejects.toThrow("Agent or admin access required");
  111. });
  112. it("rejects agent.conversations for regular user", async () => {
  113. const caller = appRouter.createCaller(createContext("user"));
  114. await expect(caller.agent.conversations({})).rejects.toThrow("Agent or admin access required");
  115. });
  116. it("rejects agent.reply for regular user", async () => {
  117. const caller = appRouter.createCaller(createContext("user"));
  118. await expect(caller.agent.reply({ conversationId: 1, content: "Hi" })).rejects.toThrow("Agent or admin access required");
  119. });
  120. it("rejects agent.messages for regular user", async () => {
  121. const caller = appRouter.createCaller(createContext("user"));
  122. await expect(caller.agent.messages({ conversationId: 1 })).rejects.toThrow("Agent or admin access required");
  123. });
  124. it("rejects agent.updateStatus for regular user", async () => {
  125. const caller = appRouter.createCaller(createContext("user"));
  126. await expect(caller.agent.updateStatus({ conversationId: 1, status: "resolved" })).rejects.toThrow("Agent or admin access required");
  127. });
  128. });
  129. describe("Agent users (role=agent)", () => {
  130. it("allows agent.stats for agent user", async () => {
  131. const caller = appRouter.createCaller(createContext("agent"));
  132. const stats = await caller.agent.stats();
  133. expect(stats).toHaveProperty("total");
  134. expect(stats).toHaveProperty("active");
  135. expect(stats).toHaveProperty("escalated");
  136. });
  137. it("allows agent.conversations for agent user", async () => {
  138. const caller = appRouter.createCaller(createContext("agent"));
  139. const result = await caller.agent.conversations({});
  140. expect(Array.isArray(result)).toBe(true);
  141. });
  142. it("allows agent.messages for agent user", async () => {
  143. const caller = appRouter.createCaller(createContext("agent"));
  144. const result = await caller.agent.messages({ conversationId: 1 });
  145. expect(Array.isArray(result)).toBe(true);
  146. });
  147. it("allows agent.reply for agent user", async () => {
  148. const caller = appRouter.createCaller(createContext("agent"));
  149. const result = await caller.agent.reply({ conversationId: 1, content: "I can help you" });
  150. expect(result).toHaveProperty("id");
  151. expect(result.content).toBe("I can help you");
  152. });
  153. });
  154. describe("Admin users (role=admin)", () => {
  155. it("allows agent.stats for admin user", async () => {
  156. const caller = appRouter.createCaller(createContext("admin"));
  157. const stats = await caller.agent.stats();
  158. expect(stats.total).toBe(5);
  159. });
  160. it("allows agent.conversations for admin user", async () => {
  161. const caller = appRouter.createCaller(createContext("admin"));
  162. const result = await caller.agent.conversations({});
  163. expect(Array.isArray(result)).toBe(true);
  164. });
  165. });
  166. });
  167. /* ═══════════════════════════════════════════════════════════════
  168. ADMIN PROCEDURES — require admin role only
  169. ═══════════════════════════════════════════════════════════════ */
  170. describe("Admin procedures — role-based access", () => {
  171. describe("Users management", () => {
  172. it("rejects users.list for unauthenticated user", async () => {
  173. const caller = appRouter.createCaller(createContext(null));
  174. await expect(caller.users.list()).rejects.toThrow();
  175. });
  176. it("rejects users.list for regular user", async () => {
  177. const caller = appRouter.createCaller(createContext("user"));
  178. await expect(caller.users.list()).rejects.toThrow();
  179. });
  180. it("rejects users.list for agent user", async () => {
  181. const caller = appRouter.createCaller(createContext("agent"));
  182. await expect(caller.users.list()).rejects.toThrow();
  183. });
  184. it("allows users.list for admin user", async () => {
  185. const caller = appRouter.createCaller(createContext("admin"));
  186. const result = await caller.users.list();
  187. expect(Array.isArray(result)).toBe(true);
  188. expect(result.length).toBe(3);
  189. });
  190. it("allows users.updateRole for admin user", async () => {
  191. const caller = appRouter.createCaller(createContext("admin"));
  192. const result = await caller.users.updateRole({ userId: 2, role: "admin" });
  193. expect(result).toHaveProperty("role", "admin");
  194. });
  195. it("prevents admin from changing own role", async () => {
  196. const caller = appRouter.createCaller(createContext("admin"));
  197. await expect(
  198. caller.users.updateRole({ userId: 1, role: "user" })
  199. ).rejects.toThrow("You cannot change your own role");
  200. });
  201. it("rejects users.updateRole for agent user", async () => {
  202. const caller = appRouter.createCaller(createContext("agent"));
  203. await expect(
  204. caller.users.updateRole({ userId: 3, role: "agent" })
  205. ).rejects.toThrow();
  206. });
  207. it("allows users.getById for admin user", async () => {
  208. const caller = appRouter.createCaller(createContext("admin"));
  209. const result = await caller.users.getById({ userId: 2 });
  210. expect(result).toHaveProperty("name", "Test Agent");
  211. });
  212. });
  213. describe("Workflow management", () => {
  214. it("rejects workflow.save for unauthenticated user", async () => {
  215. const caller = appRouter.createCaller(createContext(null));
  216. await expect(
  217. caller.workflow.save({ workflowId: "test", nodes: [], edges: [] })
  218. ).rejects.toThrow();
  219. });
  220. it("rejects workflow.save for regular user", async () => {
  221. const caller = appRouter.createCaller(createContext("user"));
  222. await expect(
  223. caller.workflow.save({ workflowId: "test", nodes: [], edges: [] })
  224. ).rejects.toThrow();
  225. });
  226. it("rejects workflow.save for agent user", async () => {
  227. const caller = appRouter.createCaller(createContext("agent"));
  228. await expect(
  229. caller.workflow.save({ workflowId: "test", nodes: [], edges: [] })
  230. ).rejects.toThrow();
  231. });
  232. it("allows workflow.save for admin user", async () => {
  233. const caller = appRouter.createCaller(createContext("admin"));
  234. const result = await caller.workflow.save({
  235. workflowId: "test",
  236. nodes: [{
  237. workflowId: "test",
  238. nodeId: "n1",
  239. type: "greeting",
  240. label: "Welcome",
  241. config: {},
  242. positionX: 100,
  243. positionY: 100,
  244. }],
  245. edges: [],
  246. });
  247. expect(result.workflowId).toBe("test");
  248. expect(result.nodeCount).toBe(1);
  249. });
  250. it("rejects workflow.load for agent user", async () => {
  251. const caller = appRouter.createCaller(createContext("agent"));
  252. await expect(
  253. caller.workflow.load({ workflowId: "test" })
  254. ).rejects.toThrow();
  255. });
  256. it("allows workflow.load for admin user", async () => {
  257. const caller = appRouter.createCaller(createContext("admin"));
  258. const result = await caller.workflow.load({ workflowId: "test" });
  259. expect(result).toHaveProperty("nodes");
  260. expect(result).toHaveProperty("edges");
  261. });
  262. });
  263. });
  264. /* ═══════════════════════════════════════════════════════════════
  265. PUBLIC PROCEDURES — accessible without auth
  266. ═══════════════════════════════════════════════════════════════ */
  267. describe("Public procedures — no auth required", () => {
  268. it("allows auth.me without authentication", async () => {
  269. const caller = appRouter.createCaller(createContext(null));
  270. const result = await caller.auth.me();
  271. expect(result).toBeNull();
  272. });
  273. it("returns user for authenticated auth.me", async () => {
  274. const caller = appRouter.createCaller(createContext("admin"));
  275. const result = await caller.auth.me();
  276. expect(result).toHaveProperty("name", "Test Admin");
  277. expect(result).toHaveProperty("role", "admin");
  278. });
  279. it("allows chat.getMessages without authentication", async () => {
  280. const caller = appRouter.createCaller(createContext(null));
  281. const result = await caller.chat.getMessages({ sessionId: "test" });
  282. expect(result).toHaveProperty("messages");
  283. });
  284. });