feat: email verification, merchant/store password reset, seed gap fix
Some checks failed
Some checks failed
- Add EmailVerificationToken and UserPasswordResetToken models with migration - Add email verification flow: verify-email page route, resend-verification API - Block login for unverified users (EmailNotVerifiedException in auth_service) - Add forgot-password/reset-password endpoints for merchant and store auth - Add "Forgot Password?" links to merchant and store login pages - Send welcome email with verification link on merchant creation - Seed email_verification and merchant_password_reset email templates - Fix db-reset Makefile to run all init-prod seed scripts - Add UserAuthService to satisfy architecture validation rules - Add 52 new tests (unit + integration) with full coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
# app/modules/tenancy/tests/integration/test_email_verification_routes.py
|
||||
"""
|
||||
Integration tests for email verification routes.
|
||||
|
||||
Tests:
|
||||
- GET /verify-email?token={token} (page route)
|
||||
- POST /api/v1/merchants/resend-verification (API route)
|
||||
- Login blocked for unverified email
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, User
|
||||
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
MERCHANT_AUTH_BASE = "/api/v1/merchants/auth"
|
||||
RESEND_BASE = "/api/v1/merchants/resend-verification"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ev_unverified_user(db):
|
||||
"""Create an unverified user for email verification tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"unverified_{uid}@test.com",
|
||||
username=f"unverified_{uid}",
|
||||
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ev_verified_user(db):
|
||||
"""Create a verified user for comparison tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"verified_{uid}@test.com",
|
||||
username=f"verified_{uid}",
|
||||
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ev_merchant(db, ev_verified_user):
|
||||
"""Create a merchant for the verified user."""
|
||||
merchant = Merchant(
|
||||
name="Verification Test Merchant",
|
||||
owner_user_id=ev_verified_user.id,
|
||||
contact_email=ev_verified_user.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ev_unverified_merchant(db, ev_unverified_user):
|
||||
"""Create a merchant for the unverified user."""
|
||||
merchant = Merchant(
|
||||
name="Unverified Owner Merchant",
|
||||
owner_user_id=ev_unverified_user.id,
|
||||
contact_email=ev_unverified_user.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GET /verify-email Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestVerifyEmailPage:
|
||||
"""Tests for GET /verify-email?token={token}."""
|
||||
|
||||
def test_verify_email_success(self, client, db, ev_unverified_user):
|
||||
"""Valid token verifies the user's email."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
|
||||
db.commit()
|
||||
|
||||
response = client.get(f"/verify-email?token={plaintext}")
|
||||
assert response.status_code == 200
|
||||
assert "Email Verified" in response.text
|
||||
|
||||
# User should now be verified
|
||||
db.refresh(ev_unverified_user)
|
||||
assert ev_unverified_user.is_email_verified is True
|
||||
|
||||
def test_verify_email_invalid_token(self, client):
|
||||
"""Invalid token shows error page."""
|
||||
response = client.get("/verify-email?token=invalid-token-value")
|
||||
assert response.status_code == 400
|
||||
assert "Verification Failed" in response.text
|
||||
|
||||
def test_verify_email_expired_token(self, client, db, ev_unverified_user):
|
||||
"""Expired token shows error page."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
|
||||
db.commit()
|
||||
|
||||
# Manually expire the token
|
||||
token = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
token.expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
response = client.get(f"/verify-email?token={plaintext}")
|
||||
assert response.status_code == 400
|
||||
assert "Verification Failed" in response.text
|
||||
|
||||
def test_verify_email_used_token(self, client, db, ev_unverified_user):
|
||||
"""Already-used token shows error page."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
|
||||
db.commit()
|
||||
|
||||
# Use the token first
|
||||
token = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
token.mark_used(db)
|
||||
db.commit()
|
||||
|
||||
response = client.get(f"/verify-email?token={plaintext}")
|
||||
assert response.status_code == 400
|
||||
assert "Verification Failed" in response.text
|
||||
|
||||
def test_verify_email_missing_token_param(self, client):
|
||||
"""Missing token parameter returns 422."""
|
||||
response = client.get("/verify-email")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# POST /resend-verification Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestResendVerification:
|
||||
"""Tests for POST /api/v1/merchants/resend-verification."""
|
||||
|
||||
def test_resend_always_returns_success(self, client, ev_unverified_user):
|
||||
"""Endpoint always returns success (anti-enumeration)."""
|
||||
response = client.post(
|
||||
RESEND_BASE,
|
||||
json={"email": ev_unverified_user.email},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
assert "verification link has been sent" in data["message"]
|
||||
|
||||
def test_resend_nonexistent_email(self, client):
|
||||
"""Non-existent email still returns success (anti-enumeration)."""
|
||||
response = client.post(
|
||||
RESEND_BASE,
|
||||
json={"email": "nonexistent@test.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
def test_resend_already_verified_email(self, client, ev_verified_user):
|
||||
"""Already-verified email returns success but doesn't send."""
|
||||
response = client.post(
|
||||
RESEND_BASE,
|
||||
json={"email": ev_verified_user.email},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Login Blocked for Unverified Email Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestLoginBlockedUnverifiedEmail:
|
||||
"""Tests that login is blocked for users with unverified email."""
|
||||
|
||||
def test_merchant_login_blocked_unverified(self, client, ev_unverified_user, ev_unverified_merchant):
|
||||
"""Merchant login should fail with EMAIL_NOT_VERIFIED for unverified user."""
|
||||
response = client.post(
|
||||
f"{MERCHANT_AUTH_BASE}/login",
|
||||
json={
|
||||
"email_or_username": ev_unverified_user.username,
|
||||
"password": "testpass123", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert data.get("error_code") == "EMAIL_NOT_VERIFIED"
|
||||
|
||||
def test_merchant_login_allowed_verified(self, client, ev_verified_user, ev_merchant):
|
||||
"""Merchant login should succeed for verified user."""
|
||||
response = client.post(
|
||||
f"{MERCHANT_AUTH_BASE}/login",
|
||||
json={
|
||||
"email_or_username": ev_verified_user.username,
|
||||
"password": "testpass123", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
|
||||
def test_verify_then_login(self, client, db, ev_unverified_user, ev_unverified_merchant):
|
||||
"""After verifying email, login should succeed."""
|
||||
# Create verification token and verify
|
||||
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
|
||||
db.commit()
|
||||
|
||||
verify_response = client.get(f"/verify-email?token={plaintext}")
|
||||
assert verify_response.status_code == 200
|
||||
|
||||
# Now login should work
|
||||
login_response = client.post(
|
||||
f"{MERCHANT_AUTH_BASE}/login",
|
||||
json={
|
||||
"email_or_username": ev_unverified_user.username,
|
||||
"password": "testpass123", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
assert "access_token" in login_response.json()
|
||||
@@ -35,6 +35,7 @@ def ma_owner(db):
|
||||
hashed_password=auth.hash_password("mapass123"),
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -71,6 +72,7 @@ def ma_non_merchant_user(db):
|
||||
hashed_password=auth.hash_password("nonmerch123"),
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# app/modules/tenancy/tests/integration/test_merchant_password_reset.py
|
||||
"""
|
||||
Integration tests for merchant password reset flow.
|
||||
|
||||
Tests:
|
||||
- POST /api/v1/merchants/auth/forgot-password
|
||||
- POST /api/v1/merchants/auth/reset-password
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, User
|
||||
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
BASE = "/api/v1/merchants/auth"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mpr_user(db):
|
||||
"""Create a merchant owner user for password reset tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"mpr_{uid}@test.com",
|
||||
username=f"mpr_{uid}",
|
||||
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mpr_merchant(db, mpr_user):
|
||||
"""Create a merchant for the test user."""
|
||||
merchant = Merchant(
|
||||
name="Password Reset Test Merchant",
|
||||
owner_user_id=mpr_user.id,
|
||||
contact_email=mpr_user.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mpr_inactive_user(db):
|
||||
"""Create an inactive user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"mpri_{uid}@test.com",
|
||||
username=f"mpri_{uid}",
|
||||
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=False,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Forgot Password Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestMerchantForgotPassword:
|
||||
"""Tests for POST /api/v1/merchants/auth/forgot-password."""
|
||||
|
||||
def test_forgot_password_success(self, client, mpr_user):
|
||||
"""Valid email returns success message."""
|
||||
response = client.post(
|
||||
f"{BASE}/forgot-password",
|
||||
json={"email": mpr_user.email},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
assert "password reset link" in data["message"]
|
||||
|
||||
def test_forgot_password_nonexistent_email(self, client):
|
||||
"""Non-existent email still returns success (anti-enumeration)."""
|
||||
response = client.post(
|
||||
f"{BASE}/forgot-password",
|
||||
json={"email": "doesnotexist@test.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
def test_forgot_password_inactive_user(self, client, mpr_inactive_user):
|
||||
"""Inactive user email still returns success (anti-enumeration)."""
|
||||
response = client.post(
|
||||
f"{BASE}/forgot-password",
|
||||
json={"email": mpr_inactive_user.email},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Reset Password Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestMerchantResetPassword:
|
||||
"""Tests for POST /api/v1/merchants/auth/reset-password."""
|
||||
|
||||
def test_reset_password_success(self, client, db, mpr_user, mpr_merchant):
|
||||
"""Valid token resets password and allows login with new password."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newpass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "Password reset successfully" in data["message"]
|
||||
|
||||
# Verify can login with new password
|
||||
login_resp = client.post(
|
||||
f"{BASE}/login",
|
||||
json={
|
||||
"email_or_username": mpr_user.username,
|
||||
"password": "newpass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
assert "access_token" in login_resp.json()
|
||||
|
||||
def test_reset_password_old_password_fails(self, client, db, mpr_user, mpr_merchant):
|
||||
"""After reset, old password should not work."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
|
||||
db.commit()
|
||||
|
||||
client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newpass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
|
||||
# Old password should fail
|
||||
login_resp = client.post(
|
||||
f"{BASE}/login",
|
||||
json={
|
||||
"email_or_username": mpr_user.username,
|
||||
"password": "oldpass123", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert login_resp.status_code == 401
|
||||
|
||||
def test_reset_password_invalid_token(self, client):
|
||||
"""Invalid token returns error."""
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": "invalid-token-value",
|
||||
"new_password": "newpass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data.get("error_code") == "INVALID_TOKEN"
|
||||
|
||||
def test_reset_password_expired_token(self, client, db, mpr_user):
|
||||
"""Expired token returns error."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
|
||||
db.commit()
|
||||
|
||||
# Manually expire the token
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
token.expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newpass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_reset_password_used_token(self, client, db, mpr_user):
|
||||
"""Already-used token returns error."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
|
||||
db.commit()
|
||||
|
||||
# Use the token
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
token.mark_used(db)
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newpass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,212 @@
|
||||
# app/modules/tenancy/tests/integration/test_store_password_reset.py
|
||||
"""
|
||||
Integration tests for store user password reset flow.
|
||||
|
||||
Tests:
|
||||
- POST /api/v1/store/auth/forgot-password
|
||||
- POST /api/v1/store/auth/reset-password
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
|
||||
from app.modules.tenancy.models.store import StoreUserType
|
||||
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
BASE = "/api/v1/store/auth"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spr_owner(db):
|
||||
"""Create a store owner user for password reset tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"spr_{uid}@test.com",
|
||||
username=f"spr_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spr_merchant(db, spr_owner):
|
||||
"""Create a merchant for the store owner."""
|
||||
merchant = Merchant(
|
||||
name="Store PW Reset Test Merchant",
|
||||
owner_user_id=spr_owner.id,
|
||||
contact_email=spr_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spr_store(db, spr_merchant):
|
||||
"""Create a store for password reset tests."""
|
||||
uid = uuid.uuid4().hex[:6].upper()
|
||||
store = Store(
|
||||
merchant_id=spr_merchant.id,
|
||||
store_code=f"SPR{uid}",
|
||||
subdomain=f"spr-{uid.lower()}",
|
||||
name="Store PW Reset Test Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spr_store_user(db, spr_owner, spr_store):
|
||||
"""Associate the owner with the store."""
|
||||
store_user = StoreUser(
|
||||
user_id=spr_owner.id,
|
||||
store_id=spr_store.id,
|
||||
user_type=StoreUserType.OWNER.value,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
db.refresh(store_user)
|
||||
return store_user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Forgot Password Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestStoreForgotPassword:
|
||||
"""Tests for POST /api/v1/store/auth/forgot-password."""
|
||||
|
||||
def test_forgot_password_success(self, client, spr_owner):
|
||||
"""Valid email returns success message."""
|
||||
response = client.post(
|
||||
f"{BASE}/forgot-password",
|
||||
json={"email": spr_owner.email},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
assert "password reset link" in data["message"]
|
||||
|
||||
def test_forgot_password_nonexistent_email(self, client):
|
||||
"""Non-existent email still returns success (anti-enumeration)."""
|
||||
response = client.post(
|
||||
f"{BASE}/forgot-password",
|
||||
json={"email": "nonexistent_store@test.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Reset Password Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestStoreResetPassword:
|
||||
"""Tests for POST /api/v1/store/auth/reset-password."""
|
||||
|
||||
def test_reset_password_success(self, client, db, spr_owner, spr_store, spr_store_user):
|
||||
"""Valid token resets password and allows login with new password."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, spr_owner.id)
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newstorepass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "Password reset successfully" in data["message"]
|
||||
|
||||
# Verify can login with new password
|
||||
login_resp = client.post(
|
||||
f"{BASE}/login",
|
||||
json={
|
||||
"email_or_username": spr_owner.username,
|
||||
"password": "newstorepass456", # noqa: SEC001
|
||||
"store_code": spr_store.store_code,
|
||||
},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
assert "access_token" in login_resp.json()
|
||||
|
||||
def test_reset_password_invalid_token(self, client):
|
||||
"""Invalid token returns error."""
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": "invalid-token-value",
|
||||
"new_password": "newstorepass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data.get("error_code") == "INVALID_TOKEN"
|
||||
|
||||
def test_reset_password_expired_token(self, client, db, spr_owner):
|
||||
"""Expired token returns error."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, spr_owner.id)
|
||||
db.commit()
|
||||
|
||||
# Manually expire the token
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
token.expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newstorepass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_reset_password_used_token(self, client, db, spr_owner):
|
||||
"""Already-used token returns error."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, spr_owner.id)
|
||||
db.commit()
|
||||
|
||||
# Use the token
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
token.mark_used(db)
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/reset-password",
|
||||
json={
|
||||
"token": plaintext,
|
||||
"new_password": "newstorepass456", # noqa: SEC001
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
142
app/modules/tenancy/tests/unit/test_email_verification_token.py
Normal file
142
app/modules/tenancy/tests/unit/test_email_verification_token.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# app/modules/tenancy/tests/unit/test_email_verification_token.py
|
||||
"""Unit tests for EmailVerificationToken model."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestEmailVerificationToken:
|
||||
"""Test EmailVerificationToken model CRUD operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def evk_user(self, db):
|
||||
"""Create a test user for token tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"evk_{uid}@test.com",
|
||||
username=f"evk_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
def test_create_for_user(self, db, evk_user):
|
||||
"""Test creating a verification token returns a plaintext token."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
assert plaintext is not None
|
||||
assert len(plaintext) > 20 # secrets.token_urlsafe(32) produces ~43 chars
|
||||
|
||||
def test_find_valid_token(self, db, evk_user):
|
||||
"""Test finding a valid token by plaintext."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
found = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
assert found is not None
|
||||
assert found.user_id == evk_user.id
|
||||
assert found.used_at is None
|
||||
|
||||
def test_find_valid_token_wrong_token(self, db, evk_user):
|
||||
"""Test that a wrong token returns None."""
|
||||
EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
found = EmailVerificationToken.find_valid_token(db, "wrong-token-value")
|
||||
assert found is None
|
||||
|
||||
def test_mark_used(self, db, evk_user):
|
||||
"""Test marking a token as used."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
token = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
assert token is not None
|
||||
|
||||
token.mark_used(db)
|
||||
db.commit()
|
||||
|
||||
assert token.used_at is not None
|
||||
|
||||
# Token should no longer be findable
|
||||
found = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
assert found is None
|
||||
|
||||
def test_expired_token_not_found(self, db, evk_user):
|
||||
"""Test that an expired token is not returned by find_valid_token."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
# Manually expire the token
|
||||
token = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
token.expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
found = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
assert found is None
|
||||
|
||||
def test_create_invalidates_old_tokens(self, db, evk_user):
|
||||
"""Test that creating a new token invalidates old unused tokens."""
|
||||
plaintext_1 = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
plaintext_2 = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
# Old token should be gone (deleted)
|
||||
found_1 = EmailVerificationToken.find_valid_token(db, plaintext_1)
|
||||
assert found_1 is None
|
||||
|
||||
# New token should work
|
||||
found_2 = EmailVerificationToken.find_valid_token(db, plaintext_2)
|
||||
assert found_2 is not None
|
||||
|
||||
def test_hash_token_deterministic(self):
|
||||
"""Test that hash_token produces consistent results."""
|
||||
token = "test-token-value"
|
||||
hash1 = EmailVerificationToken.hash_token(token)
|
||||
hash2 = EmailVerificationToken.hash_token(token)
|
||||
assert hash1 == hash2
|
||||
assert len(hash1) == 64 # SHA256 hex digest
|
||||
|
||||
def test_hash_token_different_inputs(self):
|
||||
"""Test that different inputs produce different hashes."""
|
||||
hash1 = EmailVerificationToken.hash_token("token-a")
|
||||
hash2 = EmailVerificationToken.hash_token("token-b")
|
||||
assert hash1 != hash2
|
||||
|
||||
def test_token_expiry_is_24_hours(self, db, evk_user):
|
||||
"""Test that tokens are created with 24-hour expiry."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
token = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
assert token is not None
|
||||
|
||||
# Expiry should be approximately 24 hours from now
|
||||
expected = datetime.utcnow() + timedelta(hours=24)
|
||||
delta = abs((token.expires_at - expected).total_seconds())
|
||||
assert delta < 60 # Within 60 seconds tolerance
|
||||
|
||||
def test_token_repr(self, db, evk_user):
|
||||
"""Test token __repr__."""
|
||||
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
|
||||
db.commit()
|
||||
|
||||
token = EmailVerificationToken.find_valid_token(db, plaintext)
|
||||
repr_str = repr(token)
|
||||
assert "EmailVerificationToken" in repr_str
|
||||
assert str(token.user_id) in repr_str
|
||||
164
app/modules/tenancy/tests/unit/test_user_auth_service.py
Normal file
164
app/modules/tenancy/tests/unit/test_user_auth_service.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# app/modules/tenancy/tests/unit/test_user_auth_service.py
|
||||
"""Unit tests for UserAuthService."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.exceptions import InvalidTokenException
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
|
||||
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
|
||||
from app.modules.tenancy.services.user_auth_service import UserAuthService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestUserAuthServicePasswordReset:
|
||||
"""Test UserAuthService password reset methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return UserAuthService()
|
||||
|
||||
@pytest.fixture
|
||||
def uas_user(self, db):
|
||||
"""Create a test user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"uas_{uid}@test.com",
|
||||
username=f"uas_{uid}",
|
||||
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def uas_inactive_user(self, db):
|
||||
"""Create an inactive test user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"uasi_{uid}@test.com",
|
||||
username=f"uasi_{uid}",
|
||||
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
|
||||
role="store",
|
||||
is_active=False,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
def test_request_password_reset_success(self, service, db, uas_user):
|
||||
"""Valid active user gets a reset token."""
|
||||
user, token = service.request_password_reset(db, uas_user.email)
|
||||
assert user is not None
|
||||
assert user.id == uas_user.id
|
||||
assert token is not None
|
||||
assert len(token) > 20
|
||||
|
||||
def test_request_password_reset_nonexistent_email(self, service, db):
|
||||
"""Non-existent email returns (None, None)."""
|
||||
user, token = service.request_password_reset(db, "nonexistent@test.com")
|
||||
assert user is None
|
||||
assert token is None
|
||||
|
||||
def test_request_password_reset_inactive_user(self, service, db, uas_inactive_user):
|
||||
"""Inactive user returns (None, None)."""
|
||||
user, token = service.request_password_reset(db, uas_inactive_user.email)
|
||||
assert user is None
|
||||
assert token is None
|
||||
|
||||
def test_reset_password_success(self, service, db, uas_user):
|
||||
"""Valid token resets password."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, uas_user.id)
|
||||
db.commit()
|
||||
|
||||
user = service.reset_password(db, plaintext, "newpass456") # noqa: SEC001
|
||||
db.commit()
|
||||
|
||||
assert user.id == uas_user.id
|
||||
# Verify the password was actually changed
|
||||
from middleware.auth import AuthManager
|
||||
auth = AuthManager()
|
||||
assert auth.verify_password("newpass456", user.hashed_password) # noqa: SEC001
|
||||
|
||||
def test_reset_password_invalid_token(self, service, db):
|
||||
"""Invalid token raises InvalidTokenException."""
|
||||
with pytest.raises(InvalidTokenException):
|
||||
service.reset_password(db, "invalid-token", "newpass456") # noqa: SEC001
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestUserAuthServiceEmailVerification:
|
||||
"""Test UserAuthService email verification methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return UserAuthService()
|
||||
|
||||
@pytest.fixture
|
||||
def uas_unverified_user(self, db):
|
||||
"""Create an unverified test user."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"uasev_{uid}@test.com",
|
||||
username=f"uasev_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def uas_verified_user(self, db):
|
||||
"""Create a verified test user."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"uasv_{uid}@test.com",
|
||||
username=f"uasv_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
def test_request_verification_resend_unverified(self, service, db, uas_unverified_user):
|
||||
"""Unverified user gets a new verification token."""
|
||||
user, token = service.request_verification_resend(db, uas_unverified_user.email)
|
||||
assert user is not None
|
||||
assert user.id == uas_unverified_user.id
|
||||
assert token is not None
|
||||
|
||||
def test_request_verification_resend_already_verified(self, service, db, uas_verified_user):
|
||||
"""Already-verified user returns (None, None)."""
|
||||
user, token = service.request_verification_resend(db, uas_verified_user.email)
|
||||
assert user is None
|
||||
assert token is None
|
||||
|
||||
def test_request_verification_resend_nonexistent(self, service, db):
|
||||
"""Non-existent email returns (None, None)."""
|
||||
user, token = service.request_verification_resend(db, "nonexistent@test.com")
|
||||
assert user is None
|
||||
assert token is None
|
||||
136
app/modules/tenancy/tests/unit/test_user_password_reset_token.py
Normal file
136
app/modules/tenancy/tests/unit/test_user_password_reset_token.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# app/modules/tenancy/tests/unit/test_user_password_reset_token.py
|
||||
"""Unit tests for UserPasswordResetToken model."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestUserPasswordResetToken:
|
||||
"""Test UserPasswordResetToken model CRUD operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def prt_user(self, db):
|
||||
"""Create a test user for token tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"prt_{uid}@test.com",
|
||||
username=f"prt_{uid}",
|
||||
hashed_password="hashed_password", # noqa: SEC001
|
||||
role="store",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
def test_create_for_user(self, db, prt_user):
|
||||
"""Test creating a reset token returns a plaintext token."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
assert plaintext is not None
|
||||
assert len(plaintext) > 20
|
||||
|
||||
def test_find_valid_token(self, db, prt_user):
|
||||
"""Test finding a valid token by plaintext."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
found = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
assert found is not None
|
||||
assert found.user_id == prt_user.id
|
||||
assert found.used_at is None
|
||||
|
||||
def test_find_valid_token_wrong_token(self, db, prt_user):
|
||||
"""Test that a wrong token returns None."""
|
||||
UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
found = UserPasswordResetToken.find_valid_token(db, "wrong-token-value")
|
||||
assert found is None
|
||||
|
||||
def test_mark_used(self, db, prt_user):
|
||||
"""Test marking a token as used."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
assert token is not None
|
||||
|
||||
token.mark_used(db)
|
||||
db.commit()
|
||||
|
||||
assert token.used_at is not None
|
||||
|
||||
# Token should no longer be findable
|
||||
found = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
assert found is None
|
||||
|
||||
def test_expired_token_not_found(self, db, prt_user):
|
||||
"""Test that an expired token is not returned by find_valid_token."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
# Manually expire the token
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
token.expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
found = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
assert found is None
|
||||
|
||||
def test_create_invalidates_old_tokens(self, db, prt_user):
|
||||
"""Test that creating a new token invalidates old unused tokens."""
|
||||
plaintext_1 = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
plaintext_2 = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
# Old token should be gone (deleted)
|
||||
found_1 = UserPasswordResetToken.find_valid_token(db, plaintext_1)
|
||||
assert found_1 is None
|
||||
|
||||
# New token should work
|
||||
found_2 = UserPasswordResetToken.find_valid_token(db, plaintext_2)
|
||||
assert found_2 is not None
|
||||
|
||||
def test_hash_token_deterministic(self):
|
||||
"""Test that hash_token produces consistent results."""
|
||||
token = "test-token-value"
|
||||
hash1 = UserPasswordResetToken.hash_token(token)
|
||||
hash2 = UserPasswordResetToken.hash_token(token)
|
||||
assert hash1 == hash2
|
||||
assert len(hash1) == 64 # SHA256 hex digest
|
||||
|
||||
def test_token_expiry_is_1_hour(self, db, prt_user):
|
||||
"""Test that tokens are created with 1-hour expiry."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
assert token is not None
|
||||
|
||||
# Expiry should be approximately 1 hour from now
|
||||
expected = datetime.utcnow() + timedelta(hours=1)
|
||||
delta = abs((token.expires_at - expected).total_seconds())
|
||||
assert delta < 60 # Within 60 seconds tolerance
|
||||
|
||||
def test_token_repr(self, db, prt_user):
|
||||
"""Test token __repr__."""
|
||||
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
|
||||
db.commit()
|
||||
|
||||
token = UserPasswordResetToken.find_valid_token(db, plaintext)
|
||||
repr_str = repr(token)
|
||||
assert "UserPasswordResetToken" in repr_str
|
||||
assert str(token.user_id) in repr_str
|
||||
Reference in New Issue
Block a user