"""Tests for the audit trail writer (governance/audit.py) and admin hardening.""" from unittest import mock from django.contrib.auth.models import Group from django.contrib.auth.models import User as AuthUser from django.test import RequestFactory, TestCase from access_core.governance.admin import AuditEventAdmin from access_core.governance.audit import ( AuditRequestMiddleware, _request_context, ) from access_core.governance.models import AuditEvent from access_core.identity.models import User as IdentityUser class AuditWriterTests(TestCase): def _events_for(self, pk, table): return AuditEvent.objects.filter(target_table=table, target_record_id=pk) def test_create_writes_event_with_system_actor(self): user = IdentityUser.objects.create( email="audit-create@example.com", full_name="A" ) events = self._events_for(user.pk, "identity.User") self.assertEqual(events.count(), 1) event = events.first() self.assertEqual(event.action, "create") self.assertIsNone(event.user) self.assertIsNone(event.old_value_hash) self.assertIsNotNone(event.new_value_hash) self.assertIsNone(event.changed_fields) def test_edit_records_changed_fields_and_row_hashes(self): user = IdentityUser.objects.create( email="audit-edit@example.com", full_name="A" ) user.email = "audit-edit-changed@example.com" user.save() events = list(self._events_for(user.pk, "identity.User")) self.assertEqual(len(events), 2) edit = [e for e in events if e.action == "edit"] self.assertEqual(len(edit), 1) self.assertEqual(edit[0].changed_fields, ["email"]) self.assertIsNotNone(edit[0].old_value_hash) self.assertIsNotNone(edit[0].new_value_hash) self.assertNotEqual(edit[0].old_value_hash, edit[0].new_value_hash) def test_noop_save_writes_nothing(self): user = IdentityUser.objects.create( email="audit-noop@example.com", full_name="A" ) user.save() # no field changes self.assertEqual(self._events_for(user.pk, "identity.User").count(), 1) def test_delete_writes_event_with_old_hash(self): user = IdentityUser.objects.create( email="audit-delete@example.com", full_name="A" ) pk = user.pk user.delete() events = list(self._events_for(pk, "identity.User")) deletes = [e for e in events if e.action == "delete"] self.assertEqual(len(deletes), 1) self.assertIsNotNone(deletes[0].old_value_hash) self.assertIsNone(deletes[0].new_value_hash) def test_human_actor_captured_from_request_context(self): actor = AuthUser.objects.create_user(username="auditor", email="a@example.com") token = _request_context.set( {"user": actor, "ip_address": "10.0.0.5", "user_agent": "test-agent"} ) try: user = IdentityUser.objects.create( email="audit-actor@example.com", full_name="A" ) finally: _request_context.reset(token) event = self._events_for(user.pk, "identity.User").get() self.assertEqual(event.user, actor) self.assertEqual(event.ip_address, "10.0.0.5") self.assertEqual(event.user_agent, "test-agent") def test_cms_request_creation_is_submit_to_cms(self): from access_core.cms_integration.models import CMSApiRequest request = CMSApiRequest.objects.create( api_category="eligibility", endpoint="$check-eligibility", method="POST", request_payload={}, request_headers={"Content-Type": "application/fhir+json"}, ) events = list(self._events_for(request.pk, "cms_integration.CMSApiRequest")) self.assertEqual(len(events), 1) self.assertEqual(events[0].action, "submit_to_cms") def test_unregistered_model_is_not_audited(self): group = Group.objects.create(name="audited-group-probe") self.assertFalse( AuditEvent.objects.filter( target_table="auth.Group", target_record_id=group.pk ).exists() ) def test_audit_failure_does_not_break_primary_operation(self): with mock.patch.object( AuditEvent.objects, "create", side_effect=RuntimeError("db down") ): # Must not raise despite the audit write failing. user = IdentityUser.objects.create( email="audit-fail@example.com", full_name="A" ) self.assertIsNotNone(user.pk) class AuditMiddlewareTests(TestCase): def test_middleware_sets_and_clears_context(self): actor = AuthUser.objects.create_user(username="mw-user", email="mw@example.com") request = RequestFactory().get("/admin/") request.user = actor request.META["REMOTE_ADDR"] = "192.168.1.9" request.META["HTTP_USER_AGENT"] = "Mozilla/5.0 (audit test)" seen = {} def get_response(req): context = _request_context.get("unset") seen.update(context) return "ok" self.assertEqual(_request_context.get("unset"), "unset") response = AuditRequestMiddleware(get_response)(request) self.assertEqual(response, "ok") self.assertIs(seen["user"], actor) self.assertEqual(seen["ip_address"], "192.168.1.9") self.assertEqual(seen["user_agent"], "Mozilla/5.0 (audit test)") self.assertEqual(_request_context.get("unset"), "unset") def test_middleware_anonymous_request_has_null_user(self): request = RequestFactory().get("/public/") seen = {} def get_response(req): seen.update(_request_context.get("unset")) return "ok" AuditRequestMiddleware(get_response)(request) self.assertIsNone(seen["user"]) class ClientIpResolutionTests(TestCase): """X-Forwarded-For is spoofable — only honored from configured trusted proxies.""" def _ip(self, remote_addr, xff=None, **settings_overrides): from django.test import override_settings from access_core.governance.audit import _client_ip request = RequestFactory().get("/") request.META["REMOTE_ADDR"] = remote_addr if xff is not None: request.META["HTTP_X_FORWARDED_FOR"] = xff with override_settings(**settings_overrides): return _client_ip(request) def test_no_xff_returns_remote_addr(self): self.assertEqual(self._ip("192.0.2.4"), "192.0.2.4") def test_xff_ignored_when_peer_not_trusted(self): # Direct peer is not a configured proxy — the client-controlled header # must be discarded entirely, even if it looks plausible. self.assertEqual( self._ip( "203.0.113.7", xff="6.6.6.6, 203.0.113.7, 198.51.100.1", AUDIT_TRUSTED_PROXY_ADDRESSES=["10.0.0.1"], AUDIT_PROXY_HOPS=2, ), "203.0.113.7", ) def test_xff_honored_from_trusted_proxy_two_hops(self): # XFF = , , ; the last two # entries were appended by the trusted proxy chain (hops=2). self.assertEqual( self._ip( "10.0.0.1", xff="6.6.6.6, 198.51.100.23, 198.51.100.9", AUDIT_TRUSTED_PROXY_ADDRESSES=["10.0.0.1"], AUDIT_PROXY_HOPS=2, ), "198.51.100.23", ) def test_xff_single_proxy_hop(self): self.assertEqual( self._ip( "10.0.0.1", xff="6.6.6.6, 198.51.100.23", AUDIT_TRUSTED_PROXY_ADDRESSES=["10.0.0.1"], AUDIT_PROXY_HOPS=1, ), "198.51.100.23", ) def test_xff_shorter_than_hops_falls_back_to_remote(self): self.assertEqual( self._ip( "10.0.0.1", xff="6.6.6.6", AUDIT_TRUSTED_PROXY_ADDRESSES=["10.0.0.1"], AUDIT_PROXY_HOPS=2, ), "10.0.0.1", ) class OldStateBoundTests(TestCase): """_old_states pairs pre_save with post_save; a save that raises in between would leak entries forever — the map must stay bounded.""" def test_age_based_prune_drops_stale_entries(self): import time as _time from access_core.governance import audit as audit_mod from access_core.identity.models import User as IdentityUser user = IdentityUser.objects.create(email="leak-age@example.com") try: audit_mod._on_pre_save(sender=IdentityUser, instance=user) self.assertEqual(len(audit_mod._old_states), 1) with mock.patch.object(audit_mod, "_OLD_STATE_MAX_AGE_SECONDS", -1.0): audit_mod._prune_old_states(_time.monotonic()) self.assertEqual(len(audit_mod._old_states), 0) finally: audit_mod._old_states.clear() def test_size_cap_evicts_oldest_when_post_save_never_fires(self): from access_core.governance import audit as audit_mod from access_core.identity.models import User as IdentityUser users = [ IdentityUser.objects.create(email=f"leak-{i}@example.com") for i in range(10) ] try: with ( mock.patch.object(audit_mod, "_OLD_STATE_MAX_ENTRIES", 4), # Age prune disabled so only the size cap can bound the map. mock.patch.object( audit_mod, "_OLD_STATE_MAX_AGE_SECONDS", float("inf") ), ): for u in users: audit_mod._on_pre_save(sender=IdentityUser, instance=u) self.assertLessEqual(len(audit_mod._old_states), 4) finally: audit_mod._old_states.clear() class AuditAdminHardeningTests(TestCase): def test_admin_is_append_only(self): admin_instance = AuditEventAdmin(AuditEvent, None) request = RequestFactory().get("/") self.assertFalse(admin_instance.has_add_permission(request)) self.assertFalse(admin_instance.has_delete_permission(request, obj=None)) admin_instance.user_agent = "test" # change-view delete check with an object requires a real row only for the # permission logic; passing a model instance is sufficient. probe = AuditEvent(action="view", target_table="identity.User") self.assertFalse(admin_instance.has_delete_permission(request, obj=probe)) def test_admin_all_fields_readonly(self): admin_instance = AuditEventAdmin(AuditEvent, None) field_names = {f.name for f in AuditEvent._meta.fields} self.assertEqual(set(admin_instance.readonly_fields), field_names)