497 lines
25 KiB
Python
Executable File
497 lines
25 KiB
Python
Executable File
"""Exact-level service grants and durable, provider-independent revocation."""
|
||
|
||
import json
|
||
import os
|
||
import runpy
|
||
from contextlib import contextmanager
|
||
from datetime import timedelta
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
from django.core.exceptions import ImproperlyConfigured
|
||
from django.db import connection
|
||
from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings
|
||
from django.utils import timezone
|
||
|
||
from members import billing, views
|
||
from members.models import Account, CryptoInvoice, ManagedGroup, Subscription
|
||
from members.service_groups import configured_groups, service_names, validate_service_groups
|
||
from ldap3 import Connection, MOCK_SYNC, Server
|
||
|
||
BASE = "ou=groups,dc=example,dc=org"
|
||
DN = "uid=alice,ou=people,dc=example,dc=org"
|
||
TIER_GROUP = "cn=supporter," + BASE
|
||
OTHER_TIER_GROUP = "cn=patron," + BASE
|
||
TOP_TIER_GROUP = "cn=premier," + BASE
|
||
FREE_GROUP = "cn=free-service," + BASE
|
||
PAID_GROUP = "cn=paid-service," + BASE
|
||
SECOND_PAID_GROUP = "cn=second-paid-service," + BASE
|
||
OTHER_SERVICE_GROUP = "cn=patron-service," + BASE
|
||
TOP_SERVICE_GROUP = "cn=premier-service," + BASE
|
||
HISTORICAL_GROUP = "cn=retired-service," + BASE
|
||
TIERS = {
|
||
"supporter": {"label": "Bronze", "level": 1, "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": TIER_GROUP},
|
||
"patron": {"label": "Silver", "level": 2, "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_TIER_GROUP},
|
||
"premier": {"label": "Diamond", "level": 10, "price_id": "price_premier", "price_label": "$50 / month", "group_dn": TOP_TIER_GROUP},
|
||
}
|
||
SERVICE_GROUPS = {
|
||
"0": [FREE_GROUP],
|
||
"1": [PAID_GROUP, SECOND_PAID_GROUP],
|
||
"2": [OTHER_SERVICE_GROUP],
|
||
"10": [TOP_SERVICE_GROUP],
|
||
}
|
||
ALL_GROUPS = {
|
||
TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, FREE_GROUP,
|
||
PAID_GROUP, SECOND_PAID_GROUP, OTHER_SERVICE_GROUP, TOP_SERVICE_GROUP,
|
||
}
|
||
|
||
|
||
@override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}, LDAP_SEARCH_BASE_DN="ou=people,dc=example,dc=org", LDAP_GROUP_PLACEHOLDER_DN="cn=sentinel,dc=example,dc=org")
|
||
class ServiceGroupLDAPIntegrationTests(TestCase):
|
||
def test_free_grant_and_mapping_removal_modify_real_offline_entries(self):
|
||
member = Account.objects.create(uid="alice", email="alice@example.org", ldap_dn=DN, ldap_uuid="uuid-alice")
|
||
conn = Connection(Server("mock"), client_strategy=MOCK_SYNC)
|
||
conn.bind()
|
||
self.addCleanup(conn.unbind)
|
||
sentinel = "cn=sentinel,dc=example,dc=org"
|
||
conn.strategy.add_entry(DN, {"objectClass": ["inetOrgPerson"], "uid": "alice", "mail": "alice@example.org", "entryUUID": "uuid-alice"})
|
||
conn.strategy.add_entry(sentinel, {"objectClass": ["organizationalRole"], "cn": "sentinel"})
|
||
conn.strategy.add_entry(FREE_GROUP, {"objectClass": ["groupOfUniqueNames"], "cn": "free-service", "uniqueMember": [sentinel]})
|
||
|
||
@contextmanager
|
||
def offline():
|
||
yield conn
|
||
|
||
def members():
|
||
conn.search(FREE_GROUP, "(objectClass=*)", attributes=["uniqueMember"])
|
||
return set(conn.entries[0].uniqueMember.values)
|
||
|
||
with patch("members.directory.connection", offline):
|
||
self.assertTrue(billing.sync_account(member.pk, refresh_stripe=False))
|
||
self.assertEqual(members(), {sentinel, DN})
|
||
# Case-only mapping changes must not revoke the shared target.
|
||
with override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP.upper()]}):
|
||
billing.sync_account(member.pk, refresh_stripe=False)
|
||
self.assertEqual(members(), {sentinel, DN})
|
||
with override_settings(MEMBERSHIP_SERVICE_GROUPS={}):
|
||
billing.sync_account(member.pk, refresh_stripe=False)
|
||
self.assertEqual(members(), {sentinel})
|
||
self.assertTrue(ManagedGroup.objects.filter(account=member, dn=FREE_GROUP).exists())
|
||
|
||
|
||
@override_settings(MEMBERSHIP_TIERS=TIERS)
|
||
class ServiceDisplayTests(TestCase):
|
||
def test_published_tier_copy_matches_role_defaults(self):
|
||
root = Path(__file__).absolute().parents[3]
|
||
tiers = json.loads((root / "membership/tiers.eom.json").read_text(encoding="utf-8"))
|
||
role = root.parent / "ansible-role-www/defaults/main.yml"
|
||
if not role.exists():
|
||
self.skipTest("Role checkout is not present in the application test image")
|
||
import yaml
|
||
|
||
deployed = yaml.safe_load(role.read_text(encoding="utf-8"))["www_membership_tiers"]
|
||
for key, tier in tiers.items():
|
||
self.assertEqual(tier["description"], deployed[key]["description"])
|
||
self.assertEqual(tier["benefits"], deployed[key]["benefits"])
|
||
|
||
def test_homepage_lists_services_with_clear_access_labels(self):
|
||
root = Path(__file__).absolute().parents[3]
|
||
homepage = root / "htdocs/index.html"
|
||
if not homepage.exists():
|
||
self.skipTest("Static website checkout is not present in the application test image")
|
||
from html.parser import HTMLParser
|
||
|
||
class DirectoryParser(HTMLParser):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.active = False
|
||
self.text = []
|
||
self.links = []
|
||
|
||
def handle_starttag(self, tag, attrs):
|
||
attributes = dict(attrs)
|
||
if tag == "section" and attributes.get("id") == "services":
|
||
self.active = True
|
||
if self.active and tag == "a":
|
||
self.links.append(attributes.get("href"))
|
||
|
||
def handle_endtag(self, tag):
|
||
if tag == "section":
|
||
self.active = False
|
||
|
||
def handle_data(self, data):
|
||
if self.active:
|
||
self.text.append(data)
|
||
|
||
parser = DirectoryParser()
|
||
parser.feed(homepage.read_text(encoding="utf-8"))
|
||
text = " ".join(parser.text)
|
||
for name in ("Dex", "Discourse", "Matrix", "Gitea", "Mastodon", "PeerTube", "Hosted games", "Owncast"):
|
||
self.assertIn(name, text)
|
||
self.assertIn("Free · Level 0 and above", text)
|
||
self.assertIn("Gold · Levels 3–5", text)
|
||
self.assertIn("not currently offered", text)
|
||
self.assertIn("/members/", parser.links)
|
||
self.assertIn("https://element.eom.dev/", parser.links)
|
||
|
||
@override_settings(MEMBERSHIP_SERVICE_GROUPS={
|
||
"0": ["cn=Dex Users," + BASE, "cn=Matrix Users," + BASE],
|
||
"1": ["cn=Gitea Users," + BASE],
|
||
"2": ["cn=Mastodon Users," + BASE, "cn=PeerTube Users," + BASE],
|
||
})
|
||
def test_home_displays_exact_level_services_without_ldap_or_settings_mutation(self):
|
||
with patch("members.directory.connection", side_effect=AssertionError("Public page must not contact LDAP")):
|
||
response = self.client.get("/members/")
|
||
self.assertContains(response, "Dex · single sign-on")
|
||
self.assertEqual(response.context["free_services"], ["Dex · single sign-on", "Matrix · community chat"])
|
||
self.assertEqual(response.context["tiers"]["supporter"]["services"], ["Gitea · Git hosting"])
|
||
self.assertEqual(response.context["tiers"]["patron"]["services"], ["Mastodon · social networking", "PeerTube · video sharing"])
|
||
self.assertEqual(response.context["tiers"]["premier"]["services"], [])
|
||
self.assertNotContains(response, BASE)
|
||
self.assertNotIn("services", TIERS["supporter"])
|
||
|
||
@override_settings(MEMBERSHIP_SERVICE_GROUPS={})
|
||
def test_unconfigured_services_do_not_advertise_access(self):
|
||
response = self.client.get("/members/")
|
||
self.assertContains(response, "No additional service access is currently configured.")
|
||
self.assertNotContains(response, "Mastodon · social networking")
|
||
|
||
@override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [r"cn=\<script\>alert(1)\</script\>," + BASE]})
|
||
def test_group_labels_are_html_escaped(self):
|
||
response = self.client.get("/members/")
|
||
self.assertContains(response, "<script>alert(1)</script>")
|
||
self.assertNotContains(response, "<script>alert(1)</script>")
|
||
|
||
def test_custom_labels_decode_dn_escapes_and_hide_hierarchy(self):
|
||
self.assertEqual(service_names(0, {"0": [r"cn=Games\, Friends Users," + BASE]}), ["Games, Friends"])
|
||
self.assertEqual(service_names(0, {"0": [r"cn=Caf\c3\a9 Users," + BASE]}), ["Café"])
|
||
self.assertEqual(service_names(0, {"0": ["ou=services," + BASE]}), ["Additional community service"])
|
||
|
||
|
||
class ServiceGroupValidationTests(SimpleTestCase):
|
||
def test_valid_mapping_is_returned_unchanged(self):
|
||
self.assertIs(validate_service_groups(SERVICE_GROUPS, {1, 2, 10}), SERVICE_GROUPS)
|
||
|
||
def test_empty_mapping_and_empty_group_lists_are_valid(self):
|
||
for mapping in ({}, {"0": []}, {"1": []}):
|
||
with self.subTest(mapping=mapping):
|
||
self.assertEqual(validate_service_groups(mapping, {1}), mapping)
|
||
|
||
def test_free_only_mapping_needs_no_paid_tiers(self):
|
||
mapping = {"0": [FREE_GROUP]}
|
||
self.assertEqual(validate_service_groups(mapping, set()), mapping)
|
||
|
||
def test_shared_group_across_levels_is_valid(self):
|
||
mapping = {"0": [FREE_GROUP], "1": [FREE_GROUP, PAID_GROUP]}
|
||
self.assertEqual(validate_service_groups(mapping, {1}), mapping)
|
||
|
||
def test_mapping_must_be_a_dict(self):
|
||
for value in (None, [], [FREE_GROUP], "{}", "0", 0, True):
|
||
with self.subTest(value=value), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups(value, {1, 2, 10})
|
||
|
||
def test_levels_must_be_canonical_known_numeric_strings(self):
|
||
for level in (0, 1, True, False, -1, "-1", "3", "11", "01", "00", "+1", "1.0", " 1", "1 ", "1\n", "", "free", "True", "١"):
|
||
with self.subTest(level=level), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups({level: [PAID_GROUP]}, {1, 2, 10})
|
||
|
||
def test_each_level_requires_a_list_not_a_string_or_other_container(self):
|
||
for groups in (PAID_GROUP, "", None, {}, (PAID_GROUP,), {PAID_GROUP}, 1, True):
|
||
with self.subTest(groups=groups), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups({"1": groups}, {1})
|
||
|
||
def test_group_dns_require_nonempty_bounded_strings_without_controls(self):
|
||
for dn in (None, 1, True, [], {}, "", " ", "cn=" + "a" * 1022, "cn=bad\x00," + BASE, "cn=bad\n," + BASE, "cn=bad\t," + BASE):
|
||
with self.subTest(dn=dn), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups({"1": [dn]}, {1})
|
||
|
||
def test_malformed_dns_are_rejected(self):
|
||
for dn in ("not-a-dn", "cn", "=missing-attribute", "cn=service,not-a-dn", "cn=service,,dc=example", "cn=service,dc"):
|
||
with self.subTest(dn=dn), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups({"1": [dn]}, {1})
|
||
|
||
def test_outer_whitespace_is_rejected_not_silently_normalized(self):
|
||
for dn in (" " + PAID_GROUP, PAID_GROUP + " ", "\t" + PAID_GROUP, PAID_GROUP + "\n"):
|
||
with self.subTest(dn=dn), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups({"1": [dn]}, {1})
|
||
|
||
def test_duplicate_dns_are_rejected_case_insensitively_within_level(self):
|
||
for duplicate in (PAID_GROUP, PAID_GROUP.upper()):
|
||
with self.subTest(duplicate=duplicate), self.assertRaises(ImproperlyConfigured):
|
||
validate_service_groups({"1": [PAID_GROUP, duplicate]}, {1})
|
||
|
||
def test_escaped_dn_values_are_valid(self):
|
||
mapping = {"1": [r"cn=service\, special," + BASE]}
|
||
self.assertEqual(validate_service_groups(mapping, {1}), mapping)
|
||
|
||
def test_settings_load_and_validate_service_group_json(self):
|
||
environment = {
|
||
"MEMBERSHIP_TIERS_JSON": json.dumps(TIERS),
|
||
"MEMBERSHIP_SERVICE_GROUPS_JSON": json.dumps(SERVICE_GROUPS),
|
||
}
|
||
with patch.dict(os.environ, environment):
|
||
loaded = runpy.run_path(str(Path(__file__).absolute().parents[2] / "config" / "settings.py"))
|
||
self.assertEqual(loaded["MEMBERSHIP_SERVICE_GROUPS"], SERVICE_GROUPS)
|
||
|
||
def test_settings_reject_invalid_service_group_json_or_mapping(self):
|
||
for encoded in ("{", "[]", json.dumps({"3": [PAID_GROUP]}), json.dumps({"1": PAID_GROUP})):
|
||
with self.subTest(encoded=encoded), patch.dict(os.environ, {
|
||
"MEMBERSHIP_TIERS_JSON": json.dumps(TIERS),
|
||
"MEMBERSHIP_SERVICE_GROUPS_JSON": encoded,
|
||
}), self.assertRaises(ImproperlyConfigured):
|
||
runpy.run_path(str(Path(__file__).absolute().parents[2] / "config" / "settings.py"))
|
||
|
||
def test_configured_groups_unions_every_level_with_original_tier_groups(self):
|
||
self.assertEqual(configured_groups(TIERS, SERVICE_GROUPS), ALL_GROUPS)
|
||
|
||
def test_configured_groups_deduplicates_shared_tier_and_service_targets(self):
|
||
self.assertEqual(configured_groups(TIERS, {"0": [TIER_GROUP], "1": [TIER_GROUP, PAID_GROUP]}), {
|
||
TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, PAID_GROUP,
|
||
})
|
||
self.assertEqual(configured_groups({}, {}), set())
|
||
self.assertEqual(configured_groups({}, {"0": [FREE_GROUP]}), {FREE_GROUP})
|
||
|
||
|
||
class ServiceGroupFixture:
|
||
"""Use local entitlement snapshots; fail immediately on any network access."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.now = timezone.now()
|
||
self.member = Account.objects.create(
|
||
uid="alice", email="alice@example.org", ldap_dn=DN,
|
||
ldap_uuid="11111111-1111-1111-1111-111111111111",
|
||
stripe_customer="cus_alice",
|
||
)
|
||
self.ldap = self.start_patch("members.billing.reconcile_groups", return_value=DN)
|
||
self.stripe = self.start_patch("members.billing.refresh_billing", side_effect=AssertionError("Local sweep must not refresh Stripe"))
|
||
self.crypto = self.start_patch("members.billing.shkeeper.refresh_account", side_effect=AssertionError("Local sweep must not refresh SHKeeper"))
|
||
self.start_patch("members.billing.client", side_effect=AssertionError("No Stripe transport in offline tests"))
|
||
self.start_patch("members.shkeeper.request", side_effect=AssertionError("No SHKeeper transport in offline tests"))
|
||
self.start_patch("members.directory.connection", side_effect=AssertionError("No LDAP connection in offline tests"))
|
||
self.start_patch("members.billing.timezone.now", return_value=self.now)
|
||
|
||
def start_patch(self, target, **kwargs):
|
||
patcher = patch(target, **kwargs)
|
||
mocked = patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
return mocked
|
||
|
||
def subscription(self, **changes):
|
||
values = {
|
||
"account": self.member, "stripe_id": "sub_service", "tier": "supporter",
|
||
"status": "active", "paid_until": self.now + timedelta(days=1),
|
||
}
|
||
return Subscription.objects.create(**{**values, **changes})
|
||
|
||
def invoice(self, **changes):
|
||
values = {
|
||
"account": self.member, "tier": "supporter", "usd_amount": "5.00",
|
||
"crypto": "BTC", "status": "PAID", "credited_at": self.now,
|
||
"access_start": self.now - timedelta(days=1),
|
||
"access_end": self.now + timedelta(days=1),
|
||
}
|
||
return CryptoInvoice.objects.create(**{**values, **changes})
|
||
|
||
def managed_groups(self):
|
||
return set(ManagedGroup.objects.filter(account=self.member).values_list("dn", flat=True))
|
||
|
||
def assert_sync(self, desired, level, managed=None):
|
||
self.ldap.reset_mock()
|
||
self.assertTrue(billing.sync_account(self.member.pk, refresh_stripe=False))
|
||
self.ldap.assert_called_once()
|
||
synced_account, actual_desired, actual_managed = self.ldap.call_args.args
|
||
self.assertEqual(synced_account.pk, self.member.pk)
|
||
self.assertEqual(actual_desired, desired)
|
||
expected_managed = ALL_GROUPS if managed is None else managed
|
||
self.assertEqual(actual_managed, expected_managed)
|
||
self.assertEqual(self.managed_groups(), expected_managed)
|
||
self.member.refresh_from_db()
|
||
self.assertEqual(self.member.membership_level, level)
|
||
self.assertEqual(self.member.ldap_dn, DN)
|
||
self.assertEqual(self.member.last_ldap_sync, self.now)
|
||
self.stripe.assert_not_called()
|
||
self.crypto.assert_not_called()
|
||
|
||
|
||
@override_settings(MEMBERSHIP_TIERS=TIERS, MEMBERSHIP_SERVICE_GROUPS=SERVICE_GROUPS)
|
||
class ServiceGroupReconciliationTests(ServiceGroupFixture, TestCase):
|
||
def test_free_account_gets_only_level_zero_services(self):
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
@override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]})
|
||
def test_free_service_grants_without_any_paid_tier_configuration(self):
|
||
self.assert_sync({FREE_GROUP}, 0, managed={FREE_GROUP})
|
||
|
||
def test_paid_stripe_grants_exact_multiple_services_and_original_tier_group(self):
|
||
self.subscription()
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
|
||
def test_past_due_with_unexpired_paid_time_keeps_paid_services(self):
|
||
self.subscription(status="past_due")
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
|
||
def test_trial_unpaid_and_other_ineligible_stripe_statuses_get_no_paid_grants(self):
|
||
sub = self.subscription()
|
||
for status in ("trialing", "unpaid", "canceled", "paused", "incomplete", "incomplete_expired", "missing"):
|
||
with self.subTest(status=status):
|
||
Subscription.objects.filter(pk=sub.pk).update(status=status)
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
def test_active_stripe_without_paid_time_gets_no_paid_grants(self):
|
||
self.subscription(paid_until=None)
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
def test_paid_and_overpaid_shkeeper_grant_services_and_original_tier_group(self):
|
||
invoice = self.invoice()
|
||
for status in ("PAID", "OVERPAID"):
|
||
with self.subTest(status=status):
|
||
CryptoInvoice.objects.filter(pk=invoice.pk).update(status=status)
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
|
||
def test_unpaid_partial_and_unconfirmed_crypto_get_no_paid_grants(self):
|
||
invoice = self.invoice()
|
||
for status in ("creating", "UNPAID", "PARTIAL", "UNCONFIRMED"):
|
||
with self.subTest(status=status):
|
||
CryptoInvoice.objects.filter(pk=invoice.pk).update(status=status)
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
def test_crypto_requires_current_access_window(self):
|
||
invoice = self.invoice()
|
||
for changes in (
|
||
{"access_start": None},
|
||
{"access_start": self.now + timedelta(seconds=1)},
|
||
{"access_end": None},
|
||
{"access_end": self.now},
|
||
{"access_end": self.now - timedelta(seconds=1)},
|
||
):
|
||
with self.subTest(changes=changes):
|
||
CryptoInvoice.objects.filter(pk=invoice.pk).update(
|
||
access_start=self.now - timedelta(days=1),
|
||
access_end=self.now + timedelta(days=1),
|
||
)
|
||
CryptoInvoice.objects.filter(pk=invoice.pk).update(**changes)
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
def test_crypto_access_starts_at_inclusive_boundary(self):
|
||
self.invoice(access_start=self.now)
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
|
||
def test_highest_stripe_level_only_no_service_inheritance(self):
|
||
self.subscription()
|
||
self.subscription(stripe_id="sub_patron", tier="patron")
|
||
self.subscription(stripe_id="sub_premier", tier="premier")
|
||
self.assert_sync({TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP}, 10)
|
||
|
||
def test_highest_shkeeper_level_only_no_service_inheritance(self):
|
||
self.invoice()
|
||
self.invoice(tier="patron")
|
||
self.invoice(tier="premier")
|
||
self.assert_sync({TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP}, 10)
|
||
|
||
def test_highest_level_is_max_across_both_providers_in_either_order(self):
|
||
sub = self.subscription(tier="premier")
|
||
invoice = self.invoice(tier="patron")
|
||
desired = {TOP_TIER_GROUP, OTHER_TIER_GROUP, TOP_SERVICE_GROUP}
|
||
self.assert_sync(desired, 10)
|
||
Subscription.objects.filter(pk=sub.pk).update(tier="patron")
|
||
CryptoInvoice.objects.filter(pk=invoice.pk).update(tier="premier")
|
||
self.assert_sync(desired, 10)
|
||
|
||
def test_cached_level_is_not_an_entitlement(self):
|
||
Account.objects.filter(pk=self.member.pk).update(membership_level=10)
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
def test_unknown_paid_tier_keys_cannot_grant_services(self):
|
||
self.subscription(tier="removed")
|
||
self.invoice(tier="removed")
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
@override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP], "1": [PAID_GROUP]})
|
||
def test_unmapped_paid_level_does_not_fall_back_to_free_or_lower_services(self):
|
||
self.subscription(tier="patron")
|
||
self.assert_sync({OTHER_TIER_GROUP}, 2, managed={
|
||
TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, FREE_GROUP, PAID_GROUP,
|
||
})
|
||
|
||
def test_expiry_falls_back_to_free_and_revokes_old_groups_without_providers(self):
|
||
sub = self.subscription()
|
||
invoice = self.invoice(tier="premier")
|
||
self.assert_sync({TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP}, 10)
|
||
Subscription.objects.filter(pk=sub.pk).update(paid_until=self.now)
|
||
CryptoInvoice.objects.filter(pk=invoice.pk).update(access_end=self.now)
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
self.assertTrue({TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP} <= self.ldap.call_args.args[2])
|
||
|
||
def test_expired_high_tier_falls_back_to_remaining_paid_level_not_free(self):
|
||
self.subscription()
|
||
self.invoice(tier="premier", access_end=self.now - timedelta(seconds=1))
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
|
||
def test_removed_mapping_remains_managed_for_revocation(self):
|
||
self.subscription()
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
with override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}):
|
||
self.assert_sync({TIER_GROUP}, 1)
|
||
self.assertIn(PAID_GROUP, self.ldap.call_args.args[2])
|
||
self.assertIn(SECOND_PAID_GROUP, self.ldap.call_args.args[2])
|
||
|
||
def test_removed_tiers_and_service_configuration_revoke_all_historical_targets(self):
|
||
self.subscription()
|
||
self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1)
|
||
with override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={}):
|
||
self.assert_sync(set(), 0)
|
||
|
||
def test_preexisting_historical_managed_group_is_not_forgotten(self):
|
||
ManagedGroup.objects.create(account=self.member, dn=HISTORICAL_GROUP)
|
||
self.assert_sync({FREE_GROUP}, 0, managed=ALL_GROUPS | {HISTORICAL_GROUP})
|
||
|
||
def test_remember_groups_includes_all_maps_is_idempotent_and_never_uses_ldap(self):
|
||
ManagedGroup.objects.create(account=self.member, dn=HISTORICAL_GROUP)
|
||
views.remember_groups(self.member)
|
||
views.remember_groups(self.member)
|
||
self.assertEqual(self.managed_groups(), ALL_GROUPS | {HISTORICAL_GROUP})
|
||
self.assertEqual(ManagedGroup.objects.filter(account=self.member).count(), len(ALL_GROUPS) + 1)
|
||
self.ldap.assert_not_called()
|
||
self.member.refresh_from_db()
|
||
self.assertEqual(self.member.membership_level, 0)
|
||
self.assertIsNone(self.member.last_ldap_sync)
|
||
|
||
|
||
@override_settings(MEMBERSHIP_TIERS=TIERS, MEMBERSHIP_SERVICE_GROUPS=SERVICE_GROUPS)
|
||
class ServiceGroupDurabilityTests(ServiceGroupFixture, TransactionTestCase):
|
||
def test_all_configuration_is_stored_before_first_ldap_call(self):
|
||
self.assertFalse(connection.in_atomic_block)
|
||
self.assertFalse(self.managed_groups())
|
||
|
||
def reconcile(account, desired, managed):
|
||
self.assertEqual(self.managed_groups(), ALL_GROUPS)
|
||
self.assertEqual(managed, ALL_GROUPS)
|
||
self.assertEqual(desired, {FREE_GROUP})
|
||
return DN
|
||
|
||
self.ldap.side_effect = reconcile
|
||
self.assert_sync({FREE_GROUP}, 0)
|
||
|
||
def test_failed_ldap_intent_survives_rollback_and_removed_config_on_retry(self):
|
||
self.assertFalse(connection.in_atomic_block)
|
||
self.subscription()
|
||
|
||
def fail_ldap(account, desired, managed):
|
||
self.assertEqual(self.managed_groups(), ALL_GROUPS)
|
||
self.assertEqual(managed, ALL_GROUPS)
|
||
self.assertEqual(desired, {TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP})
|
||
raise OSError("LDAP unavailable")
|
||
|
||
self.ldap.side_effect = fail_ldap
|
||
with self.assertRaisesRegex(OSError, "LDAP unavailable"):
|
||
billing.sync_account(self.member.pk, refresh_stripe=False)
|
||
self.assertFalse(connection.in_atomic_block)
|
||
self.assertEqual(self.managed_groups(), ALL_GROUPS)
|
||
self.member.refresh_from_db()
|
||
self.assertIsNone(self.member.last_ldap_sync)
|
||
self.assertEqual(self.member.membership_level, 0)
|
||
self.ldap.side_effect = None
|
||
with override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}):
|
||
self.assert_sync({FREE_GROUP}, 0)
|