import { describe, it, expect, beforeEach, vi } from "vitest"; import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; // Mock better-auth modules before importing AuthGuard (which imports AuthService) vi.mock("better-auth/node", () => ({ toNodeHandler: vi.fn().mockReturnValue(vi.fn()), })); vi.mock("better-auth", () => ({ betterAuth: vi.fn().mockReturnValue({ handler: vi.fn(), api: { getSession: vi.fn() }, }), })); vi.mock("better-auth/adapters/prisma", () => ({ prismaAdapter: vi.fn().mockReturnValue({}), })); vi.mock("better-auth/plugins", () => ({ genericOAuth: vi.fn().mockReturnValue({ id: "generic-oauth" }), })); import { AuthGuard } from "./auth.guard"; import type { AuthService } from "../auth.service"; describe("AuthGuard", () => { let guard: AuthGuard; const mockAuthService = { verifySession: vi.fn(), }; beforeEach(() => { // Directly construct the guard with the mock to avoid NestJS DI issues guard = new AuthGuard(mockAuthService as unknown as AuthService); vi.clearAllMocks(); }); const createMockExecutionContext = ( headers: Record = {}, cookies: Record = {} ): ExecutionContext => { const mockRequest = { headers, cookies, }; return { switchToHttp: () => ({ getRequest: () => mockRequest, }), } as ExecutionContext; }; describe("canActivate", () => { const mockSessionData = { user: { id: "user-123", email: "test@example.com", name: "Test User", }, session: { id: "session-123", token: "session-token", expiresAt: new Date(Date.now() + 86400000), }, }; describe("Bearer token authentication", () => { it("should return true for valid Bearer token", async () => { mockAuthService.verifySession.mockResolvedValue(mockSessionData); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); const result = await guard.canActivate(context); expect(result).toBe(true); expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token"); }); it("should throw UnauthorizedException for invalid Bearer token", async () => { mockAuthService.verifySession.mockResolvedValue(null); const context = createMockExecutionContext({ authorization: "Bearer invalid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow("Invalid or expired session"); }); }); describe("Cookie-based authentication", () => { it("should return true for valid session cookie", async () => { mockAuthService.verifySession.mockResolvedValue(mockSessionData); const context = createMockExecutionContext( {}, { "better-auth.session_token": "cookie-token", } ); const result = await guard.canActivate(context); expect(result).toBe(true); expect(mockAuthService.verifySession).toHaveBeenCalledWith("cookie-token"); }); it("should prefer cookie over Bearer token when both present", async () => { mockAuthService.verifySession.mockResolvedValue(mockSessionData); const context = createMockExecutionContext( { authorization: "Bearer bearer-token", }, { "better-auth.session_token": "cookie-token", } ); const result = await guard.canActivate(context); expect(result).toBe(true); expect(mockAuthService.verifySession).toHaveBeenCalledWith("cookie-token"); }); it("should fallback to Bearer token if no cookie", async () => { mockAuthService.verifySession.mockResolvedValue(mockSessionData); const context = createMockExecutionContext( { authorization: "Bearer bearer-token", }, {} ); const result = await guard.canActivate(context); expect(result).toBe(true); expect(mockAuthService.verifySession).toHaveBeenCalledWith("bearer-token"); }); }); describe("Error handling", () => { it("should throw UnauthorizedException if no token provided", async () => { const context = createMockExecutionContext({}, {}); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow( "No authentication token provided" ); }); it("should propagate non-auth errors as-is (not wrap as 401)", async () => { const infraError = new Error("connect ECONNREFUSED 127.0.0.1:5432"); mockAuthService.verifySession.mockRejectedValue(infraError); const context = createMockExecutionContext({ authorization: "Bearer error-token", }); await expect(guard.canActivate(context)).rejects.toThrow(infraError); await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException); }); it("should propagate database errors so GlobalExceptionFilter returns 500", async () => { const dbError = new Error("PrismaClientKnownRequestError: Connection refused"); mockAuthService.verifySession.mockRejectedValue(dbError); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(dbError); await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException); }); it("should propagate timeout errors so GlobalExceptionFilter returns 503", async () => { const timeoutError = new Error("Connection timeout after 5000ms"); mockAuthService.verifySession.mockRejectedValue(timeoutError); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(timeoutError); await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException); }); }); describe("user data validation", () => { const mockSession = { id: "session-123", token: "session-token", expiresAt: new Date(Date.now() + 86400000), }; it("should throw UnauthorizedException when user is missing id", async () => { mockAuthService.verifySession.mockResolvedValue({ user: { email: "a@b.com", name: "Test" }, session: mockSession, }); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow( "Invalid user data in session" ); }); it("should throw UnauthorizedException when user is missing email", async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "1", name: "Test" }, session: mockSession, }); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow( "Invalid user data in session" ); }); it("should throw UnauthorizedException when user is missing name", async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "1", email: "a@b.com" }, session: mockSession, }); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow( "Invalid user data in session" ); }); it("should throw UnauthorizedException when user is a string", async () => { mockAuthService.verifySession.mockResolvedValue({ user: "not-an-object", session: mockSession, }); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow( "Invalid user data in session" ); }); it("should reject when user is null (typeof null === 'object' causes TypeError on 'in' operator)", async () => { // Note: typeof null === "object" in JS, so the guard's typeof check passes // but "id" in null throws TypeError. The catch block propagates non-auth errors as-is. mockAuthService.verifySession.mockResolvedValue({ user: null, session: mockSession, }); const context = createMockExecutionContext({ authorization: "Bearer valid-token", }); await expect(guard.canActivate(context)).rejects.toThrow(TypeError); await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf( UnauthorizedException ); }); }); describe("request attachment", () => { it("should attach user and session to request on success", async () => { mockAuthService.verifySession.mockResolvedValue(mockSessionData); const mockRequest = { headers: { authorization: "Bearer valid-token", }, cookies: {}, }; const context = { switchToHttp: () => ({ getRequest: () => mockRequest, }), } as ExecutionContext; await guard.canActivate(context); expect(mockRequest).toHaveProperty("user", mockSessionData.user); expect(mockRequest).toHaveProperty("session", mockSessionData.session); }); }); }); });