import hashlib import hmac import json import re import time from contextlib import contextmanager from datetime import timedelta from io import StringIO from unittest.mock import MagicMock, patch from django.conf import settings from django.core import mail from django.core.management import call_command from django.core.management.base import CommandError from django.test import Client, TestCase, TransactionTestCase, override_settings from django.utils import timezone from ldap3 import Connection, MOCK_SYNC, OFFLINE_SLAPD_2_4, Server from members import billing, directory, views from members.models import Account, ManagedGroup, Registration, Subscription, WebhookEvent GROUP = "cn=supporter,ou=groups,dc=example,dc=org" OTHER_GROUP = "cn=patron,ou=groups,dc=example,dc=org" BASE = "ou=people,dc=example,dc=org" DN = "uid=alice," + BASE SENTINEL = "cn=sentinel,dc=example,dc=org" TIERS = { "supporter": {"label": "Bronze", "level": 1, "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP}, "patron": {"label": "Diamond", "level": 2, "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_GROUP}, } def account(**kwargs): if kwargs.get("oidc_sub"): kwargs.setdefault("oidc_issuer", settings.DEX_ISSUER) return Account.objects.create(uid="alice", email="alice@example.org", ldap_dn=DN, ldap_uuid="11111111-1111-1111-1111-111111111111", **kwargs) def paged(values): response = MagicMock() response.auto_paging_iter.side_effect = lambda: iter(values) return response @override_settings(MEMBERSHIP_TIERS=TIERS) class RegistrationTests(TestCase): def test_public_home_and_registration(self): response = self.client.get("/members/") self.assertContains(response, "Create a free account") self.assertContains(response, "Bronze") self.assertEqual(response.headers["Cache-Control"], "no-store") self.assertContains(self.client.get("/members/register/"), "Send verification email") def start_signup(self): self.client.post("/members/register/", {"uid": "alice", "email": "ALICE@example.org"}) match = re.search(r"/verify/([^/]+)/", str(mail.outbox[-1].body)) assert match is not None token = match.group(1) self.assertNotEqual(Registration.objects.get().pk, token) return token @patch("members.views.directory.create_account") def test_verified_one_use_signup_no_paid_access(self, provision): provision.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} token = self.start_signup() self.assertFalse(Account.objects.exists()) self.assertFalse(provision.called) self.assertRedirects(self.client.get(f"/members/verify/{token}/"), "/members/register/finish/") password = "correct-horse-purple-planet-943!" response = self.client.post("/members/register/finish/", {"password": password, "confirm_password": password}) self.assertContains(response, "Account created") provision.assert_called_once_with("alice", "alice@example.org", password) self.assertEqual(Account.objects.count(), 1) self.assertEqual(Account.objects.get().membership_level, 0) self.assertFalse(Subscription.objects.exists()) self.assertFalse(Registration.objects.exists()) self.assertNotIn("account_id", self.client.session) self.assertEqual(self.client.session["registration_identity"]["account_id"], str(Account.objects.get().pk)) self.assertNotIn(password, str(list(Account.objects.values()))) self.client.post("/members/register/finish/", {"password": password, "confirm_password": password}) self.assertEqual(provision.call_count, 1) @patch("members.views.directory.create_account") def test_expired_token_cannot_provision(self, provision): token = self.start_signup() Registration.objects.update(expires_at=timezone.now() - timedelta(seconds=1)) self.assertContains(self.client.get(f"/members/verify/{token}/"), "Link expired") self.client.post("/members/register/finish/", {"password": "not-used"}) provision.assert_not_called() @patch("members.views.directory.create_account") def test_password_validation(self, provision): token = self.start_signup() self.client.get(f"/members/verify/{token}/") response = self.client.post("/members/register/finish/", {"password": "123", "confirm_password": "123"}) self.assertContains(response, "too short") provision.assert_not_called() def test_signup_validation_and_throttle(self): self.client.post("/members/register/", {"uid": "*)(uid=*)", "email": "not-an-email"}) self.assertFalse(Registration.objects.exists()) for _ in range(3): self.assertEqual(self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org"}).status_code, 200) self.assertEqual(self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org"}).status_code, 429) @patch("members.views.send_mail", side_effect=OSError("SMTP unavailable")) def test_mail_failure_invalidates_pending_token(self, send): response = self.client.post("/members/register/", {"uid": "alice", "email": "alice@example.org"}) self.assertContains(response, "Email unavailable") self.assertFalse(Registration.objects.exists()) def test_csrf_and_method_safety(self): strict = Client(enforce_csrf_checks=True) for path in ("register/", "login/", "logout/", "billing/", "checkout/supporter/"): self.assertEqual(strict.post("/members/" + path).status_code, 403) self.assertEqual(self.client.get("/members/login/").status_code, 405) self.assertEqual(self.client.get("/members/checkout/supporter/").status_code, 405) @patch("members.views.dex") def test_same_origin_login_preserves_csrf_protection(self, dex): from django.http import HttpResponseRedirect strict = Client(enforce_csrf_checks=True) response = strict.get("/members/", secure=True) self.assertEqual(response.headers["Referrer-Policy"], "same-origin") token = strict.cookies[settings.CSRF_COOKIE_NAME].value dex.return_value.authorize_redirect.return_value = HttpResponseRedirect(settings.DEX_ISSUER + "/auth") response = strict.post("/members/login/", {"csrfmiddlewaretoken": token}, secure=True, HTTP_ORIGIN="https://testserver") self.assertEqual(response.status_code, 302) dex.return_value.authorize_redirect.assert_called_once() for origin in ("null", "https://attacker.example"): response = strict.post("/members/login/", {"csrfmiddlewaretoken": token}, secure=True, HTTP_ORIGIN=origin) self.assertEqual(response.status_code, 403) response = strict.post("/members/login/", {"csrfmiddlewaretoken": token}, secure=True, HTTP_REFERER="https://testserver/members/") self.assertEqual(response.status_code, 302) @override_settings(MEMBERSHIP_TIERS=TIERS) class IdentityTests(TestCase): def claims(self, **changes): return {"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "alice"}, **changes} @patch("members.directory.connection", side_effect=AssertionError("Login must not contact LDAP")) def test_first_and_returning_dex_login_need_no_ldap(self, connection): first = views.link_identity(self.claims()) second = views.link_identity(self.claims(email="updated@example.org")) self.assertEqual(first.pk, second.pk) second.refresh_from_db() self.assertEqual(second.oidc_sub, "dex-alice") self.assertEqual(second.oidc_issuer, settings.DEX_ISSUER) self.assertEqual(second.email, "updated@example.org") self.assertIsNone(second.ldap_uuid) self.assertEqual(second.ldap_dn, "") self.assertEqual(second.membership_level, 0) self.assertEqual(ManagedGroup.objects.filter(account=second).count(), len(TIERS)) self.assertFalse(Subscription.objects.exists()) connection.assert_not_called() def test_invalid_claims_rejected(self): from django.core.exceptions import ValidationError for changes in ({"email_verified": False}, {"iss": "https://evil.example"}, {"sub": ""}, {"sub": "x" * 513}, {"preferred_username": "*)(uid=*)"}, {"email": None}, {"email": "invalid"}): with self.subTest(changes=changes), self.assertRaises((ValueError, ValidationError)): views.link_identity(self.claims(**changes)) self.assertFalse(Account.objects.exists()) def test_wrong_or_missing_signed_connector_identity_rejected(self): for federated in ({"connector_id": "github", "user_id": "alice"}, {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "different-uid"}, {}, ["malformed"]): with self.assertRaises(ValueError): views.link_identity(self.claims(federated_claims=federated)) self.assertFalse(Account.objects.exists()) def test_existing_subject_and_directory_pin_preserved(self): original = account(oidc_sub="dex-alice", stripe_customer="cus_existing") linked = views.link_identity(self.claims()) self.assertEqual(linked.pk, original.pk) self.assertEqual(linked.ldap_uuid, original.ldap_uuid) self.assertEqual(linked.ldap_dn, original.ldap_dn) self.assertEqual(linked.stripe_customer, "cus_existing") def test_different_subject_cannot_take_over_existing_account(self): original = account(oidc_sub="original") with self.assertRaises(ValueError): views.link_identity(self.claims(), registration_account_id=str(original.pk)) original.refresh_from_db() self.assertEqual(original.oidc_sub, "original") def test_issuer_change_does_not_reuse_subject(self): original = account(oidc_sub="dex-alice", oidc_issuer="https://previous.example/") with self.assertRaises(ValueError): views.link_identity(self.claims()) original.refresh_from_db() self.assertEqual(original.oidc_issuer, "https://previous.example/") def test_email_match_does_not_link_different_identity(self): original = account(oidc_sub="original") other = views.link_identity(self.claims(sub="dex-bob", preferred_username="bob", federated_claims={"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "bob"})) self.assertNotEqual(other.pk, original.pk) self.assertIsNone(other.stripe_customer) def test_subject_cannot_be_reused_with_another_uid(self): account(oidc_sub="dex-alice") with self.assertRaises(ValueError): views.link_identity(self.claims(preferred_username="bob", federated_claims={"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "bob"})) self.assertEqual(Account.objects.count(), 1) def test_unbound_account_requires_registration_proof(self): original = account() for proof in (None, "another-account"): with self.assertRaises(ValueError): views.link_identity(self.claims(), registration_account_id=proof) linked = views.link_identity(self.claims(), registration_account_id=str(original.pk)) self.assertEqual(linked.pk, original.pk) self.assertEqual(linked.ldap_uuid, original.ldap_uuid) self.assertEqual(linked.oidc_sub, "dex-alice") @patch("members.views.dex") def test_callback_consumes_registration_proof_and_rejects_expired_proof(self, dex): original = account() dex.return_value.authorize_access_token.return_value = {"id_token": "private-token", "userinfo": self.claims()} session = self.client.session session["registration_identity"] = {"account_id": str(original.pk), "expires_at": time.time() - 10} session.save() self.assertContains(self.client.get("/members/oidc/callback/"), "could not be verified") original.refresh_from_db() self.assertIsNone(original.oidc_sub) session = self.client.session session["registration_identity"] = {"account_id": str(original.pk), "expires_at": time.time() + 300} session.save() self.assertRedirects(self.client.get("/members/oidc/callback/"), "/members/") self.assertEqual(dict(self.client.session), {"account_id": str(original.pk)}) def test_operator_binding_is_explicit_idempotent_and_cannot_rebind(self): original = account() options = {"account": str(original.pk), "uid": "alice", "subject": "dex-alice", "stdout": StringIO()} with self.assertRaises(CommandError): call_command("bind_dex_identity", **{**options, "uid": "bob"}) call_command("bind_dex_identity", **options) call_command("bind_dex_identity", **options) self.assertEqual(views.link_identity(self.claims()).pk, original.pk) with self.assertRaises(CommandError): call_command("bind_dex_identity", **{**options, "subject": "different-sub"}) def test_operator_cannot_bind_subject_owned_by_another_account(self): original = account() Account.objects.create(uid="bob", email="bob@example.org", oidc_sub="dex-alice", oidc_issuer=settings.DEX_ISSUER) with self.assertRaises(CommandError): call_command("bind_dex_identity", account=str(original.pk), uid="alice", subject="dex-alice", stdout=StringIO()) original.refresh_from_db() self.assertIsNone(original.oidc_sub) def test_operator_binding_rejects_invalid_account_ids(self): with self.assertRaises(CommandError): call_command("bind_dex_identity", account="not-a-uuid", uid="alice", subject="dex-alice", stdout=StringIO()) @patch("members.views.dex") @patch("members.views.link_identity") def test_callback_rotates_session_and_does_not_store_tokens(self, link, dex): member = account() link.return_value = member session = self.client.session session["old_state"] = "old" session.save() old_key = session.session_key dex.return_value.authorize_access_token.return_value = {"id_token": "never-persist-this", "userinfo": self.claims(), "access_token": "also-private"} self.assertRedirects(self.client.get("/members/oidc/callback/?state=valid&code=example"), "/members/") self.assertNotEqual(self.client.session.session_key, old_key) self.assertEqual(dict(self.client.session), {"account_id": str(member.pk)}) @patch("members.views.dex") def test_bad_callback_has_no_login(self, dex): dex.return_value.authorize_access_token.side_effect = ValueError("bad state / nonce") response = self.client.get("/members/oidc/callback/?code=bad") self.assertContains(response, "could not be verified") self.assertNotIn("account_id", self.client.session) @patch("members.views.dex") @patch("members.views.link_identity") def test_database_failure_logs_only_safe_sqlstate_and_recovery_hint(self, link, dex): from django.db import ProgrammingError from psycopg.errors import InsufficientPrivilege, UndefinedColumn, UndefinedTable dex.return_value.authorize_access_token.return_value = {"id_token": "private-token", "userinfo": self.claims()} for cause, expected, hint in ( (UndefinedColumn("private SQL and email alice@example.org"), "42703", "members.0004_dex_login_identity"), (UndefinedTable("private SQL"), "42P01", "undefined_table"), (InsufficientPrivilege("private SQL"), "42501", "insufficient_privilege"), (None, "unknown", "matching application/migration images"), ): with self.subTest(sqlstate=expected): error = ProgrammingError("private database details") error.__cause__ = cause link.side_effect = error with self.assertLogs("members.views", level="ERROR") as logs: response = self.client.get("/members/oidc/callback/?code=private-code") self.assertContains(response, "could not be verified") self.assertNotIn("account_id", self.client.session) output = " ".join(logs.output) self.assertIn(f"sqlstate={expected}", output) self.assertIn(hint, output) for private in ("private", "alice@example.org", "Traceback"): self.assertNotIn(private, output) self.assertNotIn("sqlstate", response.content.decode()) self.assertFalse(Account.objects.exists()) @patch("members.views.dex") @patch("members.directory.Connection") @override_settings(LDAP_URL="", LDAP_BIND_DN="", LDAP_BIND_PASSWORD="", LDAP_PASSWORD_SCHEME="{CRYPT}") def test_callback_works_without_ldap_configuration(self, conn_factory, dex): from ldap3.core.exceptions import LDAPInvalidCredentialsResult dex.return_value.authorize_access_token.return_value = {"id_token": "private-token", "userinfo": self.claims()} conn_factory.return_value.bind.side_effect = LDAPInvalidCredentialsResult(description="private LDAP diagnostic") self.assertRedirects(self.client.get("/members/oidc/callback/?code=private-code"), "/members/") self.assertEqual(dict(self.client.session), {"account_id": str(Account.objects.get().pk)}) conn_factory.assert_not_called() @override_settings(MEMBERSHIP_TIERS=TIERS, STRIPE_WEBHOOK_SECRET="whsec_test_only", STRIPE_PORTAL_CONFIGURATION="bpc_test") class BillingViewsTests(TestCase): def setUp(self): self.member = account(stripe_customer="cus_alice") session = self.client.session session["account_id"] = str(self.member.pk) session.save() def signed_event(self, event, age=0): payload = json.dumps(event) timestamp = int(time.time()) - age signature = hmac.new(settings.STRIPE_WEBHOOK_SECRET.encode(), f"{timestamp}.{payload}".encode(), hashlib.sha256).hexdigest() return self.client.post("/members/stripe/webhook/", payload, content_type="application/json", HTTP_STRIPE_SIGNATURE=f"t={timestamp},v1={signature}") def event(self, **changes): return {"id": "evt_example", "object": "event", "livemode": False, "type": "invoice.paid", "data": {"object": {"customer": "cus_alice"}}, **changes} def test_signed_webhook_durable_and_duplicate_safe(self): self.assertEqual(self.signed_event(self.event()).status_code, 200) self.assertEqual(self.signed_event(self.event()).status_code, 200) self.assertEqual(WebhookEvent.objects.count(), 1) self.assertEqual(WebhookEvent.objects.get().account, self.member) self.assertIsNone(WebhookEvent.objects.get().processed_at) self.assertFalse(Subscription.objects.exists()) def test_rejects_bad_expired_wrong_mode_and_connect_events(self): self.assertEqual(self.client.post("/members/stripe/webhook/", "{}", content_type="application/json").status_code, 400) self.assertEqual(self.signed_event(self.event(), age=301).status_code, 400) self.assertEqual(self.signed_event(self.event(livemode=True)).status_code, 400) self.assertEqual(self.signed_event(self.event(account="acct_other")).status_code, 400) self.assertFalse(WebhookEvent.objects.exists()) @patch("members.views.billing.client") def test_checkout_server_allowlist_and_metadata(self, api_factory): api = api_factory.return_value api.subscriptions.list.return_value = paged([]) api.checkout.sessions.create.return_value = {"id": "cs_first", "url": "https://checkout.stripe.com/c/pay/first"} response = self.client.post("/members/checkout/supporter/", {"price_id": "price_attacker", "customer": "cus_other"}) self.assertEqual(response.status_code, 302) params = api.checkout.sessions.create.call_args.args[0] self.assertEqual(params["customer"], "cus_alice") self.assertEqual(params["line_items"], [{"price": "price_supporter", "quantity": 1}]) self.assertEqual(params["subscription_data"]["metadata"]["membership_account"], str(self.member.pk)) self.assertFalse(Subscription.objects.exists()) self.assertEqual(self.client.post("/members/checkout/attacker/").status_code, 404) @patch("members.views.billing.client") def test_open_checkout_reused_and_duplicate_subscription_blocked(self, api_factory): api = api_factory.return_value api.subscriptions.list.return_value = paged([]) Account.objects.filter(pk=self.member.pk).update(checkout_id="cs_open") api.checkout.sessions.retrieve.return_value = {"status": "open", "url": "https://checkout.stripe.com/c/pay/open"} self.assertEqual(self.client.post("/members/checkout/patron/").status_code, 302) api.checkout.sessions.create.assert_not_called() api.subscriptions.list.return_value = paged([{"status": "active"}]) self.assertContains(self.client.post("/members/checkout/patron/"), "already have a subscription") @patch("members.views.billing.client") def test_portal_scoped_to_session_account(self, api_factory): api = api_factory.return_value api.billing_portal.sessions.create.return_value = {"url": "https://billing.stripe.com/p/session/test"} response = self.client.post("/members/billing/", {"customer": "cus_victim"}) self.assertEqual(response.status_code, 302) self.assertEqual(api.billing_portal.sessions.create.call_args.args[0]["customer"], "cus_alice") def test_billing_requires_session(self): self.client.post("/members/logout/") self.assertRedirects(self.client.post("/members/checkout/supporter/"), "/members/") @override_settings(MEMBERSHIP_TIERS=TIERS) class ReconciliationTests(TestCase): def setUp(self): self.member = account(stripe_customer="cus_alice") self.now = timezone.now() self.end = int((self.now + timedelta(days=30)).timestamp()) self.sub = {"id": "sub_alice", "customer": "cus_alice", "livemode": False, "metadata": {"membership_account": str(self.member.pk)}, "status": "active", "items": {"data": [{"price": {"id": "price_supporter"}}], "has_more": False}, "cancel_at_period_end": False} self.invoice = {"id": "in_paid", "customer": "cus_alice", "status": "paid", "livemode": False, "parent": {"subscription_details": {"subscription": "sub_alice"}}} self.line = {"amount": 500, "parent": {"subscription_item_details": {"subscription": "sub_alice"}}, "pricing": {"price_details": {"price": "price_supporter"}}, "period": {"start": int(self.now.timestamp()) - 60, "end": self.end}} def api(self): api = MagicMock() api.subscriptions.list.return_value = paged([self.sub]) api.invoices.list.return_value = paged([self.invoice]) api.invoices.line_items.list.return_value = paged([self.line]) return api @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_paid_invoice_grants_and_records_service_expiry(self, factory, ldap): factory.return_value = self.api() self.assertTrue(billing.sync_account(self.member.pk)) sub = Subscription.objects.get() assert sub.paid_until is not None self.assertEqual(int(sub.paid_until.timestamp()), self.end) self.assertEqual(sub.tier, "supporter") self.assertEqual(ldap.call_args.args[1], {GROUP}) self.member.refresh_from_db() self.assertEqual(self.member.membership_level, 1) self.assertEqual(ManagedGroup.objects.count(), 2) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_cancellation_at_period_end_retains_paid_time(self, factory, ldap): self.sub["cancel_at_period_end"] = True factory.return_value = self.api() billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], {GROUP}) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_canceled_trial_unpaid_paused_unknown_never_grant(self, factory, ldap): for status in ("canceled", "trialing", "unpaid", "incomplete", "incomplete_expired", "paused"): self.sub["status"] = status factory.return_value = self.api() billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set(), status) self.sub["status"] = "active" self.sub["items"]["data"][0]["price"]["id"] = "price_unknown" factory.return_value = self.api() billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_no_invoice_no_grant_even_if_active(self, factory, ldap): api = self.api() api.invoices.list.return_value = paged([]) factory.return_value = api billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_wrong_invoice_price_negative_and_future_lines_rejected(self, factory, ldap): for change in ({"amount": -500}, {"pricing": {"price_details": {"price": "price_other"}}}, {"period": {"start": self.end, "end": self.end + 300}}): api = self.api() api.invoices.line_items.list.return_value = paged([{**self.line, **change}]) factory.return_value = api billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_past_due_only_retains_previously_paid_time(self, factory, ldap): self.sub["status"] = "past_due" factory.return_value = self.api() billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], {GROUP}) self.line["period"]["end"] = int((self.now - timedelta(seconds=1)).timestamp()) factory.return_value = self.api() billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) self.member.refresh_from_db() self.assertEqual(self.member.membership_level, 0) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client", side_effect=OSError("offline")) def test_local_expiry_during_stripe_outage(self, factory, ldap): Subscription.objects.create(stripe_id="sub_alice", account=self.member, tier="supporter", status="active", paid_until=self.now - timedelta(seconds=1)) self.assertFalse(billing.sync_account(self.member.pk)) self.assertEqual(ldap.call_args.args[1], set()) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_local_only_sweep_never_calls_stripe(self, factory, ldap): Subscription.objects.create(stripe_id="sub_expired", account=self.member, tier="supporter", status="active", paid_until=self.now - timedelta(seconds=1)) self.assertTrue(billing.sync_account(self.member.pk, refresh_stripe=False)) factory.assert_not_called() self.assertEqual(ldap.call_args.args[1], set()) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_snapshot_is_not_partially_applied_on_error(self, factory, ldap): old_end = self.now + timedelta(days=1) Subscription.objects.create(stripe_id="sub_alice", account=self.member, tier="supporter", status="active", paid_until=old_end) api = self.api() api.invoices.line_items.list.side_effect = OSError("network unavailable") factory.return_value = api self.assertFalse(billing.sync_account(self.member.pk)) self.assertEqual(Subscription.objects.get().paid_until, old_end) self.assertEqual(ldap.call_args.args[1], {GROUP}) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_removed_tier_still_revoked(self, factory, ldap): factory.return_value = self.api() billing.sync_account(self.member.pk) with override_settings(MEMBERSHIP_TIERS={}): billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) self.assertIn(GROUP, ldap.call_args.args[2]) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_wrong_customer_mode_metadata_cannot_grant(self, factory, ldap): for changes in ({"customer": "cus_victim"}, {"livemode": True}, {"metadata": {"membership_account": "attacker"}}): api = self.api() api.subscriptions.list.return_value = paged([{**self.sub, **changes}]) factory.return_value = api billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) @patch("members.billing.reconcile_groups", side_effect=OSError("LDAP offline")) @patch("members.billing.client") def test_ldap_failure_keeps_retry_event_and_management_intent(self, factory, ldap): factory.return_value = self.api() WebhookEvent.objects.create(stripe_id="evt_pending", account=self.member) with self.assertRaises(OSError): billing.sync_account(self.member.pk) self.assertIsNone(WebhookEvent.objects.get().processed_at) self.assertEqual(ManagedGroup.objects.count(), 2) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_reordered_webhook_never_restores_canceled_access(self, factory, ldap): self.sub["status"] = "canceled" factory.return_value = self.api() WebhookEvent.objects.create(stripe_id="evt_old_paid", account=self.member) billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) self.assertIsNotNone(WebhookEvent.objects.get().processed_at) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client") def test_multiple_subscriptions_aggregate_groups(self, factory, ldap): api = self.api() other_sub = {**self.sub, "id": "sub_other", "items": {"data": [{"price": {"id": "price_patron"}}]}} other_invoice = {**self.invoice, "id": "in_other", "parent": {"subscription_details": {"subscription": "sub_other"}}} other_line = {**self.line, "parent": {"subscription_item_details": {"subscription": "sub_other"}}, "pricing": {"price_details": {"price": "price_patron"}}} api.subscriptions.list.return_value = paged([self.sub, other_sub]) api.invoices.list.side_effect = lambda params: paged([self.invoice] if params["subscription"] == "sub_alice" else [other_invoice]) api.invoices.line_items.list.side_effect = lambda inv, params: paged([self.line] if inv == "in_paid" else [other_line]) factory.return_value = api billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP}) self.member.refresh_from_db() self.assertEqual(self.member.membership_level, 2) @override_settings(LDAP_SEARCH_BASE_DN=BASE, LDAP_GROUP_PLACEHOLDER_DN=SENTINEL) class DirectoryTests(TestCase): def setUp(self): # ldap3 supports offline schemas; its third-party annotation omits them. server = Server("mock", get_info=OFFLINE_SLAPD_2_4) # pyright: ignore[reportArgumentType] self.conn = Connection(server, client_strategy=MOCK_SYNC) self.conn.bind() self.conn.strategy.add_entry(DN, {"objectClass": ["inetOrgPerson"], "uid": "alice", "cn": "alice", "sn": "alice", "mail": "alice@example.org", "entryUUID": "11111111-1111-1111-1111-111111111111"}) self.conn.strategy.add_entry(SENTINEL, {"objectClass": ["organizationalRole"], "cn": "sentinel"}) self.conn.strategy.add_entry(GROUP, {"objectClass": ["groupOfUniqueNames"], "cn": "supporter", "uniqueMember": [DN]}) self.member = Account(uid="alice", ldap_dn=DN, ldap_uuid="11111111-1111-1111-1111-111111111111") @contextmanager def mock_connection(self): yield self.conn def members(self): self.conn.search(GROUP, "(objectClass=*)", attributes=["uniqueMember"]) return set(self.conn.entries[0].uniqueMember.values) def test_final_member_revoked_with_sentinel_and_idempotency(self): with patch("members.directory.connection", self.mock_connection): directory.reconcile_groups(self.member, set(), {GROUP}) self.assertEqual(self.members(), {SENTINEL}) directory.reconcile_groups(self.member, set(), {GROUP}) directory.reconcile_groups(self.member, {GROUP}, {GROUP}) directory.reconcile_groups(self.member, {GROUP}, {GROUP}) self.assertEqual(self.members(), {SENTINEL, DN}) def test_missing_sentinel_and_missing_identity_fail_closed(self): with patch("members.directory.connection", self.mock_connection): self.conn.delete(SENTINEL) with self.assertRaises(Exception): directory.reconcile_groups(self.member, set(), {GROUP}) self.assertEqual(self.members(), {DN}) self.member.ldap_uuid = "missing" with self.assertRaises(ValueError): directory.reconcile_groups(self.member, {GROUP}, {GROUP}) def test_worker_resolves_and_pins_first_time_dex_identity(self): self.member.ldap_uuid = None self.member.ldap_dn = "" self.member.oidc_issuer = settings.DEX_ISSUER self.member.oidc_sub = "dex-alice" with patch("members.directory.connection", self.mock_connection), patch.object(self.conn, "search", wraps=self.conn.search) as search: self.assertEqual(directory.reconcile_groups(self.member, {GROUP}, {GROUP}), DN) self.assertEqual(search.call_args_list[0].kwargs["attributes"], ["uid", "mail", "entryUUID"]) self.assertIn("(uid=alice)", search.call_args_list[0].args[1]) self.assertEqual(self.member.ldap_uuid, "11111111-1111-1111-1111-111111111111") # A recreated uid must never substitute for the now-pinned LDAP entry. self.conn.delete(DN) self.conn.strategy.add_entry(DN, {"objectClass": ["inetOrgPerson"], "uid": "alice", "cn": "alice", "sn": "alice", "mail": "alice@example.org", "entryUUID": "22222222-2222-2222-2222-222222222222"}) with patch("members.directory.connection", self.mock_connection), patch.object(self.conn, "modify", wraps=self.conn.modify) as modify: with self.assertRaises(ValueError): directory.reconcile_groups(self.member, {GROUP}, {GROUP}) modify.assert_not_called() def test_worker_unpinned_identity_requires_trusted_mapping(self): self.member.ldap_uuid = None for uid, issuer, subject in (("alice", "", None), ("alice", "https://other.example", "sub"), ("*)(uid=*)", settings.DEX_ISSUER, "sub")): self.member.uid, self.member.oidc_issuer, self.member.oidc_sub = uid, issuer, subject with patch("members.directory.connection", self.mock_connection): with self.assertRaises(ValueError): directory.reconcile_groups(self.member, {GROUP}, {GROUP}) def test_worker_refuses_directory_uuid_already_owned_by_another_account(self): account() self.member.ldap_uuid = None self.member.oidc_issuer = settings.DEX_ISSUER self.member.oidc_sub = "dex-alice" with patch("members.directory.connection", self.mock_connection), patch.object(self.conn, "modify", wraps=self.conn.modify) as modify: with self.assertRaises(ValueError): directory.reconcile_groups(self.member, {GROUP}, {GROUP}) modify.assert_not_called() @override_settings(LDAP_URL="ldaps://ldap.example.org", LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE) @patch("members.directory.Connection") def test_service_bind_failure_is_redacted_and_unbound(self, conn_factory): from ldap3.core.exceptions import LDAPInvalidCredentialsResult conn_factory.return_value.bind.side_effect = LDAPInvalidCredentialsResult(description="private diagnostic") with self.assertRaises(directory.LDAPServiceBindError) as raised: with directory.connection(): self.fail("Rejected credentials must not reach LDAP operations") self.assertIn("LDAP_BIND_PASSWORD", str(raised.exception)) self.assertNotIn("private diagnostic", str(raised.exception)) conn_factory.return_value.unbind.assert_called_once() @override_settings(LDAP_URL="ldap://ldap.example.org", LDAP_ALLOW_INSECURE=False, LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE) @patch("members.directory.Connection") def test_starttls_failure_never_binds_credentials(self, conn_factory): conn_factory.return_value.start_tls.return_value = False with self.assertRaises(Exception): with directory.connection(): pass conn_factory.return_value.bind.assert_not_called() conn_factory.return_value.unbind.assert_called_once() @override_settings(LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE, LDAP_CA_CERT_FILE=None) @patch("members.directory.Connection") @patch("members.directory.Server") def test_ldap_transport_modes(self, server_factory, conn_factory): import ssl for scheme, insecure, starttls, port in ( ("ldap", False, True, 389), ("ldap", True, False, 389), ("ldaps", False, False, 636), ("ldaps", True, False, 636), ): with self.subTest(scheme=scheme, insecure=insecure): conn_factory.reset_mock() with override_settings(LDAP_URL=f"{scheme}://ldap.example.org", LDAP_ALLOW_INSECURE=insecure): with directory.connection() as conn: self.assertIs(conn, conn_factory.return_value) self.assertEqual(conn_factory.return_value.start_tls.call_count, int(starttls)) conn_factory.return_value.open.assert_called_once() conn_factory.return_value.bind.assert_called_once() conn_factory.return_value.unbind.assert_called_once() options = server_factory.call_args.kwargs self.assertEqual(options["use_ssl"], scheme == "ldaps") self.assertEqual(options["port"], port) self.assertEqual(options["tls"].validate, ssl.CERT_REQUIRED) @override_settings(LDAP_SEARCH_BASE_DN=BASE, LDAP_GROUP_PLACEHOLDER_DN=SENTINEL, MEMBERSHIP_TIERS=TIERS) class DeferredDirectoryPersistenceTests(TestCase): def test_worker_persists_uuid_and_dn_after_real_offline_ldap_sync(self): member = views.link_identity({"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "alice"}}) server = Server("mock", get_info=OFFLINE_SLAPD_2_4) # pyright: ignore[reportArgumentType] conn = Connection(server, client_strategy=MOCK_SYNC) conn.bind() conn.strategy.add_entry(DN, {"objectClass": ["inetOrgPerson"], "uid": "alice", "cn": "alice", "sn": "alice", "mail": "alice@example.org", "entryUUID": "11111111-1111-1111-1111-111111111111"}) conn.strategy.add_entry(SENTINEL, {"objectClass": ["organizationalRole"], "cn": "sentinel"}) for group in (GROUP, OTHER_GROUP): conn.strategy.add_entry(group, {"objectClass": ["groupOfUniqueNames"], "cn": "paid", "uniqueMember": [SENTINEL]}) @contextmanager def mock_connection(): yield conn with patch("members.directory.connection", mock_connection): self.assertTrue(billing.sync_account(member.pk, refresh_stripe=False)) member.refresh_from_db() self.assertEqual(member.ldap_uuid, "11111111-1111-1111-1111-111111111111") self.assertEqual(member.ldap_dn, DN) self.assertIsNotNone(member.last_ldap_sync) self.assertEqual(member.membership_level, 0) class DexIdentityMigrationTests(TransactionTestCase): @override_settings(DEX_ISSUER="https://original-issuer.example/") def test_upgrade_preserves_existing_bindings_and_allows_unresolved_users(self): from django.db import connection from django.db.migrations.executor import MigrationExecutor previous = [("members", "0003_membership_levels")] current = [("members", "0004_dex_login_identity")] executor = MigrationExecutor(connection) latest = executor.loader.graph.leaf_nodes() executor.migrate(previous) try: OldAccount = executor.loader.project_state(previous).apps.get_model("members", "Account") linked = OldAccount.objects.create(uid="alice", email="alice@example.org", ldap_dn=DN, ldap_uuid="uuid-alice", oidc_sub="existing-sub", stripe_customer="cus_existing") unlinked = OldAccount.objects.create(uid="bob", email="bob@example.org", ldap_dn="uid=bob," + BASE, ldap_uuid="uuid-bob") executor = MigrationExecutor(connection) executor.migrate(current) NewAccount = executor.loader.project_state(current).apps.get_model("members", "Account") restored = NewAccount.objects.values("oidc_issuer", "oidc_sub", "ldap_uuid", "stripe_customer").get(pk=linked.pk) self.assertEqual(restored["oidc_issuer"], "https://original-issuer.example/") self.assertEqual(restored["oidc_sub"], "existing-sub") self.assertEqual(restored["ldap_uuid"], "uuid-alice") self.assertEqual(restored["stripe_customer"], "cus_existing") self.assertEqual(NewAccount.objects.values_list("oidc_issuer", flat=True).get(pk=unlinked.pk), "") for uid in ("charlie", "david"): new = NewAccount.objects.create(uid=uid, email=f"{uid}@example.org", oidc_issuer=settings.DEX_ISSUER, oidc_sub=f"dex-{uid}") self.assertIsNone(NewAccount.objects.values_list("ldap_uuid", flat=True).get(pk=new.pk)) finally: MigrationExecutor(connection).migrate(latest)