feat(#413): add OIDC provider health check with 30s cache
All checks were successful
ci/woodpecker/push/api Pipeline was successful
All checks were successful
ci/woodpecker/push/api Pipeline was successful
- isOidcProviderReachable() fetches discovery URL with 2s timeout - getAuthConfig() omits authentik when provider unreachable - 30-second cache prevents repeated network calls Refs #413 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
@@ -30,6 +30,12 @@ describe("AuthService", () => {
|
||||
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();
|
||||
@@ -90,21 +96,128 @@ describe("AuthService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthConfig", () => {
|
||||
it("should return only email provider when OIDC is disabled", () => {
|
||||
it("should return only email provider when OIDC is disabled", async () => {
|
||||
delete process.env.OIDC_ENABLED;
|
||||
|
||||
const result = service.getAuthConfig();
|
||||
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", () => {
|
||||
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 result = service.getAuthConfig();
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const result = await service.getAuthConfig();
|
||||
|
||||
expect(result).toEqual({
|
||||
providers: [
|
||||
@@ -112,20 +225,34 @@ describe("AuthService", () => {
|
||||
{ id: "authentik", name: "Authentik", type: "oauth" },
|
||||
],
|
||||
});
|
||||
|
||||
delete process.env.OIDC_ENABLED;
|
||||
});
|
||||
|
||||
it("should return only email provider when OIDC_ENABLED is false", () => {
|
||||
it("should return only email provider when OIDC_ENABLED is false", async () => {
|
||||
process.env.OIDC_ENABLED = "false";
|
||||
|
||||
const result = service.getAuthConfig();
|
||||
const result = await service.getAuthConfig();
|
||||
|
||||
expect(result).toEqual({
|
||||
providers: [{ id: "email", name: "Email", type: "credentials" }],
|
||||
});
|
||||
});
|
||||
|
||||
delete process.env.OIDC_ENABLED;
|
||||
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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user