"""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_access_catalog, 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"]) self.assertIn(f"{tier['label']}-tier giveaways", tier["benefits"]) self.assertFalse(any("exclusive to" in benefit.casefold() for benefit in tier["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 ("Discourse", "Matrix", "Gitea", "Mastodon", "PeerTube", "Owncast"): self.assertIn(name, text) self.assertIn("Free · Level 0 and above", text) self.assertIn("Gold · Levels 3–5", text) self.assertIn("/members/", parser.links) self.assertIn("https://element.eom.dev/", parser.links) homepage_html = homepage.read_text(encoding="utf-8") self.assertNotIn('id="start-here"', homepage_html) self.assertNotIn("Three ways into the lab", homepage_html) self.assertNotIn("Hosted games", text) for host in ("discourse.eom.dev", "element.eom.dev", "gitea.eom.dev", "mastodon.eom.dev", "peertube.eom.dev", "owncast.eom.dev"): self.assertIn(f'src="https://{host}/', homepage_html) self.assertNotIn("Dex · Shared sign-in", text) projects = homepage_html.split('id="projects"', 1)[1].split("", 1)[0] self.assertIn('id="gitea-projects"', projects) self.assertIn("https://gitea.eom.dev/explore/repos", projects) self.assertIn('data-channels="eric_tech"', projects) gaming = homepage_html.split('id="gaming"', 1)[1].split("", 1)[0] for label in ("Wikideck", "The Button", "Luanti"): self.assertIn(label, gaming) self.assertNotIn("My Luanti server", gaming) self.assertIn("Season V · Public server", gaming) self.assertIn("eleven team islands, markets and an adventure-map spawn", gaming) self.assertIn("luanti.eom.dev:30000", gaming) for destination in ( "https://gitea.eom.dev/WikiDeck/wikideck", "https://button.eom.dev/", "https://discourse.eom.dev/t/luanti-season-v/690", ): self.assertIn(destination, gaming) self.assertNotIn("Browser client source", gaming) self.assertNotIn("Broadcast from your own stack", projects) self.assertNotIn("Make patterns, make sound", projects) @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/") catalog = response.context["services"] self.assertEqual(catalog, [ {"name": "Matrix · community chat", "access": "Open Lab", "url": "https://element.eom.dev/", "favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png"}, {"name": "Gitea · Git hosting", "access": "Bronze", "url": "https://gitea.eom.dev/", "favicon": "https://gitea.eom.dev/assets/img/favicon.svg"}, {"name": "Mastodon · social networking", "access": "Silver", "url": "https://mastodon.eom.dev/", "favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png"}, {"name": "PeerTube · video sharing", "access": "Silver", "url": "https://peertube.eom.dev/", "favicon": "https://peertube.eom.dev/client/assets/images/favicon.png"}, ]) self.assertNotContains(response, "Dex · single sign-on") self.assertContains(response, 'href="https://element.eom.dev/"') self.assertContains(response, 'alt="" width="32" height="32"') self.assertIn("https://element.eom.dev", response["Content-Security-Policy"]) self.assertEqual(response.content.count(b"Matrix \xc2\xb7 community chat"), 1) self.assertContains(response, "Plus all previous benefits") self.assertNotContains(response, "except tier-exclusive giveaways") self.assertContains(response, "Support independent projects and the community
  • Plus all previous benefits
  • ") self.assertContains(response, "How do membership giveaways work?") self.assertContains(response, 'href="https://element.eom.dev/"') self.assertContains(response, 'src="https://element.eom.dev/vector-icons/144.5a63bf2.png"') self.assertIn("https://element.eom.dev", response["Content-Security-Policy"]) self.assertNotContains(response, BASE) def test_service_catalog_collapses_services_shared_by_every_level(self): tiers = {"bronze": {"level": 1, "label": "Bronze"}, "gold": {"level": 3, "label": "Gold"}} groups = { "0": ["cn=Matrix Users," + BASE], "1": ["cn=Matrix Users," + BASE], "3": ["cn=Matrix Users," + BASE, "cn=Mastodon Users," + BASE], } self.assertEqual(service_access_catalog(tiers, groups), [ {"name": "Matrix · community chat", "access": "Every membership level", "url": "https://element.eom.dev/", "favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png"}, {"name": "Mastodon · social networking", "access": "Gold", "url": "https://mastodon.eom.dev/", "favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png"}, ]) @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=\alert(1)\," + BASE]}) def test_group_labels_are_html_escaped(self): response = self.client.get("/members/") self.assertContains(response, "<script>alert(1)</script>") self.assertNotContains(response, "") 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)