diff --git a/src/app.py b/src/app.py index 2c0b290..4f1838b 100644 --- a/src/app.py +++ b/src/app.py @@ -112,10 +112,7 @@ def register_error_handlers(app: Flask) -> None: @app.errorhandler(429) def rate_limit_error(_error): """Handle 429 Too Many Requests errors.""" - from flask import flash, redirect, request - - flash("Too many attempts. Please try again later.", "error") - return redirect(request.referrer or "/"), 429 + return render_template("errors/429.html"), 429 def register_setup_check(app: Flask) -> None: @@ -140,6 +137,7 @@ def register_setup_check(app: Flask) -> None: "static", "health", "participant.register", + "participant.register_success", "participant.request_access", "participant.magic_login", "participant.dashboard", diff --git a/src/config.py b/src/config.py index dec0059..dfccd38 100644 --- a/src/config.py +++ b/src/config.py @@ -96,6 +96,9 @@ class TestConfig(Config): # Use a predictable secret key for tests SECRET_KEY = "test-secret-key" + # Set FLASK_ENV to development to enable dev mode in EmailService + FLASK_ENV = "development" + # Configuration dictionary for easy access config = { diff --git a/src/routes/participant.py b/src/routes/participant.py index dfba076..0acb2ea 100644 --- a/src/routes/participant.py +++ b/src/routes/participant.py @@ -1,10 +1,18 @@ """Participant routes for Sneaky Klaus application.""" -from flask import Blueprint, abort, render_template +import hashlib +import secrets +from datetime import UTC, datetime, timedelta + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from src.app import db from src.forms.participant import ParticipantRegistrationForm from src.models.exchange import Exchange +from src.models.magic_token import MagicToken +from src.models.participant import Participant +from src.services.email import EmailService +from src.utils.rate_limit import check_rate_limit, increment_rate_limit participant_bp = Blueprint("participant", __name__, url_prefix="") @@ -17,7 +25,7 @@ def register(slug: str): slug: Exchange registration slug. Returns: - Rendered registration page template. + Rendered registration page template or redirect to success. """ # Find the exchange by slug exchange = db.session.query(Exchange).filter_by(slug=slug).first() @@ -27,8 +35,126 @@ def register(slug: str): # Create the registration form form = ParticipantRegistrationForm() + # Handle POST request + if form.validate_on_submit(): + # Check if exchange is open for registration + if exchange.state != Exchange.STATE_REGISTRATION_OPEN: + flash("Registration is not currently open for this exchange.", "error") + return render_template( + "participant/register.html", + exchange=exchange, + form=form, + ) + + # Rate limiting: 10 registrations per hour per IP + ip_address = request.remote_addr or "unknown" + rate_limit_key = f"register:{slug}:{ip_address}" + + if check_rate_limit(rate_limit_key, max_attempts=10, window_minutes=60): + abort(429) # Too Many Requests + + # Lowercase email for consistency + email = form.email.data.lower() + name = form.name.data + gift_ideas = form.gift_ideas.data or None + # Get reminder_enabled from form data, defaulting to True + # If checkbox not in POST data at all, it should default to True + # If explicitly unchecked, it will be False + reminder_enabled = form.reminder_enabled.data + + # Create participant record + participant = Participant( + exchange_id=exchange.id, + name=name, + email=email, + gift_ideas=gift_ideas, + reminder_enabled=reminder_enabled, + ) + db.session.add(participant) + db.session.flush() # Get participant ID + + # Generate magic token + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode()).hexdigest() + + magic_token = MagicToken( + token_hash=token_hash, + token_type="magic_link", + email=email, + participant_id=participant.id, + exchange_id=exchange.id, + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + magic_token.validate() + db.session.add(magic_token) + + # Commit before sending email + db.session.commit() + + # Send registration confirmation email + magic_link_url = url_for( + "participant.magic_login", + token=token, + _external=True, + ) + + email_service = EmailService() + try: + email_service.send_registration_confirmation( + to=email, + participant_name=name, + magic_link_url=magic_link_url, + exchange_name=exchange.name, + exchange_description=exchange.description, + budget_amount=float(exchange.budget.replace("$", "")), + gift_exchange_date=exchange.exchange_date.strftime("%Y-%m-%d"), + ) + except Exception as e: + # Log error but don't fail registration + # In production, we'd want proper logging + print(f"Failed to send confirmation email: {e}") + + # Increment rate limit + increment_rate_limit(rate_limit_key, window_minutes=60) + + return redirect(url_for("participant.register_success", slug=slug)) + return render_template( "participant/register.html", exchange=exchange, form=form, ) + + +@participant_bp.route("/exchange//register/success") +def register_success(slug: str): + """Registration success page. + + Args: + slug: Exchange registration slug. + + Returns: + Rendered success page template. + """ + exchange = db.session.query(Exchange).filter_by(slug=slug).first() + if not exchange: + abort(404) + + return render_template( + "participant/register_success.html", + exchange=exchange, + ) + + +@participant_bp.route("/auth/participant/magic/") +def magic_login(token: str): # noqa: ARG001 + """Magic link login for participants. + + Args: + token: Magic token from email link. + + Returns: + Redirect to participant dashboard. + """ + # Placeholder for Story 5.2 + abort(404) diff --git a/src/templates/errors/429.html b/src/templates/errors/429.html new file mode 100644 index 0000000..0187dae --- /dev/null +++ b/src/templates/errors/429.html @@ -0,0 +1,12 @@ +{% extends "layouts/base.html" %} + +{% block title %}Too Many Requests - Sneaky Klaus{% endblock %} + +{% block content %} +
+
+

