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

View 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

View 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