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:
2025-12-22 17:19:56 -07:00
parent 43bfce3913
commit 321d7b1395
4 changed files with 389 additions and 1 deletions

View 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