429 - Too Many Requests

+
+

Too many registration attempts. Please try again later.

+
+{% endblock %} diff --git a/src/templates/layouts/base.html b/src/templates/layouts/base.html index 02f035c..41c8b0e 100644 --- a/src/templates/layouts/base.html +++ b/src/templates/layouts/base.html @@ -12,6 +12,15 @@
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+

{{ message }}

+
+ {% endfor %} + {% endif %} + {% endwith %} {% block content %}{% endblock %}
diff --git a/src/templates/participant/register_success.html b/src/templates/participant/register_success.html new file mode 100644 index 0000000..a544361 --- /dev/null +++ b/src/templates/participant/register_success.html @@ -0,0 +1,33 @@ +{% extends "layouts/base.html" %} + +{% block title %}Registration Complete - {{ exchange.name }}{% endblock %} + +{% block content %} +
+
+

Registration Complete!

+
+ +
+

You've successfully registered for {{ exchange.name }}.

+ +

Check your email for a confirmation message with a magic link to access your participant dashboard.

+ +

What's Next?

+
    +
  • You'll receive an email with a link to access your dashboard
  • +
  • After registration closes, you'll be assigned your Secret Santa recipient
  • +
  • You'll receive reminders about important dates
  • +
+ +

Exchange Details

+
+
Budget
+
{{ exchange.budget }}
+ +
Gift Exchange Date
+
{{ exchange.exchange_date.strftime('%Y-%m-%d') }}
+
+
+
+{% endblock %} diff --git a/tests/integration/test_story_4_2_new_participant_registration.py b/tests/integration/test_story_4_2_new_participant_registration.py new file mode 100644 index 0000000..66faf6a --- /dev/null +++ b/tests/integration/test_story_4_2_new_participant_registration.py @@ -0,0 +1,318 @@ +"""Tests for Story 4.2: New Participant Registration. + +As a potential participant, I want to submit my registration information +so that I can join the Secret Santa exchange. +""" + +from datetime import datetime, timedelta + +from src.models.exchange import Exchange +from src.models.participant import Participant + + +class TestStory42NewParticipantRegistration: + """Test suite for Story 4.2: New Participant Registration.""" + + def test_successful_participant_registration(self, client, db): + """Test successful participant registration creates record and sends email.""" + # Create an exchange in registration_open state + 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", + description="Annual office gift exchange", + 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.commit() + + # Submit registration form + response = client.post( + "/exchange/winter2025/register", + data={ + "name": "John Doe", + "email": "john.doe@example.com", + "gift_ideas": "Books, gadgets", + "reminder_enabled": True, + }, + follow_redirects=False, + ) + + # Should redirect to success page + assert response.status_code == 302 + assert "/exchange/winter2025/register/success" in response.location + + # Should create participant record + participant = ( + db.session.query(Participant) + .filter_by(email="john.doe@example.com", exchange_id=exchange.id) + .first() + ) + assert participant is not None + assert participant.name == "John Doe" + assert participant.email == "john.doe@example.com" + assert participant.gift_ideas == "Books, gadgets" + assert participant.reminder_enabled is True + assert participant.withdrawn_at is None + + def test_registration_lowercases_email(self, client, db): + """Test that email is stored in lowercase.""" + 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() + + # Submit with uppercase email + client.post( + "/exchange/test123/register", + data={ + "name": "Jane Smith", + "email": "Jane.Smith@Example.COM", + "gift_ideas": "", + "reminder_enabled": False, + }, + ) + + # Email should be lowercased + participant = ( + db.session.query(Participant) + .filter_by(email="jane.smith@example.com") + .first() + ) + assert participant is not None + assert participant.email == "jane.smith@example.com" + + def test_registration_fails_if_exchange_not_open(self, client, db): + """Test registration fails if exchange is not in registration_open state.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="closed123", + name="Closed Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_DRAFT, # Not open for registration + ) + db.session.add(exchange) + db.session.commit() + + response = client.post( + "/exchange/closed123/register", + data={ + "name": "John Doe", + "email": "john@example.com", + }, + follow_redirects=False, + ) + + # Should show error and not redirect + assert response.status_code == 200 + assert b"Registration is not currently open" in response.data + + # Should not create participant + participant = ( + db.session.query(Participant).filter_by(email="john@example.com").first() + ) + assert participant is None + + def test_registration_validates_required_fields(self, client, db): + """Test that required fields are 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() + + # Missing name + response = client.post( + "/exchange/test123/register", + data={ + "email": "test@example.com", + }, + ) + assert response.status_code == 200 + assert b"Name is required" in response.data + + # Missing email + response = client.post( + "/exchange/test123/register", + data={ + "name": "Test User", + }, + ) + assert response.status_code == 200 + assert b"Email is required" in response.data + + def test_registration_validates_email_format(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/register", + data={ + "name": "Test User", + "email": "not-an-email", + }, + ) + assert response.status_code == 200 + assert b"valid email address" in response.data + + def test_registration_rate_limiting(self, client, db): + """Test rate limiting prevents spam registrations (10 per hour per IP).""" + 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() + + # Make 10 registration attempts + for i in range(10): + response = client.post( + "/exchange/test123/register", + data={ + "name": f"User {i}", + "email": f"user{i}@example.com", + }, + follow_redirects=False, + ) + # First 10 should succeed + assert response.status_code == 302 + + # 11th attempt should be rate limited + response = client.post( + "/exchange/test123/register", + data={ + "name": "User 11", + "email": "user11@example.com", + }, + ) + assert response.status_code == 429 # Too Many Requests + assert b"Too many registration attempts" in response.data + + def test_registration_success_page_displays(self, client, db): + """Test that success page displays after registration.""" + 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() + + # Register + client.post( + "/exchange/test123/register", + data={ + "name": "John Doe", + "email": "john@example.com", + }, + ) + + # Access success page + response = client.get("/exchange/test123/register/success") + assert response.status_code == 200 + assert ( + b"successfully registered" in response.data + or b"Registration Complete" in response.data + ) + assert b"Test Exchange" in response.data + + def test_registration_handles_optional_fields(self, client, db): + """Test that optional fields (gift_ideas, reminder_enabled) work 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() + + # Register without gift ideas and unchecked reminder + client.post( + "/exchange/test123/register", + data={ + "name": "John Doe", + "email": "john@example.com", + # gift_ideas omitted + # reminder_enabled omitted (unchecked checkbox = False) + }, + ) + + participant = ( + db.session.query(Participant).filter_by(email="john@example.com").first() + ) + assert participant.gift_ideas is None or participant.gift_ideas == "" + # Unchecked checkbox means False + assert participant.reminder_enabled is False diff --git a/tests/unit/test_email_service.py b/tests/unit/test_email_service.py index ccd5093..8178d37 100644 --- a/tests/unit/test_email_service.py +++ b/tests/unit/test_email_service.py @@ -15,6 +15,7 @@ class TestEmailService: """Test EmailService initialization with API key.""" with app.app_context(): app.config["RESEND_API_KEY"] = "test-api-key" + app.config["FLASK_ENV"] = "production" service = EmailService() assert service.api_key == "test-api-key" assert service.dev_mode is False