import re import uuid from types import SimpleNamespace from urllib.parse import parse_qs, urlparse from allauth.core import context from allauth.mfa.models import Authenticator from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.core import mail from django.db import IntegrityError from django.http import HttpResponse from django.template.loader import render_to_string from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from club.models import Club, ClubRole from members.models import Member from .adapters import RosterChiefMFAAdapter, webauthn_rp_id from .middleware import RequireMFAMiddleware, mfa_required_for User = get_user_model() def enrol_mfa(user): """Give ``user`` a second factor (enough for is_mfa_enabled).""" return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) class UserManagerTests(TestCase): def test_create_user_defaults(self): user = User.objects.create_user(email="alice@example.com", password="secret123") self.assertEqual(user.email, "alice@example.com") self.assertTrue(user.check_password("secret123")) self.assertFalse(user.is_staff) self.assertFalse(user.is_superuser) self.assertTrue(user.is_active) def test_create_user_requires_email(self): with self.assertRaises(ValueError): User.objects.create_user(email="", password="secret123") def test_create_user_normalizes_email_domain(self): # BaseUserManager lowercases the domain part of the address. user = User.objects.create_user(email="Bob@Example.COM", password="secret123") self.assertEqual(user.email, "Bob@example.com") def test_create_user_password_is_hashed(self): user = User.objects.create_user(email="carol@example.com", password="secret123") self.assertNotEqual(user.password, "secret123") def test_create_user_without_password_is_unusable(self): user = User.objects.create_user(email="dave@example.com") self.assertFalse(user.has_usable_password()) def test_create_superuser_defaults(self): admin = User.objects.create_superuser(email="admin@example.com", password="secret123") self.assertTrue(admin.is_staff) self.assertTrue(admin.is_superuser) self.assertTrue(admin.is_active) def test_create_superuser_rejects_non_staff(self): with self.assertRaises(ValueError): User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False) def test_create_superuser_rejects_non_superuser(self): with self.assertRaises(ValueError): User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False) class UserModelTests(TestCase): def test_email_is_username_field(self): self.assertEqual(User.USERNAME_FIELD, "email") self.assertEqual(User.REQUIRED_FIELDS, []) def test_email_is_unique(self): User.objects.create_user(email="dup@example.com", password="x") with self.assertRaises(IntegrityError): User.objects.create_user(email="dup@example.com", password="y") def test_pk_is_uuid(self): user = User.objects.create_user(email="uuid@example.com", password="x") self.assertIsInstance(user.pk, uuid.UUID) def test_str_and_names_fall_back_to_email_without_member(self): user = User.objects.create_user(email="lonely@example.com", password="x") self.assertEqual(str(user), "lonely@example.com") self.assertEqual(user.get_full_name(), "lonely@example.com") self.assertEqual(user.get_short_name(), "lonely@example.com") def test_str_and_names_use_linked_member(self): user = User.objects.create_user(email="linked@example.com", password="x") Member.objects.create(user=user, first_name="Jane", last_name="Doe") # Re-fetch so the reverse OneToOne relation is resolved from the DB. user = User.objects.get(pk=user.pk) self.assertEqual(str(user), "Jane Doe") self.assertEqual(user.get_full_name(), "Jane Doe") self.assertEqual(user.get_short_name(), "Jane") @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", MFA_WEBAUTHN_RP_NAME="RosterChief", ALLOWED_HOSTS=[".rosterchief.app", "example.test"], ) class WebAuthnRelyingPartyTests(TestCase): """A passkey is bound to a Relying Party ID (a domain). allauth's default RP ID is the request host, which under our subdomain tenancy would bind a passkey to a single club. We pin it to the registrable parent domain so ONE passkey works across every club. """ def rp_entity(self, host): request = RequestFactory().get("/", HTTP_HOST=host) with context.request_context(request): return RosterChiefMFAAdapter().get_public_key_credential_rp_entity() def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self): self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["id"], "rosterchief.app") def test_rp_id_is_identical_across_clubs(self): # The whole point: a passkey registered at one club works at the others. here = self.rp_entity("ajax-united.rosterchief.app") there = self.rp_entity("rival-fc.rosterchief.app") self.assertEqual(here["id"], there["id"]) def test_rp_name_comes_from_settings(self): self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["name"], "RosterChief") @override_settings(ROSTERCHIEF_BASE_DOMAIN="") def test_falls_back_to_the_request_host_without_a_base_domain(self): request = RequestFactory().get("/", HTTP_HOST="example.test:8000") with context.request_context(request): self.assertEqual(webauthn_rp_id(), "example.test") class MFARequirementTests(TestCase): @classmethod def setUpTestData(cls): # Read-only for every test here: each one brings its own user and role. cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def make_user(self, email, **kwargs): return User.objects.create_user(email=email, password="pw-secret-123", **kwargs) def with_role(self, user, role): member = Member.objects.create(user=user, first_name="Ada", last_name="Min") ClubRole.objects.create(club=self.club, member=member, role=role) return user def test_staff_must_have_mfa(self): self.assertTrue(mfa_required_for(self.make_user("staff@example.com", is_staff=True))) def test_superuser_must_have_mfa(self): self.assertTrue(mfa_required_for(User.objects.create_superuser(email="root@example.com", password="pw-secret-123"))) def test_club_admin_must_have_mfa(self): user = self.with_role(self.make_user("admin@example.com"), ClubRole.Roles.ADMIN) self.assertTrue(mfa_required_for(user)) def test_editor_must_have_mfa(self): user = self.with_role(self.make_user("editor@example.com"), ClubRole.Roles.EDITOR) self.assertTrue(mfa_required_for(user)) def test_plain_member_does_not_need_mfa(self): user = self.with_role(self.make_user("member@example.com"), ClubRole.Roles.MEMBER) self.assertFalse(mfa_required_for(user)) def test_user_without_any_role_does_not_need_mfa(self): self.assertFalse(mfa_required_for(self.make_user("nobody@example.com"))) class RequireMFAMiddlewareTests(TestCase): def setUp(self): self.factory = RequestFactory() self.middleware = RequireMFAMiddleware(lambda request: HttpResponse("ok")) def dispatch(self, user, path="/"): request = self.factory.get(path) request.user = user return self.middleware(request) def make_staff(self): return User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True) def test_anonymous_passes_through(self): self.assertEqual(self.dispatch(AnonymousUser()).content, b"ok") def test_unprivileged_user_passes_through(self): user = User.objects.create_user(email="plain@example.com", password="pw-secret-123") self.assertEqual(self.dispatch(user).content, b"ok") def test_privileged_user_without_mfa_is_sent_to_enrolment(self): response = self.dispatch(self.make_staff()) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse("mfa_index")) def test_privileged_user_can_still_reach_the_enrolment_pages(self): # Otherwise they'd be redirected in a loop and could never enrol. response = self.dispatch(self.make_staff(), path="/accounts/2fa/totp/activate/") self.assertEqual(response.content, b"ok") def test_enrolled_privileged_user_passes_through(self): staff = self.make_staff() enrol_mfa(staff) self.assertEqual(self.dispatch(staff).content, b"ok") class AdminLoginRoutingTests(TestCase): def test_admin_login_is_routed_through_allauth(self): # Django's own admin login knows nothing about second factors. response = self.client.get("/admin/login/", {"next": "/admin/"}) self.assertEqual(response.status_code, 302) redirect = urlparse(response.url) self.assertEqual(redirect.path, reverse("account_login")) # The original destination survives the hop (percent-encoded). self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"]) class AuthFormRenderingTests(TestCase): """Every allauth form must actually render its fields. Regression: the `fields` element passed `attrs.exclude` straight into a filter. On a page that never sets it, resolving a filter *argument* raises VariableDoesNotExist — which Django swallows inside {% if %} and reads as false — so every field was silently dropped from every form except the login page (the one page that does pass `exclude`). """ def test_the_login_form_renders_its_fields(self): self.assertContains(self.client.get(reverse("account_login")), 'name="login"') def test_the_password_reset_form_renders_its_fields(self): self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"') def test_self_registration_is_closed(self): # A club has no reason to let a stranger create an account: they're made by # an admin, by the family-registration form, or by an approved parent claim # (members/views.py). The route is shadowed rather than removed so that the # `account_signup` name allauth's own templates reverse still resolves. response = self.client.get(reverse("account_signup")) self.assertEqual(response.status_code, 403) self.assertNotContains(response, 'name="password1"', status_code=403) @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ) class PasswordResetEmailTests(TestCase): """allauth auto-attaches templates/account/email/password_reset_key_message.html as an HTML alternative next to its own .txt body -- see allauth.account.adapter.DefaultAccountAdapter.render_mail, which looks for "_message." for ext in [TEMPLATE_EXTENSION ("html", unset here), "txt"]. No Python override needed; this only exercises the template.""" def test_the_html_email_carries_the_clubs_branding_on_a_club_subdomain(self): Club.objects.create(name="Ajax United", slug="ajax-united") User.objects.create_user(email="parent@example.com", password="pw-secret-123") response = self.client.post(reverse("account_reset_password"), {"email": "parent@example.com"}, HTTP_HOST="ajax-united.rosterchief.app") self.assertEqual(response.status_code, 302) self.assertEqual(len(mail.outbox), 1) [(html_body, mimetype)] = mail.outbox[0].alternatives self.assertEqual(mimetype, "text/html") self.assertIn("Ajax United", html_body) self.assertIn("/accounts/password/reset/key/", html_body) def test_the_html_email_falls_back_to_rosterchief_branding_off_a_club_subdomain(self): # The base domain has no tenant, e.g. a platform control-panel user # resetting their own password -- club.context_processors.branding # leaves `club` unset there, so the template must not assume one. User.objects.create_user(email="admin@example.com", password="pw-secret-123") self.client.post(reverse("account_reset_password"), {"email": "admin@example.com"}) self.assertEqual(len(mail.outbox), 1) [(html_body, mimetype)] = mail.outbox[0].alternatives self.assertEqual(mimetype, "text/html") # The wordmark splits "Chief" into its own for the sky-blue accent # (matching templates/_platform_base.html), so the two halves aren't # contiguous text in the raw HTML -- check for both rather than the # combined word. self.assertIn("Roster", html_body) self.assertIn("Chief", html_body) def test_the_password_reset_key_html_template_renders_directly(self): # Mirrors authentication.tests.AuthFormRenderingTests' direct-render # style: exercises the template's own branching (club vs. none, logo # vs. initials) without going through the full request/email pipeline. club = Club.objects.create(name="Ajax United", slug="ajax-united", primary_color="#1e40af") with_club = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"}) self.assertIn("Ajax United", with_club) self.assertIn("AU", with_club) # initials fallback: no logo set self.assertIn("https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/", with_club) def test_the_initials_badge_text_contrasts_against_the_fallback_colour(self): # Club has no secondary_color, so the badge falls back to #ec4899 -- # black text (contrast_color("#ec4899")) reads far better on it than # the white the template used to hardcode. See # club/templatetags/club_email.py::contrast_color. club = Club.objects.create(name="Ajax United", slug="ajax-united") rendered = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"}) self.assertIn("background-color:#ec4899", rendered) self.assertIn("color:#000000", rendered) def test_the_reset_button_text_contrasts_against_the_no_club_fallback_colour(self): # No club at all (the base-domain flow) -- the button falls back to # #0ea5e9, RosterChief's own sky blue, which also needs black text # for a passing contrast ratio, not the white previously hardcoded. rendered = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": None}) self.assertIn("background-color:#0ea5e9", rendered) self.assertIn("color:#000000", rendered) without_club = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": SimpleNamespace(name="rosterchief.app")}) self.assertIn("Roster", without_club) self.assertIn("Chief", without_club) self.assertIn("https://rosterchief.app/accounts/password/reset/key/abc-def/", without_club) class TwoFactorPageTests(TestCase): @classmethod def setUpTestData(cls): enrol_mfa(User.objects.create_user(email="mfa@example.com", password="pw-secret-123")) def setUp(self): # The test client is per-test, so the sign-in itself cannot be hoisted. # Password accepted, second factor still owed: this is the 2FA challenge page. self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) def test_the_code_field_renders_as_an_otp_input(self): self.assertContains(self.response, 'name="code"') self.assertContains(self.response, "otp otp-lg") def test_the_input_comes_after_the_boxes(self): # daisyUI places each box with nth-child, which counts every child. With the input # first, all six boxes shift a stride right, the container grows to seven strides # and the ::after focus marker appears as a phantom seventh box. html = self.response.content.decode() otp = html[html.index('class="otp otp-lg"') : html.index('name="code"')] self.assertEqual(otp.count(""), 6) def test_the_boxes_are_wrapped_in_a_label_so_tapping_focuses_the_input(self): # daisyUI's overlaid otp input carries `pointer-events: none` (so clicks land on the # boxes, not a naked input) — which also means a tap on the boxes never reaches the # input directly. A