feat: implement Stories 5.2 & 5.3 - Magic Link Login and Participant Session

Implemented complete participant authentication flow with magic link login and session management.

Story 5.2 - Magic Link Login:
- Participants can click magic links to securely access their dashboard
- Single-use tokens that expire after 1 hour
- Session creation with participant_id, user_type, and exchange_id
- Error handling for expired, used, or invalid tokens
- Fixed timezone-aware datetime comparison for SQLite compatibility

Story 5.3 - Participant Session:
- Authenticated participants can access their exchange dashboard
- participant_required decorator protects participant-only routes
- Participants can only access their own exchange (403 for others)
- Logout functionality clears session and redirects appropriately
- Unauthenticated access returns 403 Forbidden

Technical changes:
- Added magic_login() route for token validation and session creation
- Added dashboard() route with exchange and participant data
- Added logout() route with smart redirect to request access page
- Added participant_required decorator for route protection
- Enhanced MagicToken.is_expired for timezone-naive datetime handling
- Added participant.logout to setup check exclusions
- Created templates: dashboard.html, magic_link_error.html, 403.html
- Comprehensive test coverage for all user flows

Acceptance Criteria Met:
✓ Valid magic links create authenticated sessions
✓ Invalid/expired/used tokens show appropriate errors
✓ Authenticated participants see their dashboard
✓ Participants cannot access other exchanges
✓ Unauthenticated users cannot access protected routes
✓ Logout clears session and provides feedback

🤖 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:41:29 -07:00
parent 321d7b1395
commit 44ef77ca68
9 changed files with 584 additions and 7 deletions

View File

