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:
@@ -141,6 +141,7 @@ def register_setup_check(app: Flask) -> None:
|
|||||||
"participant.request_access",
|
"participant.request_access",
|
||||||
"participant.magic_login",
|
"participant.magic_login",
|
||||||
"participant.dashboard",
|
"participant.dashboard",
|
||||||
|
"participant.logout",
|
||||||
]:
|
]:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import flash, redirect, session, url_for
|
from flask import abort, flash, redirect, session, url_for
|
||||||
|
|
||||||
|
|
||||||
def admin_required(f):
|
def admin_required(f):
|
||||||
@@ -26,3 +26,24 @@ def admin_required(f):
|
|||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def participant_required(f):
|
||||||
|
"""Decorator to require participant authentication for a route.
|
||||||
|
|
||||||
|
Checks if user is logged in as participant. If not, returns 403 Forbidden.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
f: The function to decorate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorated function that checks authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if "user_id" not in session or session.get("user_type") != "participant":
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|||||||
@@ -82,7 +82,16 @@ class MagicToken(db.Model): # type: ignore[name-defined]
|
|||||||
@property
|
@property
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
"""Check if token has expired."""
|
"""Check if token has expired."""
|
||||||
return bool(datetime.now(UTC) > self.expires_at)
|
# Handle both timezone-aware and timezone-naive datetimes
|
||||||
|
# SQLite stores datetimes as strings and may lose timezone info
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
expires = self.expires_at
|
||||||
|
|
||||||
|
# If expires_at is timezone-naive, make now timezone-naive for comparison
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
now = now.replace(tzinfo=None)
|
||||||
|
|
||||||
|
return bool(now > expires)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_used(self) -> bool:
|
def is_used(self) -> bool:
|
||||||
|
|||||||
@@ -4,9 +4,19 @@ import hashlib
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
abort,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
|
||||||
from src.app import db
|
from src.app import db
|
||||||
|
from src.decorators.auth import participant_required
|
||||||
from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
|
from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
|
||||||
from src.models.exchange import Exchange
|
from src.models.exchange import Exchange
|
||||||
from src.models.magic_token import MagicToken
|
from src.models.magic_token import MagicToken
|
||||||
@@ -263,14 +273,101 @@ def request_access(slug: str):
|
|||||||
|
|
||||||
|
|
||||||
@participant_bp.route("/auth/participant/magic/<token>")
|
@participant_bp.route("/auth/participant/magic/<token>")
|
||||||
def magic_login(token: str): # noqa: ARG001
|
def magic_login(token: str):
|
||||||
"""Magic link login for participants.
|
"""Magic link login for participants.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: Magic token from email link.
|
token: Magic token from email link.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Redirect to participant dashboard.
|
Redirect to participant dashboard or error page.
|
||||||
"""
|
"""
|
||||||
# Placeholder for Story 5.2
|
# Hash the incoming token to look it up
|
||||||
abort(404)
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Look up the token
|
||||||
|
magic_token = db.session.query(MagicToken).filter_by(token_hash=token_hash).first()
|
||||||
|
|
||||||
|
if not magic_token:
|
||||||
|
flash("Invalid or expired magic link.", "error")
|
||||||
|
return render_template("errors/magic_link_error.html"), 200
|
||||||
|
|
||||||
|
# Validate token
|
||||||
|
if magic_token.is_expired:
|
||||||
|
flash("This magic link has expired. Please request a new one.", "error")
|
||||||
|
return render_template("errors/magic_link_error.html"), 200
|
||||||
|
|
||||||
|
if magic_token.is_used:
|
||||||
|
flash(
|
||||||
|
"This magic link has already been used. Please request a new one.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return render_template("errors/magic_link_error.html"), 200
|
||||||
|
|
||||||
|
# Mark token as used
|
||||||
|
magic_token.used_at = datetime.now(UTC)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session["user_id"] = magic_token.participant_id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = magic_token.exchange_id
|
||||||
|
|
||||||
|
# Redirect to participant dashboard
|
||||||
|
return redirect(url_for("participant.dashboard", id=magic_token.exchange_id))
|
||||||
|
|
||||||
|
|
||||||
|
@participant_bp.route("/participant/exchange/<int:id>")
|
||||||
|
@participant_required
|
||||||
|
def dashboard(id: int): # noqa: A002
|
||||||
|
"""Participant dashboard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: Exchange ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered dashboard template.
|
||||||
|
"""
|
||||||
|
# Verify participant has access to this exchange
|
||||||
|
if session.get("exchange_id") != id:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
# Get exchange and participant
|
||||||
|
exchange = db.session.query(Exchange).filter_by(id=id).first()
|
||||||
|
if not exchange:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
|
||||||
|
if not participant:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"participant/dashboard.html",
|
||||||
|
exchange=exchange,
|
||||||
|
participant=participant,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@participant_bp.route("/participant/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
"""Participant logout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to homepage or success message.
|
||||||
|
"""
|
||||||
|
# Store exchange_id before clearing session
|
||||||
|
exchange_id = session.get("exchange_id")
|
||||||
|
|
||||||
|
# Clear session
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
flash("You've been logged out successfully.", "success")
|
||||||
|
|
||||||
|
# Redirect to the exchange's request access page if we know the exchange
|
||||||
|
if exchange_id:
|
||||||
|
exchange = db.session.query(Exchange).filter_by(id=exchange_id).first()
|
||||||
|
if exchange:
|
||||||
|
return redirect(url_for("participant.request_access", slug=exchange.slug))
|
||||||
|
|
||||||
|
# Fallback to root
|
||||||
|
return redirect("/")
|
||||||
|
|||||||
12
src/templates/errors/403.html
Normal file
12
src/templates/errors/403.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Forbidden - Sneaky Klaus{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>403 - Forbidden</h1>
|
||||||
|
</header>
|
||||||
|
<p>You don't have permission to access this page.</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
13
src/templates/errors/magic_link_error.html
Normal file
13
src/templates/errors/magic_link_error.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Magic Link Error - Sneaky Klaus{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Magic Link Error</h1>
|
||||||
|
</header>
|
||||||
|
<p>There was a problem with your magic link.</p>
|
||||||
|
<p>Please request a new one to access your participant dashboard.</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
48
src/templates/participant/dashboard.html
Normal file
48
src/templates/participant/dashboard.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ exchange.name }} - Participant Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>{{ exchange.name }}</h1>
|
||||||
|
<p>Welcome, {{ participant.name }}!</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Exchange Details</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Budget</dt>
|
||||||
|
<dd>{{ exchange.budget }}</dd>
|
||||||
|
|
||||||
|
<dt>Gift Exchange Date</dt>
|
||||||
|
<dd>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</dd>
|
||||||
|
|
||||||
|
<dt>Registration Deadline</dt>
|
||||||
|
<dd>{{ exchange.registration_close_date.strftime('%Y-%m-%d') }}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Your Information</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{{ participant.name }}</dd>
|
||||||
|
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{{ participant.email }}</dd>
|
||||||
|
|
||||||
|
{% if participant.gift_ideas %}
|
||||||
|
<dt>Gift Ideas</dt>
|
||||||
|
<dd>{{ participant.gift_ideas }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<form method="POST" action="{{ url_for('participant.logout') }}">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
194
tests/integration/test_story_5_2_magic_link_login.py
Normal file
194
tests/integration/test_story_5_2_magic_link_login.py
Normal 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
|
||||||
182
tests/integration/test_story_5_3_participant_session.py
Normal file
182
tests/integration/test_story_5_3_participant_session.py
Normal 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]
|
||||||
Reference in New Issue
Block a user