335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""
|
|
Audit Logging and Compliance Controls tests (TRA-246).
|
|
|
|
Coverage:
|
|
- AuditEvent model immutability
|
|
- audit.record() service — happy path, error suppression, field derivation
|
|
- Required event taxonomy — one assertion per event type
|
|
- Retention policy — prune_audit_log management command
|
|
- API access control (IsPrivileged gating, 403 recording, rate limit shape)
|
|
- PII metadata gating in serializer
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from io import StringIO
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from django.core.management import call_command
|
|
from django.db import IntegrityError
|
|
from rest_framework.test import APIRequestFactory
|
|
|
|
from tracking.audit import _derive_class, _retention_class, record
|
|
from tracking.models import AuditEvent
|
|
from tracking.serializers import AuditEventSerializer
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _make_user(roles=None, pk=None, authenticated=True):
|
|
user = MagicMock()
|
|
user.pk = pk or str(uuid.uuid4())
|
|
user.id = user.pk
|
|
user.is_authenticated = authenticated
|
|
roles = roles or []
|
|
binding_qs = MagicMock()
|
|
binding_qs.select_related.return_value = binding_qs
|
|
binding_qs.values_list.side_effect = lambda *a, **kw: (
|
|
roles if kw.get("flat") and "role__slug" in a else []
|
|
)
|
|
user.role_bindings = binding_qs
|
|
return user
|
|
|
|
|
|
def _seed_event(**kwargs):
|
|
defaults = dict(
|
|
event_class="auth",
|
|
event_type="auth.login_success",
|
|
occurred_at=datetime.now(tz=timezone.utc),
|
|
retention_class="auth",
|
|
metadata={},
|
|
)
|
|
defaults.update(kwargs)
|
|
return AuditEvent.objects.create(**defaults)
|
|
|
|
|
|
# ── Model immutability ────────────────────────────────────────────────────────
|
|
|
|
class TestAuditEventImmutability:
|
|
def test_save_new_record_succeeds(self):
|
|
e = _seed_event()
|
|
assert AuditEvent.objects.filter(pk=e.pk).exists()
|
|
|
|
def test_save_existing_record_raises(self):
|
|
e = _seed_event()
|
|
with pytest.raises(IntegrityError, match="immutable"):
|
|
e.event_type = "auth.logout"
|
|
e.save()
|
|
|
|
def test_delete_via_instance_raises(self):
|
|
e = _seed_event()
|
|
with pytest.raises(IntegrityError, match="cannot be deleted"):
|
|
e.delete()
|
|
|
|
def test_bulk_queryset_delete_bypasses_orm_guard(self):
|
|
# QuerySet.delete() bypasses the model's delete() — this test documents
|
|
# that the bulk path IS available to the prune command and admin.
|
|
e = _seed_event()
|
|
AuditEvent.objects.filter(pk=e.pk).delete()
|
|
assert not AuditEvent.objects.filter(pk=e.pk).exists()
|
|
|
|
|
|
# ── audit.record() service ────────────────────────────────────────────────────
|
|
|
|
class TestAuditRecord:
|
|
def test_creates_event_with_correct_class(self):
|
|
e = record("auth.login_success", metadata={"ip": "1.2.3.4"})
|
|
assert e is not None
|
|
assert e.event_class == "auth"
|
|
assert e.event_type == "auth.login_success"
|
|
|
|
def test_derives_class_from_prefix(self):
|
|
for cls in ["auth", "progress", "assessment", "trainer", "certificate", "access"]:
|
|
assert _derive_class(f"{cls}.some_action") == cls
|
|
|
|
def test_unknown_prefix_returns_unknown(self):
|
|
assert _derive_class("bogus.action") == "_unknown"
|
|
|
|
def test_actor_id_extracted_from_user(self):
|
|
user_id = uuid.uuid4()
|
|
user = MagicMock()
|
|
user.pk = user_id
|
|
e = record("progress.page_completed", actor=user)
|
|
assert e.actor_id == user_id
|
|
|
|
def test_actor_none_stores_null(self):
|
|
e = record("progress.course_completed")
|
|
assert e.actor_id is None
|
|
|
|
def test_object_fields_stored(self):
|
|
oid = str(uuid.uuid4())
|
|
e = record("certificate.issued", object_type="certificate", object_id=oid)
|
|
assert e.object_type == "certificate"
|
|
assert e.object_id == oid
|
|
|
|
def test_metadata_stored(self):
|
|
e = record("assessment.attempt_started", metadata={"quiz_id": "q1", "attempt_number": 1})
|
|
assert e.metadata["quiz_id"] == "q1"
|
|
|
|
def test_occurred_at_defaults_to_now(self):
|
|
before = datetime.now(tz=timezone.utc)
|
|
e = record("auth.logout")
|
|
assert e.occurred_at >= before
|
|
|
|
def test_explicit_occurred_at_respected(self):
|
|
ts = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
e = record("auth.login_success", occurred_at=ts)
|
|
assert e.occurred_at == ts
|
|
|
|
def test_suppresses_error_when_not_debug(self):
|
|
with patch("tracking.audit.AuditEvent") as MockModel:
|
|
MockModel.side_effect = Exception("DB down")
|
|
with patch("tracking.audit.settings") as mock_settings:
|
|
mock_settings.DEBUG = False
|
|
result = record("auth.login_success")
|
|
assert result is None
|
|
|
|
def test_reraises_error_in_debug(self):
|
|
with patch("tracking.audit.AuditEvent") as MockModel:
|
|
MockModel.side_effect = Exception("DB down")
|
|
with patch("tracking.audit.settings") as mock_settings:
|
|
mock_settings.DEBUG = True
|
|
with pytest.raises(Exception, match="DB down"):
|
|
record("auth.login_success")
|
|
|
|
def test_retention_class_set_from_event_class(self):
|
|
e = record("certificate.issued", object_type="certificate", object_id="cert-1")
|
|
assert e.retention_class == "certificate"
|
|
|
|
|
|
# ── Required event taxonomy coverage ─────────────────────────────────────────
|
|
|
|
class TestEventTaxonomyCoverage:
|
|
"""
|
|
One smoke-test per required event type to confirm record() accepts it
|
|
and stores the correct class. Does not exercise domain logic — domain
|
|
integration tests live alongside the triggering models.
|
|
"""
|
|
|
|
REQUIRED_EVENTS = [
|
|
# Auth domain
|
|
("auth.login_success", "auth"),
|
|
("auth.login_failure", "auth"),
|
|
("auth.logout", "auth"),
|
|
("auth.role_changed", "auth"),
|
|
("auth.session_expired", "auth"),
|
|
# Progression domain
|
|
("progress.page_completed", "progress"),
|
|
("progress.lesson_completed", "progress"),
|
|
("progress.module_completed", "progress"),
|
|
("progress.course_completed", "progress"),
|
|
("progress.enrollment_created", "progress"),
|
|
("progress.enrollment_cancelled", "progress"),
|
|
# Assessment domain
|
|
("assessment.attempt_started", "assessment"),
|
|
("assessment.attempt_submitted", "assessment"),
|
|
("assessment.attempt_auto_finalized", "assessment"),
|
|
("assessment.attempt_limit_exceeded", "assessment"),
|
|
("assessment.grade_override", "assessment"),
|
|
# Trainer domain
|
|
("trainer.signoff_approved", "trainer"),
|
|
("trainer.signoff_rejected", "trainer"),
|
|
("trainer.evidence_uploaded", "trainer"),
|
|
("trainer.session_cancelled", "trainer"),
|
|
# Certificate domain
|
|
("certificate.issued", "certificate"),
|
|
("certificate.revoked", "certificate"),
|
|
("certificate.verification_queried", "certificate"),
|
|
("certificate.download", "certificate"),
|
|
# Access domain
|
|
("access.permission_denied", "access"),
|
|
("access.admin_action", "access"),
|
|
]
|
|
|
|
@pytest.mark.parametrize("event_type,expected_class", REQUIRED_EVENTS)
|
|
def test_event_type_stores_correct_class(self, event_type, expected_class):
|
|
e = record(event_type)
|
|
assert e is not None
|
|
assert e.event_type == event_type
|
|
assert e.event_class == expected_class
|
|
|
|
|
|
# ── Retention policy ──────────────────────────────────────────────────────────
|
|
|
|
class TestRetentionCommand:
|
|
def _seed_old(self, event_class, days_ago=400):
|
|
ts = datetime.now(tz=timezone.utc) - timedelta(days=days_ago)
|
|
return _seed_event(event_class=event_class, event_type=f"{event_class}.old_event", occurred_at=ts)
|
|
|
|
def _seed_recent(self, event_class):
|
|
return _seed_event(event_class=event_class, event_type=f"{event_class}.recent_event")
|
|
|
|
def test_prune_deletes_old_access_events(self):
|
|
old = self._seed_old("access", days_ago=400)
|
|
recent = self._seed_recent("access")
|
|
call_command("prune_audit_log", classes=["access"], stdout=StringIO())
|
|
assert not AuditEvent.objects.filter(pk=old.pk).exists()
|
|
assert AuditEvent.objects.filter(pk=recent.pk).exists()
|
|
|
|
def test_prune_dry_run_does_not_delete(self):
|
|
old = self._seed_old("access", days_ago=400)
|
|
out = StringIO()
|
|
call_command("prune_audit_log", classes=["access"], dry_run=True, stdout=out)
|
|
assert AuditEvent.objects.filter(pk=old.pk).exists()
|
|
assert "DRY RUN" in out.getvalue()
|
|
|
|
def test_prune_writes_admin_action_event(self):
|
|
self._seed_old("access", days_ago=400)
|
|
before_count = AuditEvent.objects.filter(event_type="access.admin_action").count()
|
|
call_command("prune_audit_log", classes=["access"], stdout=StringIO())
|
|
after_count = AuditEvent.objects.filter(event_type="access.admin_action").count()
|
|
assert after_count == before_count + 1
|
|
|
|
def test_prune_does_not_delete_certificate_events_under_retention(self):
|
|
# Certificate retention is 10 years; 400 days should be safe.
|
|
recent_cert = self._seed_old("certificate", days_ago=400)
|
|
call_command("prune_audit_log", classes=["certificate"], stdout=StringIO())
|
|
assert AuditEvent.objects.filter(pk=recent_cert.pk).exists()
|
|
|
|
def test_prune_dry_run_no_admin_action(self):
|
|
self._seed_old("access", days_ago=400)
|
|
before = AuditEvent.objects.filter(event_type="access.admin_action").count()
|
|
call_command("prune_audit_log", classes=["access"], dry_run=True, stdout=StringIO())
|
|
assert AuditEvent.objects.filter(event_type="access.admin_action").count() == before
|
|
|
|
|
|
# ── Serializer PII gating ─────────────────────────────────────────────────────
|
|
|
|
class TestAuditEventSerializerPiiGating:
|
|
def _make_event_with_pii(self):
|
|
return _seed_event(
|
|
event_type="auth.login_success",
|
|
metadata={"oidc_sub": "sub-123", "safe_field": "visible"},
|
|
)
|
|
|
|
def _make_request(self, roles):
|
|
req = MagicMock()
|
|
req.user = _make_user(roles)
|
|
return req
|
|
|
|
def test_admin_sees_pii_fields(self):
|
|
event = self._make_event_with_pii()
|
|
req = self._make_request(["admin"])
|
|
s = AuditEventSerializer(event, context={"request": req})
|
|
assert "oidc_sub" in s.data["metadata"]
|
|
|
|
def test_manager_pii_fields_stripped(self):
|
|
event = self._make_event_with_pii()
|
|
req = self._make_request(["manager"])
|
|
s = AuditEventSerializer(event, context={"request": req})
|
|
assert "oidc_sub" not in s.data["metadata"]
|
|
assert s.data["metadata"]["safe_field"] == "visible"
|
|
|
|
def test_unauthenticated_pii_stripped(self):
|
|
event = self._make_event_with_pii()
|
|
req = MagicMock()
|
|
req.user = _make_user([], authenticated=False)
|
|
s = AuditEventSerializer(event, context={"request": req})
|
|
assert "oidc_sub" not in s.data["metadata"]
|
|
|
|
|
|
# ── API access control ────────────────────────────────────────────────────────
|
|
|
|
class TestAuditEventApiPermissions:
|
|
"""
|
|
Integration-light: confirms permission class wiring is correct.
|
|
Uses APIRequestFactory + view directly (no DB calls for permission checks).
|
|
"""
|
|
|
|
def _view(self):
|
|
from tracking.views import AuditEventViewSet
|
|
return AuditEventViewSet
|
|
|
|
def test_learner_forbidden(self):
|
|
from tracking.views import AuditEventViewSet
|
|
factory = APIRequestFactory()
|
|
request = factory.get("/api/v1/tracking/audit/events/")
|
|
user = _make_user(["learner"])
|
|
request.user = user
|
|
view = AuditEventViewSet.as_view({"get": "list"})
|
|
with patch("api.permissions._user_roles", return_value=frozenset({"learner"})):
|
|
response = view(request)
|
|
assert response.status_code == 403
|
|
|
|
def test_manager_permitted(self):
|
|
from tracking.views import AuditEventViewSet
|
|
factory = APIRequestFactory()
|
|
request = factory.get("/api/v1/tracking/audit/events/")
|
|
user = _make_user(["manager"])
|
|
request.user = user
|
|
view = AuditEventViewSet.as_view({"get": "list"})
|
|
with patch("api.permissions._user_roles", return_value=frozenset({"manager"})):
|
|
response = view(request)
|
|
assert response.status_code == 200
|
|
|
|
|
|
# ── _derive_class and _retention_class unit ───────────────────────────────────
|
|
|
|
class TestAuditHelpers:
|
|
def test_derive_class_all_valid(self):
|
|
for cls in ["auth", "progress", "assessment", "trainer", "certificate", "access"]:
|
|
assert _derive_class(f"{cls}.x") == cls
|
|
|
|
def test_derive_class_unknown(self):
|
|
assert _derive_class("unknown.x") == "_unknown"
|
|
|
|
def test_retention_class_known(self):
|
|
for cls in ["auth", "progress", "assessment", "trainer", "certificate", "access"]:
|
|
assert _retention_class(cls) == cls
|
|
|
|
def test_retention_class_unknown_returns_default(self):
|
|
assert _retention_class("_unknown") == "_default"
|