from django.test import TestCase from django.urls import reverse from rest_framework.test import APIClient from users.models import OtpCode, User class AuthFlowTests(TestCase): def setUp(self): self.client = APIClient() self.otp_user = User.objects.create_user(mobile="7712345", full_name="Otp User") self.password_user = User.objects.create_user( mobile="7798765", full_name="Password User", password="correct-horse-battery", auth_method=User.AuthMethod.PASSWORD, ) # --- step 1 --------------------------------------------------------- def test_start_reports_password_method(self): response = self.client.post(reverse("auth-start"), {"mobile": "7798765"}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["next"], "password") self.assertFalse(OtpCode.objects.exists()) def test_start_sends_a_code_for_otp_accounts(self): response = self.client.post(reverse("auth-start"), {"mobile": "771 2345"}) self.assertEqual(response.data["next"], "otp") self.assertEqual(response.data["mobile"], "+9607712345") self.assertEqual(OtpCode.objects.filter(user=self.otp_user).count(), 1) def test_start_does_not_say_whether_a_number_has_an_account(self): known = self.client.post(reverse("auth-start"), {"mobile": "7712345"}) unknown = self.client.post(reverse("auth-start"), {"mobile": "7700000"}) self.assertEqual(unknown.status_code, known.status_code) self.assertEqual(set(unknown.data), set(known.data)) self.assertEqual(unknown.data["next"], "otp") def test_start_never_returns_a_masked_number(self): for mobile in ["7798765", "7712345", "7700000"]: with self.subTest(mobile=mobile): response = self.client.post(reverse("auth-start"), {"mobile": mobile}) self.assertNotIn("mobile_masked", response.data) def test_start_falls_back_to_a_code_when_no_password_is_set(self): self.otp_user.auth_method = User.AuthMethod.PASSWORD self.otp_user.save(update_fields=["auth_method"]) response = self.client.post(reverse("auth-start"), {"mobile": "7712345"}) self.assertEqual(response.data["next"], "otp") def test_start_rejects_a_malformed_number(self): response = self.client.post(reverse("auth-start"), {"mobile": "123"}) self.assertEqual(response.status_code, 400) self.assertEqual(response.data["code"], "invalid") self.assertIn("mobile", response.data["errors"]) # --- step 2: password ---------------------------------------------- def test_password_login_returns_a_token(self): response = self.client.post( reverse("auth-login-password"), {"mobile": "7798765", "password": "correct-horse-battery"}, ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["next"], "dashboard") self.assertIn("token", response.data) self.assertEqual(response.data["user"]["mobile"], "+9607798765") def test_password_login_rejects_a_wrong_password(self): response = self.client.post( reverse("auth-login-password"), {"mobile": "7798765", "password": "nope"}, ) self.assertEqual(response.status_code, 400) self.assertEqual(response.data["code"], "invalid_credentials") # --- step 2: code --------------------------------------------------- def _issue_code(self, user=None): target = user or self.otp_user return OtpCode.issue( mobile=target.mobile, purpose=OtpCode.Purpose.LOGIN, user=target ) def test_verifying_a_code_signs_the_account_in(self): _, code = self._issue_code() response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": code} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["next"], "dashboard") self.assertIn("token", response.data) self.otp_user.refresh_from_db() self.assertTrue(self.otp_user.mobile_verified) def test_a_code_works_only_once(self): _, code = self._issue_code() self.client.post(reverse("auth-verify"), {"mobile": "7712345", "code": code}) response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": code} ) self.assertEqual(response.status_code, 400) self.assertEqual(response.data["code"], "code_expired") def test_wrong_code_counts_attempts(self): self._issue_code() response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": "000000"} ) self.assertEqual(response.status_code, 400) self.assertEqual(response.data["code"], "invalid_code") self.assertEqual(response.data["attempts_left"], 4) def test_code_is_exhausted_after_max_attempts(self): self._issue_code() for _ in range(5): self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": "000000"} ) response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": "000000"} ) self.assertEqual(response.status_code, 429) self.assertEqual(response.data["code"], "code_exhausted") def test_issuing_a_new_code_invalidates_the_previous_one(self): _, first = self._issue_code() _, second = self._issue_code() self.assertEqual(OtpCode.objects.active().count(), 1) response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": first} ) self.assertEqual(response.status_code, 400) response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": second} ) self.assertEqual(response.status_code, 200) def test_a_disabled_account_is_told_only_after_verifying(self): self.otp_user.is_active = False self.otp_user.save(update_fields=["is_active"]) start = self.client.post(reverse("auth-start"), {"mobile": "7712345"}) self.assertEqual(start.data["next"], "otp") _, code = self._issue_code() response = self.client.post( reverse("auth-verify"), {"mobile": "7712345", "code": code} ) self.assertEqual(response.status_code, 403) self.assertEqual(response.data["code"], "account_disabled") # --- resend ---------------------------------------------------------- def test_resend_is_rate_limited_by_the_cooldown(self): self.client.post(reverse("auth-start"), {"mobile": "7712345"}) response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7712345"}) self.assertEqual(response.status_code, 429) self.assertEqual(response.data["code"], "resend_cooldown") def test_resend_works_for_a_number_without_an_account(self): response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7700000"}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["next"], "otp") self.assertTrue(OtpCode.objects.filter(mobile="+9607700000").exists()) def test_resend_does_not_reveal_that_an_account_uses_a_password(self): response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7798765"}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["next"], "otp") self.assertFalse(OtpCode.objects.exists()) # --- session -------------------------------------------------------- def test_me_requires_a_token(self): self.assertEqual(self.client.get(reverse("auth-me")).status_code, 401) def test_me_returns_the_signed_in_account(self): login = self.client.post( reverse("auth-login-password"), {"mobile": "7798765", "password": "correct-horse-battery"}, ) self.client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}") response = self.client.get(reverse("auth-me")) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["mobile"], "+9607798765") self.assertFalse(response.data["is_admin"])