import { describe, expect, it, vi, beforeEach } from "vitest"; import { appRouter } from "./routers"; import { COOKIE_NAME } from "../shared/const"; import type { TrpcContext } from "./_core/context"; type AuthenticatedUser = NonNullable; /* ─── Context helpers ─── */ function createPublicContext() { const cookies: { name: string; value: string; options: Record }[] = []; const clearedCookies: { name: string; options: Record }[] = []; const ctx: TrpcContext = { user: null, req: { protocol: "https", headers: {} } as TrpcContext["req"], res: { cookie: (name: string, value: string, options: Record) => { cookies.push({ name, value, options }); }, clearCookie: (name: string, options: Record) => { clearedCookies.push({ name, options }); }, } as unknown as TrpcContext["res"], }; return { ctx, cookies, clearedCookies }; } function createAuthContext(role: "admin" | "agent" | "user") { const user: AuthenticatedUser = { id: 1, openId: "test-openid", email: "test@homelegance.com", name: "Test User", loginMethod: "password", role, createdAt: new Date(), updatedAt: new Date(), lastSignedIn: new Date(), }; return { user, req: { protocol: "https", headers: {} } as TrpcContext["req"], res: { cookie: vi.fn(), clearCookie: vi.fn(), } as unknown as TrpcContext["res"], }; } /* ─── Mock database ─── */ const mockUsers: any[] = []; const mockResetTokens: any[] = []; let userIdCounter = 1; let tokenIdCounter = 1; vi.mock("./db", () => ({ getUserByEmailWithPassword: vi.fn(async (email: string) => { return mockUsers.find(u => u.email === email) || null; }), createUserWithPassword: vi.fn(async (data: any) => { const user = { id: userIdCounter++, openId: `email-${data.email}`, email: data.email, name: data.name, passwordHash: data.passwordHash, role: "user", loginMethod: "password", createdAt: new Date(), updatedAt: new Date(), lastSignedIn: new Date(), }; mockUsers.push(user); return user; }), updateUserPassword: vi.fn(async (userId: number, hash: string) => { const user = mockUsers.find(u => u.id === userId); if (user) user.passwordHash = hash; }), createPasswordResetToken: vi.fn(async (data: any) => { const token = { id: tokenIdCounter++, ...data, usedAt: null, createdAt: new Date() }; mockResetTokens.push(token); return token; }), getPasswordResetToken: vi.fn(async (token: string) => { return mockResetTokens.find(t => t.token === token) || null; }), markPasswordResetTokenUsed: vi.fn(async (id: number) => { const token = mockResetTokens.find(t => t.id === id); if (token) token.usedAt = new Date(); }), getUserById: vi.fn(async (id: number) => { return mockUsers.find(u => u.id === id) || null; }), // Other db exports needed by routers createConversation: vi.fn(async (data: any) => ({ id: 1, ...data })), getConversations: vi.fn(async () => []), getConversationById: vi.fn(async () => null), getConversationBySessionId: vi.fn(async () => null), updateConversationStatus: vi.fn(async () => null), getConversationStats: vi.fn(async () => ({ total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 })), addMessage: vi.fn(async (data: any) => ({ id: 1, ...data })), getMessagesByConversation: vi.fn(async () => []), saveWorkflow: vi.fn(async () => ({})), getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })), getAllUsers: vi.fn(async () => []), updateUserRole: vi.fn(async () => null), getUserByEmail: vi.fn(async () => null), deleteUser: vi.fn(async () => null), createInvitation: vi.fn(async () => ({})), getAllInvitations: vi.fn(async () => []), getInvitationByToken: vi.fn(async () => null), getInvitationByEmail: vi.fn(async () => []), updateInvitationStatus: vi.fn(async () => null), expireOldInvitations: vi.fn(async () => {}), createAuditLog: vi.fn(async () => ({})), getAuditLogs: vi.fn(async () => []), 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" }, finish_reason: "stop" }], })), })); vi.mock("./_core/notification", () => ({ notifyOwner: vi.fn(async () => true), })); vi.mock("./_core/sdk", () => ({ sdk: { createSessionToken: vi.fn(async () => "mock-session-token"), }, })); vi.mock("bcryptjs", () => ({ default: { hash: vi.fn(async (password: string) => `hashed-${password}`), compare: vi.fn(async (password: string, hash: string) => hash === `hashed-${password}`), }, })); beforeEach(() => { mockUsers.length = 0; mockResetTokens.length = 0; userIdCounter = 1; tokenIdCounter = 1; }); /* ═══════════════════════════════════════════════════════════════ REGISTRATION ═══════════════════════════════════════════════════════════════ */ describe("auth.register", () => { it("creates a new user and sets session cookie", async () => { const { ctx, cookies } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.register({ email: "new@homelegance.com", password: "StrongPass1", name: "New User", }); expect(result.success).toBe(true); expect(result.user.email).toBe("new@homelegance.com"); expect(result.user.name).toBe("New User"); expect(result.user.role).toBe("user"); expect(cookies.length).toBe(1); expect(cookies[0].name).toBe(COOKIE_NAME); expect(cookies[0].value).toBe("mock-session-token"); }); it("rejects duplicate email registration", async () => { const { ctx: ctx1 } = createPublicContext(); const caller1 = appRouter.createCaller(ctx1); await caller1.auth.register({ email: "dup@homelegance.com", password: "StrongPass1", name: "First User", }); const { ctx: ctx2 } = createPublicContext(); const caller2 = appRouter.createCaller(ctx2); await expect( caller2.auth.register({ email: "dup@homelegance.com", password: "AnotherPass1", name: "Second User", }) ).rejects.toThrow("An account with this email already exists"); }); it("rejects password shorter than 8 characters", async () => { const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.register({ email: "short@homelegance.com", password: "Short1", name: "Short Pass", }) ).rejects.toThrow(); }); }); /* ═══════════════════════════════════════════════════════════════ LOGIN ═══════════════════════════════════════════════════════════════ */ describe("auth.login", () => { it("logs in with correct credentials and sets session cookie", async () => { // First register a user const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "login@homelegance.com", password: "CorrectPass1", name: "Login User", }); // Now login const { ctx, cookies } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.login({ email: "login@homelegance.com", password: "CorrectPass1", }); expect(result.success).toBe(true); expect(result.user.email).toBe("login@homelegance.com"); expect(cookies.length).toBe(1); expect(cookies[0].name).toBe(COOKIE_NAME); }); it("rejects login with wrong password", async () => { const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "wrong@homelegance.com", password: "CorrectPass1", name: "Wrong Pass User", }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.login({ email: "wrong@homelegance.com", password: "WrongPassword1", }) ).rejects.toThrow("Invalid email or password"); }); it("rejects login with non-existent email", async () => { const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.login({ email: "nonexistent@homelegance.com", password: "SomePass1", }) ).rejects.toThrow("Invalid email or password"); }); }); /* ═══════════════════════════════════════════════════════════════ FORGOT PASSWORD ═══════════════════════════════════════════════════════════════ */ describe("auth.forgotPassword", () => { it("returns success and token for existing user", async () => { // Register a user first const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "forgot@homelegance.com", password: "OldPass123", name: "Forgot User", }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.forgotPassword({ email: "forgot@homelegance.com", }); expect(result.success).toBe(true); expect(result.resetToken).toBeDefined(); expect(typeof result.resetToken).toBe("string"); }); it("returns success even for non-existent email (prevents enumeration)", async () => { const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.forgotPassword({ email: "nobody@homelegance.com", }); expect(result.success).toBe(true); expect(result.resetToken).toBeUndefined(); }); }); /* ═══════════════════════════════════════════════════════════════ VALIDATE RESET TOKEN ═══════════════════════════════════════════════════════════════ */ describe("auth.validateResetToken", () => { it("validates a valid token", async () => { // Register and request reset const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "validate@homelegance.com", password: "OldPass123", name: "Validate User", }); const { ctx: forgotCtx } = createPublicContext(); const forgotResult = await appRouter.createCaller(forgotCtx).auth.forgotPassword({ email: "validate@homelegance.com", }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.validateResetToken({ token: forgotResult.resetToken!, }); expect(result.valid).toBe(true); if (result.valid) { expect(result.email).toBe("validate@homelegance.com"); } }); it("rejects an invalid token", async () => { const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.validateResetToken({ token: "invalid-token-xyz", }); expect(result.valid).toBe(false); }); it("rejects an expired token", async () => { // Register and create an expired token manually const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "expired@homelegance.com", password: "OldPass123", name: "Expired User", }); // Manually add an expired token mockResetTokens.push({ id: tokenIdCounter++, userId: 1, token: "expired-token", expiresAt: new Date(Date.now() - 1000), // expired 1 second ago usedAt: null, createdAt: new Date(), }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.validateResetToken({ token: "expired-token", }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.reason).toContain("expired"); } }); it("rejects an already-used token", async () => { mockResetTokens.push({ id: tokenIdCounter++, userId: 1, token: "used-token", expiresAt: new Date(Date.now() + 3600000), usedAt: new Date(), createdAt: new Date(), }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.validateResetToken({ token: "used-token", }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.reason).toContain("already been used"); } }); }); /* ═══════════════════════════════════════════════════════════════ RESET PASSWORD ═══════════════════════════════════════════════════════════════ */ describe("auth.resetPassword", () => { it("resets password with a valid token", async () => { // Register and request reset const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "reset@homelegance.com", password: "OldPass123", name: "Reset User", }); const { ctx: forgotCtx } = createPublicContext(); const forgotResult = await appRouter.createCaller(forgotCtx).auth.forgotPassword({ email: "reset@homelegance.com", }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.resetPassword({ token: forgotResult.resetToken!, newPassword: "NewStrongPass1", }); expect(result.success).toBe(true); }); it("rejects reset with invalid token", async () => { const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.resetPassword({ token: "invalid-token", newPassword: "NewPass123", }) ).rejects.toThrow("Invalid reset link"); }); it("rejects reset with expired token", async () => { mockResetTokens.push({ id: tokenIdCounter++, userId: 1, token: "expired-reset", expiresAt: new Date(Date.now() - 1000), usedAt: null, createdAt: new Date(), }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.resetPassword({ token: "expired-reset", newPassword: "NewPass123", }) ).rejects.toThrow("expired"); }); it("rejects reset with already-used token", async () => { mockResetTokens.push({ id: tokenIdCounter++, userId: 1, token: "used-reset", expiresAt: new Date(Date.now() + 3600000), usedAt: new Date(), createdAt: new Date(), }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.resetPassword({ token: "used-reset", newPassword: "NewPass123", }) ).rejects.toThrow("already been used"); }); it("rejects new password shorter than 8 characters", async () => { // Register and request reset const { ctx: regCtx } = createPublicContext(); await appRouter.createCaller(regCtx).auth.register({ email: "shortpw@homelegance.com", password: "OldPass123", name: "Short PW User", }); const { ctx: forgotCtx } = createPublicContext(); const forgotResult = await appRouter.createCaller(forgotCtx).auth.forgotPassword({ email: "shortpw@homelegance.com", }); const { ctx } = createPublicContext(); const caller = appRouter.createCaller(ctx); await expect( caller.auth.resetPassword({ token: forgotResult.resetToken!, newPassword: "Short1", }) ).rejects.toThrow(); }); }); /* ═══════════════════════════════════════════════════════════════ LOGOUT ═══════════════════════════════════════════════════════════════ */ describe("auth.logout", () => { it("clears the session cookie", async () => { const ctx = createAuthContext("admin"); const caller = appRouter.createCaller(ctx); const result = await caller.auth.logout(); expect(result.success).toBe(true); expect(ctx.res.clearCookie).toHaveBeenCalledWith( COOKIE_NAME, expect.objectContaining({ maxAge: -1 }) ); }); });