Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
248 lines
8.9 KiB
Python
248 lines
8.9 KiB
Python
"""Tests for iai_mcp.identity_audit -- Task 2.
|
|
|
|
Covers 6 behaviours from the plan:
|
|
1. continuous_audit runs s5.detect_drift_anomaly + sigma.compute_and_emit on
|
|
each tick.
|
|
2. Audit runs regardless of daemon pause state.
|
|
3. Audit does NOT acquire the fcntl exclusive lock -- never instantiates
|
|
ProcessLock inside the loop.
|
|
4. Audit shuts down cleanly when the shutdown event is set; task completes
|
|
without hanging.
|
|
5. Exception inside detect_drift_anomaly is caught, identity_audit_error
|
|
event emitted, loop continues on next tick.
|
|
6. Short interval patched -- several ticks within a fraction of a second
|
|
produce multiple detect_drift_anomaly calls.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1: continuous_audit calls s5.detect_drift_anomaly + sigma.compute_and_emit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_continuous_audit_invokes_both_underlying_calls(monkeypatch):
|
|
from iai_mcp import identity_audit
|
|
|
|
s5_calls: list = []
|
|
sigma_calls: list = []
|
|
|
|
def fake_s5(store, window):
|
|
s5_calls.append((store, window))
|
|
return []
|
|
|
|
def fake_sigma(store):
|
|
sigma_calls.append((store,))
|
|
return {"phase": "healthy"}
|
|
|
|
monkeypatch.setattr(identity_audit, "detect_drift_anomaly", fake_s5)
|
|
monkeypatch.setattr(identity_audit, "compute_and_emit", fake_sigma)
|
|
# Very short tick so the test finishes quickly.
|
|
monkeypatch.setattr(identity_audit, "AUDIT_INTERVAL_SEC", 0.02)
|
|
|
|
async def runner():
|
|
shutdown = asyncio.Event()
|
|
task = asyncio.create_task(
|
|
identity_audit.continuous_audit(object(), shutdown)
|
|
)
|
|
# Let at least one tick run.
|
|
await asyncio.sleep(0.05)
|
|
shutdown.set()
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
asyncio.run(runner())
|
|
|
|
assert len(s5_calls) >= 1, "detect_drift_anomaly never called"
|
|
assert len(sigma_calls) >= 1, "compute_and_emit never called"
|
|
# window_sessions=5 as specified in the action.
|
|
assert s5_calls[0][1] == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2: audit runs regardless of daemon pause state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_audit_runs_even_when_paused(monkeypatch):
|
|
"""C6: the daemon may be paused (state['paused_until'] in the future) but
|
|
the audit loop does NOT consult that state and continues to tick."""
|
|
from iai_mcp import identity_audit
|
|
|
|
s5_calls: list = []
|
|
|
|
def fake_s5(store, window):
|
|
s5_calls.append(1)
|
|
return []
|
|
|
|
monkeypatch.setattr(identity_audit, "detect_drift_anomaly", fake_s5)
|
|
monkeypatch.setattr(identity_audit, "compute_and_emit", lambda store: {})
|
|
monkeypatch.setattr(identity_audit, "AUDIT_INTERVAL_SEC", 0.02)
|
|
|
|
# "Paused" daemon state is just a dict the audit does not consult --
|
|
# still, we set it to be explicit about what C6 means.
|
|
daemon_state = {
|
|
"paused_until": "2099-01-01T00:00:00+00:00",
|
|
"fsm_state": "WAKE",
|
|
}
|
|
# The audit does not take state at all; this is the point of the test.
|
|
assert "paused_until" in daemon_state
|
|
|
|
async def runner():
|
|
shutdown = asyncio.Event()
|
|
task = asyncio.create_task(
|
|
identity_audit.continuous_audit(object(), shutdown)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
shutdown.set()
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
asyncio.run(runner())
|
|
|
|
assert len(s5_calls) >= 1, "audit did NOT fire while daemon was 'paused' (C6 violation)"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3: audit does NOT acquire fcntl exclusive (C6 MVCC-only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_audit_never_acquires_exclusive_lock(monkeypatch):
|
|
"""C6 grep + runtime guard: ProcessLock.try_acquire_exclusive must never
|
|
be called from within continuous_audit."""
|
|
from iai_mcp import identity_audit, concurrency
|
|
|
|
def raiser(self):
|
|
raise AssertionError(
|
|
"C6 violation: continuous_audit acquired ProcessLock exclusive"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
concurrency.ProcessLock, "try_acquire_exclusive", raiser
|
|
)
|
|
# Same for acquire_shared and holds_exclusive_nb -- audit must not touch
|
|
# the lock at all.
|
|
monkeypatch.setattr(concurrency.ProcessLock, "acquire_shared", raiser)
|
|
monkeypatch.setattr(concurrency.ProcessLock, "holds_exclusive_nb", raiser)
|
|
|
|
monkeypatch.setattr(identity_audit, "detect_drift_anomaly", lambda s, w: [])
|
|
monkeypatch.setattr(identity_audit, "compute_and_emit", lambda s: {})
|
|
monkeypatch.setattr(identity_audit, "AUDIT_INTERVAL_SEC", 0.02)
|
|
|
|
async def runner():
|
|
shutdown = asyncio.Event()
|
|
task = asyncio.create_task(
|
|
identity_audit.continuous_audit(object(), shutdown)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
shutdown.set()
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
# If the audit touched the lock, the raisers would fire and surface here.
|
|
asyncio.run(runner())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4: audit shuts down cleanly when the shutdown event is set
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_audit_shuts_down_cleanly(monkeypatch):
|
|
from iai_mcp import identity_audit
|
|
|
|
monkeypatch.setattr(identity_audit, "detect_drift_anomaly", lambda s, w: [])
|
|
monkeypatch.setattr(identity_audit, "compute_and_emit", lambda s: {})
|
|
# Long interval so we rely on shutdown to break out.
|
|
monkeypatch.setattr(identity_audit, "AUDIT_INTERVAL_SEC", 3600)
|
|
|
|
async def runner():
|
|
shutdown = asyncio.Event()
|
|
task = asyncio.create_task(
|
|
identity_audit.continuous_audit(object(), shutdown)
|
|
)
|
|
# Give one tick a chance to fire.
|
|
await asyncio.sleep(0.02)
|
|
shutdown.set()
|
|
# Task MUST complete quickly once shutdown is set -- no 1h hang.
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
assert task.done()
|
|
|
|
asyncio.run(runner())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5: exception inside detect_drift_anomaly is caught; event emitted;
|
|
# audit continues on next tick
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_audit_survives_s5_exception_and_emits_event(monkeypatch):
|
|
from iai_mcp import identity_audit
|
|
|
|
s5_calls: list = []
|
|
emitted: list = []
|
|
|
|
def flaky_s5(store, window):
|
|
s5_calls.append(1)
|
|
if len(s5_calls) == 1:
|
|
raise RuntimeError("simulated s5 failure")
|
|
return []
|
|
|
|
def capture_event(store, kind, data, *, severity=None, **kwargs):
|
|
emitted.append((kind, dict(data), severity))
|
|
return None
|
|
|
|
monkeypatch.setattr(identity_audit, "detect_drift_anomaly", flaky_s5)
|
|
monkeypatch.setattr(identity_audit, "compute_and_emit", lambda s: {})
|
|
monkeypatch.setattr(identity_audit, "write_event", capture_event)
|
|
monkeypatch.setattr(identity_audit, "AUDIT_INTERVAL_SEC", 0.01)
|
|
|
|
async def runner():
|
|
shutdown = asyncio.Event()
|
|
task = asyncio.create_task(
|
|
identity_audit.continuous_audit(object(), shutdown)
|
|
)
|
|
await asyncio.sleep(0.25)
|
|
shutdown.set()
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
asyncio.run(runner())
|
|
|
|
# identity_audit_error with stage=s5 must appear.
|
|
s5_err = [e for e in emitted if e[0] == "identity_audit_error" and e[1].get("stage") == "s5"]
|
|
assert len(s5_err) >= 1, f"no s5 error event emitted; emitted={emitted}"
|
|
assert "simulated s5 failure" in s5_err[0][1]["error"]
|
|
# Loop kept going -- at least 2 ticks.
|
|
assert len(s5_calls) >= 2, (
|
|
f"audit did not continue after s5 exception; calls={len(s5_calls)}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 6: short interval -> multiple ticks in a short real time window
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_audit_fires_multiple_times_with_short_interval(monkeypatch):
|
|
from iai_mcp import identity_audit
|
|
|
|
s5_calls: list = []
|
|
|
|
def fake_s5(store, window):
|
|
s5_calls.append(1)
|
|
return []
|
|
|
|
monkeypatch.setattr(identity_audit, "detect_drift_anomaly", fake_s5)
|
|
monkeypatch.setattr(identity_audit, "compute_and_emit", lambda s: {})
|
|
monkeypatch.setattr(identity_audit, "AUDIT_INTERVAL_SEC", 0.03)
|
|
|
|
async def runner():
|
|
shutdown = asyncio.Event()
|
|
task = asyncio.create_task(
|
|
identity_audit.continuous_audit(object(), shutdown)
|
|
)
|
|
await asyncio.sleep(0.25)
|
|
shutdown.set()
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
asyncio.run(runner())
|
|
assert len(s5_calls) >= 3, (
|
|
f"expected >=3 ticks in 0.25s @ 0.03s interval; got {len(s5_calls)}"
|
|
)
|