feat: scope project access to Django Admin

This commit is contained in:
mohamad
2026-10-29 04:39:49 +08:00
parent 9a8551ae23
commit 6fd236f518
6 changed files with 489 additions and 20 deletions
+161
View File
@@ -0,0 +1,161 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.urls import reverse
from apps.products.access import accessible_projects, can_access_project
from apps.products.models import (
Article, MainProduct, ProjectAccess, ProjectAccessPolicy, SubProduct,
)
class ProjectAdminAccessTests(TestCase):
def setUp(self):
User = get_user_model()
self.owner = User.objects.create_user(username="owner", password="password123", is_staff=True)
self.other = User.objects.create_user(username="other", password="password123", is_staff=True)
self.admin = User.objects.create_superuser(username="superadmin", password="password123")
self.main = MainProduct.objects.create(
created_by=self.owner, name="Private Alpha", slug="private-alpha",
short_description="Alpha summary", description="Alpha details",
)
self.sub = SubProduct.objects.create(
created_by=self.owner, main_product=self.main, name="Private Module",
slug="private-module", short_description="Module summary", description="Module details",
)
self.article = Article.objects.create(main_product=self.main, title="Alpha Article", description="Article body")
def test_public_website_keeps_published_projects_visible_to_everyone(self):
self.assertContains(self.client.get(reverse("products:overview")), self.main.name)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
self.assertContains(self.client.get(self.sub.get_absolute_url()), self.sub.name)
self.client.force_login(self.other)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
self.assertContains(self.client.get(self.sub.get_absolute_url()), self.sub.name)
def test_default_admin_access_is_creator_only(self):
self.assertTrue(can_access_project(self.owner, self.main, edit=True))
self.assertTrue(can_access_project(self.owner, self.sub, edit=True))
self.assertFalse(can_access_project(self.other, self.main))
self.assertFalse(can_access_project(self.other, self.sub))
self.client.force_login(self.other)
self.assertNotContains(
self.client.get(reverse("admin:products_mainproduct_changelist")), self.main.name
)
self.assertNotEqual(
self.client.get(reverse("admin:products_mainproduct_change", args=[self.main.pk])).status_code,
200,
)
def test_no_projects_mode_removes_default_owner_access(self):
ProjectAccessPolicy.objects.create(user=self.owner, mode=ProjectAccessPolicy.NONE)
self.assertFalse(can_access_project(self.owner, self.main))
self.assertFalse(can_access_project(self.owner, self.sub))
self.client.force_login(self.owner)
self.assertNotContains(
self.client.get(reverse("admin:products_mainproduct_changelist")), self.main.name
)
self.assertNotEqual(
self.client.get(reverse("admin:products_mainproduct_add")).status_code, 200
)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
def test_selected_view_and_edit_grants_are_independent(self):
main_grant = ProjectAccess.objects.create(
user=self.other, main_product=self.main, level=ProjectAccess.VIEW
)
self.assertTrue(can_access_project(self.other, self.main))
self.assertFalse(can_access_project(self.other, self.main, edit=True))
self.assertFalse(can_access_project(self.other, self.sub))
self.client.force_login(self.other)
change_url = reverse("admin:products_mainproduct_change", args=[self.main.pk])
response = self.client.get(change_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "View Main Project")
self.assertEqual(self.client.post(change_url, {"name": "Tampered"}).status_code, 403)
self.main.refresh_from_db()
self.assertEqual(self.main.name, "Private Alpha")
main_grant.level = ProjectAccess.EDIT
main_grant.save(update_fields=["level"])
self.assertTrue(can_access_project(self.other, self.main, edit=True))
self.assertEqual(self.client.get(change_url).status_code, 200)
self.assertFalse(can_access_project(self.other, self.sub, edit=True))
def test_all_projects_modes_and_explicit_denials(self):
policy = ProjectAccessPolicy.objects.create(user=self.other, mode=ProjectAccessPolicy.ALL_VIEW)
self.assertTrue(can_access_project(self.other, self.main))
self.assertFalse(can_access_project(self.other, self.main, edit=True))
policy.mode = ProjectAccessPolicy.ALL_EDIT
policy.save(update_fields=["mode"])
self.assertTrue(can_access_project(self.other, self.main, edit=True))
self.assertTrue(can_access_project(self.other, self.sub, edit=True))
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.NONE)
self.assertFalse(can_access_project(self.other, self.main))
self.assertTrue(can_access_project(self.other, self.sub, edit=True))
self.assertTrue(can_access_project(self.admin, self.main, edit=True))
def test_explicit_view_override_can_remove_owners_edit_access(self):
ProjectAccess.objects.create(user=self.owner, main_product=self.main, level=ProjectAccess.VIEW)
self.assertTrue(can_access_project(self.owner, self.main))
self.assertFalse(can_access_project(self.owner, self.main, edit=True))
self.assertTrue(can_access_project(self.owner, self.sub, edit=True))
def test_subproject_grant_does_not_grant_parent_or_siblings_in_admin(self):
ProjectAccess.objects.create(user=self.other, sub_product=self.sub, level=ProjectAccess.VIEW)
self.client.force_login(self.other)
self.assertContains(
self.client.get(reverse("admin:products_subproduct_changelist")), self.sub.name
)
self.assertNotContains(
self.client.get(reverse("admin:products_mainproduct_changelist")), self.main.name
)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
def test_related_article_admin_uses_parent_permission(self):
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.VIEW)
self.client.force_login(self.other)
change_url = reverse("admin:products_article_change", args=[self.article.pk])
self.assertEqual(self.client.get(change_url).status_code, 200)
self.assertEqual(self.client.post(change_url, {"title": "Tampered"}).status_code, 403)
def test_main_edit_does_not_expose_subproject_without_sub_edit_permission(self):
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.EDIT)
ProjectAccess.objects.create(user=self.other, sub_product=self.sub, level=ProjectAccess.VIEW)
self.client.force_login(self.other)
response = self.client.get(reverse("admin:products_mainproduct_change", args=[self.main.pk]))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'value="private-module"')
sub_url = reverse("admin:products_subproduct_change", args=[self.sub.pk])
self.assertContains(self.client.get(sub_url), "View Sub-project")
self.assertEqual(self.client.post(sub_url, {"name": "Tampered"}).status_code, 403)
def test_admin_user_form_has_global_and_per_project_controls(self):
self.client.force_login(self.admin)
response = self.client.get(reverse("admin:auth_user_change", args=[self.other.pk]))
self.assertContains(response, "project_access_policy-0-mode")
self.assertContains(response, "project_access_grants-0-level")
def test_admin_project_creation_records_creator(self):
self.client.force_login(self.other)
response = self.client.post(reverse("admin:products_mainproduct_add"), {
"name": "Admin Created", "slug": "admin-created", "short_description": "Summary",
"description": "Details", "description_format": "plain", "order": 0,
"homepage_order": 0,
"people-TOTAL_FORMS": 0, "people-INITIAL_FORMS": 0,
"articles-TOTAL_FORMS": 0, "articles-INITIAL_FORMS": 0,
"videos-TOTAL_FORMS": 0, "videos-INITIAL_FORMS": 0,
"sub_products-TOTAL_FORMS": 0, "sub_products-INITIAL_FORMS": 0,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(MainProduct.objects.get(slug="admin-created").created_by, self.other)
def test_override_must_target_one_project_and_cannot_duplicate(self):
with self.assertRaises(ValidationError):
ProjectAccess(user=self.other).full_clean()
with self.assertRaises(ValidationError):
ProjectAccess(user=self.other, main_product=self.main, sub_product=self.sub).full_clean()
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.VIEW)
with self.assertRaises(ValidationError):
ProjectAccess(user=self.other, main_product=self.main, level=ProjectAccess.EDIT).full_clean()