238 lines
8.2 KiB
Python
238 lines
8.2 KiB
Python
from django.contrib.auth import get_user_model
|
|
from django.core.cache import cache
|
|
from django.core.exceptions import ValidationError
|
|
from django.test import TestCase, override_settings
|
|
from rest_framework.test import APIClient
|
|
|
|
from apps.accounts.models import OTPCode, Role, TeacherProfile
|
|
from apps.accounts.services.otp import (
|
|
OTPCooldownError,
|
|
OTPVerificationError,
|
|
send_otp,
|
|
verify_otp,
|
|
)
|
|
from apps.accounts.validators import normalize_iran_mobile, validate_iran_mobile
|
|
|
|
User = get_user_model()
|
|
|
|
CONSOLE_SMS = "apps.accounts.services.sms.ConsoleSMSBackend"
|
|
|
|
|
|
class PhoneNormalizationTests(TestCase):
|
|
def test_normalizes_e164(self):
|
|
self.assertEqual(normalize_iran_mobile("+989121234567"), "09121234567")
|
|
|
|
def test_normalizes_0098_prefix(self):
|
|
self.assertEqual(normalize_iran_mobile("00989121234567"), "09121234567")
|
|
|
|
def test_normalizes_98_prefix(self):
|
|
self.assertEqual(normalize_iran_mobile("989121234567"), "09121234567")
|
|
|
|
def test_normalizes_no_leading_zero(self):
|
|
self.assertEqual(normalize_iran_mobile("9121234567"), "09121234567")
|
|
|
|
def test_keeps_canonical_form(self):
|
|
self.assertEqual(normalize_iran_mobile("09121234567"), "09121234567")
|
|
|
|
def test_strips_separators(self):
|
|
self.assertEqual(normalize_iran_mobile("0912 123 4567"), "09121234567")
|
|
|
|
def test_validates_canonical(self):
|
|
validate_iran_mobile("09121234567")
|
|
|
|
def test_rejects_too_short(self):
|
|
with self.assertRaises(ValidationError):
|
|
validate_iran_mobile("0912123456")
|
|
|
|
def test_rejects_non_iran(self):
|
|
with self.assertRaises(ValidationError):
|
|
validate_iran_mobile("+12025551234")
|
|
|
|
|
|
@override_settings(SMS_BACKEND=CONSOLE_SMS)
|
|
class OTPServiceTests(TestCase):
|
|
def setUp(self):
|
|
cache.clear()
|
|
|
|
def test_send_otp_creates_code(self):
|
|
otp = send_otp("09121234567")
|
|
self.assertEqual(otp.phone_number, "09121234567")
|
|
self.assertEqual(len(otp.code), 4)
|
|
self.assertFalse(otp.is_used)
|
|
|
|
def test_send_otp_within_cooldown_raises(self):
|
|
send_otp("09121234567")
|
|
with self.assertRaises(OTPCooldownError):
|
|
send_otp("09121234567")
|
|
|
|
def test_verify_correct_code_consumes(self):
|
|
otp = send_otp("09121234567")
|
|
verified = verify_otp("09121234567", otp.code)
|
|
self.assertEqual(verified.pk, otp.pk)
|
|
otp.refresh_from_db()
|
|
self.assertTrue(otp.is_used)
|
|
|
|
def test_verify_wrong_code_increments_attempts(self):
|
|
send_otp("09121234567")
|
|
with self.assertRaises(OTPVerificationError):
|
|
verify_otp("09121234567", "0000")
|
|
latest = OTPCode.objects.filter(phone_number="09121234567").first()
|
|
self.assertEqual(latest.attempts, 1)
|
|
self.assertFalse(latest.is_used)
|
|
|
|
def test_verify_with_no_active_code_raises(self):
|
|
with self.assertRaises(OTPVerificationError):
|
|
verify_otp("09121234567", "1234")
|
|
|
|
def test_issuing_new_otp_invalidates_previous(self):
|
|
first = send_otp("09121234567")
|
|
cache.clear()
|
|
second = send_otp("09121234567")
|
|
first.refresh_from_db()
|
|
self.assertTrue(first.is_used)
|
|
self.assertFalse(second.is_used)
|
|
self.assertNotEqual(first.pk, second.pk)
|
|
|
|
|
|
@override_settings(SMS_BACKEND=CONSOLE_SMS)
|
|
class AuthAPITests(TestCase):
|
|
def setUp(self):
|
|
cache.clear()
|
|
self.client = APIClient()
|
|
|
|
def _send(self, phone):
|
|
return self.client.post("/api/auth/otp/send/", {"phone_number": phone}, format="json")
|
|
|
|
def _latest_otp(self, phone):
|
|
return OTPCode.objects.get(phone_number=phone, is_used=False)
|
|
|
|
def test_signup_flow_creates_teacher_with_profile(self):
|
|
r = self._send("09121234567")
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
otp = self._latest_otp("09121234567")
|
|
|
|
r = self.client.post(
|
|
"/api/auth/otp/verify/",
|
|
{
|
|
"phone_number": "09121234567",
|
|
"code": otp.code,
|
|
"full_name": "New Teacher",
|
|
"role": "teacher",
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertTrue(body["created"])
|
|
self.assertEqual(body["user"]["role"], "teacher")
|
|
self.assertIn("access", body["tokens"])
|
|
self.assertIn("refresh", body["tokens"])
|
|
|
|
u = User.objects.get(phone_number="09121234567")
|
|
self.assertTrue(TeacherProfile.objects.filter(user=u).exists())
|
|
self.assertFalse(u.teacher_profile.is_approved)
|
|
|
|
def test_signup_flow_creates_student_no_profile(self):
|
|
self._send("09121234567")
|
|
otp = self._latest_otp("09121234567")
|
|
r = self.client.post(
|
|
"/api/auth/otp/verify/",
|
|
{
|
|
"phone_number": "09121234567",
|
|
"code": otp.code,
|
|
"full_name": "New Student",
|
|
"role": "student",
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
u = User.objects.get(phone_number="09121234567")
|
|
self.assertEqual(u.role, Role.STUDENT)
|
|
self.assertFalse(TeacherProfile.objects.filter(user=u).exists())
|
|
|
|
def test_existing_user_login_does_not_require_signup_fields(self):
|
|
u = User.objects.create_user(
|
|
phone_number="09121234567", full_name="Existing", role=Role.STUDENT
|
|
)
|
|
self._send("09121234567")
|
|
otp = self._latest_otp("09121234567")
|
|
r = self.client.post(
|
|
"/api/auth/otp/verify/",
|
|
{"phone_number": "09121234567", "code": otp.code},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 200, r.content)
|
|
body = r.json()
|
|
self.assertFalse(body["created"])
|
|
self.assertEqual(body["user"]["id"], u.id)
|
|
|
|
def test_new_user_without_signup_fields_rejected(self):
|
|
self._send("09121234567")
|
|
otp = self._latest_otp("09121234567")
|
|
r = self.client.post(
|
|
"/api/auth/otp/verify/",
|
|
{"phone_number": "09121234567", "code": otp.code},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
body = r.json()
|
|
self.assertIn("full_name", body)
|
|
self.assertIn("role", body)
|
|
|
|
def test_verify_wrong_code_400(self):
|
|
self._send("09121234567")
|
|
r = self.client.post(
|
|
"/api/auth/otp/verify/",
|
|
{
|
|
"phone_number": "09121234567",
|
|
"code": "0000",
|
|
"full_name": "X",
|
|
"role": "student",
|
|
},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_phone_normalization_in_send(self):
|
|
r = self.client.post(
|
|
"/api/auth/otp/send/",
|
|
{"phone_number": "+989121234567"},
|
|
format="json",
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
self.assertEqual(r.json()["phone_number"], "09121234567")
|
|
self.assertTrue(OTPCode.objects.filter(phone_number="09121234567").exists())
|
|
|
|
def test_invalid_phone_400(self):
|
|
r = self.client.post(
|
|
"/api/auth/otp/send/", {"phone_number": "12345"}, format="json"
|
|
)
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
def test_cooldown_429(self):
|
|
self._send("09121234567")
|
|
r = self._send("09121234567")
|
|
self.assertEqual(r.status_code, 429)
|
|
self.assertIn("retry_after", r.json())
|
|
|
|
def test_me_requires_auth(self):
|
|
r = self.client.get("/api/auth/me/")
|
|
self.assertEqual(r.status_code, 401)
|
|
|
|
def test_me_with_bearer_token(self):
|
|
User.objects.create_user(
|
|
phone_number="09121234567", full_name="X", role=Role.STUDENT
|
|
)
|
|
self._send("09121234567")
|
|
otp = self._latest_otp("09121234567")
|
|
r = self.client.post(
|
|
"/api/auth/otp/verify/",
|
|
{"phone_number": "09121234567", "code": otp.code},
|
|
format="json",
|
|
)
|
|
access = r.json()["tokens"]["access"]
|
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
|
|
r2 = self.client.get("/api/auth/me/")
|
|
self.assertEqual(r2.status_code, 200)
|
|
self.assertEqual(r2.json()["phone_number"], "09121234567")
|