| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- 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<TrpcContext["user"]>;
- /* ─── Context helpers ─── */
- function createPublicContext() {
- const cookies: { name: string; value: string; options: Record<string, unknown> }[] = [];
- const clearedCookies: { name: string; options: Record<string, unknown> }[] = [];
- const ctx: TrpcContext = {
- user: null,
- req: { protocol: "https", headers: {} } as TrpcContext["req"],
- res: {
- cookie: (name: string, value: string, options: Record<string, unknown>) => {
- cookies.push({ name, value, options });
- },
- clearCookie: (name: string, options: Record<string, unknown>) => {
- 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 })
- );
- });
- });
|