738 lines
43 KiB
Python
Executable File
738 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 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)
|