mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-09 05:12:12 +02:00
199 lines
6.7 KiB
Python
199 lines
6.7 KiB
Python
|
|
"""
|
||
|
|
Tests for the gateway audit middleware.
|
||
|
|
|
||
|
|
Verifies that gateway.request events are emitted with correct
|
||
|
|
metadata for success, error, and auth failure paths.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
from aiohttp import web
|
||
|
|
from aiohttp.test_utils import make_mocked_request
|
||
|
|
|
||
|
|
from trustgraph.gateway.audit import make_audit_middleware, _client_ip, _outcome_from_status
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _make_request(method="POST", path="/api/v1/config", headers=None,
|
||
|
|
remote="10.0.0.1"):
|
||
|
|
hdrs = headers or {}
|
||
|
|
req = make_mocked_request(method, path, headers=hdrs)
|
||
|
|
req._transport_peername = (remote, 0)
|
||
|
|
return req
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Pure helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestClientIp:
|
||
|
|
|
||
|
|
def test_uses_x_forwarded_for(self):
|
||
|
|
req = _make_request(headers={"X-Forwarded-For": "1.2.3.4, 10.0.0.1"})
|
||
|
|
assert _client_ip(req) == "1.2.3.4"
|
||
|
|
|
||
|
|
def test_falls_back_to_remote(self):
|
||
|
|
req = _make_request(remote="192.168.1.1")
|
||
|
|
assert _client_ip(req) == "192.168.1.1"
|
||
|
|
|
||
|
|
|
||
|
|
class TestOutcomeFromStatus:
|
||
|
|
|
||
|
|
def test_success_range(self):
|
||
|
|
assert _outcome_from_status(200) == "success"
|
||
|
|
assert _outcome_from_status(201) == "success"
|
||
|
|
assert _outcome_from_status(204) == "success"
|
||
|
|
assert _outcome_from_status(301) == "success"
|
||
|
|
|
||
|
|
def test_unauthenticated(self):
|
||
|
|
assert _outcome_from_status(401) == "unauthenticated"
|
||
|
|
|
||
|
|
def test_denied(self):
|
||
|
|
assert _outcome_from_status(403) == "denied"
|
||
|
|
|
||
|
|
def test_error(self):
|
||
|
|
assert _outcome_from_status(400) == "error"
|
||
|
|
assert _outcome_from_status(404) == "error"
|
||
|
|
assert _outcome_from_status(500) == "error"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Middleware integration
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestAuditMiddleware:
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def audit_publisher(self):
|
||
|
|
pub = AsyncMock()
|
||
|
|
pub.emit = AsyncMock()
|
||
|
|
return pub
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def middleware(self, audit_publisher):
|
||
|
|
return make_audit_middleware(audit_publisher)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_emits_event_on_success(self, middleware, audit_publisher):
|
||
|
|
async def handler(request):
|
||
|
|
return web.json_response({"ok": True})
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
audit_publisher.emit.assert_called_once()
|
||
|
|
event_type, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert event_type == "gateway.request"
|
||
|
|
assert payload["method"] == "POST"
|
||
|
|
assert payload["path"] == "/api/v1/config"
|
||
|
|
assert payload["status_code"] == 200
|
||
|
|
assert payload["outcome"] == "success"
|
||
|
|
assert "request_id" in payload
|
||
|
|
assert "duration_ms" in payload
|
||
|
|
assert "error" not in payload
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_emits_event_on_http_exception(self, middleware,
|
||
|
|
audit_publisher):
|
||
|
|
async def handler(request):
|
||
|
|
raise web.HTTPForbidden(reason="access denied")
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
with pytest.raises(web.HTTPForbidden):
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
audit_publisher.emit.assert_called_once()
|
||
|
|
_, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert payload["status_code"] == 403
|
||
|
|
assert payload["outcome"] == "denied"
|
||
|
|
assert payload["error"] == "access denied"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_emits_event_on_unhandled_exception(self, middleware,
|
||
|
|
audit_publisher):
|
||
|
|
async def handler(request):
|
||
|
|
raise ValueError("boom")
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
audit_publisher.emit.assert_called_once()
|
||
|
|
_, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert payload["status_code"] == 500
|
||
|
|
assert payload["outcome"] == "error"
|
||
|
|
assert payload["error"] == "ValueError"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_includes_identity_when_annotated(self, middleware,
|
||
|
|
audit_publisher):
|
||
|
|
async def handler(request):
|
||
|
|
request['audit_identity'] = "user:mark"
|
||
|
|
return web.json_response({"ok": True})
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
_, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert payload["identity"] == "user:mark"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_includes_capability_when_annotated(self, middleware,
|
||
|
|
audit_publisher):
|
||
|
|
async def handler(request):
|
||
|
|
request['audit_capability'] = "config:read"
|
||
|
|
request['audit_workspace'] = "production"
|
||
|
|
return web.json_response({"ok": True})
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
_, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert payload["capability"] == "config:read"
|
||
|
|
assert payload["workspace"] == "production"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_omits_unannotated_fields(self, middleware,
|
||
|
|
audit_publisher):
|
||
|
|
async def handler(request):
|
||
|
|
return web.json_response({"ok": True})
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
_, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert "identity" not in payload
|
||
|
|
assert "capability" not in payload
|
||
|
|
assert "workspace" not in payload
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_assigns_request_id(self, middleware, audit_publisher):
|
||
|
|
captured_id = None
|
||
|
|
|
||
|
|
async def handler(request):
|
||
|
|
nonlocal captured_id
|
||
|
|
captured_id = request.get('audit_request_id')
|
||
|
|
return web.json_response({"ok": True})
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
await middleware(req, handler)
|
||
|
|
|
||
|
|
assert captured_id is not None
|
||
|
|
_, payload = audit_publisher.emit.call_args[0]
|
||
|
|
assert payload["request_id"] == captured_id
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_still_returns_response_if_emit_fails(self, middleware,
|
||
|
|
audit_publisher):
|
||
|
|
audit_publisher.emit.side_effect = RuntimeError("pub/sub down")
|
||
|
|
|
||
|
|
async def handler(request):
|
||
|
|
return web.json_response({"ok": True})
|
||
|
|
|
||
|
|
req = _make_request()
|
||
|
|
resp = await middleware(req, handler)
|
||
|
|
assert resp.status == 200
|