feat: implement Phase 2 stabilization
Some checks failed
CI / lint-and-test (push) Has been cancelled

- Cache Graph API tokens with expiry-aware reuse in graph/auth.py
- Add tenacity-based retry/backoff wrapper (utils/http.py) and apply to all Graph/source API calls
- Add Pydantic request/response models (models/api.py) and FastAPI query constraints
- Add unit tests for event_model, auth and integration tests for API endpoints
- Configure ruff linter/formatter in pyproject.toml
- Add GitHub Actions CI pipeline (.github/workflows/ci.yml)
- Add requirements-dev.txt with pytest, mongomock, httpx, ruff
- Clean up typing imports and fix ruff linting across codebase
This commit is contained in:
2026-04-14 12:02:28 +02:00
parent 4f6e16d64d
commit 9271b4e461
29 changed files with 518 additions and 118 deletions

View File

26
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,26 @@
import mongomock
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="function")
def mock_events_collection():
client = mongomock.MongoClient()
db = client["micro_soc"]
coll = db["events"]
return coll
@pytest.fixture(scope="function")
def client(mock_events_collection, monkeypatch):
# Patch the collection in all modules that import it before the app is imported
monkeypatch.setattr("database.events_collection", mock_events_collection)
monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection)
monkeypatch.setattr("routes.events.events_collection", mock_events_collection)
monkeypatch.setattr("auth.AUTH_ENABLED", False)
# Patch health check db.command so it doesn't need a real MongoDB server
monkeypatch.setattr("database.db.command", lambda cmd: {"ok": 1} if cmd == "ping" else {})
from main import app
return TestClient(app)

98
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,98 @@
from datetime import UTC, datetime
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["database"] == "connected"
def test_list_events_empty(client):
response = client.get("/api/events")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_events_pagination(client, mock_events_collection):
for i in range(5):
mock_events_collection.insert_one({
"id": f"evt-{i}",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Directory",
"operation": "Add user",
"result": "success",
"actor_display": f"Actor {i}",
"raw_text": "",
})
response = client.get("/api/events?page=1&page_size=2")
assert response.status_code == 200
data = response.json()
assert data["total"] == 5
assert len(data["items"]) == 2
assert data["page"] == 1
assert data["page_size"] == 2
def test_list_events_filter_by_service(client, mock_events_collection):
mock_events_collection.insert_one({
"id": "evt-1",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Exchange",
"operation": "Update",
"result": "success",
"actor_display": "Alice",
"raw_text": "",
})
mock_events_collection.insert_one({
"id": "evt-2",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Directory",
"operation": "Add",
"result": "success",
"actor_display": "Bob",
"raw_text": "",
})
response = client.get("/api/events?service=Exchange")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["service"] == "Exchange"
def test_list_events_page_size_validation(client):
response = client.get("/api/events?page_size=0")
assert response.status_code == 422
response = client.get("/api/events?page_size=501")
assert response.status_code == 422
def test_filter_options(client, mock_events_collection):
mock_events_collection.insert_one({
"id": "evt-1",
"timestamp": datetime.now(UTC).isoformat(),
"service": "Intune",
"operation": "Assign",
"result": "failure",
"actor_display": "Charlie",
"actor_upn": "charlie@example.com",
"raw_text": "",
})
response = client.get("/api/filter-options")
assert response.status_code == 200
data = response.json()
assert "Intune" in data["services"]
assert "Assign" in data["operations"]
assert "failure" in data["results"]
assert "Charlie" in data["actors"]
assert "charlie@example.com" in data["actor_upns"]
def test_fetch_audit_logs_validation(client):
response = client.get("/api/fetch-audit-logs?hours=0")
assert response.status_code == 422
response = client.get("/api/fetch-audit-logs?hours=721")
assert response.status_code == 422

View File

@@ -0,0 +1,61 @@
from unittest.mock import patch
import auth
import pytest
from auth import _allowed, require_auth
from fastapi import HTTPException
@pytest.fixture(autouse=True)
def reset_cache():
auth.JWKS_CACHE["keys"] = []
auth.JWKS_CACHE["exp"] = 0
@pytest.fixture
def mock_jwks():
from Crypto.PublicKey import RSA
from jose.jwk import RSAKey
key = RSA.generate(2048)
rsa_key = RSAKey(key)
jwk_dict = {
"kty": "RSA",
"kid": "test-kid",
"n": rsa_key._key.n,
"e": rsa_key._key.e,
}
return rsa_key, jwk_dict
def test_allowed_no_restrictions():
assert _allowed({}, set(), set()) is True
def test_allowed_by_role():
assert _allowed({"roles": ["Admin"]}, {"Admin"}, set()) is True
assert _allowed({"roles": ["User"]}, {"Admin"}, set()) is False
def test_allowed_by_group():
assert _allowed({"groups": ["SecOps"]}, set(), {"SecOps"}) is True
assert _allowed({"groups": ["Users"]}, set(), {"SecOps"}) is False
@patch("auth.AUTH_ENABLED", False)
def test_require_auth_disabled():
claims = require_auth(None)
assert claims["sub"] == "anonymous"
@patch("auth.AUTH_ENABLED", True)
def test_require_auth_missing_header():
with pytest.raises(HTTPException) as exc_info:
require_auth(None)
assert exc_info.value.status_code == 401
@patch("auth.AUTH_ENABLED", True)
def test_require_auth_invalid_bearer():
with pytest.raises(HTTPException) as exc_info:
require_auth("Basic abc")
assert exc_info.value.status_code == 401

View File

@@ -0,0 +1,63 @@
from models.event_model import _make_dedupe_key, normalize_event
def test_make_dedupe_key_prefers_id_and_category():
e = {"id": "evt-123", "category": "Directory"}
assert _make_dedupe_key(e) == "evt-123|Directory"
def test_make_dedupe_key_fallback_without_id():
e = {
"activityDateTime": "2024-01-01T00:00:00Z",
"category": "Exchange",
"activityDisplayName": "Update setting",
}
key = _make_dedupe_key(e)
assert "2024-01-01T00:00:00Z|Exchange|Update setting" in key
def test_normalize_event_basic():
e = {
"id": "abc",
"activityDateTime": "2024-01-15T10:30:00Z",
"category": "UserManagement",
"activityDisplayName": "Add user",
"result": "success",
"initiatedBy": {
"user": {
"id": "u1",
"displayName": "Alice",
"userPrincipalName": "alice@example.com",
}
},
"targetResources": [
{"id": "t1", "displayName": "Bob", "type": "User"}
],
}
out = normalize_event(e)
assert out["id"] == "abc"
assert out["timestamp"] == "2024-01-15T10:30:00Z"
assert out["service"] == "UserManagement"
assert out["operation"] == "Add user"
assert out["result"] == "success"
assert out["actor_display"] == "Alice (alice@example.com)"
assert out["target_displays"] == ["Bob"]
assert out["dedupe_key"] == "abc|UserManagement"
assert "raw_text" in out
def test_normalize_event_with_resolved_actor():
e = {
"id": "def",
"activityDateTime": "2024-01-15T11:00:00Z",
"category": "ApplicationManagement",
"activityDisplayName": "Add app",
"result": "success",
"initiatedBy": {"servicePrincipal": {"id": "sp1"}},
"targetResources": [],
"_resolvedActor": {"id": "sp1", "type": "servicePrincipal", "name": "MyApp"},
"_resolvedActorOwners": ["Owner1"],
}
out = normalize_event(e)
assert out["actor_display"] == "MyApp (owners: Owner1)"
assert out["display_category"] == "Application"