feat: implement Story 5.1 - Magic Link Request
Allow participants to request magic links for dashboard access: - POST /exchange/<slug>/request-access handles form submission - Accept email, look up participant in database - Generate token (secrets.token_urlsafe(32)), store SHA-256 hash - Send magic link email via EmailService.send_magic_link() - Rate limit: 3 requests per hour per email - Always show generic success message (prevent enumeration) - Only send email if participant exists - Case-insensitive email lookup - Comprehensive test suite with 7 tests Includes: - MagicLinkRequestForm with email validation - request_access.html template - GET endpoint to display request form All tests passing (97 total), 91% coverage maintained. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
241
tests/integration/test_story_5_1_magic_link_request.py
Normal file
241
tests/integration/test_story_5_1_magic_link_request.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests for Story 5.1: Magic Link Request.
|
||||
|
||||
As a registered participant, I want to request a magic link
|
||||
so that I can access my participant dashboard.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.models.exchange import Exchange
|
||||
from src.models.magic_token import MagicToken
|
||||
from src.models.participant import Participant
|
||||
|
||||
|
||||
class TestStory51MagicLinkRequest:
|
||||
"""Test suite for Story 5.1: Magic Link Request."""
|
||||
|
||||
def test_request_magic_link_for_existing_participant(self, client, db):
|
||||
"""Test successful magic link request for existing participant."""
|
||||
future_close_date = datetime.utcnow() + timedelta(days=7)
|
||||
future_exchange_date = datetime.utcnow() + timedelta(days=14)
|
||||
|
||||
exchange = Exchange(
|
||||
slug="winter2025",
|
||||
name="Office Secret Santa 2025",
|
||||
budget="$50",
|
||||
max_participants=10,
|
||||
registration_close_date=future_close_date,
|
||||
exchange_date=future_exchange_date,
|
||||
timezone="America/New_York",
|
||||
state=Exchange.STATE_REGISTRATION_OPEN,
|
||||
)
|
||||
db.session.add(exchange)
|
||||
db.session.flush()
|
||||
|
||||
participant = Participant(
|
||||
exchange_id=exchange.id,
|
||||
name="John Doe",
|
||||
email="john@example.com",
|
||||
reminder_enabled=True,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.commit()
|
||||
|
||||
# Request magic link
|
||||
response = client.post(
|
||||
"/exchange/winter2025/request-access",
|
||||
data={"email": "john@example.com"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# Should show success message
|
||||
assert response.status_code == 200
|
||||
assert b"check your email" in response.data or b"sent you" in response.data
|
||||
|
||||
# Should create magic token
|
||||
token = (
|
||||
db.session.query(MagicToken)
|
||||
.filter_by(participant_id=participant.id)
|
||||
.first()
|
||||
)
|
||||
assert token is not None
|
||||
assert token.email == "john@example.com"
|
||||
assert token.token_type == "magic_link"
|
||||
|
||||
def test_request_magic_link_case_insensitive_email(self, client, db):
|
||||
"""Test that email lookup is case insensitive."""
|
||||
future_close_date = datetime.utcnow() + timedelta(days=7)
|
||||
future_exchange_date = datetime.utcnow() + timedelta(days=14)
|
||||
|
||||
exchange = Exchange(
|
||||
slug="test123",
|
||||
name="Test Exchange",
|
||||
budget="$30",
|
||||
max_participants=10,
|
||||
registration_close_date=future_close_date,
|
||||
exchange_date=future_exchange_date,
|
||||
timezone="America/New_York",
|
||||
state=Exchange.STATE_REGISTRATION_OPEN,
|
||||
)
|
||||
db.session.add(exchange)
|
||||
db.session.flush()
|
||||
|
||||
participant = Participant(
|
||||
exchange_id=exchange.id,
|
||||
name="Jane Smith",
|
||||
email="jane.smith@example.com",
|
||||
reminder_enabled=True,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.commit()
|
||||
|
||||
# Request with different case
|
||||
response = client.post(
|
||||
"/exchange/test123/request-access",
|
||||
data={"email": "Jane.Smith@Example.COM"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Should create token for lowercased email
|
||||
token = (
|
||||
db.session.query(MagicToken)
|
||||
.filter_by(email="jane.smith@example.com")
|
||||
.first()
|
||||
)
|
||||
assert token is not None
|
||||
|
||||
def test_request_magic_link_for_nonexistent_email(self, client, db):
|
||||
"""Test magic link request for email not registered.
|
||||
|
||||
Should prevent enumeration attacks by showing success message.
|
||||
"""
|
||||
future_close_date = datetime.utcnow() + timedelta(days=7)
|
||||
future_exchange_date = datetime.utcnow() + timedelta(days=14)
|
||||
|
||||
exchange = Exchange(
|
||||
slug="test123",
|
||||
name="Test Exchange",
|
||||
budget="$30",
|
||||
max_participants=10,
|
||||
registration_close_date=future_close_date,
|
||||
exchange_date=future_exchange_date,
|
||||
timezone="America/New_York",
|
||||
state=Exchange.STATE_REGISTRATION_OPEN,
|
||||
)
|
||||
db.session.add(exchange)
|
||||
db.session.commit()
|
||||
|
||||
# Request for email that doesn't exist
|
||||
response = client.post(
|
||||
"/exchange/test123/request-access",
|
||||
data={"email": "nonexistent@example.com"},
|
||||
)
|
||||
|
||||
# Should still show generic success message (prevent enumeration)
|
||||
assert response.status_code == 200
|
||||
assert b"check your email" in response.data or b"sent you" in response.data
|
||||
|
||||
# Should NOT create a token
|
||||
token = (
|
||||
db.session.query(MagicToken)
|
||||
.filter_by(email="nonexistent@example.com")
|
||||
.first()
|
||||
)
|
||||
assert token is None
|
||||
|
||||
def test_request_magic_link_rate_limiting(self, client, db):
|
||||
"""Test rate limiting for magic link requests (3 per hour per email)."""
|
||||
future_close_date = datetime.utcnow() + timedelta(days=7)
|
||||
future_exchange_date = datetime.utcnow() + timedelta(days=14)
|
||||
|
||||
exchange = Exchange(
|
||||
slug="test123",
|
||||
name="Test Exchange",
|
||||
budget="$30",
|
||||
max_participants=10,
|
||||
registration_close_date=future_close_date,
|
||||
exchange_date=future_exchange_date,
|
||||
timezone="America/New_York",
|
||||
state=Exchange.STATE_REGISTRATION_OPEN,
|
||||
)
|
||||
db.session.add(exchange)
|
||||
db.session.flush()
|
||||
|
||||
participant = Participant(
|
||||
exchange_id=exchange.id,
|
||||
name="Test User",
|
||||
email="test@example.com",
|
||||
reminder_enabled=True,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.commit()
|
||||
|
||||
# Make 3 requests (should succeed)
|
||||
for _i in range(3):
|
||||
response = client.post(
|
||||
"/exchange/test123/request-access",
|
||||
data={"email": "test@example.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 4th request should be rate limited
|
||||
response = client.post(
|
||||
"/exchange/test123/request-access",
|
||||
data={"email": "test@example.com"},
|
||||
)
|
||||
assert response.status_code == 429 # Too Many Requests
|
||||
|
||||
def test_request_magic_link_validates_email(self, client, db):
|
||||
"""Test that email format is validated."""
|
||||
future_close_date = datetime.utcnow() + timedelta(days=7)
|
||||
future_exchange_date = datetime.utcnow() + timedelta(days=14)
|
||||
|
||||
exchange = Exchange(
|
||||
slug="test123",
|
||||
name="Test Exchange",
|
||||
budget="$30",
|
||||
max_participants=10,
|
||||
registration_close_date=future_close_date,
|
||||
exchange_date=future_exchange_date,
|
||||
timezone="America/New_York",
|
||||
state=Exchange.STATE_REGISTRATION_OPEN,
|
||||
)
|
||||
db.session.add(exchange)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/exchange/test123/request-access",
|
||||
data={"email": "not-an-email"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"valid email" in response.data
|
||||
|
||||
def test_request_access_page_displays(self, client, db):
|
||||
"""Test that request access page displays correctly."""
|
||||
future_close_date = datetime.utcnow() + timedelta(days=7)
|
||||
future_exchange_date = datetime.utcnow() + timedelta(days=14)
|
||||
|
||||
exchange = Exchange(
|
||||
slug="test123",
|
||||
name="Test Exchange",
|
||||
budget="$30",
|
||||
max_participants=10,
|
||||
registration_close_date=future_close_date,
|
||||
exchange_date=future_exchange_date,
|
||||
timezone="America/New_York",
|
||||
state=Exchange.STATE_REGISTRATION_OPEN,
|
||||
)
|
||||
db.session.add(exchange)
|
||||
db.session.commit()
|
||||
|
||||
response = client.get("/exchange/test123/request-access")
|
||||
assert response.status_code == 200
|
||||
assert b"Test Exchange" in response.data
|
||||
assert b'name="email"' in response.data
|
||||
|
||||
def test_invalid_exchange_slug_returns_404(self, client):
|
||||
"""Test that invalid exchange slug returns 404."""
|
||||
response = client.get("/exchange/invalid/request-access")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user