feat: email verification, merchant/store password reset, seed gap fix
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

- 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:
2026-02-18 23:22:46 +01:00
parent a8b29750a5
commit d9fc52d47a
30 changed files with 2574 additions and 29 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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