|
| 1 | +""" |
| 2 | +Regression tests for the read-only LogEntry admin (site-wide action log). |
| 3 | +
|
| 4 | +Pins the two properties that matter: (1) the LogEntry changelist is a |
| 5 | +read-only viewer—no add/change/delete—and (2) it is superuser-only, so |
| 6 | +editors/contributors can neither see nor reach it. See website/admin/ |
| 7 | +logentry_admin.py. |
| 8 | +""" |
| 9 | + |
| 10 | +from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION |
| 11 | +from django.contrib.auth import get_user_model |
| 12 | +from django.contrib.contenttypes.models import ContentType |
| 13 | +from django.urls import reverse |
| 14 | + |
| 15 | +from website.admin.admin_site import ml_admin_site |
| 16 | +from website.admin.logentry_admin import LogEntryAdmin |
| 17 | +from website.tests.base import DatabaseTestCase |
| 18 | + |
| 19 | +User = get_user_model() |
| 20 | + |
| 21 | + |
| 22 | +class LogEntryAdminPermissionTests(DatabaseTestCase): |
| 23 | + """The audit log is read-only and superuser-only.""" |
| 24 | + |
| 25 | + def setUp(self): |
| 26 | + self.admin = LogEntryAdmin(LogEntry, ml_admin_site) |
| 27 | + self.superuser = User.objects.create_superuser( |
| 28 | + username="root", email="root@example.com", password="pw") |
| 29 | + self.editor = User.objects.create_user( |
| 30 | + username="editor", email="editor@example.com", password="pw", |
| 31 | + is_staff=True) |
| 32 | + |
| 33 | + def _request(self, user): |
| 34 | + # Lightweight stand-in: the permission hooks only read request.user. |
| 35 | + class _Req: |
| 36 | + pass |
| 37 | + req = _Req() |
| 38 | + req.user = user |
| 39 | + return req |
| 40 | + |
| 41 | + def test_log_is_read_only(self): |
| 42 | + req = self._request(self.superuser) |
| 43 | + self.assertFalse(self.admin.has_add_permission(req)) |
| 44 | + self.assertFalse(self.admin.has_change_permission(req)) |
| 45 | + self.assertFalse(self.admin.has_delete_permission(req)) |
| 46 | + |
| 47 | + def test_only_superusers_can_view(self): |
| 48 | + self.assertTrue( |
| 49 | + self.admin.has_view_permission(self._request(self.superuser))) |
| 50 | + self.assertTrue( |
| 51 | + self.admin.has_module_permission(self._request(self.superuser))) |
| 52 | + self.assertFalse( |
| 53 | + self.admin.has_view_permission(self._request(self.editor))) |
| 54 | + self.assertFalse( |
| 55 | + self.admin.has_module_permission(self._request(self.editor))) |
| 56 | + |
| 57 | + |
| 58 | +class LogEntryAdminViewTests(DatabaseTestCase): |
| 59 | + """End-to-end: the changelist renders for superusers and is blocked otherwise.""" |
| 60 | + |
| 61 | + def setUp(self): |
| 62 | + self.superuser = User.objects.create_superuser( |
| 63 | + username="root", email="root@example.com", password="pw") |
| 64 | + self.editor = User.objects.create_user( |
| 65 | + username="editor", email="editor@example.com", password="pw", |
| 66 | + is_staff=True) |
| 67 | + # Seed one log row of each action type so the display columns render. |
| 68 | + ct = ContentType.objects.get_for_model(User) |
| 69 | + for flag in (ADDITION, CHANGE, DELETION): |
| 70 | + LogEntry.objects.log_action( |
| 71 | + user_id=self.superuser.pk, |
| 72 | + content_type_id=ct.pk, |
| 73 | + object_id=self.editor.pk, |
| 74 | + object_repr=str(self.editor), |
| 75 | + action_flag=flag, |
| 76 | + change_message="test", |
| 77 | + ) |
| 78 | + |
| 79 | + def test_superuser_sees_changelist(self): |
| 80 | + self.client.force_login(self.superuser) |
| 81 | + url = reverse("admin:admin_logentry_changelist") |
| 82 | + resp = self.client.get(url) |
| 83 | + self.assertEqual(resp.status_code, 200) |
| 84 | + # The seeded rows' object_repr should appear in the rendered list. |
| 85 | + self.assertContains(resp, str(self.editor)) |
| 86 | + |
| 87 | + def test_non_superuser_is_denied(self): |
| 88 | + self.client.force_login(self.editor) |
| 89 | + url = reverse("admin:admin_logentry_changelist") |
| 90 | + resp = self.client.get(url) |
| 91 | + # Django admin redirects/403s when module perms are absent; either way |
| 92 | + # the editor must not get a 200 list of everyone's actions. |
| 93 | + self.assertNotEqual(resp.status_code, 200) |
| 94 | + |
| 95 | + |
| 96 | +class LogEntryAdminDisplayTests(DatabaseTestCase): |
| 97 | + """The custom display columns don't raise on any action type.""" |
| 98 | + |
| 99 | + def setUp(self): |
| 100 | + self.admin = LogEntryAdmin(LogEntry, ml_admin_site) |
| 101 | + self.user = User.objects.create_superuser( |
| 102 | + username="root", email="root@example.com", password="pw") |
| 103 | + self.ct = ContentType.objects.get_for_model(User) |
| 104 | + |
| 105 | + def _entry(self, flag): |
| 106 | + return LogEntry.objects.log_action( |
| 107 | + user_id=self.user.pk, content_type_id=self.ct.pk, |
| 108 | + object_id=self.user.pk, object_repr=str(self.user), |
| 109 | + action_flag=flag, change_message="changed something") |
| 110 | + |
| 111 | + def test_deletion_object_link_falls_back_to_repr(self): |
| 112 | + # Deletions have no live object to link to; must fall back to text, |
| 113 | + # never raise (get_admin_url would point at a possibly-gone row). |
| 114 | + entry = self._entry(DELETION) |
| 115 | + self.assertIn(str(self.user), self.admin.object_link(entry)) |
| 116 | + |
| 117 | + def test_action_label_covers_all_flags(self): |
| 118 | + for flag, expected in ((ADDITION, "Added"), |
| 119 | + (CHANGE, "Changed"), |
| 120 | + (DELETION, "Deleted")): |
| 121 | + self.assertIn(expected, self.admin.action_label(self._entry(flag))) |
0 commit comments