@@ -0,0 +1,194 @@
"""Tests for Story 5.2: Magic Link Login.
As a participant, I want to click a magic link in my email
so that I can securely access my participant dashboard.
"""
import hashlib
from datetime import UTC, datetime, timedelta
from src.models.exchange import Exchange
from src.models.magic_token import MagicToken
from src.models.participant import Participant
class TestStory52MagicLinkLogin:
"""Test suite for Story 5.2: Magic Link Login."""
def test_valid_magic_link_creates_session(self, client, db):
"""Test that valid magic link creates session and redirects."""
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.flush()
# Create magic token
token = "test-token-12345"
token_hash = hashlib.sha256(token.encode()).hexdigest()
magic_token = MagicToken(
token_hash=token_hash,
token_type="magic_link",
email="john@example.com",
participant_id=participant.id,
exchange_id=exchange.id,
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
db.session.add(magic_token)
db.session.commit()
# Click magic link
response = client.get(
f"/auth/participant/magic/{token}",
follow_redirects=False,
)
# Should redirect to dashboard
assert response.status_code == 302
assert f"/participant/exchange/{exchange.id}" in response.location
# Should create session
with client.session_transaction() as sess:
assert "user_id" in sess
assert sess["user_id"] == participant.id
assert sess["user_type"] == "participant"
assert sess["exchange_id"] == exchange.id
# Should mark token as used
db.session.refresh(magic_token)
assert magic_token.used_at is not None
def test_expired_token_shows_error(self, client, db):
"""Test that expired token shows error 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.flush()
participant = Participant(
exchange_id=exchange.id,
name="Jane Smith",
email="jane@example.com",
reminder_enabled=True,
)
db.session.add(participant)
db.session.flush()
# Create expired token
token = "expired-token-12345"
token_hash = hashlib.sha256(token.encode()).hexdigest()
magic_token = MagicToken(
token_hash=token_hash,
token_type="magic_link",
email="jane@example.com",
participant_id=participant.id,
exchange_id=exchange.id,
expires_at=datetime.now(UTC) - timedelta(hours=1), # Expired
)
db.session.add(magic_token)
db.session.commit()
# Try to use expired token
response = client.get(f"/auth/participant/magic/{token}")
# Should show error
assert response.status_code == 200
assert b"expired" in response.data or b"invalid" in response.data
# Should not create session
with client.session_transaction() as sess:
assert "user_id" not in sess
# Should not mark token as used
db.session.refresh(magic_token)
assert magic_token.used_at is None
def test_used_token_shows_error(self, client, db):
"""Test that already-used token shows error 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.flush()
participant = Participant(
exchange_id=exchange.id,
name="Bob Jones",
email="bob@example.com",
reminder_enabled=True,
)
db.session.add(participant)
db.session.flush()
# Create used token
token = "used-token-12345"
token_hash = hashlib.sha256(token.encode()).hexdigest()
magic_token = MagicToken(
token_hash=token_hash,
token_type="magic_link",
email="bob@example.com",
participant_id=participant.id,
exchange_id=exchange.id,
expires_at=datetime.now(UTC) + timedelta(hours=1),
used_at=datetime.now(UTC) - timedelta(minutes=5), # Already used
)
db.session.add(magic_token)
db.session.commit()
# Try to use token again
response = client.get(f"/auth/participant/magic/{token}")
# Should show error
assert response.status_code == 200
assert b"invalid" in response.data or b"already been used" in response.data
def test_invalid_token_shows_error(self, client):
"""Test that invalid/unknown token shows error."""
response = client.get("/auth/participant/magic/invalid-token-xyz")
# Should show error
assert response.status_code == 200
assert b"Invalid" in response.data

View File

@@ -0,0 +1,182 @@
"""Tests for Story 5.3: Participant Session.
As a logged-in participant, I want to access my dashboard
and manage my session.
"""
from datetime import datetime, timedelta
from src.models.exchange import Exchange
from src.models.participant import Participant
class TestStory53ParticipantSession:
"""Test suite for Story 5.3: Participant Session."""
def test_participant_dashboard_requires_login(self, client, db):
"""Test that dashboard redirects to request access if not logged in."""
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()
# Try to access dashboard without login
response = client.get(
f"/participant/exchange/{exchange.id}",
follow_redirects=False,
)
# Should redirect or show error
assert response.status_code in [302, 403]
def test_logged_in_participant_sees_dashboard(self, client, db):
"""Test that logged-in participant can access dashboard."""
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="John Doe",
email="john@example.com",
reminder_enabled=True,
)
db.session.add(participant)
db.session.commit()
# Log in as participant
with client.session_transaction() as sess:
sess["user_id"] = participant.id
sess["user_type"] = "participant"
sess["exchange_id"] = exchange.id
# Access dashboard
response = client.get(f"/participant/exchange/{exchange.id}")
# Should show dashboard
assert response.status_code == 200
assert b"Test Exchange" in response.data
assert b"John Doe" in response.data
def test_participant_can_logout(self, client, db):
"""Test that participant can log out."""
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@example.com",
reminder_enabled=True,
)
db.session.add(participant)
db.session.commit()
# Log in
with client.session_transaction() as sess:
sess["user_id"] = participant.id
sess["user_type"] = "participant"
sess["exchange_id"] = exchange.id
# Logout
response = client.post("/participant/logout", follow_redirects=True)
# Should show success message
assert response.status_code == 200
assert b"logged out" in response.data
def test_participant_cannot_access_other_exchange(self, client, db):
"""Test that participant cannot access another exchange's dashboard."""
future_close_date = datetime.utcnow() + timedelta(days=7)
future_exchange_date = datetime.utcnow() + timedelta(days=14)
# Create two exchanges
exchange1 = Exchange(
slug="exchange1",
name="Exchange 1",
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,
)
exchange2 = Exchange(
slug="exchange2",
name="Exchange 2",
budget="$40",
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(exchange1)
db.session.add(exchange2)
db.session.flush()
# Create participant for exchange1
participant = Participant(
exchange_id=exchange1.id,
name="Alice",
email="alice@example.com",
reminder_enabled=True,
)
db.session.add(participant)
db.session.commit()
# Log in as participant for exchange1
with client.session_transaction() as sess:
sess["user_id"] = participant.id
sess["user_type"] = "participant"
sess["exchange_id"] = exchange1.id
# Try to access exchange2's dashboard
response = client.get(f"/participant/exchange/{exchange2.id}")
# Should be forbidden
assert response.status_code == 403
def test_participant_required_decorator_works(self, client):
"""Test that participant_required decorator protects routes."""
# Try to access protected route without login
response = client.get("/participant/exchange/999")
# Should redirect or show error
assert response.status_code in [302, 403]