186 lines
8.2 KiB
Python
186 lines
8.2 KiB
Python
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"])
|