Skip to content

Commit fee3d40

Browse files
JacobCoffeecodex
andcommitted
Test group-based agreements authorization
Co-authored-by: Codex <noreply@openai.com>
1 parent 4faecec commit fee3d40

7 files changed

Lines changed: 781 additions & 6 deletions

‎apps/agreements/tests/test_agreements.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
import re
33

44
from django.contrib.auth import get_user_model
5-
from django.contrib.auth.models import Permission
5+
from django.contrib.auth.models import Group
66
from django.core import mail
77
from django.core.files.uploadedfile import SimpleUploadedFile
88
from django.test import Client, TestCase
99
from django.urls import reverse
1010
from django.utils import timezone
1111

1212
from apps.agreements import documents, workflow
13+
from apps.agreements.auth import ADMINISTRATORS
1314
from apps.agreements.documents import SIGNATURES
1415
from apps.agreements.kinds import CustomContractKind
1516
from apps.agreements.models import Agreement, AgreementRevision, CustomContract, SigningLink, Terms, TermsVersion
@@ -20,7 +21,7 @@
2021

2122
def make_officer(username="pat"):
2223
officer = User.objects.create_user(username, f"{username}@example.org", "password")
23-
officer.user_permissions.add(Permission.objects.get(codename="manage_agreement"))
24+
officer.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0])
2425
return officer
2526

2627

‎apps/agreements/tests/test_authoring_validation.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from django.contrib.auth import get_user_model
2+
from django.contrib.auth.models import Group
23
from django.test import TestCase
34
from django.urls import reverse
45

6+
from apps.agreements.auth import ADMINISTRATORS
57
from apps.agreements.models import Agreement, CustomContract, Terms
68
from apps.agreements.tests.catalog_data import make_program
79
from apps.agreements.tests.test_agreements import make_officer, offer_contract
@@ -62,8 +64,9 @@ def test_unpublished_terms_link_to_editor_and_non_admin_guidance(self):
6264
self.assertContains(response, f'href="{reverse("agreements:terms_edit", args=[terms.slug])}">Unpublished</a>')
6365
self.assertNotContains(response, reverse("admin:agreements_terms_add"))
6466

65-
def test_superuser_gets_terms_creation_link(self):
67+
def test_staff_administrator_gets_terms_creation_link(self):
6668
admin = get_user_model().objects.create_superuser("admin", "admin@example.com", "password")
69+
admin.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0])
6770
self.client.force_login(admin)
6871
response = self.client.get(reverse("agreements:terms_list"))
6972
self.assertContains(response, reverse("admin:agreements_terms_add"))
@@ -99,6 +102,7 @@ def test_record_admin_rejects_changes_even_for_superusers(self):
99102
agreement = offer_contract(self.officer)
100103
order = make_order(self.officer, program=make_program())
101104
user = get_user_model().objects.create_superuser("admin", "admin@example.com", "password")
105+
user.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0])
102106
self.client.force_login(user)
103107
for instance in (agreement, order):
104108
with self.subTest(model=type(instance).__name__):

‎apps/agreements/tests/test_draft_group_roles.py‎

