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>
183 lines
6.1 KiB
Python
183 lines
6.1 KiB
Python
"""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]
|