Files
stack/apps/api/src/auth/auth.service.spec.ts
Jason Woltje d2cec04cba
All checks were successful
ci/woodpecker/push/api Pipeline was successful
fix(auth): preserve raw BetterAuth cookie token for session lookup
2026-02-18 23:06:37 -06:00

750 lines
25 KiB
TypeScript

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>(AuthService);
prisma = module.get<PrismaService>(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();
});
});
});