Lines changed: 350 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Group boundaries must hold independently of staff flags and Django permissions."""
2+
3+
from django.contrib.auth import get_user_model
4+
from django.contrib.auth.models import Group, Permission
5+
from django.contrib.contenttypes.models import ContentType
6+
from django.core import mail
7+
from django.core.files.uploadedfile import SimpleUploadedFile
8+
from django.test import TestCase
9+
from django.urls import reverse
10+
11+
from apps.agreements import workflow
12+
from apps.agreements.auth import EDITORS
13+
from apps.agreements.models import Agreement, SignedCopy
14+
from apps.agreements.tests.test_agreements import PDF, make_officer, offer_contract
15+
16+
17+
class SigningGroupAccessTests(TestCase):
18+
def setUp(self):
19+
self.administrator = make_officer()
20+
users = get_user_model().objects
21+
self.customer = users.create_user("customer", "customer@example.com", "password")
22+
self.editor = users.create_user("editor", "editor@example.com", "password", is_staff=True)
23+
self.editor.groups.add(Group.objects.get_or_create(name=EDITORS)[0])
24+
self.staff = users.create_user("staff", "staff@example.com", "password", is_staff=True)
25+
self.superuser = users.create_superuser("root", "root@example.com", "password")
26+
self.permissions_only = users.create_user("permissions", "permissions@example.com", "password", is_staff=True)
27+
Permission.objects.get_or_create(
28+
content_type=ContentType.objects.get_for_model(Agreement),
29+
codename="manage_agreement",
30+
defaults={"name": "Legacy agreement permission"},
31+
)
32+
self.permissions_only.user_permissions.set(Permission.objects.filter(content_type__app_label="agreements"))
33+
self.agreement = offer_contract(self.administrator, counterparty_account=self.customer)
34+
35+
def test_staff_superuser_and_direct_permissions_do_not_grant_record_access(self):
36+
copy = SignedCopy.objects.create(
37+
agreement=self.agreement,
38+
kind=SignedCopy.Kind.CUSTOMER,
39+
filename="signed.pdf",
40+
content=PDF,
41+
sha256="0" * 64,
42+
uploaded_by=self.administrator,
43+
)
44+
for user in (self.staff, self.superuser, self.permissions_only):
45+
self.client.force_login(user)
46+
with self.subTest(user=user.username):
47+
self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403)
48+
self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 404)
49+
self.assertEqual(
50+
self.client.get(reverse("agreements:document", args=[self.agreement.pk, "pdf"])).status_code, 404
51+
)
52+
self.assertEqual(
53+
self.client.get(
54+
reverse("agreements:copy_download", args=[self.agreement.pk, copy.kind])
55+
).status_code,
56+
404,
57+
)
58+
59+
def test_editor_can_read_but_cannot_modify_or_send_an_offer(self):
60+
self.client.force_login(self.editor)
61+
page = self.client.get(self.agreement.get_absolute_url())
62+
self.assertContains(page, self.agreement.counterparty_name)
63+
edit_url = reverse("agreements:edit", args=[self.agreement.pk])
64+
send_url = reverse("agreements:send_link", args=[self.agreement.pk])
65+
self.assertNotContains(page, edit_url)
66+
self.assertNotContains(page, send_url)
67+
self.assertEqual(self.client.get(edit_url).status_code, 403)
68+
self.assertEqual(
69+
self.client.post(
70+
edit_url,
71+
{
72+
"markdown": self.agreement.document_markdown + "\nChanged by editor.",
73+
"note": "Not allowed",
74+
"base_sha256": self.agreement.document_sha256,
75+
},
76+
).status_code,
77+
403,
78+
)
79+
self.assertEqual(self.client.post(send_url, {"name": "Signer", "email": "signer@example.com"}).status_code, 403)
80+
self.assertEqual(self.client.post(reverse("agreements:withdraw", args=[self.agreement.pk])).status_code, 404)
81+
self.assertEqual(
82+
self.client.post(
83+
reverse("agreements:record_copy", args=[self.agreement.pk]),
84+
{
85+
"signed_copy": SimpleUploadedFile("signed.pdf", PDF),
86+
"signer_name": "Signer",
87+
"signer_title": "Director",
88+
"signer_email": "signer@example.com",
89+
"signed_on": "2026-01-01",
90+
"matches": "on",
91+
"document_sha256": self.agreement.document_sha256,
92+
},
93+
).status_code,
94+
404,
95+
)
96+
self.agreement.refresh_from_db()
97+
self.assertEqual(self.agreement.status, Agreement.Status.OFFERED)
98+
self.assertEqual(self.agreement.revision, 1)
99+
self.assertFalse(self.agreement.signing_links.exists())
100+
self.assertFalse(self.agreement.signed_copies.exists())
101+
self.assertEqual(mail.outbox, [])
102+
103+
def test_only_administrators_can_countersign_decline_or_resend(self):
104+
signed = workflow.sign(
105+
self.agreement,
106+
workflow.Signature("Customer", "Director", self.customer.email),
107+
seen_sha256=self.agreement.document_sha256,
108+
)
109+
for user in (self.editor, self.staff, self.superuser, self.permissions_only):
110+
self.client.force_login(user)
111+
for name, data in (
112+
("countersign", {"name": "Officer", "title": "Director", "accept": "on"}),
113+
("decline", {"reason": "Not allowed"}),
114+
):
115+
with self.subTest(user=user.username, action=name):
116+
self.assertEqual(
117+
self.client.post(reverse(f"agreements:{name}", args=[signed.pk]), data).status_code, 403
118+
)
119+
signed.refresh_from_db()
120+
self.assertEqual(signed.status, Agreement.Status.SIGNED)
121+
self.client.force_login(self.administrator)
122+
response = self.client.post(
123+
reverse("agreements:countersign", args=[signed.pk]),
124+
{
125+
"name": "Officer",
126+
"title": "Director",
127+
"accept": "on",
128+
"executed_copy": SimpleUploadedFile("executed.pdf", PDF),
129+
},
130+
)
131+
self.assertEqual(response.status_code, 302)
132+
signed.refresh_from_db()
133+
self.assertEqual(signed.status, Agreement.Status.EXECUTED)
134+
self.assertEqual(signed.countersigned_by, self.administrator)
135+
self.assertEqual(len(mail.outbox), 1)
136+
for user in (self.editor, self.staff, self.superuser, self.permissions_only):
137+
self.client.force_login(user)
138+
with self.subTest(user=user.username, action="resend"):
139+
self.assertEqual(
140+
self.client.post(reverse("agreements:resend_executed_copy", args=[signed.pk])).status_code, 403
141+
)
142+
self.assertEqual(len(mail.outbox), 1)
143+
144+
def test_role_removal_revokes_access_even_for_the_offer_creator(self):
145+
self.client.force_login(self.administrator)
146+
self.assertContains(self.client.get(self.agreement.get_absolute_url()), self.agreement.counterparty_name)
147+
self.administrator.groups.clear()
148+
self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403)
149+
self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 404)
150+
self.assertEqual(
151+
self.client.post(
152+
reverse("agreements:send_link", args=[self.agreement.pk]),
153+
{
154+
"name": "Signer",
155+
"email": "signer@example.com",
156+
},
157+
).status_code,
158+
403,
159+
)
160+
self.assertFalse(self.agreement.signing_links.exists())
161+
162+
def test_customer_and_anonymous_link_rights_do_not_require_group_membership(self):
163+
self.client.force_login(self.customer)
164+
self.assertContains(self.client.get(self.agreement.get_absolute_url()), self.agreement.counterparty_name)
165+
self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403)
166+
self.client.logout()
167+
self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 302)
168+
self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 302)
169+
link, token = workflow.create_signing_link(
170+
self.agreement, name="Invited", email="invited@example.com", user=self.administrator
171+
)
172+
url = reverse("agreements:sign_link", args=[token])
173+
self.assertContains(self.client.get(url), self.agreement.counterparty_name)
174+
response = self.client.post(
175+
url,
176+
{
177+
"signer_name": "Invited",
178+
"signer_title": "Director",
179+
"accept": "on",
180+
"document_sha256": self.agreement.document_sha256,
181+
},
182+
)
183+
self.assertEqual(response.status_code, 200)
184+
self.agreement.refresh_from_db()
185+
link.refresh_from_db()
186+
self.assertEqual(self.agreement.status, Agreement.Status.SIGNED)
187+
self.assertEqual(self.agreement.signer_email, "invited@example.com")
188+
self.assertIsNotNone(link.used_at)
189+
self.assertEqual(self.client.get(url).status_code, 410)
190+
191+
def test_inactive_group_member_cannot_use_management_views(self):
192+
self.administrator.is_active = False
193+
self.administrator.save(update_fields=["is_active"])
194+
self.client.force_login(self.administrator)
195+
response = self.client.get(reverse("agreements:queue"))
196+
self.assertEqual(response.status_code, 302)
197+
self.agreement.refresh_from_db()
198+
self.assertEqual(self.agreement.status, Agreement.Status.OFFERED)

0 commit comments

Comments
 (0)