This commit is contained in:
2026-10-29 14:49:44 -04:00
parent 920a608fba
commit 9eac45a7f9
6 changed files with 120 additions and 15 deletions
+26
View File
@@ -35,3 +35,29 @@ def configured_groups(tiers, service_groups):
return {tier["group_dn"] for tier in tiers.values()} | {
dn for groups in service_groups.values() for dn in groups
}
def service_names(level, service_groups):
"""Public labels from the same exact-level mapping used by reconciliation."""
labels = {
"dex users": "Dex · single sign-on",
"discourse users": "Discourse · community forums",
"gitea users": "Gitea · Git hosting",
"matrix users": "Matrix · community chat",
"mastodon users": "Mastodon · social networking",
"peertube users": "PeerTube · video sharing",
}
names = []
for dn in service_groups.get(str(level), []):
# Show only a group's common name, never its directory hierarchy.
common_name = next((value for attribute, value, _separator in parse_dn(dn) if attribute.casefold() == "cn"), "")
if not common_name:
name = "Additional community service"
else:
# LDAP DN values escape reserved characters and UTF-8 bytes.
raw = re.sub(r"\\([,=+<>#;\"\\ ])", r"\1", common_name)
raw = re.sub(r"(?:\\[0-9a-fA-F]{2})+", lambda match: bytes.fromhex(match.group().replace("\\", "")).decode("utf-8", errors="replace"), raw)
name = labels.get(raw.casefold(), re.sub(r"\s+users$", "", raw, flags=re.IGNORECASE))
if name not in names:
names.append(name)
return names
@@ -15,7 +15,7 @@ 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, validate_service_groups
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"
@@ -79,6 +79,42 @@ class ServiceGroupLDAPIntegrationTests(TestCase):
self.assertTrue(ManagedGroup.objects.filter(account=member, dn=FREE_GROUP).exists())
@override_settings(MEMBERSHIP_TIERS=TIERS)
class ServiceDisplayTests(TestCase):
@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, "&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)
+4 -2
View File
@@ -26,7 +26,7 @@ from django.views.decorators.http import require_GET, require_POST, require_http
from . import billing, directory, profiles, shkeeper
from .forms import PasswordForm, RegistrationForm
from .service_groups import configured_groups
from .service_groups import configured_groups, service_names
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
@@ -76,7 +76,9 @@ def home(request):
account = Account.objects.filter(pk=request.session.get("account_id")).first() if request.session.get("account_id") else None
subscriptions = Subscription.objects.filter(account=account) if account else []
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
# Presentation copies only: never mutate authorization/payment settings.
tiers = {key: {**tier, "services": service_names(tier["level"], settings.MEMBERSHIP_SERVICE_GROUPS)} for key, tier in settings.MEMBERSHIP_TIERS.items()}
return page(request, account=account, tiers=tiers, free_services=service_names(0, settings.MEMBERSHIP_SERVICE_GROUPS), subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
@require_http_methods(["GET", "POST"])