/**
 * ASI-08 Cascading Failures — CircuitBreaker PreToolUse enforcement tests.
 *
 * Step 0 probe (verified 2026-05-29 before coding):
 *   - CircuitBreaker.allowExecution() exists at src/security/circuit-breaker.ts:60.
 *   - Only ONE caller: src/llm-adapters/claude.ts:112 (opt-in / adapter-level).
 *   - evaluatePreFlight() in packages/governance-proxy/src/governance/evaluator.ts
 *     has ZERO circuit-breaker check — the gap this PR closes.
 *   - GovernanceConfigOptions has no `circuitBreaker` field (added here).
 *   - AgentManifest has no `circuitBreakerConfigured` field (added here).
 *
 * Step 5 RED→GREEN:
 *   All four cases are tested before the implementation exists (RED), then
 *   the implementation makes them GREEN.
 *
 *   Case 1 — breaker OPEN: evaluatePreFlight returns DENY; audit event emitted.
 *   Case 2 — breaker CLOSED: evaluatePreFlight proceeds normally (ALLOW).
 *   Case 3 — state store unavailable (throws): check returns ALLOW (advisory fail-open).
 *   Case 4 — scanner: manifest without circuitBreakerConfigured → finding + suggestedRule.
 *             manifest with circuitBreakerConfigured: true → no ASI-08 finding.
 *
 * @connexum/ai-governance  ASI-08 Cascading Failures enforcement
 */
