"""Offline self-service profile tests; never bind LDAP or deliver real email. Run only this module with config.test_settings. Credentials are ephemeral test data, not service credentials. Directory writes use mocks; LDAP reads additionally exercise ldap3's in-memory strategy. """ import copy import hashlib import secrets from datetime import timedelta from unittest.mock import MagicMock, patch from urllib.parse import urlsplit from django.conf import settings from django.core import mail, signing from django.core.exceptions import ValidationError from django.test import Client, SimpleTestCase, TestCase, override_settings from django.urls import NoReverseMatch, Resolver404, resolve, reverse from django.utils import timezone from ldap3 import BASE, MOCK_SYNC, MODIFY_REPLACE, Connection, Server from ldap3.core.exceptions import LDAPException from members import profiles from members.models import Account, ProfileEmailChange from members.profile_forms import ( MAX_INPUT_LENGTH, CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm, ProfileValuesField, ) ALICE_DN = "uid=alice,ou=people,dc=example,dc=org" SEARCH_DN = "ou=people,dc=example,dc=org" def profile_values(**overrides): """The complete READ_ATTRIBUTES shape returned by the real reader.""" values = {name: [] for name in profiles.READ_ATTRIBUTES} values.update({ "cn": ["Alice Example"], "mail": ["alice@example.org", "alice.other@example.org"], "entryUUID": ["uuid-alice"], "uid": ["alice"], "modifyTimestamp": ["20261029120000Z"], }) values.update(overrides) return values class ProfileFormTests(SimpleTestCase): def setUp(self): self.password = " " + secrets.token_urlsafe(24) + " " def form(self, data=None): return ProfileForm(data={"cn": "Alice Example", "current_password": self.password, **(data or {})}) def test_single_name_value_published_by_dex(self): form = self.form() self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.fields["cn"].label, "Name") self.assertEqual(form.cleaned_data["cn"], ["Alice Example"]) for name in ("First\nSecond", "x" * 257): with self.subTest(length=len(name)): invalid = self.form({"cn": name}) self.assertFalse(invalid.is_valid()) self.assertIn("cn", invalid.errors) def test_only_dex_claim_attributes_are_editable(self): # Dex's LDAP connector publishes uid, cn and mail; mail has its own # verified flow and uid is immutable, so only cn is on this form. self.assertEqual(profiles.TEXT_ATTRIBUTES, ("cn",)) self.assertEqual(set(ProfileForm.base_fields), {"cn", "current_password"}) for name in ("displayName", "givenName", "sn", "jpegPhoto", "sshPublicKey", "postalAddress", "telephoneNumber", "description"): self.assertNotIn(name, profiles.READ_ATTRIBUTES) def test_initial_uses_first_name_value_like_dex(self): self.assertEqual(profiles.initial(profile_values(cn=["Alice", "Alice Example"])), {"cn": "Alice"}) self.assertEqual(profiles.initial(profile_values(cn=[])), {"cn": ""}) def test_field_groups_and_unique_password_ids(self): form = self.form() grouped = [*form.identity_fields(), *form.authorization_fields()] self.assertEqual(len({field.name for field in grouped}), len(grouped)) ids = [cls()["current_password"].auto_id for cls in ( ProfileForm, ProfileEmailForm, ProfilePasswordForm, CurrentPasswordForm, )] self.assertEqual(len(set(ids)), 4) def test_multivalues_deduplicate_and_strip_control_characters(self): field = ProfileValuesField() self.assertEqual(field.clean(" Alice\r\nBob\rAlice\n\tCarol\x00\u200b "), ["Alice", "Bob", "Carol"]) self.assertEqual(field.clean(None), []) self.assertEqual(field.clean(" \n "), []) def test_value_count_length_and_total_input_limits(self): field = ProfileValuesField(value_length=3) self.assertEqual(field.clean("abc"), ["abc"]) for value in ("abcd", "\n".join(str(i) for i in range(17)), "x" * (MAX_INPUT_LENGTH + 1)): with self.subTest(length=len(value)), self.assertRaises(ValidationError): field.clean(value) self.assertEqual(len(ProfileValuesField().clean("\n".join(str(i) for i in range(16)))), 16) def test_name_required_even_when_only_unsafe_or_blank_text(self): for name in ("", " \n ", "\x00\u200b"): with self.subTest(name=repr(name)): form = self.form({"cn": name}) self.assertFalse(form.is_valid()) self.assertIn("cn", form.errors) def test_current_and_new_password_whitespace_preserved_not_rendered(self): new_password = " " + secrets.token_urlsafe(24) + " " form = ProfilePasswordForm(data={ "current_password": self.password, "password": new_password, "confirm_password": new_password, }) self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.cleaned_data["current_password"], self.password) self.assertEqual(form.cleaned_data["password"], new_password) self.assertEqual(form.cleaned_data["confirm_password"], new_password) self.assertNotIn(self.password, form.as_p()) self.assertNotIn(new_password, form.as_p()) def test_password_required_length_and_confirmation(self): for data, field in ( ({"current_password": ""}, "current_password"), ({"current_password": "x" * 1025}, "current_password"), ): with self.subTest(field=field, length=len(data[field])): form = CurrentPasswordForm(data=data) self.assertFalse(form.is_valid()) self.assertIn(field, form.errors) for password, confirm in (("short", "short"), (self.password, self.password.strip())): form = ProfilePasswordForm(data={"current_password": self.password, "password": password, "confirm_password": confirm}) self.assertFalse(form.is_valid()) def test_email_casefold_and_current_password_required(self): form = ProfileEmailForm(data={"email": "Alice.New@EXAMPLE.ORG", "current_password": self.password}) self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.cleaned_data["email"], "alice.new@example.org") form = ProfileEmailForm(data={"email": "alice.new@example.org"}) self.assertFalse(form.is_valid()) self.assertIn("current_password", form.errors) class ProfileViewTests(TestCase): def setUp(self): self.account = Account.objects.create( uid="alice", email="alice@example.org", oidc_issuer=settings.DEX_ISSUER, oidc_sub="sub-alice", ldap_uuid="uuid-alice", ldap_dn=ALICE_DN, ) self.password = " " + secrets.token_urlsafe(24) + " " self.values = profile_values() self.sign_in(self.client, self.account) self.profile_url = reverse("members:profile") self.confirm_url = reverse("members:confirm_profile_email") # Unlike a loose read mock, this checks the saved pin seen by the view. self.read = self.start_patch("members.views.profiles.read_profile", side_effect=self.read_profile) self.update = self.start_patch("members.views.profiles.update_profile") self.change_password = self.start_patch("members.views.profiles.change_password") self.verify_password = self.start_patch("members.views.profiles.verify_password") self.change_email = self.start_patch("members.views.profiles.change_email") self.start_patch("members.directory.connection", side_effect=AssertionError("Offline test attempted service LDAP")) self.start_patch("members.directory._connection", side_effect=AssertionError("Offline test attempted user LDAP")) def start_patch(self, name, **kwargs): patcher = patch(name, **kwargs) mock = patcher.start() self.addCleanup(patcher.stop) return mock def sign_in(self, client, account): session = client.session session["account_id"] = str(account.pk) session.save() def read_profile(self, account): self.assertEqual(account.pk, self.account.pk) saved = Account.objects.get(pk=account.pk) self.assertEqual((account.uid, account.ldap_uuid, account.ldap_dn), (saved.uid, "uuid-alice", ALICE_DN)) return copy.deepcopy(self.values) def profile_data(self, **overrides): return { **profiles.initial(self.values), "action": "profile", "revision": profiles.revision(self.account, self.values), "current_password": self.password, **overrides, } def assert_no_writes(self): for operation in (self.update, self.change_password, self.verify_password, self.change_email): operation.assert_not_called() def pending(self, *, account=None, expires_at=None, ldap_uuid="uuid-alice"): token = secrets.token_urlsafe(32) pending = ProfileEmailChange.objects.create( token_hash=hashlib.sha256(token.encode()).hexdigest(), account=account or self.account, email="alice.new@example.org", ldap_uuid=ldap_uuid, expires_at=expires_at or timezone.now() + timedelta(hours=1), ) return token, pending def exchange(self, token): return self.client.get(reverse("members:verify_profile_email", args=[token])) def test_anonymous_endpoints_redirect_to_sign_in_page(self): client = Client() urls = (self.profile_url, self.confirm_url, reverse("members:verify_profile_email", args=[secrets.token_urlsafe(32)])) for url in urls: with self.subTest(url=url): self.assertRedirects(client.get(url), reverse("members:home"), fetch_redirect_response=False) self.read.assert_not_called() self.assert_no_writes() def test_get_loads_full_profile_and_revision_for_signed_in_account_only(self): response = self.client.get(self.profile_url, {"uid": "mallory", "dn": "cn=admin", "account_id": str(secrets.token_hex(16))}) self.assertEqual(response.status_code, 200) self.assertTrue(response.context["profile_loaded"]) self.assertEqual(response.context["email_values"], self.values["mail"]) self.assertEqual(response.context["form"].initial, profiles.initial(self.values)) proof = signing.loads(response.context["revision"], salt="ldap-profile", max_age=3600) self.assertEqual(proof["account"], str(self.account.pk)) self.assertEqual(proof["uuid"], "uuid-alice") self.assertContains(response, 'name="csrfmiddlewaretoken"') self.assertContains(response, 'name="cn" value="Alice Example"') for removed in ('name="sshPublicKey"', 'name="jpegPhoto"', 'name="displayName"', 'name="postalAddress"', "multipart/form-data"): self.assertNotContains(response, removed) self.assert_no_writes() def test_csrf_required_for_each_mutating_action_and_email_confirmation(self): client = Client(enforce_csrf_checks=True) self.sign_in(client, self.account) token, pending = self.pending() self.assertEqual(client.get(reverse("members:verify_profile_email", args=[token])).status_code, 302) for action in ("profile", "email", "password"): with self.subTest(action=action): self.assertEqual(client.post(self.profile_url, self.profile_data(action=action)).status_code, 403) self.assertEqual(client.post(self.confirm_url, {"current_password": self.password}).status_code, 403) self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists()) self.assert_no_writes() # A real cookie/token pair passes middleware; unknown action reaches 400. client.get(self.profile_url) csrf = client.cookies[settings.CSRF_COOKIE_NAME].value self.assertEqual(client.post(self.profile_url, {"action": "unknown", "csrfmiddlewaretoken": csrf}).status_code, 400) def test_unknown_or_missing_action_is_bad_request(self): for data in ({}, {"action": "delete"}, {"action": "userPassword"}): with self.subTest(data=data): self.assertEqual(self.client.post(self.profile_url, data).status_code, 400) self.assert_no_writes() def test_load_failure_disables_all_editing_get_and_post_and_redacts_logs(self): self.read.side_effect = LDAPException(self.password) with self.assertLogs("members.views", level="ERROR") as captured: for method in (self.client.get, self.client.post): response = method(self.profile_url, self.profile_data()) self.assertEqual(response.status_code, 503) self.assertFalse(response.context["profile_loaded"]) self.assertNotContains(response, "