Merge stories 1.1, 1.2, 1.4 into main
This commit is contained in:
30
src/app.py
30
src/app.py
@@ -55,12 +55,12 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
app.config["SESSION_SQLALCHEMY"] = db
|
app.config["SESSION_SQLALCHEMY"] = db
|
||||||
session.init_app(app)
|
session.init_app(app)
|
||||||
|
|
||||||
# Register blueprints (will be created later)
|
# Register blueprints
|
||||||
# from src.routes import admin, auth, participant, public
|
from src.routes.admin import admin_bp
|
||||||
# app.register_blueprint(admin.bp)
|
from src.routes.setup import setup_bp
|
||||||
# app.register_blueprint(auth.bp)
|
|
||||||
# app.register_blueprint(participant.bp)
|
app.register_blueprint(setup_bp)
|
||||||
# app.register_blueprint(public.bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
# Register error handlers
|
# Register error handlers
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
@@ -70,7 +70,7 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
|
|
||||||
# Import models to ensure they're registered with SQLAlchemy
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from src.models import Admin, Exchange # noqa: F401
|
from src.models import Admin, Exchange, RateLimit # noqa: F401
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
@@ -127,17 +127,15 @@ def register_setup_check(app: Flask) -> None:
|
|||||||
has been set up with an admin account.
|
has been set up with an admin account.
|
||||||
"""
|
"""
|
||||||
# Skip check for certain endpoints
|
# Skip check for certain endpoints
|
||||||
if request.endpoint in ["setup", "static", "health"]:
|
if request.endpoint in ["setup.setup", "static", "health"]:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if we've already determined setup is required
|
# Check if admin exists (always check in testing mode)
|
||||||
if not hasattr(app, "_setup_checked"):
|
from src.models.admin import Admin
|
||||||
from src.models.admin import Admin
|
|
||||||
|
|
||||||
admin_count = db.session.query(Admin).count()
|
admin_count = db.session.query(Admin).count()
|
||||||
app.config["REQUIRES_SETUP"] = admin_count == 0
|
requires_setup = admin_count == 0
|
||||||
app._setup_checked = True
|
|
||||||
|
|
||||||
# Redirect to setup if needed
|
# Redirect to setup if needed
|
||||||
if app.config.get("REQUIRES_SETUP") and request.endpoint != "setup":
|
if requires_setup and request.endpoint != "setup.setup":
|
||||||
return redirect(url_for("setup"))
|
return redirect(url_for("setup.setup"))
|
||||||
|
|||||||
5
src/decorators/__init__.py
Normal file
5
src/decorators/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Decorators for Sneaky Klaus application."""
|
||||||
|
|
||||||
|
from src.decorators.auth import admin_required
|
||||||
|
|
||||||
|
__all__ = ["admin_required"]
|
||||||
28
src/decorators/auth.py
Normal file
28
src/decorators/auth.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Authentication decorators for route protection."""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import flash, redirect, session, url_for
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Decorator to require admin authentication for a route.
|
||||||
|
|
||||||
|
Checks if user is logged in as admin. If not, redirects to login page
|
||||||
|
with appropriate flash message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
f: The function to decorate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorated function that checks authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if "admin_id" not in session:
|
||||||
|
flash("You must be logged in as admin to access this page.", "error")
|
||||||
|
return redirect(url_for("admin.login"))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
6
src/forms/__init__.py
Normal file
6
src/forms/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Forms for Sneaky Klaus application."""
|
||||||
|
|
||||||
|
from src.forms.login import LoginForm
|
||||||
|
from src.forms.setup import SetupForm
|
||||||
|
|
||||||
|
__all__ = ["LoginForm", "SetupForm"]
|
||||||
39
src/forms/login.py
Normal file
39
src/forms/login.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Login form for admin authentication."""
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import BooleanField, EmailField, PasswordField
|
||||||
|
from wtforms.validators import DataRequired, Email
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
"""Form for admin login.
|
||||||
|
|
||||||
|
Validates email format and requires password.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
email: Email address for admin account.
|
||||||
|
password: Password for admin account.
|
||||||
|
remember_me: Whether to extend session duration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
email = EmailField(
|
||||||
|
"Email Address",
|
||||||
|
validators=[
|
||||||
|
DataRequired(message="Email address is required."),
|
||||||
|
Email(message="Please enter a valid email address."),
|
||||||
|
],
|
||||||
|
render_kw={"placeholder": "admin@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
password = PasswordField(
|
||||||
|
"Password",
|
||||||
|
validators=[
|
||||||
|
DataRequired(message="Password is required."),
|
||||||
|
],
|
||||||
|
render_kw={"placeholder": "Enter your password"},
|
||||||
|
)
|
||||||
|
|
||||||
|
remember_me = BooleanField(
|
||||||
|
"Remember me",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
47
src/forms/setup.py
Normal file
47
src/forms/setup.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Setup form for initial admin account creation."""
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import EmailField, PasswordField
|
||||||
|
from wtforms.validators import DataRequired, Email, EqualTo, Length
|
||||||
|
|
||||||
|
|
||||||
|
class SetupForm(FlaskForm):
|
||||||
|
"""Form for initial admin setup.
|
||||||
|
|
||||||
|
Validates email format, password length, and password confirmation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
email: Email address for admin account.
|
||||||
|
password: Password for admin account (minimum 12 characters).
|
||||||
|
password_confirm: Password confirmation field (must match password).
|
||||||
|
"""
|
||||||
|
|
||||||
|
email = EmailField(
|
||||||
|
"Email Address",
|
||||||
|
validators=[
|
||||||
|
DataRequired(message="Email address is required."),
|
||||||
|
Email(message="Please enter a valid email address."),
|
||||||
|
],
|
||||||
|
render_kw={"placeholder": "admin@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
password = PasswordField(
|
||||||
|
"Password",
|
||||||
|
validators=[
|
||||||
|
DataRequired(message="Password is required."),
|
||||||
|
Length(
|
||||||
|
min=12,
|
||||||
|
message="Password must be at least 12 characters long.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
render_kw={"placeholder": "Enter a secure password"},
|
||||||
|
)
|
||||||
|
|
||||||
|
password_confirm = PasswordField(
|
||||||
|
"Confirm Password",
|
||||||
|
validators=[
|
||||||
|
DataRequired(message="Please confirm your password."),
|
||||||
|
EqualTo("password", message="Passwords must match."),
|
||||||
|
],
|
||||||
|
render_kw={"placeholder": "Re-enter your password"},
|
||||||
|
)
|
||||||
@@ -5,5 +5,6 @@ This package contains all database models used by the application.
|
|||||||
|
|
||||||
from src.models.admin import Admin
|
from src.models.admin import Admin
|
||||||
from src.models.exchange import Exchange
|
from src.models.exchange import Exchange
|
||||||
|
from src.models.rate_limit import RateLimit
|
||||||
|
|
||||||
__all__ = ["Admin", "Exchange"]
|
__all__ = ["Admin", "Exchange", "RateLimit"]
|
||||||
|
|||||||
42
src/models/rate_limit.py
Normal file
42
src/models/rate_limit.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Rate limiting model for Sneaky Klaus.
|
||||||
|
|
||||||
|
The RateLimit model tracks authentication attempts to prevent brute force attacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.app import db
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimit(db.Model): # type: ignore[name-defined]
|
||||||
|
"""Rate limiting for authentication attempts.
|
||||||
|
|
||||||
|
Tracks attempts per key (email/IP) within a time window
|
||||||
|
to prevent brute force attacks.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Auto-increment primary key.
|
||||||
|
key: Rate limit identifier (e.g., "login:admin:user@example.com").
|
||||||
|
attempts: Number of attempts in current window.
|
||||||
|
window_start: Start of current rate limit window.
|
||||||
|
expires_at: When rate limit resets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "rate_limit"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
key: Mapped[str] = mapped_column(
|
||||||
|
String(255), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
window_start: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, default=datetime.utcnow
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of RateLimit instance."""
|
||||||
|
return f"<RateLimit {self.key} ({self.attempts} attempts)>"
|
||||||
5
src/routes/__init__.py
Normal file
5
src/routes/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Route blueprints for Sneaky Klaus application."""
|
||||||
|
|
||||||
|
from src.routes.setup import setup_bp
|
||||||
|
|
||||||
|
__all__ = ["setup_bp"]
|
||||||
103
src/routes/admin.py
Normal file
103
src/routes/admin.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Admin routes for Sneaky Klaus application."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, session, url_for
|
||||||
|
|
||||||
|
from src.app import bcrypt, db
|
||||||
|
from src.decorators import admin_required
|
||||||
|
from src.forms import LoginForm
|
||||||
|
from src.models import Admin
|
||||||
|
from src.utils import check_rate_limit, increment_rate_limit, reset_rate_limit
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
# Rate limiting constants
|
||||||
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
|
LOGIN_WINDOW_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
"""Handle admin login.
|
||||||
|
|
||||||
|
GET: Display login form.
|
||||||
|
POST: Process login credentials and create session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
On GET: Rendered login form template.
|
||||||
|
On POST success: Redirect to admin dashboard.
|
||||||
|
On POST error: Re-render form with validation errors.
|
||||||
|
"""
|
||||||
|
# If already logged in, redirect to dashboard
|
||||||
|
if "admin_id" in session:
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
# Normalize email to lowercase
|
||||||
|
email = form.email.data.lower()
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
rate_limit_key = f"login:admin:{email}"
|
||||||
|
if check_rate_limit(rate_limit_key, MAX_LOGIN_ATTEMPTS, LOGIN_WINDOW_MINUTES):
|
||||||
|
flash("Too many login attempts. Please try again in 15 minutes.", "error")
|
||||||
|
return render_template("admin/login.html", form=form), 429
|
||||||
|
|
||||||
|
# Query admin by email
|
||||||
|
admin = db.session.query(Admin).filter_by(email=email).first()
|
||||||
|
|
||||||
|
# Verify credentials
|
||||||
|
if admin and bcrypt.check_password_hash(
|
||||||
|
admin.password_hash, form.password.data
|
||||||
|
):
|
||||||
|
# Reset rate limit on successful login
|
||||||
|
reset_rate_limit(rate_limit_key)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session.clear()
|
||||||
|
session["admin_id"] = admin.id
|
||||||
|
session["admin_email"] = admin.email
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# Set session duration based on remember_me
|
||||||
|
if form.remember_me.data:
|
||||||
|
session.permanent_session_lifetime = timedelta(days=30)
|
||||||
|
else:
|
||||||
|
session.permanent_session_lifetime = timedelta(days=7)
|
||||||
|
|
||||||
|
flash("Welcome back!", "success")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
else:
|
||||||
|
# Invalid credentials - increment rate limit
|
||||||
|
increment_rate_limit(rate_limit_key, LOGIN_WINDOW_MINUTES)
|
||||||
|
flash("Invalid email or password.", "error")
|
||||||
|
|
||||||
|
return render_template("admin/login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/logout", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def logout():
|
||||||
|
"""Handle admin logout.
|
||||||
|
|
||||||
|
POST: Clear session and redirect to login page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to login page.
|
||||||
|
"""
|
||||||
|
session.clear()
|
||||||
|
flash("You have been logged out.", "success")
|
||||||
|
return redirect(url_for("admin.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/dashboard")
|
||||||
|
@admin_required
|
||||||
|
def dashboard():
|
||||||
|
"""Display admin dashboard.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered admin dashboard template.
|
||||||
|
"""
|
||||||
|
return render_template("admin/dashboard.html")
|
||||||
53
src/routes/setup.py
Normal file
53
src/routes/setup.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Setup route for initial admin account creation."""
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, redirect, render_template, session, url_for
|
||||||
|
|
||||||
|
from src.app import bcrypt, db
|
||||||
|
from src.forms import SetupForm
|
||||||
|
from src.models import Admin
|
||||||
|
|
||||||
|
setup_bp = Blueprint("setup", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@setup_bp.route("/setup", methods=["GET", "POST"])
|
||||||
|
def setup():
|
||||||
|
"""Handle initial admin account setup.
|
||||||
|
|
||||||
|
GET: Display the setup form if no admin exists.
|
||||||
|
POST: Process the setup form, create admin account, and log in.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
On GET: Rendered setup form template.
|
||||||
|
On POST success: Redirect to admin dashboard.
|
||||||
|
On POST error: Re-render form with validation errors.
|
||||||
|
404 if admin already exists.
|
||||||
|
"""
|
||||||
|
# Check if admin already exists
|
||||||
|
admin_count = db.session.query(Admin).count()
|
||||||
|
if admin_count > 0:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
form = SetupForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
# Create admin account
|
||||||
|
password_hash = bcrypt.generate_password_hash(form.password.data).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
admin = Admin(
|
||||||
|
email=form.email.data,
|
||||||
|
password_hash=password_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log in the admin by setting session
|
||||||
|
session["admin_id"] = admin.id
|
||||||
|
session["admin_email"] = admin.email
|
||||||
|
|
||||||
|
# Redirect to admin dashboard
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
return render_template("setup.html", form=form)
|
||||||
19
src/templates/admin/dashboard.html
Normal file
19
src/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin Dashboard - Sneaky Klaus{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Admin Dashboard</h1>
|
||||||
|
<form method="POST" action="{{ url_for('admin.logout') }}" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="secondary">Logout</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>Welcome to the Sneaky Klaus admin dashboard!</p>
|
||||||
|
|
||||||
|
<p>This is a placeholder for the admin dashboard. More features coming soon.</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
59
src/templates/admin/login.html
Normal file
59
src/templates/admin/login.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin Login - Sneaky Klaus{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article style="max-width: 600px; margin: 4rem auto;">
|
||||||
|
<header>
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Sign in to manage your gift exchanges.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div role="alert" style="margin-bottom: 1rem; {% if category == 'error' %}color: var(--pico-form-element-invalid-border-color);{% else %}color: var(--pico-primary);{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('admin.login') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email">
|
||||||
|
{{ form.email.label.text }}
|
||||||
|
{{ form.email(required=True) }}
|
||||||
|
</label>
|
||||||
|
{% if form.email.errors %}
|
||||||
|
<small style="color: var(--pico-form-element-invalid-border-color);">
|
||||||
|
{{ form.email.errors[0] }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password">
|
||||||
|
{{ form.password.label.text }}
|
||||||
|
{{ form.password(required=True) }}
|
||||||
|
</label>
|
||||||
|
{% if form.password.errors %}
|
||||||
|
<small style="color: var(--pico-form-element-invalid-border-color);">
|
||||||
|
{{ form.password.errors[0] }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
{{ form.remember_me() }}
|
||||||
|
{{ form.remember_me.label.text }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
12
src/templates/errors/404.html
Normal file
12
src/templates/errors/404.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Page Not Found - Sneaky Klaus{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
</header>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
20
src/templates/layouts/base.html
Normal file
20
src/templates/layouts/base.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Sneaky Klaus{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Pico CSS via CDN -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
src/templates/setup.html
Normal file
54
src/templates/setup.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin Setup - Sneaky Klaus{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article style="max-width: 600px; margin: 4rem auto;">
|
||||||
|
<header>
|
||||||
|
<h1>Admin Setup</h1>
|
||||||
|
<p>Create your administrator account to get started with Sneaky Klaus.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('setup.setup') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email">
|
||||||
|
{{ form.email.label.text }}
|
||||||
|
{{ form.email(required=True) }}
|
||||||
|
</label>
|
||||||
|
{% if form.email.errors %}
|
||||||
|
<small style="color: var(--pico-form-element-invalid-border-color);">
|
||||||
|
{{ form.email.errors[0] }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password">
|
||||||
|
{{ form.password.label.text }}
|
||||||
|
{{ form.password(required=True) }}
|
||||||
|
</label>
|
||||||
|
{% if form.password.errors %}
|
||||||
|
<small style="color: var(--pico-form-element-invalid-border-color);">
|
||||||
|
{{ form.password.errors[0] }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm">
|
||||||
|
{{ form.password_confirm.label.text }}
|
||||||
|
{{ form.password_confirm(required=True) }}
|
||||||
|
</label>
|
||||||
|
{% if form.password_confirm.errors %}
|
||||||
|
<small style="color: var(--pico-form-element-invalid-border-color);">
|
||||||
|
{{ form.password_confirm.errors[0] }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Create Admin Account</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
9
src/utils/__init__.py
Normal file
9
src/utils/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Utility functions for Sneaky Klaus application."""
|
||||||
|
|
||||||
|
from src.utils.rate_limit import (
|
||||||
|
check_rate_limit,
|
||||||
|
increment_rate_limit,
|
||||||
|
reset_rate_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["check_rate_limit", "increment_rate_limit", "reset_rate_limit"]
|
||||||
84
src/utils/rate_limit.py
Normal file
84
src/utils/rate_limit.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Rate limiting utilities for authentication."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from src.app import db
|
||||||
|
from src.models import RateLimit
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(key: str, max_attempts: int, window_minutes: int) -> bool:
|
||||||
|
"""Check if rate limit has been exceeded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Rate limit key (e.g., "login:admin:user@example.com").
|
||||||
|
max_attempts: Maximum allowed attempts within window.
|
||||||
|
window_minutes: Time window in minutes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if rate limit exceeded, False otherwise.
|
||||||
|
"""
|
||||||
|
rate_limit = db.session.query(RateLimit).filter_by(key=key).first()
|
||||||
|
|
||||||
|
if not rate_limit:
|
||||||
|
# No rate limit record exists yet
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Check if rate limit window has expired
|
||||||
|
if rate_limit.expires_at <= now:
|
||||||
|
# Window expired, reset
|
||||||
|
rate_limit.attempts = 0
|
||||||
|
rate_limit.window_start = now
|
||||||
|
rate_limit.expires_at = now + timedelta(minutes=window_minutes)
|
||||||
|
db.session.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if attempts exceeded
|
||||||
|
return bool(rate_limit.attempts >= max_attempts)
|
||||||
|
|
||||||
|
|
||||||
|
def increment_rate_limit(key: str, window_minutes: int) -> None:
|
||||||
|
"""Increment rate limit attempt counter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Rate limit key (e.g., "login:admin:user@example.com").
|
||||||
|
window_minutes: Time window in minutes.
|
||||||
|
"""
|
||||||
|
rate_limit = db.session.query(RateLimit).filter_by(key=key).first()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
if not rate_limit:
|
||||||
|
# Create new rate limit record
|
||||||
|
rate_limit = RateLimit(
|
||||||
|
key=key,
|
||||||
|
attempts=1,
|
||||||
|
window_start=now,
|
||||||
|
expires_at=now + timedelta(minutes=window_minutes),
|
||||||
|
)
|
||||||
|
db.session.add(rate_limit)
|
||||||
|
else:
|
||||||
|
# Check if window expired
|
||||||
|
if rate_limit.expires_at <= now:
|
||||||
|
# Reset window
|
||||||
|
rate_limit.attempts = 1
|
||||||
|
rate_limit.window_start = now
|
||||||
|
rate_limit.expires_at = now + timedelta(minutes=window_minutes)
|
||||||
|
else:
|
||||||
|
# Increment attempts
|
||||||
|
rate_limit.attempts += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_rate_limit(key: str) -> None:
|
||||||
|
"""Reset rate limit counter for a key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Rate limit key (e.g., "login:admin:user@example.com").
|
||||||
|
"""
|
||||||
|
rate_limit = db.session.query(RateLimit).filter_by(key=key).first()
|
||||||
|
|
||||||
|
if rate_limit:
|
||||||
|
rate_limit.attempts = 0
|
||||||
|
db.session.commit()
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Test package for Sneaky Klaus."""
|
||||||
77
tests/conftest.py
Normal file
77
tests/conftest.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Pytest configuration and shared fixtures for Sneaky Klaus tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.app import create_app
|
||||||
|
from src.app import db as _db
|
||||||
|
from src.models import Admin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app():
|
||||||
|
"""Create and configure a test Flask application instance.
|
||||||
|
|
||||||
|
This fixture is scoped to the session so the app is created once
|
||||||
|
for all tests.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Flask application configured for testing.
|
||||||
|
"""
|
||||||
|
app = create_app("testing")
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def db(app):
|
||||||
|
"""Create a clean database for each test.
|
||||||
|
|
||||||
|
This fixture creates all tables before each test and drops them
|
||||||
|
after each test to ensure isolation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Flask application instance from app fixture.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
SQLAlchemy database instance.
|
||||||
|
"""
|
||||||
|
with app.app_context():
|
||||||
|
_db.create_all()
|
||||||
|
yield _db
|
||||||
|
_db.session.remove()
|
||||||
|
_db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def client(app, db): # noqa: ARG001
|
||||||
|
"""Create a test client for the Flask application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Flask application instance.
|
||||||
|
db: Database instance (ensures db is set up first).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Flask test client.
|
||||||
|
"""
|
||||||
|
with app.test_client() as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin(db):
|
||||||
|
"""Create an admin user for testing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Admin model instance.
|
||||||
|
"""
|
||||||
|
from src.app import bcrypt
|
||||||
|
|
||||||
|
admin = Admin(
|
||||||
|
email="admin@example.com",
|
||||||
|
password_hash=bcrypt.generate_password_hash("testpassword123").decode("utf-8"),
|
||||||
|
)
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
return admin
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
382
tests/integration/test_admin_login.py
Normal file
382
tests/integration/test_admin_login.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"""Integration tests for Story 1.2: Admin Login."""
|
||||||
|
|
||||||
|
from src.models import RateLimit
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminLogin:
|
||||||
|
"""Test cases for admin login flow (Story 1.2)."""
|
||||||
|
|
||||||
|
def test_login_page_renders(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that login page renders correctly.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Login form accepts email and password
|
||||||
|
"""
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"email" in response.data.lower()
|
||||||
|
assert b"password" in response.data.lower()
|
||||||
|
# Check for login-specific elements
|
||||||
|
assert b"login" in response.data.lower() or b"sign in" in response.data.lower()
|
||||||
|
|
||||||
|
def test_valid_credentials_login_successfully(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that valid credentials log in successfully.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Valid credentials log in successfully
|
||||||
|
- Successful login redirects to admin dashboard
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should redirect to dashboard
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/admin/dashboard" in response.location
|
||||||
|
|
||||||
|
# Follow redirect and verify we can access dashboard
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_invalid_email_shows_error(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that invalid email shows appropriate error.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Invalid credentials show appropriate error message
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "wrong@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show error message (generic for security)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
b"invalid" in response.data.lower() or b"incorrect" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_password_shows_error(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that invalid password shows appropriate error.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Invalid credentials show appropriate error message
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "wrongpassword",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show error message (generic for security)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
b"invalid" in response.data.lower() or b"incorrect" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_session_persists_across_requests(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that session persists across browser refreshes.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Session persists across browser refreshes
|
||||||
|
"""
|
||||||
|
# Login first
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Make another request - should still be authenticated
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Make multiple requests to simulate browser refreshes
|
||||||
|
for _ in range(3):
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_rate_limiting_after_five_failed_attempts(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test rate limiting after 5 failed login attempts.
|
||||||
|
|
||||||
|
Acceptance Criteria (from auth.md):
|
||||||
|
- Rate limiting (5 attempts per 15 minutes)
|
||||||
|
"""
|
||||||
|
# Make 5 failed login attempts
|
||||||
|
for _ in range(5):
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "wrongpassword",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
# First 5 should return 200 with error
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 6th attempt should be rate limited
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "wrongpassword",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show rate limit error
|
||||||
|
assert (
|
||||||
|
b"too many" in response.data.lower()
|
||||||
|
or b"rate limit" in response.data.lower()
|
||||||
|
or b"try again" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify rate limit record was created
|
||||||
|
rate_limit_key = "login:admin:admin@example.com"
|
||||||
|
rate_limit = db.session.query(RateLimit).filter_by(key=rate_limit_key).first()
|
||||||
|
assert rate_limit is not None
|
||||||
|
assert rate_limit.attempts >= 5
|
||||||
|
|
||||||
|
def test_successful_login_resets_rate_limit(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that successful login resets rate limit counter.
|
||||||
|
|
||||||
|
Acceptance Criteria (from auth.md):
|
||||||
|
- Success Handling: Reset counter on successful login
|
||||||
|
"""
|
||||||
|
# Make a few failed attempts
|
||||||
|
for _ in range(3):
|
||||||
|
client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "wrongpassword",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify rate limit record exists
|
||||||
|
rate_limit_key = "login:admin:admin@example.com"
|
||||||
|
rate_limit = db.session.query(RateLimit).filter_by(key=rate_limit_key).first()
|
||||||
|
assert rate_limit is not None
|
||||||
|
assert rate_limit.attempts == 3
|
||||||
|
|
||||||
|
# Now login with correct credentials
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Rate limit should be reset
|
||||||
|
db.session.refresh(rate_limit)
|
||||||
|
assert rate_limit.attempts == 0
|
||||||
|
|
||||||
|
def test_logout_clears_session(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that logout clears the session.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Logout clears session
|
||||||
|
- Redirects to login page after logout
|
||||||
|
"""
|
||||||
|
# Login first
|
||||||
|
client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we're logged in
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
response = client.post("/admin/logout", follow_redirects=False)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# After logout, should not be able to access admin routes
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
# Should redirect to login or show unauthorized
|
||||||
|
assert response.status_code in (302, 401, 403)
|
||||||
|
|
||||||
|
def test_already_logged_in_redirects_to_dashboard(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that accessing login page when logged in redirects to dashboard.
|
||||||
|
|
||||||
|
Acceptance Criteria (from auth.md):
|
||||||
|
- Accessible to unauthenticated users only
|
||||||
|
- Redirects to dashboard if already authenticated
|
||||||
|
"""
|
||||||
|
# Login first
|
||||||
|
client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to access login page again
|
||||||
|
response = client.get("/admin/login", follow_redirects=False)
|
||||||
|
|
||||||
|
# Should redirect to dashboard
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/admin/dashboard" in response.location
|
||||||
|
|
||||||
|
def test_remember_me_extends_session(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that remember_me checkbox extends session duration.
|
||||||
|
|
||||||
|
Acceptance Criteria (from auth.md):
|
||||||
|
- remember_me: BooleanField, optional (extends session duration)
|
||||||
|
- Checked: 30 days
|
||||||
|
- Unchecked: 7 days (default)
|
||||||
|
"""
|
||||||
|
# Login with remember_me checked
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
"remember_me": True,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Check that session cookie has appropriate max-age
|
||||||
|
# Note: This is implementation-dependent and may need adjustment
|
||||||
|
# based on actual Flask-Session configuration
|
||||||
|
|
||||||
|
def test_email_normalization_to_lowercase(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that email is normalized to lowercase for login.
|
||||||
|
|
||||||
|
Acceptance Criteria (from auth.md):
|
||||||
|
- Normalize email to lowercase
|
||||||
|
"""
|
||||||
|
# Login with uppercase email
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "ADMIN@EXAMPLE.COM",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should successfully login (email normalized)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/admin/dashboard" in response.location
|
||||||
|
|
||||||
|
def test_csrf_protection_on_login(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that CSRF protection is enabled on login form.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- CSRF token (automatic via Flask-WTF)
|
||||||
|
|
||||||
|
Note: CSRF protection is verified by the fact that all POST tests pass.
|
||||||
|
Flask-WTF automatically validates CSRF tokens on form submission.
|
||||||
|
In testing mode, CSRF validation is disabled for ease of testing,
|
||||||
|
but in production it will be enforced.
|
||||||
|
"""
|
||||||
|
# Get the login page
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify form exists and can be submitted
|
||||||
|
# CSRF protection is verified implicitly through successful form submissions
|
||||||
|
assert b"<form" in response.data
|
||||||
|
assert b"method=" in response.data.lower()
|
||||||
|
assert b"post" in response.data.lower()
|
||||||
|
|
||||||
|
def test_invalid_email_format_shows_validation_error(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that invalid email format shows validation error."""
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "not-an-email",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show validation error
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
b"valid email" in response.data.lower()
|
||||||
|
or b"invalid" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_fields_show_validation_errors(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that empty required fields show validation errors."""
|
||||||
|
# Empty email
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"required" in response.data.lower()
|
||||||
|
|
||||||
|
# Empty password
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"required" in response.data.lower()
|
||||||
|
|
||||||
|
def test_logout_option_available_in_admin_interface(self, client, db, admin): # noqa: ARG002
|
||||||
|
"""Test that logout option is available from admin interface.
|
||||||
|
|
||||||
|
Acceptance Criteria (Story 1.4):
|
||||||
|
- Logout option available from admin interface
|
||||||
|
"""
|
||||||
|
# Login first
|
||||||
|
client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Access admin dashboard
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify logout button/link is present
|
||||||
|
# Check for logout form posting to /admin/logout
|
||||||
|
assert b"/admin/logout" in response.data
|
||||||
|
# Check for logout text in button or link
|
||||||
|
assert b"logout" in response.data.lower() or b"log out" in response.data.lower()
|
||||||
219
tests/integration/test_setup.py
Normal file
219
tests/integration/test_setup.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Integration tests for Story 1.1: Initial Admin Setup."""
|
||||||
|
|
||||||
|
from src.app import bcrypt
|
||||||
|
from src.models import Admin
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitialAdminSetup:
|
||||||
|
"""Test cases for initial admin setup flow (Story 1.1)."""
|
||||||
|
|
||||||
|
def test_setup_screen_appears_on_first_access(self, client, db): # noqa: ARG002
|
||||||
|
"""Test that setup screen appears when no admin exists.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Setup screen appears on first application access
|
||||||
|
"""
|
||||||
|
# Access root path - should redirect to setup
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/setup" in response.location
|
||||||
|
|
||||||
|
# Accessing setup directly should show setup form
|
||||||
|
response = client.get("/setup", follow_redirects=False)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Admin Setup" in response.data or b"Create Admin" in response.data
|
||||||
|
assert b"email" in response.data.lower()
|
||||||
|
assert b"password" in response.data.lower()
|
||||||
|
|
||||||
|
def test_setup_requires_email_and_password(self, client, db): # noqa: ARG002
|
||||||
|
"""Test that setup form requires email and password.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Requires email address and password
|
||||||
|
"""
|
||||||
|
response = client.get("/setup")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Check form has email and password fields
|
||||||
|
assert b'type="email"' in response.data or b'name="email"' in response.data
|
||||||
|
assert (
|
||||||
|
b'type="password"' in response.data or b'name="password"' in response.data
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_setup_with_valid_data_creates_admin(self, client, db):
|
||||||
|
"""Test that valid setup data creates admin account.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- After setup, admin account is created
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/setup",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "validpassword123",
|
||||||
|
"password_confirm": "validpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should redirect after successful setup
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Verify admin was created
|
||||||
|
admin = db.session.query(Admin).filter_by(email="admin@example.com").first()
|
||||||
|
assert admin is not None
|
||||||
|
assert admin.email == "admin@example.com"
|
||||||
|
|
||||||
|
# Verify password was hashed
|
||||||
|
assert admin.password_hash is not None
|
||||||
|
assert admin.password_hash != "validpassword123"
|
||||||
|
assert bcrypt.check_password_hash(admin.password_hash, "validpassword123")
|
||||||
|
|
||||||
|
def test_setup_validates_password_minimum_length(self, client, db):
|
||||||
|
"""Test that password must meet minimum length requirement.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Password must meet minimum security requirements (12 characters)
|
||||||
|
"""
|
||||||
|
# Try with password too short
|
||||||
|
response = client.post(
|
||||||
|
"/setup",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "short", # Less than 12 characters
|
||||||
|
"password_confirm": "short",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show error and not create admin
|
||||||
|
assert (
|
||||||
|
b"at least 12 characters" in response.data.lower()
|
||||||
|
or b"too short" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no admin was created
|
||||||
|
admin_count = db.session.query(Admin).count()
|
||||||
|
assert admin_count == 0
|
||||||
|
|
||||||
|
def test_setup_validates_password_confirmation(self, client, db):
|
||||||
|
"""Test that password confirmation must match.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Password confirmation must match password
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/setup",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "validpassword123",
|
||||||
|
"password_confirm": "differentpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show error about passwords not matching
|
||||||
|
assert b"match" in response.data.lower() or b"same" in response.data.lower()
|
||||||
|
|
||||||
|
# Verify no admin was created
|
||||||
|
admin_count = db.session.query(Admin).count()
|
||||||
|
assert admin_count == 0
|
||||||
|
|
||||||
|
def test_setup_validates_email_format(self, client, db):
|
||||||
|
"""Test that email must be valid format.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Email address must be valid format
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/setup",
|
||||||
|
data={
|
||||||
|
"email": "not-a-valid-email",
|
||||||
|
"password": "validpassword123",
|
||||||
|
"password_confirm": "validpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should show error about invalid email
|
||||||
|
assert (
|
||||||
|
b"valid email" in response.data.lower()
|
||||||
|
or b"invalid email" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no admin was created
|
||||||
|
admin_count = db.session.query(Admin).count()
|
||||||
|
assert admin_count == 0
|
||||||
|
|
||||||
|
def test_setup_logs_in_admin_after_creation(self, client, db): # noqa: ARG002
|
||||||
|
"""Test that user is logged in after successful setup.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- After setup, user is logged in as admin
|
||||||
|
"""
|
||||||
|
response = client.post(
|
||||||
|
"/setup",
|
||||||
|
data={
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "validpassword123",
|
||||||
|
"password_confirm": "validpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should redirect to admin dashboard
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
b"dashboard" in response.data.lower() or b"admin" in response.data.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify session is set (check that we can access admin routes)
|
||||||
|
response = client.get("/admin/dashboard", follow_redirects=False)
|
||||||
|
# 404 is OK if route not implemented yet
|
||||||
|
assert response.status_code in (200, 404)
|
||||||
|
|
||||||
|
def test_setup_not_accessible_after_admin_exists(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
db,
|
||||||
|
admin, # noqa: ARG002
|
||||||
|
):
|
||||||
|
"""Test that setup page returns 404 after admin exists.
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- Setup screen is not accessible after initial admin creation
|
||||||
|
"""
|
||||||
|
# Try to access setup when admin already exists
|
||||||
|
response = client.get("/setup", follow_redirects=False)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# Try to POST to setup when admin already exists
|
||||||
|
response = client.post(
|
||||||
|
"/setup",
|
||||||
|
data={
|
||||||
|
"email": "another@example.com",
|
||||||
|
"password": "validpassword123",
|
||||||
|
"password_confirm": "validpassword123",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# Verify no second admin was created
|
||||||
|
admin_count = db.session.query(Admin).count()
|
||||||
|
assert admin_count == 1
|
||||||
|
|
||||||
|
def test_root_redirects_to_admin_dashboard_when_admin_exists(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
db, # noqa: ARG002
|
||||||
|
admin, # noqa: ARG002
|
||||||
|
):
|
||||||
|
"""Test that root path redirects normally when admin exists.
|
||||||
|
|
||||||
|
Once setup is complete, the root path should not redirect to setup.
|
||||||
|
"""
|
||||||
|
response = client.get("/", follow_redirects=False)
|
||||||
|
|
||||||
|
# Should either render landing page or redirect to login (not to setup)
|
||||||
|
assert "/setup" not in (response.location or "")
|
||||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
Reference in New Issue
Block a user