diff --git a/src/lib/covenant/integrations.ts b/src/lib/covenant/integrations.ts index 2f1cb4d..e0f669f 100644 --- a/src/lib/covenant/integrations.ts +++ b/src/lib/covenant/integrations.ts @@ -1,19 +1,3 @@ -import { createClient } from "redis"; - -// Reuse client if it exists globally to avoid reconnecting on every request -declare global { - var _redisClient: ReturnType | undefined; -} - -async function getRedisClient() { - if (!global._redisClient) { - global._redisClient = createClient({ url: process.env.REDIS_URL || "redis://localhost:6379" }); - global._redisClient.on("error", (err) => console.error("Redis error:", err)); - await global._redisClient.connect().catch(() => {}); - } - return global._redisClient; -} - export class IntegrationUnavailable extends Error { readonly code = "INTEGRATION_UNAVAILABLE"; } @@ -28,11 +12,13 @@ export function requireIntegration(name: string, value: string | undefined): str return value.replace(/\/$/, ""); } -export async function postIntegration(url: string, body: unknown, headers: Record = {}): Promise> { +export async function postIntegration( + url: string, + body: unknown, + headers: Record = {}, +): Promise> { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second fail-fast - - const cacheKey = `cAPI:integration:${Buffer.from(url).toString('base64')}:${Buffer.from(JSON.stringify(body)).toString('base64')}`; + const timeoutId = setTimeout(() => controller.abort(), 3000); try { const response = await fetch(url, { @@ -41,48 +27,29 @@ export async function postIntegration(url: string, body: unknown, headers: Recor body: JSON.stringify(body), signal: controller.signal, }); - clearTimeout(timeoutId); if (!response.ok) { if (response.status === 401 || response.status === 403) { throw new AuthorityDenied(`Authority denied: HTTP ${response.status}`); } - throw new Error(`HTTP ${response.status}`); + throw new IntegrationUnavailable(`Integration failed: HTTP ${response.status}`); } - + const result: unknown = await response.json(); if (!result || typeof result !== "object" || Array.isArray(result)) { - throw new Error("Invalid response"); + throw new IntegrationUnavailable("Integration failed: invalid response"); } - // Cache successful response asynchronously - getRedisClient().then(client => { - if (client.isOpen) client.setEx(cacheKey, 3600, JSON.stringify(result)).catch(console.error); - }).catch(console.error); - return result as Record; } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof AuthorityDenied) { + if (error instanceof AuthorityDenied || error instanceof IntegrationUnavailable) { throw error; } - - // Attempt to retrieve stale data - try { - const client = await getRedisClient(); - if (client.isOpen) { - const cached = await client.get(cacheKey); - if (cached) { - const parsed = JSON.parse(cached); - parsed._stale = true; // Mark as stale - return parsed; - } - } - } catch (redisError) { - console.error("Failed to retrieve stale cache:", redisError); - } - throw new IntegrationUnavailable(`Integration failed: ${error instanceof Error ? error.message : "Unknown error"}`); + throw new IntegrationUnavailable( + `Integration failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + clearTimeout(timeoutId); } } diff --git a/tests/integrations.fail-closed.test.ts b/tests/integrations.fail-closed.test.ts new file mode 100644 index 0000000..735e5ee --- /dev/null +++ b/tests/integrations.fail-closed.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AuthorityDenied, + IntegrationUnavailable, + postIntegration, +} from "../src/lib/covenant/integrations"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("postIntegration fail-closed authority boundary", () => { + it("does not replay a prior successful response after the authority service fails", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ decision: "APPROVED" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce(new Response("upstream failure", { status: 503 })); + + vi.stubGlobal("fetch", fetchMock); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-1" }), + ).resolves.toEqual({ decision: "APPROVED" }); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-1" }), + ).rejects.toBeInstanceOf(IntegrationUnavailable); + }); + + it("does not replay a prior successful response after a network rejection", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ decision: "APPROVED" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockRejectedValueOnce(new Error("network unavailable")); + + vi.stubGlobal("fetch", fetchMock); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-network" }), + ).resolves.toEqual({ decision: "APPROVED" }); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-network" }), + ).rejects.toBeInstanceOf(IntegrationUnavailable); + }); + + it("does not replay a prior successful response after the three-second timeout", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ decision: "APPROVED" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockImplementationOnce((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted", "AbortError")); + }); + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-timeout" }), + ).resolves.toEqual({ decision: "APPROVED" }); + + vi.useFakeTimers(); + const timedOutRequest = postIntegration("https://cappo.example.test/authorize", { + run_id: "run-timeout", + }); + const timeoutExpectation = expect(timedOutRequest).rejects.toBeInstanceOf( + IntegrationUnavailable, + ); + + await vi.advanceTimersByTimeAsync(3000); + await timeoutExpectation; + }); + + it("preserves explicit authority denial", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("denied", { status: 403 })), + ); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-2" }), + ).rejects.toBeInstanceOf(AuthorityDenied); + }); + + it("fails closed on invalid success payloads", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(["not", "an", "authority", "object"]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + + await expect( + postIntegration("https://cappo.example.test/authorize", { run_id: "run-3" }), + ).rejects.toBeInstanceOf(IntegrationUnavailable); + }); +});