You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

467 lines
21 KiB

from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.contrib.auth import get_user_model
from django.contrib.auth.tokens import default_token_generator
from rest_framework.test import APIClient
from rest_framework import status
from apps.account.models.verification import VerificationDocument
User = get_user_model()
class Phase1AuthAndIdentityTests(TestCase):
"""
Automated test suite for Phase 1:
- User Registration & Immediate JWT Issuance
- JWT Token Lifecycle (Obtain, Refresh, Verify)
- User Profile (/api/v1/auth/me/) Retrieval & Patch Updates
- Verification Document Upload & Retrieval
- Password Recovery & Reset
- Role-Based Access Control (RBAC)
"""
def setUp(self):
self.client = APIClient()
self.user_password = "SecurePassword123!"
self.user = User.objects.create_user(
email="representative@mashhadcenter.org",
password=self.user_password,
fullname="Hassan Razavi",
user_type=User.UserType.INSTITUTION_ADMIN,
country="Iran",
city="Mashhad",
languages=["fa", "ar", "en"],
skills=["Diplomacy", "Arabic Translation"]
)
def test_user_registration_success(self):
payload = {
"email": "new.member@shiahub.org",
"password": "StrongMemberPassword123!",
"fullname": "Fatima Al-Zahra",
"user_type": "editor",
"languages": ["ar", "en"],
"skills": ["Content Management", "Graphic Design"],
"country": "Lebanon",
"city": "Beirut"
}
response = self.client.post("/api/v1/auth/register/", payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn("user", response.data)
self.assertIn("access", response.data)
self.assertIn("refresh", response.data)
self.assertEqual(response.data["user"]["email"], "new.member@shiahub.org")
self.assertEqual(response.data["user"]["fullname"], "Fatima Al-Zahra")
self.assertEqual(response.data["user"]["user_type"], "editor")
self.assertIn("editor", response.data["user"]["roles"])
def test_user_registration_duplicate_email(self):
payload = {
"email": "representative@mashhadcenter.org",
"password": "AnotherPassword123!",
"fullname": "Duplicate User"
}
response = self.client.post("/api/v1/auth/register/", payload, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_jwt_token_lifecycle(self):
# 1. Obtain Pair
obtain_resp = self.client.post("/api/v1/auth/token/", {
"email": "representative@mashhadcenter.org",
"password": self.user_password
}, format="json")
self.assertEqual(obtain_resp.status_code, status.HTTP_200_OK)
access_token = obtain_resp.data["access"]
refresh_token = obtain_resp.data["refresh"]
# 2. Verify
verify_resp = self.client.post("/api/v1/auth/token/verify/", {
"token": access_token
}, format="json")
self.assertEqual(verify_resp.status_code, status.HTTP_200_OK)
# 3. Refresh
refresh_resp = self.client.post("/api/v1/auth/token/refresh/", {
"refresh": refresh_token
}, format="json")
self.assertEqual(refresh_resp.status_code, status.HTTP_200_OK)
self.assertIn("access", refresh_resp.data)
def test_user_me_unauthenticated(self):
response = self.client.get("/api/v1/auth/me/")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_user_me_authenticated(self):
self.client.force_authenticate(user=self.user)
response = self.client.get("/api/v1/auth/me/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["email"], self.user.email)
self.assertEqual(response.data["fullname"], "Hassan Razavi")
self.assertIn("institution_admin", response.data["roles"])
self.assertIn("manage_institution_profile", response.data["permissions"])
self.assertEqual(response.data["verification_status"], "unverified")
self.assertFalse(response.data["is_verified"])
def test_user_me_patch_update(self):
self.client.force_authenticate(user=self.user)
patch_payload = {
"fullname": "Hassan M. Razavi",
"bio": "Senior Director of International Relations & Cultural Affairs.",
"city": "Tehran",
"skills": ["Cultural Diplomacy", "Conference Management"],
"languages": ["fa", "ar", "en", "ur"]
}
response = self.client.patch("/api/v1/auth/me/", patch_payload, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["fullname"], "Hassan M. Razavi")
self.assertEqual(response.data["bio"], patch_payload["bio"])
self.assertEqual(response.data["city"], "Tehran")
self.assertEqual(response.data["languages"], ["fa", "ar", "en", "ur"])
# Check DB update
self.user.refresh_from_db()
self.assertEqual(self.user.fullname, "Hassan M. Razavi")
self.assertEqual(self.user.info, patch_payload["bio"])
def test_verification_document_upload_and_status(self):
self.client.force_authenticate(user=self.user)
# 1. Upload Document
test_file = SimpleUploadedFile(
"institution_license.pdf",
b"%PDF-1.4 Mock License Content",
content_type="application/pdf"
)
upload_resp = self.client.post("/api/v1/auth/documents/", {
"document_type": "institution_license",
"title": "Official Cultural Center License",
"document_file": test_file
}, format="multipart")
self.assertEqual(upload_resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(upload_resp.data["status"], "pending")
self.assertEqual(upload_resp.data["document_type"], "institution_license")
# 2. List Documents
list_resp = self.client.get("/api/v1/auth/documents/")
self.assertEqual(list_resp.status_code, status.HTTP_200_OK)
self.assertEqual(len(list_resp.data), 1)
# 3. Check User Me Verification Status
me_resp = self.client.get("/api/v1/auth/me/")
self.assertEqual(me_resp.data["verification_status"], "pending")
# 4. Admin Approves Document
doc = VerificationDocument.objects.get(id=upload_resp.data["id"])
doc.status = VerificationDocument.Status.VERIFIED
doc.save()
me_resp_after = self.client.get("/api/v1/auth/me/")
self.assertEqual(me_resp_after.data["verification_status"], "verified")
self.assertTrue(me_resp_after.data["is_verified"])
def test_password_recovery_and_reset_flow(self):
# 1. Recovery request for existing user
rec_resp = self.client.post("/api/v1/auth/recover-password/", {
"email": "representative@mashhadcenter.org"
}, format="json")
self.assertEqual(rec_resp.status_code, status.HTTP_200_OK)
# 2. Recovery request for non-existing user (silent 200 for security)
rec_non_exist = self.client.post("/api/v1/auth/recover-password/", {
"email": "unknown@example.com"
}, format="json")
self.assertEqual(rec_non_exist.status_code, status.HTTP_200_OK)
# 3. Generate token & reset password
token = default_token_generator.make_token(self.user)
reset_resp = self.client.post("/api/v1/auth/reset-password/", {
"email": "representative@mashhadcenter.org",
"token": token,
"new_password": "NewBrandSecurePass123!"
}, format="json")
self.assertEqual(reset_resp.status_code, status.HTTP_200_OK)
# 4. Verify login with new password
login_resp = self.client.post("/api/v1/auth/token/", {
"email": "representative@mashhadcenter.org",
"password": "NewBrandSecurePass123!"
}, format="json")
self.assertEqual(login_resp.status_code, status.HTTP_200_OK)
self.assertIn("access", login_resp.data)
def test_rbac_user_properties(self):
self.assertTrue(self.user.is_institution_admin)
self.assertTrue(self.user.is_editor)
self.assertFalse(self.user.is_super_admin)
super_admin = User.objects.create_superuser(
email="superadmin@razavi.org",
password="SuperPassword123!"
)
self.assertTrue(super_admin.is_super_admin)
self.assertTrue(super_admin.is_regional_admin)
self.assertTrue(super_admin.is_institution_admin)
self.assertTrue(super_admin.is_editor)
def test_user_profile_extended_fields_crud(self):
self.client.force_authenticate(user=self.user)
payload = {
"first_name": "Ali",
"last_name": "Rezaei",
"title": "Dr",
"rank": "Professor",
"institution": "Ferdowsi University",
"website": "https://example.com/scholar",
"phone": "09123456789",
"address": "Mashhad, Iran"
}
# PATCH update
response = self.client.patch("/api/v1/auth/me/", payload, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["first_name"], "Ali")
self.assertEqual(response.data["last_name"], "Rezaei")
self.assertEqual(response.data["fullname"], "Ali Rezaei")
self.assertEqual(response.data["title"], "Dr")
self.assertEqual(response.data["rank"], "Professor")
self.assertEqual(response.data["institution"], "Ferdowsi University")
self.assertEqual(response.data["website"], "https://example.com/scholar")
self.assertEqual(response.data["phone"], "09123456789")
self.assertEqual(response.data["address"], "Mashhad, Iran")
# GET retrieval
get_resp = self.client.get("/api/v1/auth/me/")
self.assertEqual(get_resp.status_code, status.HTTP_200_OK)
self.assertEqual(get_resp.data["first_name"], "Ali")
self.assertEqual(get_resp.data["last_name"], "Rezaei")
self.assertEqual(get_resp.data["title"], "Dr")
self.assertEqual(get_resp.data["rank"], "Professor")
self.assertEqual(get_resp.data["institution"], "Ferdowsi University")
self.assertEqual(get_resp.data["website"], "https://example.com/scholar")
self.assertEqual(get_resp.data["phone"], "09123456789")
self.assertEqual(get_resp.data["address"], "Mashhad, Iran")
class TwoFactorAuthenticationTests(TestCase):
"""
Automated test suite for Two-Factor Authentication (2FA):
- User self-toggle ON/OFF (/api/v1/auth/2fa/toggle/ and /api/v1/auth/me/)
- Email OTP 2FA challenge & verification (/api/v1/auth/token/ -> /api/v1/auth/2fa/verify/)
- Authenticator App (TOTP RFC 6238) setup, QR code, and verification
- Single-use 8-character Backup Recovery Codes verification & consumption
- Admin Force 2FA permission (`require_2fa` on User.custom_permissions and Role.require_2fa)
preventing login without 2FA and blocking user from disabling 2FA
"""
def setUp(self):
self.client = APIClient()
self.password = "StrongPass2FA#2026"
self.user = User.objects.create_user(
email="scholar2fa@mashhadcenter.org",
password=self.password,
fullname="Dr. Mahdi Ansari",
user_type=User.UserType.CLIENT,
)
self.admin = User.objects.create_superuser(
email="admin2fa@mashhadcenter.org",
password=self.password,
fullname="System Security Admin",
)
def test_user_self_toggle_email_2fa_and_login_flow(self):
# 1. Initially 2FA is not required; login issues JWT tokens directly
login_initial = self.client.post("/api/v1/auth/token/", {
"email": self.user.email,
"password": self.password,
}, format="json")
self.assertEqual(login_initial.status_code, status.HTTP_200_OK)
self.assertIn("access", login_initial.data)
self.assertFalse(login_initial.data.get("requires_2fa", False))
# 2. User enables Email 2FA for themselves
self.client.force_authenticate(user=self.user)
toggle_on = self.client.post("/api/v1/auth/2fa/toggle/", {
"enabled": True,
"method": "email",
}, format="json")
self.assertEqual(toggle_on.status_code, status.HTTP_200_OK)
self.assertTrue(toggle_on.data["two_factor_enabled"])
self.assertTrue(toggle_on.data["is_2fa_required"])
self.assertFalse(toggle_on.data["is_2fa_forced_by_admin"])
# 3. Now logging in with password returns 2FA challenge instead of JWT tokens
self.client.force_authenticate(user=None)
login_2fa = self.client.post("/api/v1/auth/token/", {
"email": self.user.email,
"password": self.password,
}, format="json")
self.assertEqual(login_2fa.status_code, status.HTTP_200_OK)
self.assertTrue(login_2fa.data.get("requires_2fa"))
self.assertNotIn("access", login_2fa.data)
temp_token = login_2fa.data["temp_token"]
# Retrieve the generated OTP from RedisManager
from utils.redis import RedisManager
redis_mgr = RedisManager()
otp_code = redis_mgr.get_2fa_email_otp(temp_token)
self.assertIsNotNone(otp_code)
self.assertEqual(len(otp_code), 6)
# 4. Wrong OTP fails with 400
bad_verify = self.client.post("/api/v1/auth/2fa/verify/", {
"temp_token": temp_token,
"code": "000000",
"method": "email",
}, format="json")
self.assertEqual(bad_verify.status_code, status.HTTP_400_BAD_REQUEST)
# 5. Correct OTP succeeds and issues JWT access + refresh tokens
good_verify = self.client.post("/api/v1/auth/2fa/verify/", {
"temp_token": temp_token,
"code": otp_code,
"method": "email",
}, format="json")
self.assertEqual(good_verify.status_code, status.HTTP_200_OK)
self.assertIn("access", good_verify.data)
self.assertIn("refresh", good_verify.data)
self.assertEqual(good_verify.data["user"]["email"], self.user.email)
# 6. User can toggle 2FA OFF for themselves when not forced by admin
self.client.force_authenticate(user=self.user)
toggle_off = self.client.post("/api/v1/auth/2fa/toggle/", {
"enabled": False,
}, format="json")
self.assertEqual(toggle_off.status_code, status.HTTP_200_OK)
self.assertFalse(toggle_off.data["two_factor_enabled"])
self.assertFalse(toggle_off.data["is_2fa_required"])
def test_admin_force_2fa_locks_user_and_enforces_2fa_on_login(self):
# 1. Admin forces 2FA on user via AdminUserDetailView PATCH
self.client.force_authenticate(user=self.admin)
admin_patch = self.client.patch(f"/api/v1/account/admin/users/{self.user.id}/", {
"require_2fa": True,
}, format="json")
self.assertEqual(admin_patch.status_code, status.HTTP_200_OK)
self.assertTrue(admin_patch.data["require_2fa"])
self.assertTrue(admin_patch.data["is_2fa_forced_by_admin"])
self.assertTrue(admin_patch.data["is_2fa_required"])
self.user.refresh_from_db()
self.assertTrue(self.user.is_2fa_forced_by_admin())
self.assertTrue(self.user.is_2fa_required())
# 2. User cannot log in without completing 2FA
self.client.force_authenticate(user=None)
login_resp = self.client.post("/api/v1/auth/token/", {
"email": self.user.email,
"password": self.password,
}, format="json")
self.assertEqual(login_resp.status_code, status.HTTP_200_OK)
self.assertTrue(login_resp.data["requires_2fa"])
self.assertTrue(login_resp.data["is_forced_by_admin"])
self.assertNotIn("access", login_resp.data)
# 3. User cannot disable 2FA via /api/v1/auth/2fa/toggle/ (403 Forbidden)
self.client.force_authenticate(user=self.user)
disable_toggle = self.client.post("/api/v1/auth/2fa/toggle/", {
"enabled": False,
}, format="json")
self.assertEqual(disable_toggle.status_code, status.HTTP_403_FORBIDDEN)
# 4. User cannot disable 2FA via /api/v1/auth/2fa/disable/ (403 Forbidden)
disable_view = self.client.post("/api/v1/auth/2fa/disable/", {
"password": self.password,
}, format="json")
self.assertEqual(disable_view.status_code, status.HTTP_403_FORBIDDEN)
# 5. User cannot disable 2FA via PATCH /api/v1/auth/me/ (403 PermissionDenied / 400 ValidationError)
me_patch = self.client.patch("/api/v1/auth/me/", {
"two_factor_enabled": False,
}, format="json")
self.assertIn(me_patch.status_code, (status.HTTP_400_BAD_REQUEST, status.HTTP_403_FORBIDDEN))
# 6. Admin un-forces require_2fa -> user can now toggle 2FA OFF
self.client.force_authenticate(user=self.admin)
admin_unforce = self.client.patch(f"/api/v1/account/admin/users/{self.user.id}/", {
"require_2fa": False,
}, format="json")
self.assertEqual(admin_unforce.status_code, status.HTTP_200_OK)
self.assertFalse(admin_unforce.data["is_2fa_forced_by_admin"])
self.user.refresh_from_db()
self.client.force_authenticate(user=self.user)
user_toggle_off = self.client.post("/api/v1/auth/2fa/toggle/", {
"enabled": False,
}, format="json")
self.assertEqual(user_toggle_off.status_code, status.HTTP_200_OK)
self.assertFalse(user_toggle_off.data["is_2fa_required"])
def test_totp_setup_confirm_and_backup_codes_login(self):
from utils.totp import get_totp_code
self.client.force_authenticate(user=self.user)
# 1. Initiate TOTP setup
init_resp = self.client.post("/api/v1/auth/2fa/setup/initiate/", {
"method": "totp",
}, format="json")
self.assertEqual(init_resp.status_code, status.HTTP_200_OK)
self.assertIn("secret", init_resp.data)
self.assertIn("qr_code_data_url", init_resp.data)
secret = init_resp.data["secret"]
# 2. Confirm TOTP setup with live RFC 6238 code
live_totp = get_totp_code(secret)
confirm_resp = self.client.post("/api/v1/auth/2fa/setup/confirm/", {
"method": "totp",
"code": live_totp,
}, format="json")
self.assertEqual(confirm_resp.status_code, status.HTTP_200_OK)
self.assertTrue(confirm_resp.data["two_factor_enabled"])
self.assertEqual(confirm_resp.data["two_factor_method"], "totp")
backup_codes = confirm_resp.data["backup_codes"]
self.assertEqual(len(backup_codes), 8)
# 3. Login with TOTP code
self.client.force_authenticate(user=None)
login_challenge = self.client.post("/api/v1/auth/token/", {
"email": self.user.email,
"password": self.password,
}, format="json")
self.assertTrue(login_challenge.data["requires_2fa"])
self.assertEqual(login_challenge.data["method"], "totp")
verify_totp = self.client.post("/api/v1/auth/2fa/verify/", {
"temp_token": login_challenge.data["temp_token"],
"code": get_totp_code(secret),
"method": "totp",
}, format="json")
self.assertEqual(verify_totp.status_code, status.HTTP_200_OK)
self.assertIn("access", verify_totp.data)
# 4. Login with a single-use Backup Code
login_challenge_2 = self.client.post("/api/v1/auth/token/", {
"email": self.user.email,
"password": self.password,
}, format="json")
first_backup_code = backup_codes[0]
verify_backup = self.client.post("/api/v1/auth/2fa/verify/", {
"temp_token": login_challenge_2.data["temp_token"],
"code": first_backup_code,
"method": "backup_code",
}, format="json")
self.assertEqual(verify_backup.status_code, status.HTTP_200_OK)
self.assertIn("access", verify_backup.data)
# 5. Re-using the same consumed backup code fails
login_challenge_3 = self.client.post("/api/v1/auth/token/", {
"email": self.user.email,
"password": self.password,
}, format="json")
reuse_backup = self.client.post("/api/v1/auth/2fa/verify/", {
"temp_token": login_challenge_3.data["temp_token"],
"code": first_backup_code,
"method": "backup_code",
}, format="json")
self.assertEqual(reuse_backup.status_code, status.HTTP_400_BAD_REQUEST)