import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; // Mock better-auth modules before importing 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 { AuthService } from "./auth.service"; import { PrismaService } from "../prisma/prisma.service"; describe("AuthService", () => { let service: AuthService; let prisma: PrismaService; const mockPrismaService = { user: { findUnique: vi.fn(), }, }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, { provide: PrismaService, useValue: mockPrismaService, }, ], }).compile(); service = module.get(AuthService); prisma = module.get(PrismaService); vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); delete process.env.OIDC_ENABLED; delete process.env.OIDC_ISSUER; }); describe("getAuth", () => { it("should return BetterAuth instance", () => { const auth = service.getAuth(); expect(auth).toBeDefined(); expect(auth.handler).toBeDefined(); }); }); describe("getUserById", () => { const mockUser = { id: "user-123", email: "test@example.com", name: "Test User", authProviderId: "auth-123", }; it("should get user by ID", async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser); const result = await service.getUserById("user-123"); expect(result).toEqual(mockUser); expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ where: { id: "user-123" }, select: { id: true, email: true, name: true, authProviderId: true, }, }); }); it("should return null when user is not found", async () => { mockPrismaService.user.findUnique.mockResolvedValue(null); const result = await service.getUserById("nonexistent-id"); expect(result).toBeNull(); expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ where: { id: "nonexistent-id" }, select: { id: true, email: true, name: true, authProviderId: true, }, }); }); }); describe("getUserByEmail", () => { const mockUser = { id: "user-123", email: "test@example.com", name: "Test User", authProviderId: "auth-123", }; it("should get user by email", async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser); const result = await service.getUserByEmail("test@example.com"); expect(result).toEqual(mockUser); expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ where: { email: "test@example.com" }, select: { id: true, email: true, name: true, authProviderId: true, }, }); }); it("should return null when user is not found", async () => { mockPrismaService.user.findUnique.mockResolvedValue(null); const result = await service.getUserByEmail("unknown@example.com"); expect(result).toBeNull(); expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ where: { email: "unknown@example.com" }, select: { id: true, email: true, name: true, authProviderId: true, }, }); }); }); describe("isOidcProviderReachable", () => { const discoveryUrl = "https://auth.example.com/.well-known/openid-configuration"; beforeEach(() => { process.env.OIDC_ISSUER = "https://auth.example.com/"; // Reset the cache by accessing private fields via bracket notation // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthResult = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).consecutiveHealthFailures = 0; }); it("should return true when discovery URL returns 200", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, }); vi.stubGlobal("fetch", mockFetch); const result = await service.isOidcProviderReachable(); expect(result).toBe(true); expect(mockFetch).toHaveBeenCalledWith(discoveryUrl, { signal: expect.any(AbortSignal) as AbortSignal, }); }); it("should return false on network error", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); vi.stubGlobal("fetch", mockFetch); const result = await service.isOidcProviderReachable(); expect(result).toBe(false); }); it("should return false on timeout", async () => { const mockFetch = vi.fn().mockRejectedValue(new DOMException("The operation was aborted")); vi.stubGlobal("fetch", mockFetch); const result = await service.isOidcProviderReachable(); expect(result).toBe(false); }); it("should return false when discovery URL returns non-200", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503, }); vi.stubGlobal("fetch", mockFetch); const result = await service.isOidcProviderReachable(); expect(result).toBe(false); }); it("should cache result for 30 seconds", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, }); vi.stubGlobal("fetch", mockFetch); // First call - fetches const result1 = await service.isOidcProviderReachable(); expect(result1).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(1); // Second call within 30s - uses cache const result2 = await service.isOidcProviderReachable(); expect(result2).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1, no new fetch // Simulate cache expiry by moving lastHealthCheck back // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = Date.now() - 31_000; // Third call after cache expiry - fetches again const result3 = await service.isOidcProviderReachable(); expect(result3).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(2); // Now 2 }); it("should cache false results too", async () => { const mockFetch = vi .fn() .mockRejectedValueOnce(new Error("ECONNREFUSED")) .mockResolvedValueOnce({ ok: true, status: 200 }); vi.stubGlobal("fetch", mockFetch); // First call - fails const result1 = await service.isOidcProviderReachable(); expect(result1).toBe(false); expect(mockFetch).toHaveBeenCalledTimes(1); // Second call within 30s - returns cached false const result2 = await service.isOidcProviderReachable(); expect(result2).toBe(false); expect(mockFetch).toHaveBeenCalledTimes(1); }); it("should escalate to error level after 3 consecutive failures", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); vi.stubGlobal("fetch", mockFetch); const loggerWarn = vi.spyOn(service["logger"], "warn"); const loggerError = vi.spyOn(service["logger"], "error"); // Failures 1 and 2 should log at warn level await service.isOidcProviderReachable(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; // Reset cache await service.isOidcProviderReachable(); expect(loggerWarn).toHaveBeenCalledTimes(2); expect(loggerError).not.toHaveBeenCalled(); // Failure 3 should escalate to error level // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; await service.isOidcProviderReachable(); expect(loggerError).toHaveBeenCalledTimes(1); expect(loggerError).toHaveBeenCalledWith( expect.stringContaining("OIDC provider unreachable") ); }); it("should escalate to error level after 3 consecutive non-OK responses", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); vi.stubGlobal("fetch", mockFetch); const loggerWarn = vi.spyOn(service["logger"], "warn"); const loggerError = vi.spyOn(service["logger"], "error"); // Failures 1 and 2 at warn level await service.isOidcProviderReachable(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; await service.isOidcProviderReachable(); expect(loggerWarn).toHaveBeenCalledTimes(2); expect(loggerError).not.toHaveBeenCalled(); // Failure 3 at error level // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; await service.isOidcProviderReachable(); expect(loggerError).toHaveBeenCalledTimes(1); expect(loggerError).toHaveBeenCalledWith( expect.stringContaining("OIDC provider returned non-OK status") ); }); it("should reset failure counter and log recovery on success after failures", async () => { const mockFetch = vi .fn() .mockRejectedValueOnce(new Error("ECONNREFUSED")) .mockRejectedValueOnce(new Error("ECONNREFUSED")) .mockResolvedValueOnce({ ok: true, status: 200 }); vi.stubGlobal("fetch", mockFetch); const loggerLog = vi.spyOn(service["logger"], "log"); // Two failures await service.isOidcProviderReachable(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; await service.isOidcProviderReachable(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; // Recovery const result = await service.isOidcProviderReachable(); expect(result).toBe(true); expect(loggerLog).toHaveBeenCalledWith( expect.stringContaining("OIDC provider recovered after 2 consecutive failure(s)") ); // Verify counter reset // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((service as any).consecutiveHealthFailures).toBe(0); }); }); describe("getAuthConfig", () => { it("should return only email provider when OIDC is disabled", async () => { delete process.env.OIDC_ENABLED; const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [{ id: "email", name: "Email", type: "credentials" }], }); }); it("should return both email and authentik providers when OIDC is enabled and reachable", async () => { process.env.OIDC_ENABLED = "true"; process.env.OIDC_ISSUER = "https://auth.example.com/"; const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); vi.stubGlobal("fetch", mockFetch); const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [ { id: "email", name: "Email", type: "credentials" }, { id: "authentik", name: "Authentik", type: "oauth" }, ], }); }); it("should return only email provider when OIDC_ENABLED is false", async () => { process.env.OIDC_ENABLED = "false"; const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [{ id: "email", name: "Email", type: "credentials" }], }); }); it("should omit authentik when OIDC is enabled but provider is unreachable", async () => { process.env.OIDC_ENABLED = "true"; process.env.OIDC_ISSUER = "https://auth.example.com/"; // Reset cache // eslint-disable-next-line @typescript-eslint/no-explicit-any (service as any).lastHealthCheck = 0; const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); vi.stubGlobal("fetch", mockFetch); const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [{ id: "email", name: "Email", type: "credentials" }], }); }); }); describe("verifySession", () => { const mockSessionData = { user: { id: "user-123", email: "test@example.com", name: "Test User", }, session: { id: "session-123", token: "test-token", }, }; it("should validate session token using secure BetterAuth cookie header", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockResolvedValue(mockSessionData); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("valid-token"); expect(result).toEqual(mockSessionData); expect(mockGetSession).toHaveBeenCalledTimes(1); expect(mockGetSession).toHaveBeenCalledWith({ headers: { cookie: "__Secure-better-auth.session_token=valid-token", }, }); }); it("should preserve raw cookie token value without URL re-encoding", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockResolvedValue(mockSessionData); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("tok/with+=chars="); expect(result).toEqual(mockSessionData); expect(mockGetSession).toHaveBeenCalledWith({ headers: { cookie: "__Secure-better-auth.session_token=tok/with+=chars=", }, }); }); it("should fall back to Authorization header when cookie-based lookups miss", async () => { const auth = service.getAuth(); const mockGetSession = vi .fn() .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(mockSessionData); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("valid-token"); expect(result).toEqual(mockSessionData); expect(mockGetSession).toHaveBeenNthCalledWith(1, { headers: { cookie: "__Secure-better-auth.session_token=valid-token", }, }); expect(mockGetSession).toHaveBeenNthCalledWith(2, { headers: { cookie: "better-auth.session_token=valid-token", }, }); expect(mockGetSession).toHaveBeenNthCalledWith(3, { headers: { cookie: "__Host-better-auth.session_token=valid-token", }, }); expect(mockGetSession).toHaveBeenNthCalledWith(4, { headers: { authorization: "Bearer valid-token", }, }); }); it("should return null for invalid session", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockResolvedValue(null); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("invalid-token"); expect(result).toBeNull(); }); it("should return null for 'invalid token' auth error", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid token provided")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("bad-token"); expect(result).toBeNull(); }); it("should return null for 'expired' auth error", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Token expired")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("expired-token"); expect(result).toBeNull(); }); it("should return null for 'session not found' auth error", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Session not found")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("missing-session"); expect(result).toBeNull(); }); it("should return null for 'unauthorized' auth error", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Unauthorized")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("unauth-token"); expect(result).toBeNull(); }); it("should return null for 'invalid session' auth error", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid session")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("invalid-session"); expect(result).toBeNull(); }); it("should return null for 'session expired' auth error", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Session expired")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("expired-session"); expect(result).toBeNull(); }); it("should return null for bare 'unauthorized' (exact match)", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("unauthorized")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("unauth-token"); expect(result).toBeNull(); }); it("should return null for bare 'expired' (exact match)", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("expired")); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("expired-token"); expect(result).toBeNull(); }); it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired")); auth.api = { getSession: mockGetSession } as any; await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired"); }); it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => { const auth = service.getAuth(); const mockGetSession = vi .fn() .mockRejectedValue(new Error("Unauthorized: Access denied for user")); auth.api = { getSession: mockGetSession } as any; await expect(service.verifySession("any-token")).rejects.toThrow( "Unauthorized: Access denied for user" ); }); it("should return null when a non-Error value is thrown", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue("string-error"); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("any-token"); expect(result).toBeNull(); }); it("should return null when getSession throws a non-Error value (string)", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue("some error"); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("any-token"); expect(result).toBeNull(); }); it("should return null when getSession throws a non-Error value (object)", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue({ code: "ERR_UNKNOWN" }); auth.api = { getSession: mockGetSession } as any; const result = await service.verifySession("any-token"); expect(result).toBeNull(); }); it("should re-throw unexpected errors that are not known auth errors", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Verification failed")); auth.api = { getSession: mockGetSession } as any; await expect(service.verifySession("error-token")).rejects.toThrow("Verification failed"); }); it("should re-throw Prisma infrastructure errors", async () => { const auth = service.getAuth(); const prismaError = new Error("connect ECONNREFUSED 127.0.0.1:5432"); const mockGetSession = vi.fn().mockRejectedValue(prismaError); auth.api = { getSession: mockGetSession } as any; await expect(service.verifySession("any-token")).rejects.toThrow("ECONNREFUSED"); }); it("should re-throw timeout errors as infrastructure errors", async () => { const auth = service.getAuth(); const timeoutError = new Error("Connection timeout after 5000ms"); const mockGetSession = vi.fn().mockRejectedValue(timeoutError); auth.api = { getSession: mockGetSession } as any; await expect(service.verifySession("any-token")).rejects.toThrow("timeout"); }); it("should re-throw errors with Prisma-prefixed constructor name", async () => { const auth = service.getAuth(); class PrismaClientKnownRequestError extends Error { constructor(message: string) { super(message); this.name = "PrismaClientKnownRequestError"; } } const prismaError = new PrismaClientKnownRequestError("Database connection lost"); const mockGetSession = vi.fn().mockRejectedValue(prismaError); auth.api = { getSession: mockGetSession } as any; await expect(service.verifySession("any-token")).rejects.toThrow("Database connection lost"); }); it("should redact Bearer tokens from logged error messages", async () => { const auth = service.getAuth(); const errorWithToken = new Error( "Request failed: Bearer eyJhbGciOiJIUzI1NiJ9.secret-payload in header" ); const mockGetSession = vi.fn().mockRejectedValue(errorWithToken); auth.api = { getSession: mockGetSession } as any; const loggerError = vi.spyOn(service["logger"], "error"); await expect(service.verifySession("any-token")).rejects.toThrow(); expect(loggerError).toHaveBeenCalledWith( "Session verification failed due to unexpected error", expect.stringContaining("Bearer [REDACTED]") ); expect(loggerError).toHaveBeenCalledWith( "Session verification failed due to unexpected error", expect.not.stringContaining("eyJhbGciOiJIUzI1NiJ9") ); }); it("should redact Bearer tokens from error stack traces", async () => { const auth = service.getAuth(); const errorWithToken = new Error("Something went wrong"); errorWithToken.stack = "Error: Something went wrong\n at fetch (Bearer abc123-secret-token)\n at verifySession"; const mockGetSession = vi.fn().mockRejectedValue(errorWithToken); auth.api = { getSession: mockGetSession } as any; const loggerError = vi.spyOn(service["logger"], "error"); await expect(service.verifySession("any-token")).rejects.toThrow(); expect(loggerError).toHaveBeenCalledWith( "Session verification failed due to unexpected error", expect.stringContaining("Bearer [REDACTED]") ); expect(loggerError).toHaveBeenCalledWith( "Session verification failed due to unexpected error", expect.not.stringContaining("abc123-secret-token") ); }); it("should warn when a non-Error string value is thrown", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue("string-error"); auth.api = { getSession: mockGetSession } as any; const loggerWarn = vi.spyOn(service["logger"], "warn"); const result = await service.verifySession("any-token"); expect(result).toBeNull(); expect(loggerWarn).toHaveBeenCalledWith( "Session verification received non-Error thrown value", "string-error" ); }); it("should warn with JSON when a non-Error object is thrown", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue({ code: "ERR_UNKNOWN" }); auth.api = { getSession: mockGetSession } as any; const loggerWarn = vi.spyOn(service["logger"], "warn"); const result = await service.verifySession("any-token"); expect(result).toBeNull(); expect(loggerWarn).toHaveBeenCalledWith( "Session verification received non-Error thrown value", JSON.stringify({ code: "ERR_UNKNOWN" }) ); }); it("should not warn for expected auth errors (Error instances)", async () => { const auth = service.getAuth(); const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid token provided")); auth.api = { getSession: mockGetSession } as any; const loggerWarn = vi.spyOn(service["logger"], "warn"); const result = await service.verifySession("bad-token"); expect(result).toBeNull(); expect(loggerWarn).not.toHaveBeenCalled(); }); }); });