feat: implement Phase 2 stabilization
Some checks failed
CI / lint-and-test (push) Has been cancelled
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:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
26
backend/tests/conftest.py
Normal file
26
backend/tests/conftest.py
Normal 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
98
backend/tests/test_api.py
Normal 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
|
||||
61
backend/tests/test_auth.py
Normal file
61
backend/tests/test_auth.py
Normal 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
|
||||
63
backend/tests/test_event_model.py
Normal file
63
backend/tests/test_event_model.py
Normal 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"
|
||||
Reference in New Issue
Block a user