register and sign in pages
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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"])
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from users.mobile import normalize_mobile
|
||||
|
||||
|
||||
class NormalizeMobileTests(SimpleTestCase):
|
||||
def test_accepts_every_way_a_number_is_typed(self):
|
||||
for value in ["7712345", "+9607712345", "9607712345", "960 771 2345", "009607712345", "771-2345"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(normalize_mobile(value), "+9607712345")
|
||||
|
||||
def test_rejects_invalid_numbers(self):
|
||||
for value in ["", "123", "1712345", "77123456", "abcdefg", None]:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
normalize_mobile(value)
|
||||
@@ -0,0 +1,250 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from locations.models import Atoll, Island
|
||||
from users.models import OtpCode, RegistrationTicket, User
|
||||
|
||||
|
||||
class RegistrationFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.atoll = Atoll.objects.get(name="Faafu")
|
||||
self.island = Island.objects.get(name="Dharanboodhoo")
|
||||
self.other_atoll = Atoll.objects.create(name="Kaafu", code="K")
|
||||
self.other_island = Island.objects.create(atoll=self.other_atoll, name="Male")
|
||||
|
||||
# --- verifying the number -------------------------------------------
|
||||
def test_unknown_number_gets_a_code_without_being_told_anything(self):
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "otp")
|
||||
self.assertEqual(response.data["mobile"], "+9607700000")
|
||||
|
||||
otp = OtpCode.objects.get(mobile="+9607700000")
|
||||
self.assertEqual(otp.purpose, OtpCode.Purpose.REGISTRATION)
|
||||
self.assertIsNone(otp.user)
|
||||
|
||||
def test_verifying_an_unknown_number_hands_back_a_ticket(self):
|
||||
code = self._send_registration_code("7700000")
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "register")
|
||||
self.assertTrue(response.data["registration_token"])
|
||||
self.assertEqual(response.data["mobile"], "+9607700000")
|
||||
self.assertNotIn("token", response.data)
|
||||
|
||||
def test_verify_rejects_a_wrong_code(self):
|
||||
self._send_registration_code("7700000")
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": "000000"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["code"], "invalid_code")
|
||||
self.assertFalse(RegistrationTicket.objects.exists())
|
||||
|
||||
def test_a_code_signs_in_an_account_created_in_the_meantime(self):
|
||||
code = self._send_registration_code("7700000")
|
||||
User.objects.create_user(mobile="7700000")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "dashboard")
|
||||
self.assertFalse(RegistrationTicket.objects.exists())
|
||||
|
||||
# --- the form -------------------------------------------------------
|
||||
def test_registration_creates_a_pending_account(self):
|
||||
token = self._verified_token("7700000")
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["status"], "pending")
|
||||
# No token: registering does not sign anyone in.
|
||||
self.assertNotIn("token", response.data)
|
||||
|
||||
user = User.objects.get(mobile="+9607700000")
|
||||
self.assertEqual(user.status, User.Status.PENDING)
|
||||
self.assertEqual(user.full_name, "Ahmed Ali")
|
||||
self.assertEqual(user.idnumber, "WP123456")
|
||||
self.assertEqual(user.date_of_birth, date(1995, 4, 17))
|
||||
self.assertEqual(user.island, self.island)
|
||||
self.assertTrue(user.mobile_verified)
|
||||
self.assertFalse(user.has_usable_password())
|
||||
self.assertIsNotNone(user.terms_accepted_at)
|
||||
self.assertIsNotNone(user.policy_accepted_at)
|
||||
|
||||
def test_the_number_comes_from_the_token_not_the_form(self):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {"mobile": "7711111"}
|
||||
self.client.post(reverse("auth-register"), payload)
|
||||
|
||||
self.assertTrue(User.objects.filter(mobile="+9607700000").exists())
|
||||
self.assertFalse(User.objects.filter(mobile="+9607711111").exists())
|
||||
|
||||
def test_a_token_works_only_once(self):
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
User.objects.all().delete()
|
||||
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("registration_token", response.data["errors"])
|
||||
|
||||
def test_an_expired_token_is_refused(self):
|
||||
token = self._verified_token("7700000")
|
||||
RegistrationTicket.objects.filter(key=token).update(
|
||||
expires_at=timezone.now() - timedelta(minutes=1)
|
||||
)
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("registration_token", response.data["errors"])
|
||||
|
||||
def test_registration_requires_a_token(self):
|
||||
payload = self._form("nope")
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("registration_token", response.data["errors"])
|
||||
|
||||
def test_both_agreements_are_required(self):
|
||||
for field in ["terms_accepted", "policy_accepted"]:
|
||||
with self.subTest(field=field):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {field: False}
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn(field, response.data["errors"])
|
||||
self.assertFalse(User.objects.exists())
|
||||
|
||||
def test_island_must_belong_to_the_selected_atoll(self):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {"island": self.other_island.pk}
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("island", response.data["errors"])
|
||||
|
||||
def test_required_fields(self):
|
||||
token = self._verified_token("7700000")
|
||||
response = self.client.post(
|
||||
reverse("auth-register"), {"registration_token": token}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
for field in [
|
||||
"full_name",
|
||||
"idnumber",
|
||||
"date_of_birth",
|
||||
"atoll",
|
||||
"island",
|
||||
]:
|
||||
self.assertIn(field, response.data["errors"])
|
||||
|
||||
def test_future_date_of_birth_is_refused(self):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {
|
||||
"date_of_birth": (date.today() + timedelta(days=1)).isoformat()
|
||||
}
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("date_of_birth", response.data["errors"])
|
||||
|
||||
def test_cannot_register_a_number_that_already_has_an_account(self):
|
||||
token = self._verified_token("7700000")
|
||||
User.objects.create_user(mobile="7700000")
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("mobile", response.data["errors"])
|
||||
|
||||
# --- after submitting ------------------------------------------------
|
||||
def test_a_pending_applicant_can_sign_in_and_see_their_status(self):
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
start = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
|
||||
self.assertEqual(start.data["next"], "otp")
|
||||
|
||||
otp = OtpCode.objects.active().filter(mobile="+9607700000").first()
|
||||
self.assertIsNotNone(otp)
|
||||
self.assertEqual(otp.purpose, OtpCode.Purpose.LOGIN)
|
||||
|
||||
_, code = OtpCode.issue(
|
||||
mobile="+9607700000",
|
||||
purpose=OtpCode.Purpose.LOGIN,
|
||||
user=User.objects.get(mobile="+9607700000"),
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "dashboard")
|
||||
self.assertEqual(response.data["user"]["status"], "pending")
|
||||
|
||||
def test_approval_flips_the_status_and_records_the_reviewer(self):
|
||||
admin = User.objects.create_superuser(mobile="7711111", password="x")
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
user = User.objects.get(mobile="+9607700000")
|
||||
user.approve(reviewer=admin)
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.status, User.Status.APPROVED)
|
||||
self.assertTrue(user.is_approved)
|
||||
self.assertEqual(user.reviewed_by, admin)
|
||||
self.assertIsNotNone(user.reviewed_at)
|
||||
|
||||
def test_rejection_records_the_reason(self):
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
user = User.objects.get(mobile="+9607700000")
|
||||
user.reject(reason="Document unreadable")
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.status, User.Status.REJECTED)
|
||||
self.assertEqual(user.rejection_reason, "Document unreadable")
|
||||
|
||||
# --- helpers ---------------------------------------------------------
|
||||
def _send_registration_code(self, mobile: str) -> str:
|
||||
otp, code = OtpCode.issue(
|
||||
mobile=f"+960{mobile}", purpose=OtpCode.Purpose.REGISTRATION
|
||||
)
|
||||
self.assertIsNotNone(otp)
|
||||
return code
|
||||
|
||||
def _verified_token(self, mobile: str) -> str:
|
||||
code = self._send_registration_code(mobile)
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": mobile, "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
return response.data["registration_token"]
|
||||
|
||||
def _form(self, token: str) -> dict:
|
||||
return {
|
||||
"registration_token": token,
|
||||
"full_name": "Ahmed Ali",
|
||||
"idnumber": "wp123456",
|
||||
"date_of_birth": "1995-04-17",
|
||||
"atoll": self.atoll.pk,
|
||||
"island": self.island.pk,
|
||||
"terms_accepted": True,
|
||||
"policy_accepted": True,
|
||||
}
|
||||
|
||||
|
||||
class LocationsApiTests(TestCase):
|
||||
def test_atolls_are_public_and_include_islands(self):
|
||||
response = APIClient().get(reverse("atoll-list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {atoll["name"]: atoll for atoll in response.data}
|
||||
self.assertIn("Faafu", names)
|
||||
self.assertEqual(
|
||||
[island["name"] for island in names["Faafu"]["islands"]],
|
||||
["Dharanboodhoo"],
|
||||
)
|
||||
Reference in New Issue
Block a user