Files
training-software/tests/test_audit.py
Paperclip CTO 01661bf5a8
Some checks failed
CI / lint (push) Successful in 6s
CI / test (push) Failing after 7s
CI / build-container (push) Has been skipped
fix: restore CI by fixing lint, test collection, and notification syntax
2026-05-18 14:53:29 +02:00

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"