"""Offline self-service profile tests; never bind LDAP or deliver real email. Run only this module with config.test_settings. Credentials and SSH keys 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 io import BytesIO from unittest.mock import MagicMock, patch from urllib.parse import urlsplit from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa from django.conf import settings from django.core import mail, signing from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client, SimpleTestCase, TestCase, override_settings from django.urls import 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 PIL import Image from members import profiles from members.models import Account, ProfileEmailChange from members.profile_forms import ( MAX_INPUT_LENGTH, MAX_PHOTO_BYTES, 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({ "description": ["Community member"], "displayName": ["AliceInGame"], "givenName": ["Alice"], "sn": ["Example"], "mail": ["alice@example.org", "alice.other@example.org"], "postalAddress": ["1 Example Street$Example City", "2 Other Road$Other City"], "labeledURI": ["https://example.org/ Personal website"], "entryUUID": ["uuid-alice"], "uid": ["alice"], "modifyTimestamp": ["20261029120000Z"], "objectClass": ["top", "person", "organizationalPerson", "inetOrgPerson"], }) values.update(overrides) return values def jpeg_bytes(size=(800, 400), *, metadata=True): output = BytesIO() image = Image.new("RGB", size, (50, 80, 120)) options = {} if metadata: exif = Image.Exif() exif[0x010E] = "private image description" options = {"exif": exif, "icc_profile": b"private-test-icc"} image.save(output, format="JPEG", **options) return output.getvalue() def photo_upload(raw=None): return SimpleUploadedFile( "photo.jpg", jpeg_bytes() if raw is None else raw, content_type="image/jpeg", ) class ProfileFormTests(SimpleTestCase): def setUp(self): self.password = " " + secrets.token_urlsafe(24) + " " def form(self, data=None, files=None): return ProfileForm( data={"sn": "Example", "current_password": self.password, **(data or {})}, files=files, ) def test_friendly_labels_and_single_gamer_tag(self): form = self.form({"displayName": "AliceInGame"}) self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.fields["displayName"].label, "Gamer tag") self.assertEqual(form.fields["sn"].label, "Last name") self.assertEqual(form.fields["l"].label, "City/town") self.assertEqual(form.cleaned_data["displayName"], ["AliceInGame"]) invalid = self.form({"displayName": "FirstTag\nSecondTag"}) self.assertFalse(invalid.is_valid()) self.assertIn("displayName", invalid.errors) def test_all_editable_text_attributes_have_fields_but_identity_is_read_only(self): self.assertTrue(set(profiles.TEXT_ATTRIBUTES) <= set(ProfileForm.base_fields)) for name in ("uid", "dn", "ldap_dn", "entryUUID", "mail", "userPassword", "objectClass"): self.assertNotIn(name, ProfileForm.base_fields) def test_field_groups_and_unique_password_ids(self): form = self.form() grouped = [ *form.identity_fields(), *form.contact_fields(), *form.address_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_surname_required_even_when_only_unsafe_or_blank_text(self): for surname in ("", " \n ", "\x00\u200b"): with self.subTest(surname=repr(surname)): form = self.form({"sn": surname}) self.assertFalse(form.is_valid()) self.assertIn("sn", form.errors) def test_postal_addresses_round_trip_multiple_multiline_values(self): values = profile_values() initial = profiles.initial(values) self.assertEqual(initial["postalAddress"], "1 Example Street\nExample City\n\n2 Other Road\nOther City") form = self.form(initial) self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.cleaned_data["postalAddress"], values["postalAddress"]) self.assertEqual(ProfileValuesField(postal=True).clean(" A \r\n B \r\n\r\n A\nB "), ["A$B"]) def test_postal_literal_dollar_and_backslash_round_trip(self): stored = [r"Unit \24 Five$Backslash \5c24", r"Another\5cAddress"] text = profiles.initial(profile_values(postalAddress=stored))["postalAddress"] self.assertEqual(ProfileValuesField(postal=True).clean(text), stored) def test_http_https_urls_with_optional_labels(self): form = self.form({"labeledURI": "https://example.org/a?q=b Website\nhttp://example.net/"}) self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.cleaned_data["labeledURI"], ["https://example.org/a?q=b Website", "http://example.net/"]) def test_urls_reject_other_schemes_credentials_and_backslashes(self): for url in ( "javascript:alert(1)", "data:text/html,test", "file:///etc/passwd", "ftp://example.org/file", "//example.org/", "https://user@example.org/", "https://user:secret@example.org/", "https://example.org/\\path", "not-a-url", ): with self.subTest(url=url): form = self.form({"labeledURI": url}) self.assertFalse(form.is_valid()) self.assertIn("labeledURI", form.errors) def test_supported_ssh_keys_multivalued_and_deduplicated_by_key_not_comment(self): keys = [ ed25519.Ed25519PrivateKey.generate().public_key(), rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key(), ec.generate_private_key(ec.SECP256R1()).public_key(), ec.generate_private_key(ec.SECP384R1()).public_key(), ec.generate_private_key(ec.SECP521R1()).public_key(), ] lines = [key.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH).decode() for key in keys] form = self.form({"sshPublicKey": "\n".join([lines[0] + " laptop", lines[0] + " other comment", *lines[1:]])}) self.assertTrue(form.is_valid(), form.errors) self.assertEqual(form.cleaned_data["sshPublicKey"], [lines[0] + " laptop", *lines[1:]]) def test_invalid_ssh_options_or_private_material_rejected(self): public = ed25519.Ed25519PrivateKey.generate().public_key().public_bytes( serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH, ).decode() for value in ("ssh-ed25519 invalid", 'command="id" ' + public, "ssh-dss invalid", "-----BEGIN OPENSSH PRIVATE KEY-----"): with self.subTest(value=value): form = self.form({"sshPublicKey": value}) self.assertFalse(form.is_valid()) self.assertIn("sshPublicKey", form.errors) def test_jpeg_is_resized_reencoded_and_metadata_removed(self): raw = jpeg_bytes() form = self.form(files={"jpegPhoto": photo_upload(raw)}) self.assertTrue(form.is_valid(), form.errors) clean = form.cleaned_data["jpegPhoto"] self.assertIsInstance(clean, bytes) self.assertNotEqual(clean, raw) with Image.open(BytesIO(clean)) as image: self.assertEqual(image.format, "JPEG") self.assertEqual(image.mode, "RGB") self.assertEqual(image.size, (512, 256)) self.assertFalse(image.getexif()) self.assertNotIn("icc_profile", image.info) self.assertNotIn("comment", image.info) def test_fake_truncated_empty_and_non_jpeg_uploads_rejected(self): png = BytesIO() Image.new("RGB", (2, 2)).save(png, "PNG") for raw in (b"", b"not an image", b"\xff\xd8fake JPEG", jpeg_bytes()[:30], png.getvalue()): with self.subTest(length=len(raw)): form = self.form(files={"jpegPhoto": photo_upload(raw)}) self.assertFalse(form.is_valid()) self.assertIn("jpegPhoto", form.errors) def test_oversize_upload_size_and_actual_read_limits(self): for declared_size in (MAX_PHOTO_BYTES + 1, 1): with self.subTest(declared_size=declared_size): upload = photo_upload(b"x" * (MAX_PHOTO_BYTES + 1)) upload.size = declared_size form = self.form(files={"jpegPhoto": upload}) self.assertFalse(form.is_valid()) self.assertIn("jpegPhoto", form.errors) def test_photo_dimension_limit(self): form = self.form(files={"jpegPhoto": photo_upload(jpeg_bytes((4097, 1)))}) self.assertFalse(form.is_valid()) self.assertIn("jpegPhoto", form.errors) def test_photo_omission_removal_and_conflicting_replacement(self): form = self.form() self.assertTrue(form.is_valid(), form.errors) self.assertIsNone(form.cleaned_data["jpegPhoto"]) form = self.form({"remove_photo": "on"}) self.assertTrue(form.is_valid(), form.errors) self.assertTrue(form.cleaned_data["remove_photo"]) form = self.form({"remove_photo": "on"}, {"jpegPhoto": photo_upload()}) self.assertFalse(form.is_valid()) self.assertIn("remove_photo", 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.photo_url = reverse("members:profile_photo") 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.photo_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.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, "