from __future__ import annotations from types import SimpleNamespace from allauth.exceptions import ImmediateHttpResponse from django.contrib.auth.models import AnonymousUser from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core import signing from django.test import RequestFactory, TestCase from django.urls import reverse from apps.accounts.forms import NativePasswordResetForm from apps.accounts.identity import build_unique_username, normalize_email from apps.accounts.models import ExternalIdentity, NativeAccountSecurity from apps.accounts.social_auth import KeywardenSocialAccountAdapter class IdentityHelpersTests(TestCase): def test_normalize_email(self): self.assertEqual(normalize_email(" USER@Example.Com "), "user@example.com") def test_build_unique_username_appends_suffix(self): User = get_user_model() User.objects.create_user(username="alice", email="alice@example.com", password="pass12345") generated = build_unique_username("alice", "alice2@example.com") self.assertNotEqual(generated, "alice") self.assertTrue(generated.startswith("alice")) class ImmutableEmailTests(TestCase): def test_email_change_is_blocked(self): User = get_user_model() user = User.objects.create_user(username="bob", email="bob@example.com", password="pass12345") user.email = "newbob@example.com" with self.assertRaises(ValidationError): user.save() class ExternalIdentityTests(TestCase): def test_identity_str(self): User = get_user_model() user = User.objects.create_user(username="charlie", email="charlie@example.com", password="pass12345") identity = ExternalIdentity.objects.create( user=user, provider_type=ExternalIdentity.ProviderType.OIDC, provider_id="corporate-sso", issuer="https://id.example.com/realms/main", subject="sub-123", email_at_link="charlie@example.com", ) self.assertIn("corporate-sso", str(identity)) class _DummySocialLogin: def __init__( self, *, email: str, provider: str = "github", uid: str = "subject-1", state: dict | None = None, is_existing: bool = False, existing_user=None, email_verified: bool = True, ): self.account = SimpleNamespace( provider=provider, uid=uid, extra_data={"email": email, "name": "Test User"}, ) self.user = existing_user or SimpleNamespace(email=email, pk=None) self.state = state or {} self.is_existing = is_existing self.email_addresses = [SimpleNamespace(email=email, verified=email_verified)] self.connected_user = None def connect(self, request, user) -> None: self.connected_user = user self.user = user self.is_existing = True class SocialAdapterTests(TestCase): def setUp(self): super().setUp() self.factory = RequestFactory() self.adapter = KeywardenSocialAccountAdapter() def _request(self, *, user=None, path: str = "/accounts/sso/github/login/callback/"): request = self.factory.get(path) session_middleware = SessionMiddleware(lambda r: None) session_middleware.process_request(request) request.session.save() request._messages = FallbackStorage(request) request.user = user or AnonymousUser() return request def test_login_requires_prelinked_github_identity(self): User = get_user_model() User.objects.create_user(username="social1", email="social1@example.com", password="pass12345") request = self._request() sociallogin = _DummySocialLogin( email="social1@example.com", state={"process": "login"}, ) with self.assertRaises(ImmediateHttpResponse): self.adapter.pre_social_login(request, sociallogin) self.assertFalse( ExternalIdentity.objects.filter( provider_type=ExternalIdentity.ProviderType.SOCIAL, provider_id="github", ).exists() ) def test_connect_flow_links_github_to_current_user(self): User = get_user_model() user = User.objects.create_user( username="social2", email="social2@example.com", password="pass12345", ) request = self._request(user=user) sociallogin = _DummySocialLogin( email="gh-social2@example.net", uid="github-sub-2", state={"process": "connect"}, ) self.adapter.pre_social_login(request, sociallogin) identity = ExternalIdentity.objects.get( provider_type=ExternalIdentity.ProviderType.SOCIAL, provider_id="github", subject="github-sub-2", ) self.assertEqual(identity.user_id, user.id) self.assertEqual(identity.email_at_link, "gh-social2@example.net") self.assertEqual(sociallogin.connected_user, user) def test_login_succeeds_when_github_identity_is_linked(self): User = get_user_model() user = User.objects.create_user( username="social3", email="social3@example.com", password="pass12345", ) ExternalIdentity.objects.create( user=user, provider_type=ExternalIdentity.ProviderType.SOCIAL, provider_id="github", subject="github-sub-3", email_at_link="gh-social3@example.net", ) request = self._request() sociallogin = _DummySocialLogin( email="gh-social3@example.net", uid="github-sub-3", state={"process": "login"}, ) self.adapter.pre_social_login(request, sociallogin) self.assertEqual(sociallogin.connected_user, user) identity = ExternalIdentity.objects.get( provider_type=ExternalIdentity.ProviderType.SOCIAL, provider_id="github", subject="github-sub-3", ) self.assertIsNotNone(identity.last_login_at) def test_connect_flow_allows_local_and_github_email_mismatch(self): User = get_user_model() user = User.objects.create_user( username="social4", email="social4@example.com", password="pass12345", ) request = self._request(user=user) sociallogin = _DummySocialLogin( email="different@example.com", uid="github-sub-4", state={"process": "connect"}, ) self.adapter.pre_social_login(request, sociallogin) identity = ExternalIdentity.objects.get( provider_type=ExternalIdentity.ProviderType.SOCIAL, provider_id="github", subject="github-sub-4", ) self.assertEqual(identity.user_id, user.id) self.assertEqual(identity.email_at_link, "different@example.com") class NativePasswordResetFormTests(TestCase): def test_native_password_reset_excludes_sso_accounts(self): User = get_user_model() native_user = User.objects.create_user( username="native-user", email="native@example.com", password="pass12345", ) sso_user = User.objects.create_user( username="sso-user", email="sso@example.com", password="pass12345", ) ExternalIdentity.objects.create( user=sso_user, provider_type=ExternalIdentity.ProviderType.OIDC, provider_id="oidc", subject="oidc-subject", email_at_link="sso@example.com", ) native_form = NativePasswordResetForm(data={"email": "native@example.com"}) self.assertTrue(native_form.is_valid()) native_users = list(native_form.get_users("native@example.com")) self.assertEqual([user.pk for user in native_users], [native_user.pk]) sso_form = NativePasswordResetForm(data={"email": "sso@example.com"}) self.assertTrue(sso_form.is_valid()) sso_users = list(sso_form.get_users("sso@example.com")) self.assertEqual(sso_users, []) class NativeEmailVerificationTests(TestCase): def test_email_verification_marks_native_account_verified(self): User = get_user_model() user = User.objects.create_user( username="verify-native", email="verify-native@example.com", password="pass12345", ) token = signing.dumps( {"uid": user.pk, "email": "verify-native@example.com"}, salt="accounts.native-email-verification", ) response = self.client.get(reverse("accounts:email_verify", kwargs={"token": token})) self.assertEqual(response.status_code, 302) security = NativeAccountSecurity.objects.get(user=user) self.assertTrue(security.email_verified) self.assertIsNotNone(security.email_verified_at) def test_email_verification_does_not_verify_sso_account(self): User = get_user_model() user = User.objects.create_user( username="verify-sso", email="verify-sso@example.com", password="pass12345", ) ExternalIdentity.objects.create( user=user, provider_type=ExternalIdentity.ProviderType.OIDC, provider_id="oidc", subject="sso-subject-verify", email_at_link="verify-sso@example.com", ) token = signing.dumps( {"uid": user.pk, "email": "verify-sso@example.com"}, salt="accounts.native-email-verification", ) response = self.client.get(reverse("accounts:email_verify", kwargs={"token": token})) self.assertEqual(response.status_code, 302) self.assertFalse(NativeAccountSecurity.objects.filter(user=user).exists())