Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
248
tests/test_identity_audit.py
Normal file
248
tests/test_identity_audit.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""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)}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue