import { describe, it, expect, beforeEach, vi } from "vitest"; // Mock better-auth modules before importing AuthService (pulled in by AuthController) 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 { Test, TestingModule } from "@nestjs/testing"; import { HttpException, HttpStatus, UnauthorizedException } from "@nestjs/common"; import type { AuthUser, AuthSession } from "@mosaic/shared"; import type { Request as ExpressRequest, Response as ExpressResponse } from "express"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; describe("AuthController", () => { let controller: AuthController; const mockNodeHandler = vi.fn().mockResolvedValue(undefined); const mockAuthService = { getAuth: vi.fn(), getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler), getAuthConfig: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], providers: [ { provide: AuthService, useValue: mockAuthService, }, ], }).compile(); controller = module.get(AuthController); vi.clearAllMocks(); // Restore mock implementations after clearAllMocks mockAuthService.getNodeHandler.mockReturnValue(mockNodeHandler); mockNodeHandler.mockResolvedValue(undefined); }); describe("handleAuth", () => { it("should delegate to BetterAuth node handler with Express req/res", async () => { const mockRequest = { method: "GET", url: "/auth/session", headers: {}, ip: "127.0.0.1", socket: { remoteAddress: "127.0.0.1" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; await controller.handleAuth(mockRequest, mockResponse); expect(mockAuthService.getNodeHandler).toHaveBeenCalled(); expect(mockNodeHandler).toHaveBeenCalledWith(mockRequest, mockResponse); }); it("should throw HttpException with 500 when handler throws before headers sent", async () => { const handlerError = new Error("BetterAuth internal failure"); mockNodeHandler.mockRejectedValueOnce(handlerError); const mockRequest = { method: "POST", url: "/auth/sign-in", headers: {}, ip: "192.168.1.10", socket: { remoteAddress: "192.168.1.10" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; try { await controller.handleAuth(mockRequest, mockResponse); // Should not reach here expect.unreachable("Expected HttpException to be thrown"); } catch (err) { expect(err).toBeInstanceOf(HttpException); expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); expect((err as HttpException).getResponse()).toBe( "Unable to complete authentication. Please try again in a moment." ); } }); it("should preserve better-call status and body for handler APIError", async () => { const apiError = { statusCode: HttpStatus.BAD_REQUEST, message: "Invalid OAuth configuration", body: { message: "Invalid OAuth configuration", code: "INVALID_OAUTH_CONFIGURATION", }, }; mockNodeHandler.mockRejectedValueOnce(apiError); const mockRequest = { method: "POST", url: "/auth/sign-in/oauth2", headers: {}, ip: "192.168.1.10", socket: { remoteAddress: "192.168.1.10" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; try { await controller.handleAuth(mockRequest, mockResponse); expect.unreachable("Expected HttpException to be thrown"); } catch (err) { expect(err).toBeInstanceOf(HttpException); expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); expect((err as HttpException).getResponse()).toMatchObject({ message: "Invalid OAuth configuration", }); } }); it("should log warning and not throw when handler throws after headers sent", async () => { const handlerError = new Error("Stream interrupted"); mockNodeHandler.mockRejectedValueOnce(handlerError); const mockRequest = { method: "POST", url: "/auth/sign-up", headers: {}, ip: "10.0.0.5", socket: { remoteAddress: "10.0.0.5" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: true, } as unknown as ExpressResponse; // Should not throw when headers already sent await expect(controller.handleAuth(mockRequest, mockResponse)).resolves.toBeUndefined(); }); it("should handle non-Error thrown values", async () => { mockNodeHandler.mockRejectedValueOnce("string error"); const mockRequest = { method: "GET", url: "/auth/callback", headers: {}, ip: "127.0.0.1", socket: { remoteAddress: "127.0.0.1" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException); }); }); describe("getConfig", () => { it("should return auth config from service", async () => { const mockConfig = { providers: [ { id: "email", name: "Email", type: "credentials" as const }, { id: "authentik", name: "Authentik", type: "oauth" as const }, ], }; mockAuthService.getAuthConfig.mockResolvedValue(mockConfig); const result = await controller.getConfig(); expect(result).toEqual(mockConfig); expect(mockAuthService.getAuthConfig).toHaveBeenCalled(); }); it("should return correct response shape with only email provider", async () => { const mockConfig = { providers: [{ id: "email", name: "Email", type: "credentials" as const }], }; mockAuthService.getAuthConfig.mockResolvedValue(mockConfig); const result = await controller.getConfig(); expect(result).toEqual(mockConfig); expect(result.providers).toHaveLength(1); expect(result.providers[0]).toEqual({ id: "email", name: "Email", type: "credentials", }); }); it("should never leak secrets in auth config response", async () => { // Set ALL sensitive environment variables with known values const sensitiveEnv: Record = { OIDC_CLIENT_SECRET: "test-client-secret", OIDC_CLIENT_ID: "test-client-id", OIDC_ISSUER: "https://auth.test.com/", OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik", BETTER_AUTH_SECRET: "test-better-auth-secret", JWT_SECRET: "test-jwt-secret", CSRF_SECRET: "test-csrf-secret", DATABASE_URL: "postgresql://user:password@localhost/db", OIDC_ENABLED: "true", }; const originalEnv: Record = {}; for (const [key, value] of Object.entries(sensitiveEnv)) { originalEnv[key] = process.env[key]; process.env[key] = value; } try { // Mock the service to return a realistic config with both providers const mockConfig = { providers: [ { id: "email", name: "Email", type: "credentials" as const }, { id: "authentik", name: "Authentik", type: "oauth" as const }, ], }; mockAuthService.getAuthConfig.mockResolvedValue(mockConfig); const result = await controller.getConfig(); const serialized = JSON.stringify(result); // Assert no secret values leak into the serialized response const forbiddenPatterns = [ "test-client-secret", "test-client-id", "test-better-auth-secret", "test-jwt-secret", "test-csrf-secret", "auth.test.com", "callback", "password", ]; for (const pattern of forbiddenPatterns) { expect(serialized).not.toContain(pattern); } // Assert response contains ONLY expected fields expect(result).toHaveProperty("providers"); expect(Object.keys(result)).toEqual(["providers"]); expect(Array.isArray(result.providers)).toBe(true); for (const provider of result.providers) { const keys = Object.keys(provider); expect(keys).toEqual(expect.arrayContaining(["id", "name", "type"])); expect(keys).toHaveLength(3); } } finally { // Restore original environment for (const [key] of Object.entries(sensitiveEnv)) { if (originalEnv[key] === undefined) { delete process.env[key]; } else { process.env[key] = originalEnv[key]; } } } }); }); describe("getSession", () => { it("should return user and session data", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", name: "Test User", workspaceId: "workspace-123", }; const mockSession = { id: "session-123", token: "session-token", expiresAt: new Date(Date.now() + 86400000), }; const mockRequest = { user: mockUser, session: mockSession, }; const result = controller.getSession(mockRequest); const expected: AuthSession = { user: mockUser, session: { id: mockSession.id, token: mockSession.token, expiresAt: mockSession.expiresAt, }, }; expect(result).toEqual(expected); }); it("should throw UnauthorizedException when req.user is undefined", () => { const mockRequest = { session: { id: "session-123", token: "session-token", expiresAt: new Date(Date.now() + 86400000), }, }; expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException); expect(() => controller.getSession(mockRequest as never)).toThrow( "Missing authentication context" ); }); it("should throw UnauthorizedException when req.session is undefined", () => { const mockRequest = { user: { id: "user-123", email: "test@example.com", name: "Test User", }, }; expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException); expect(() => controller.getSession(mockRequest as never)).toThrow( "Missing authentication context" ); }); it("should throw UnauthorizedException when both req.user and req.session are undefined", () => { const mockRequest = {}; expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException); expect(() => controller.getSession(mockRequest as never)).toThrow( "Missing authentication context" ); }); }); describe("getProfile", () => { it("should return complete user profile with identity fields", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", name: "Test User", image: "https://example.com/avatar.jpg", emailVerified: true, }; const result = controller.getProfile(mockUser); expect(result).toEqual({ id: mockUser.id, email: mockUser.email, name: mockUser.name, image: mockUser.image, emailVerified: mockUser.emailVerified, }); }); it("should return user profile with only required fields", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", name: "Test User", }; const result = controller.getProfile(mockUser); expect(result).toEqual({ id: mockUser.id, email: mockUser.email, name: mockUser.name, }); // Workspace fields are not included — served by GET /api/workspaces expect(result).not.toHaveProperty("workspaceId"); expect(result).not.toHaveProperty("currentWorkspaceId"); expect(result).not.toHaveProperty("workspaceRole"); }); }); describe("getClientIp (via handleAuth)", () => { it("should extract IP from X-Forwarded-For with single IP", async () => { const mockRequest = { method: "GET", url: "/auth/callback", headers: { "x-forwarded-for": "203.0.113.50" }, ip: "127.0.0.1", socket: { remoteAddress: "127.0.0.1" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; // Spy on the logger to verify the extracted IP const debugSpy = vi.spyOn(controller["logger"], "debug"); await controller.handleAuth(mockRequest, mockResponse); expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50")); }); it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => { const mockRequest = { method: "GET", url: "/auth/callback", headers: { "x-forwarded-for": "203.0.113.50, 70.41.3.18" }, ip: "127.0.0.1", socket: { remoteAddress: "127.0.0.1" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; const debugSpy = vi.spyOn(controller["logger"], "debug"); await controller.handleAuth(mockRequest, mockResponse); expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50")); // Ensure it does NOT contain the second IP in the extracted position expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18")); }); it("should extract first IP from X-Forwarded-For as array", async () => { const mockRequest = { method: "GET", url: "/auth/callback", headers: { "x-forwarded-for": ["203.0.113.50", "70.41.3.18"] }, ip: "127.0.0.1", socket: { remoteAddress: "127.0.0.1" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; const debugSpy = vi.spyOn(controller["logger"], "debug"); await controller.handleAuth(mockRequest, mockResponse); expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50")); }); it("should fallback to req.ip when no X-Forwarded-For header", async () => { const mockRequest = { method: "GET", url: "/auth/callback", headers: {}, ip: "192.168.1.100", socket: { remoteAddress: "192.168.1.100" }, } as unknown as ExpressRequest; const mockResponse = { headersSent: false, } as unknown as ExpressResponse; const debugSpy = vi.spyOn(controller["logger"], "debug"); await controller.handleAuth(mockRequest, mockResponse); expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100")); }); }); });