750 lines
25 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|