| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- import { describe, expect, it, vi, beforeEach } from "vitest";
- import { appRouter } from "./routers";
- import type { TrpcContext } from "./_core/context";
- type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
- /* ─── Context helpers ─── */
- function createContext(role: "admin" | "agent" | "user" | null): TrpcContext {
- if (!role) {
- return {
- user: null,
- req: { protocol: "https", headers: {} } as TrpcContext["req"],
- res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
- };
- }
- const user: AuthenticatedUser = {
- id: role === "admin" ? 1 : role === "agent" ? 2 : 3,
- openId: `${role}-openid`,
- email: `${role}@homelegance.com`,
- name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`,
- loginMethod: "manus",
- role,
- createdAt: new Date(),
- updatedAt: new Date(),
- lastSignedIn: new Date(),
- };
- return {
- user,
- req: { protocol: "https", headers: {} } as TrpcContext["req"],
- res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
- };
- }
- /* ─── Mock database ─── */
- const mockUsers = [
- { id: 1, openId: "admin-openid", name: "Test Admin", email: "admin@homelegance.com", role: "admin", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
- { id: 2, openId: "agent-openid", name: "Test Agent", email: "agent@homelegance.com", role: "agent", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
- { id: 3, openId: "user-openid", name: "Test User", email: "user@homelegance.com", role: "user", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
- ];
- const mockInvitations: any[] = [];
- const mockAuditLogs: any[] = [];
- let inviteIdCounter = 1;
- let auditIdCounter = 1;
- vi.mock("./db", () => ({
- createConversation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date(), updatedAt: new Date() })),
- getConversations: vi.fn(async () => []),
- getConversationById: vi.fn(async (id: number) => ({ id, sessionId: "test", status: "active", createdAt: new Date(), updatedAt: new Date() })),
- getConversationBySessionId: vi.fn(async () => null),
- updateConversationStatus: vi.fn(async (id: number, status: string) => ({ id, status })),
- getConversationStats: vi.fn(async () => ({ total: 5, active: 2, escalated: 1, resolved: 1, closed: 1 })),
- addMessage: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
- getMessagesByConversation: vi.fn(async () => []),
- saveWorkflow: vi.fn(async (wid: string, nodes: any[], edges: any[]) => ({ workflowId: wid, nodeCount: nodes.length, edgeCount: edges.length })),
- getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })),
- getAllUsers: vi.fn(async () => [...mockUsers]),
- updateUserRole: vi.fn(async (userId: number, role: string) => {
- const u = mockUsers.find(u => u.id === userId);
- if (!u) return null;
- return { ...u, role };
- }),
- getUserById: vi.fn(async (userId: number) => mockUsers.find(u => u.id === userId) || null),
- getUserByEmail: vi.fn(async (email: string) => mockUsers.find(u => u.email === email) || null),
- deleteUser: vi.fn(async (userId: number) => {
- const u = mockUsers.find(u => u.id === userId);
- return u || null;
- }),
- createInvitation: vi.fn(async (data: any) => {
- const inv = { id: inviteIdCounter++, ...data, createdAt: new Date() };
- mockInvitations.push(inv);
- return inv;
- }),
- getAllInvitations: vi.fn(async () => [...mockInvitations]),
- getInvitationByToken: vi.fn(async (token: string) => mockInvitations.find(i => i.token === token) || null),
- getInvitationByEmail: vi.fn(async (email: string) => mockInvitations.filter(i => i.email === email)),
- updateInvitationStatus: vi.fn(async (id: number, status: string, acceptedById?: number) => {
- const inv = mockInvitations.find(i => i.id === id);
- if (inv) {
- inv.status = status;
- if (acceptedById) inv.acceptedById = acceptedById;
- inv.acceptedAt = status === "accepted" ? new Date() : null;
- }
- return inv;
- }),
- expireOldInvitations: vi.fn(async () => {}),
- createAuditLog: vi.fn(async (data: any) => {
- const log = { id: auditIdCounter++, ...data, createdAt: new Date() };
- mockAuditLogs.push(log);
- return log;
- }),
- getAuditLogs: vi.fn(async (limit?: number) => mockAuditLogs.slice(0, limit || 50)),
- upsertUser: vi.fn(),
- getUserByOpenId: vi.fn(),
- }));
- vi.mock("./_core/llm", () => ({
- invokeLLM: vi.fn(async () => ({
- id: "test",
- created: Date.now(),
- model: "test",
- choices: [{ index: 0, message: { role: "assistant", content: "Test response" }, finish_reason: "stop" }],
- })),
- }));
- vi.mock("./_core/notification", () => ({
- notifyOwner: vi.fn(async () => true),
- }));
- /* ═══════════════════════════════════════════════════════════════
- INVITATION SYSTEM
- ═══════════════════════════════════════════════════════════════ */
- describe("Invitation system", () => {
- beforeEach(() => {
- mockInvitations.length = 0;
- mockAuditLogs.length = 0;
- inviteIdCounter = 1;
- auditIdCounter = 1;
- });
- describe("Send invitation", () => {
- it("admin can send an invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const result = await caller.invitations.send({
- email: "newuser@example.com",
- role: "agent",
- message: "Welcome to the team!",
- });
- expect(result).toHaveProperty("email", "newuser@example.com");
- expect(result).toHaveProperty("role", "agent");
- expect(result).toHaveProperty("token");
- expect(result).toHaveProperty("status", "pending");
- });
- it("rejects invitation for existing user email", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- await expect(
- caller.invitations.send({ email: "agent@homelegance.com", role: "admin" })
- ).rejects.toThrow("already exists");
- });
- it("rejects duplicate pending invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- await caller.invitations.send({ email: "dup@example.com", role: "agent" });
- await expect(
- caller.invitations.send({ email: "dup@example.com", role: "admin" })
- ).rejects.toThrow("pending invitation already exists");
- });
- it("agent cannot send invitations", async () => {
- const caller = appRouter.createCaller(createContext("agent"));
- await expect(
- caller.invitations.send({ email: "test@example.com", role: "agent" })
- ).rejects.toThrow();
- });
- it("regular user cannot send invitations", async () => {
- const caller = appRouter.createCaller(createContext("user"));
- await expect(
- caller.invitations.send({ email: "test@example.com", role: "agent" })
- ).rejects.toThrow();
- });
- });
- describe("List invitations", () => {
- it("admin can list invitations", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- await caller.invitations.send({ email: "list@example.com", role: "agent" });
- const result = await caller.invitations.list();
- expect(Array.isArray(result)).toBe(true);
- expect(result.length).toBeGreaterThan(0);
- });
- it("agent cannot list invitations", async () => {
- const caller = appRouter.createCaller(createContext("agent"));
- await expect(caller.invitations.list()).rejects.toThrow();
- });
- });
- describe("Revoke invitation", () => {
- it("admin can revoke a pending invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const inv = await caller.invitations.send({ email: "revoke@example.com", role: "agent" });
- const result = await caller.invitations.revoke({ invitationId: inv.id });
- expect(result).toHaveProperty("status", "revoked");
- });
- it("cannot revoke a non-pending invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const inv = await caller.invitations.send({ email: "revoke2@example.com", role: "agent" });
- await caller.invitations.revoke({ invitationId: inv.id });
- await expect(
- caller.invitations.revoke({ invitationId: inv.id })
- ).rejects.toThrow("Cannot revoke");
- });
- });
- describe("Validate invitation", () => {
- it("validates a pending invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const inv = await caller.invitations.send({ email: "validate@example.com", role: "agent" });
- const publicCaller = appRouter.createCaller(createContext(null));
- const result = await publicCaller.invitations.validate({ token: inv.token });
- expect(result.valid).toBe(true);
- if (result.valid) {
- expect(result.email).toBe("validate@example.com");
- expect(result.role).toBe("agent");
- }
- });
- it("returns invalid for non-existent token", async () => {
- const publicCaller = appRouter.createCaller(createContext(null));
- const result = await publicCaller.invitations.validate({ token: "nonexistent" });
- expect(result.valid).toBe(false);
- });
- it("returns invalid for revoked invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const inv = await caller.invitations.send({ email: "revoked@example.com", role: "agent" });
- await caller.invitations.revoke({ invitationId: inv.id });
- const publicCaller = appRouter.createCaller(createContext(null));
- const result = await publicCaller.invitations.validate({ token: inv.token });
- expect(result.valid).toBe(false);
- });
- });
- describe("Accept invitation", () => {
- it("authenticated user can accept a valid invitation", async () => {
- const adminCaller = appRouter.createCaller(createContext("admin"));
- const inv = await adminCaller.invitations.send({ email: "accept@example.com", role: "agent" });
- const userCaller = appRouter.createCaller(createContext("user"));
- const result = await userCaller.invitations.accept({ token: inv.token });
- expect(result.success).toBe(true);
- expect(result.role).toBe("agent");
- });
- it("unauthenticated user cannot accept invitation", async () => {
- const adminCaller = appRouter.createCaller(createContext("admin"));
- const inv = await adminCaller.invitations.send({ email: "noauth@example.com", role: "agent" });
- const publicCaller = appRouter.createCaller(createContext(null));
- await expect(
- publicCaller.invitations.accept({ token: inv.token })
- ).rejects.toThrow();
- });
- });
- describe("Resend invitation", () => {
- it("admin can resend a pending invitation", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const inv = await caller.invitations.send({ email: "resend@example.com", role: "agent" });
- const result = await caller.invitations.resend({ invitationId: inv.id });
- expect(result).toHaveProperty("email", "resend@example.com");
- expect(result).toHaveProperty("status", "pending");
- expect(result.token).not.toBe(inv.token); // New token
- });
- });
- });
- /* ═══════════════════════════════════════════════════════════════
- USER DELETION
- ═══════════════════════════════════════════════════════════════ */
- describe("User deletion", () => {
- beforeEach(() => {
- mockAuditLogs.length = 0;
- auditIdCounter = 1;
- });
- it("admin can delete a user", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const result = await caller.users.delete({ userId: 2 });
- expect(result.success).toBe(true);
- });
- it("admin cannot delete themselves", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- await expect(
- caller.users.delete({ userId: 1 })
- ).rejects.toThrow("You cannot delete your own account");
- });
- it("agent cannot delete users", async () => {
- const caller = appRouter.createCaller(createContext("agent"));
- await expect(
- caller.users.delete({ userId: 3 })
- ).rejects.toThrow();
- });
- it("regular user cannot delete users", async () => {
- const caller = appRouter.createCaller(createContext("user"));
- await expect(
- caller.users.delete({ userId: 2 })
- ).rejects.toThrow();
- });
- });
- /* ═══════════════════════════════════════════════════════════════
- BULK ACTIONS
- ═══════════════════════════════════════════════════════════════ */
- describe("Bulk actions", () => {
- beforeEach(() => {
- mockAuditLogs.length = 0;
- auditIdCounter = 1;
- });
- it("admin can bulk update roles", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const results = await caller.users.bulkUpdateRole({ userIds: [2, 3], role: "agent" });
- expect(results).toHaveLength(2);
- expect(results.every(r => r.success)).toBe(true);
- });
- it("bulk update skips self", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const results = await caller.users.bulkUpdateRole({ userIds: [1, 2], role: "user" });
- expect(results[0].success).toBe(false);
- expect(results[0].error).toBe("Cannot change own role");
- expect(results[1].success).toBe(true);
- });
- it("admin can bulk delete users", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const results = await caller.users.bulkDelete({ userIds: [2, 3] });
- expect(results).toHaveLength(2);
- expect(results.every(r => r.success)).toBe(true);
- });
- it("bulk delete skips self", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const results = await caller.users.bulkDelete({ userIds: [1, 2] });
- expect(results[0].success).toBe(false);
- expect(results[0].error).toBe("Cannot delete own account");
- expect(results[1].success).toBe(true);
- });
- it("agent cannot perform bulk actions", async () => {
- const caller = appRouter.createCaller(createContext("agent"));
- await expect(
- caller.users.bulkUpdateRole({ userIds: [3], role: "agent" })
- ).rejects.toThrow();
- await expect(
- caller.users.bulkDelete({ userIds: [3] })
- ).rejects.toThrow();
- });
- });
- /* ═══════════════════════════════════════════════════════════════
- AUDIT LOGS
- ═══════════════════════════════════════════════════════════════ */
- describe("Audit logs", () => {
- beforeEach(() => {
- mockAuditLogs.length = 0;
- auditIdCounter = 1;
- });
- it("admin can view audit logs", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const result = await caller.auditLogs.list({ limit: 10 });
- expect(Array.isArray(result)).toBe(true);
- });
- it("agent cannot view audit logs", async () => {
- const caller = appRouter.createCaller(createContext("agent"));
- await expect(caller.auditLogs.list({ limit: 10 })).rejects.toThrow();
- });
- it("audit log is created on role change", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- await caller.users.updateRole({ userId: 2, role: "admin" });
- expect(mockAuditLogs.length).toBeGreaterThan(0);
- expect(mockAuditLogs[0].action).toBe("role_change");
- });
- it("audit log is created on user deletion", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- await caller.users.delete({ userId: 2 });
- expect(mockAuditLogs.some(l => l.action === "user_deleted")).toBe(true);
- });
- it("audit log is created on invitation send", async () => {
- mockInvitations.length = 0;
- inviteIdCounter = 1;
- const caller = appRouter.createCaller(createContext("admin"));
- await caller.invitations.send({ email: "auditlog@example.com", role: "agent" });
- expect(mockAuditLogs.some(l => l.action === "invitation_sent")).toBe(true);
- });
- });
- /* ═══════════════════════════════════════════════════════════════
- CSV EXPORT
- ═══════════════════════════════════════════════════════════════ */
- describe("CSV export", () => {
- it("admin can export users as CSV", async () => {
- const caller = appRouter.createCaller(createContext("admin"));
- const result = await caller.users.exportCsv();
- expect(result).toHaveProperty("csv");
- expect(result).toHaveProperty("count", 3);
- expect(result.csv).toContain("ID,Name,Email,Role");
- expect(result.csv).toContain("admin@homelegance.com");
- });
- it("agent cannot export CSV", async () => {
- const caller = appRouter.createCaller(createContext("agent"));
- await expect(caller.users.exportCsv()).rejects.toThrow();
- });
- });
|