Files
www/membership/members/tests/test_service_groups.py
2026-10-31 15:11:21 -04:00

550 lines
29 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("</section>", 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("</section>", 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</li><li>Plus all previous benefits</li>")
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=\<script\>alert(1)\</script\>," + BASE]})
def test_group_labels_are_html_escaped(self):
response = self.client.get("/members/")
self.assertContains(response, "&lt;script&gt;alert(1)&lt;/script&gt;")
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)