import { describe, it, beforeEach, afterEach } from 'node:test';
import * as assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CircuitBreaker } from '../../security/circuit-breaker.js';
import { evaluatePreFlightWithBreaker, } from '../circuit-breaker-preflight.js';
import { scanAgent, OWASP_AGENTIC_RISK, } from '../owasp-agentic-scanner.js';
import { CAPABILITY_CLASS } from '../capability-classes.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let tmpDir;
beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asi08-test-'));
});
afterEach(() => {
    try {
        fs.rmSync(tmpDir, { recursive: true, force: true });
    }
    catch {
        // best-effort cleanup
    }
});
function makeAlwaysAllowEvaluator() {
    return (_t, _a, _tool, _args) => ({
        verdict: 'ALLOW',
        triggeringPackIds: [],
        triggeringClassifierIds: [],
        reason: 'test passthrough',
        timestamp: Date.now(),
    });
}
function makeOpenBreaker(stateDir, agentName = 'test-agent') {
    const cb = new CircuitBreaker({ stateDir, agentName, failureThreshold: 1 });
    // Trip it open by recording enough failures
    cb.recordFailure('test: force open');
    return cb;
}
function makeClosedBreaker(stateDir, agentName = 'test-agent') {
    return new CircuitBreaker({ stateDir, agentName });
    // Fresh breaker starts CLOSED
}
function minimalManifest(over = {}) {
    return {
        agentId: 'asi08-test-agent',
        name: 'ASI-08 Test Agent',
        tools: ['Bash'],
        hooks: { preToolUse: true, postToolUse: true, postResponse: true },
        llmProvider: 'connexum-proxy/anthropic',
        pluginIntegrityVerified: true,
        rateLimitsConfigured: true,
        declaredApprovalGates: [{ capability: CAPABILITY_CLASS.SHELL_EXEC, reviewer: 'human' }],
        declaredForbiddenCapabilities: [],
        ...over,
    };
}
// ---------------------------------------------------------------------------
// Case 1: breaker OPEN → PreToolUse must deny + emit audit event
// ---------------------------------------------------------------------------
describe('ASI-08 Case 1 — breaker OPEN blocks tool call', () => {
    it('returns DENY verdict when the agent circuit is OPEN', async () => {
        const cb = makeOpenBreaker(tmpDir);
        const auditEvents = [];
        const opts = {
            circuitBreaker: cb,
            agentId: 'test-agent',
            evaluatorFn: makeAlwaysAllowEvaluator(),
            onAuditEvent: (event, data) => auditEvents.push({ event, data }),
        };
        const result = await evaluatePreFlightWithBreaker(opts, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
        assert.equal(result.verdict, 'DENY', 'OPEN breaker must return DENY verdict');
        assert.ok(result.reason.includes('circuit') || result.reason.includes('OPEN') || result.reason.includes('suspended'), `reason must mention circuit/OPEN/suspended; got: "${result.reason}"`);
        assert.ok(result.triggeringPackIds.includes('ASI-08'), 'triggeringPackIds must include ASI-08 tag');
    });
    it('OPEN breaker does NOT affect other agents (per-agent isolation)', async () => {
        // Agent A is OPEN; agent B is CLOSED — agent B must still get ALLOW
        const cbA = makeOpenBreaker(tmpDir, 'agent-a');
        // Create a separate state dir for agent-b so there's no cross-contamination
        const tmpDirB = fs.mkdtempSync(path.join(os.tmpdir(), 'asi08-test-b-'));
        try {
            const cbB = makeClosedBreaker(tmpDirB, 'agent-b');
            const optsB = {
                circuitBreaker: cbB,
                agentId: 'agent-b',
                evaluatorFn: makeAlwaysAllowEvaluator(),
            };
            const resultB = await evaluatePreFlightWithBreaker(optsB, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
            assert.equal(resultB.verdict, 'ALLOW', 'CLOSED breaker for agent-b must still ALLOW');
            // Also confirm the OPEN-breaker still denies
            const optsA = {
                circuitBreaker: cbA,
                agentId: 'agent-a',
                evaluatorFn: makeAlwaysAllowEvaluator(),
            };
            const resultA = await evaluatePreFlightWithBreaker(optsA, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
            assert.equal(resultA.verdict, 'DENY', 'OPEN breaker for agent-a must deny');
        }
        finally {
            fs.rmSync(tmpDirB, { recursive: true, force: true });
        }
    });
    it('emits an audit event when the breaker is OPEN', async () => {
        const cb = makeOpenBreaker(tmpDir);
        const auditEvents = [];
        const opts = {
            circuitBreaker: cb,
            agentId: 'audited-agent',
            evaluatorFn: makeAlwaysAllowEvaluator(),
            onAuditEvent: (event, data) => auditEvents.push({ event, data }),
        };
        await evaluatePreFlightWithBreaker(opts, 'tenant-1', { model: 'test', messages: [] });
        assert.ok(auditEvents.length >= 1, 'at least one audit event must be emitted on OPEN block');
        const tripEvent = auditEvents.find((e) => e.event.includes('circuit'));
        assert.ok(tripEvent !== undefined, `expected a "circuit" event; got: ${JSON.stringify(auditEvents.map((e) => e.event))}`);
        assert.ok(tripEvent.data['agentId'] === 'audited-agent', 'audit event must carry the agentId');
        assert.ok(tripEvent.data['owaspTag'] === 'ASI-08', 'audit event must carry OWASP tag ASI-08');
    });
});
// ---------------------------------------------------------------------------
// Case 2: breaker CLOSED → tool call proceeds normally (ALLOW from evaluatorFn)
// ---------------------------------------------------------------------------
describe('ASI-08 Case 2 — breaker CLOSED passes through to evaluatorFn', () => {
    it('returns ALLOW when breaker is CLOSED and evaluatorFn ALLOWs', async () => {
        const cb = makeClosedBreaker(tmpDir);
        const opts = {
            circuitBreaker: cb,
            agentId: 'test-agent',
            evaluatorFn: makeAlwaysAllowEvaluator(),
        };
        const result = await evaluatePreFlightWithBreaker(opts, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
        assert.equal(result.verdict, 'ALLOW');
        assert.equal(result.reason, 'test passthrough');
    });
    it('propagates DENY from evaluatorFn when breaker is CLOSED', async () => {
        const cb = makeClosedBreaker(tmpDir);
        const denyEvaluator = () => ({
            verdict: 'DENY',
            triggeringPackIds: ['hipaa'],
            triggeringClassifierIds: ['phi'],
            reason: 'PHI detected',
            timestamp: Date.now(),
        });
        const opts = {
            circuitBreaker: cb,
            agentId: 'test-agent',
            evaluatorFn: denyEvaluator,
        };
        const result = await evaluatePreFlightWithBreaker(opts, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
        assert.equal(result.verdict, 'DENY');
        assert.equal(result.reason, 'PHI detected');
    });
});
// ---------------------------------------------------------------------------
// Case 3: state store unavailable / allowExecution() throws → fail-open (ALLOW)
//          This proves Invariant 3 (graceful degradation, no throw on infra error)
// ---------------------------------------------------------------------------
describe('ASI-08 Case 3 — breaker state store unavailable → advisory fail-open', () => {
    it('returns ALLOW (not throw) when circuitBreaker.allowExecution() throws', async () => {
        // Create a breaker that throws on allowExecution() to simulate store unavailability
        const throwingBreaker = {
            allowExecution: () => {
                throw new Error('state store unavailable — disk full');
            },
        };
        const auditEvents = [];
        const opts = {
            circuitBreaker: throwingBreaker,
            agentId: 'test-agent',
            evaluatorFn: makeAlwaysAllowEvaluator(),
            onAuditEvent: (event, data) => auditEvents.push({ event, data }),
        };
        // Must NOT throw — must return structured ALLOW response
        let result;
        try {
            result = await evaluatePreFlightWithBreaker(opts, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
        }
        catch (err) {
            assert.fail(`evaluatePreFlightWithBreaker must not throw on breaker infra error; threw: ${err.message}`);
        }
        assert.equal(result.verdict, 'ALLOW', 'infra error must fail-open (ALLOW)');
        assert.ok(result.reason.includes('advisory') || result.reason.includes('fail-open') || result.reason.includes('unavailable'), `reason must signal advisory/fail-open; got: "${result.reason}"`);
        // An advisory audit event should be emitted so the operator can see the degradation
        const advisoryEvent = auditEvents.find((e) => e.event.includes('advisory') || e.event.includes('degraded') || e.event.includes('circuit'));
        assert.ok(advisoryEvent !== undefined, `expected advisory audit event on breaker infra error; got events: ${JSON.stringify(auditEvents.map((e) => e.event))}`);
    });
    it('does NOT throw when evaluatePreFlightWithBreaker receives a null circuitBreaker (no-op)', async () => {
        // If circuitBreaker is undefined/null (config opt-in not set), the function
        // must still work — it simply skips the check.
        const opts = {
            circuitBreaker: undefined,
            agentId: 'test-agent',
            evaluatorFn: makeAlwaysAllowEvaluator(),
        };
        const result = await evaluatePreFlightWithBreaker(opts, 'tenant-1', { model: 'claude-sonnet-4-6', messages: [] });
        assert.equal(result.verdict, 'ALLOW', 'no-op (undefined breaker) must pass through ALLOW');
    });
});
// ---------------------------------------------------------------------------
// Case 4: scanner — AgentManifest.circuitBreakerConfigured
// ---------------------------------------------------------------------------
describe('ASI-08 Case 4 — scanner: circuitBreakerConfigured field', () => {
    it('emits MED/HIGH ASI-08 finding + suggestedRule when circuitBreakerConfigured is absent', () => {
        const manifest = minimalManifest({
            // circuitBreakerConfigured intentionally omitted / undefined
            circuitBreakerConfigured: undefined,
        });
        const report = scanAgent(manifest);
        const asi08Finding = report.findings.find((f) => f.risk === OWASP_AGENTIC_RISK.ASI08_CASCADING_FAILURES);
        assert.ok(asi08Finding !== undefined, `expected an ASI08_CASCADING_FAILURES finding when circuitBreakerConfigured is absent; ` +
            `got findings: ${JSON.stringify(report.findings.map((f) => ({ risk: f.risk, title: f.title })))}`);
        assert.ok(asi08Finding.severity === 'MED' || asi08Finding.severity === 'HIGH', `ASI-08 circuit-breaker finding must be MED or HIGH; got ${asi08Finding.severity}`);
        assert.ok(asi08Finding.suggestedRule !== undefined, 'ASI-08 circuit-breaker finding must carry a suggestedRule');
    });
    it('does NOT emit ASI-08 finding when circuitBreakerConfigured is true', () => {
        const manifest = minimalManifest({ circuitBreakerConfigured: true });
        const report = scanAgent(manifest);
        const asi08Finding = report.findings.find((f) => f.risk === OWASP_AGENTIC_RISK.ASI08_CASCADING_FAILURES);
        assert.ok(asi08Finding === undefined, `should NOT emit ASI08_CASCADING_FAILURES finding when circuitBreakerConfigured=true; ` +
            `got finding: ${JSON.stringify(asi08Finding)}`);
    });
    it('existing A04 rate-limit finding is NOT removed when circuitBreakerConfigured=true', () => {
        // Adding circuit-breaker coverage should not suppress the existing rate-limit finding
        const manifest = minimalManifest({
            circuitBreakerConfigured: true,
            rateLimitsConfigured: false, // <- rate limits still missing
        });
        const report = scanAgent(manifest);
        const rateLimitFinding = report.findings.find((f) => f.risk === OWASP_AGENTIC_RISK.A04_DOS && /rate limit/i.test(f.title));
        assert.ok(rateLimitFinding !== undefined, 'A04 rate-limit finding must still be emitted even when circuitBreakerConfigured=true');
    });
});
//# sourceMappingURL=asi08-circuit-breaker-pretooluse.test.js.map