Files
www/membership/members/tests/test_membership.py
claudeandClaude Opus 5.5 5954ec1061 Members page: show a live total member count
Count every membership account (free and paid together) on each page
load and show it under the headline; no per-tier numbers. Hidden when
there are no members.

Also correct the reference tiers.eom.json and service-groups.eom.json
to the real LDAP DNs (ou=Memberships; ou=<Service>,ou=Services), which
now match the ansible-role-www defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:49:18 -05:00

747 lines
43 KiB
Python
Executable File

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 test_home_shows_live_total_member_count(self):
self.assertNotContains(self.client.get("/members/"), "member-count")
Account.objects.create(uid="alice", email="alice@example.org")
self.assertContains(self.client.get("/members/"), "<strong>1</strong> member and counting", html=False)
Account.objects.create(uid="bob", email="bob@example.org", membership_level=3)
response = self.client.get("/members/")
# One total across free and paid levels; no per-tier numbers.
self.assertContains(response, "<strong>2</strong> members and counting", html=False)
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)