feat: implement initial admin setup (Story 1.1)
Add complete initial admin setup functionality including: - SetupForm with email, password, and password confirmation fields - Password validation (minimum 12 characters) and confirmation matching - Email format validation - Setup route with GET (display form) and POST (process setup) handlers - bcrypt password hashing before storing admin credentials - Auto-login after successful setup with session management - First-run detection middleware that redirects to /setup if no admin exists - Setup page returns 404 after admin account is created - Base HTML template with Pico CSS integration - Admin dashboard placeholder template - 404 error template All tests pass with 90.09% code coverage (exceeds 80% requirement). Code passes ruff linting and mypy type checking. Story: 1.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
28
src/app.py
28
src/app.py
@@ -55,12 +55,12 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
app.config["SESSION_SQLALCHEMY"] = db
|
||||
session.init_app(app)
|
||||
|
||||
# Register blueprints (will be created later)
|
||||
# from src.routes import admin, auth, participant, public
|
||||
# app.register_blueprint(admin.bp)
|
||||
# app.register_blueprint(auth.bp)
|
||||
# app.register_blueprint(participant.bp)
|
||||
# app.register_blueprint(public.bp)
|
||||
# Register blueprints
|
||||
from src.routes.admin import admin_bp
|
||||
from src.routes.setup import setup_bp
|
||||
|
||||
app.register_blueprint(setup_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
@@ -127,17 +127,15 @@ def register_setup_check(app: Flask) -> None:
|
||||
has been set up with an admin account.
|
||||
"""
|
||||
# Skip check for certain endpoints
|
||||
if request.endpoint in ["setup", "static", "health"]:
|
||||
if request.endpoint in ["setup.setup", "static", "health"]:
|
||||
return
|
||||
|
||||
# Check if we've already determined setup is required
|
||||
if not hasattr(app, "_setup_checked"):
|
||||
from src.models.admin import Admin
|
||||
# Check if admin exists (always check in testing mode)
|
||||
from src.models.admin import Admin
|
||||
|
||||
admin_count = db.session.query(Admin).count()
|
||||
app.config["REQUIRES_SETUP"] = admin_count == 0
|
||||
app._setup_checked = True
|
||||
admin_count = db.session.query(Admin).count()
|
||||
requires_setup = admin_count == 0
|
||||
|
||||
# Redirect to setup if needed
|
||||
if app.config.get("REQUIRES_SETUP") and request.endpoint != "setup":
|
||||
return redirect(url_for("setup"))
|
||||
if requires_setup and request.endpoint != "setup.setup":
|
||||
return redirect(url_for("setup.setup"))
|
||||
|
||||
5
src/forms/__init__.py
Normal file
5
src/forms/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Forms for Sneaky Klaus application."""
|
||||
|
||||
from src.forms.setup import SetupForm
|
||||
|
||||
__all__ = ["SetupForm"]
|
||||
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
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"]
|
||||
15
src/routes/admin.py
Normal file
15
src/routes/admin.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Admin routes for Sneaky Klaus application."""
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@admin_bp.route("/dashboard")
|
||||
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)
|
||||
15
src/templates/admin/dashboard.html
Normal file
15
src/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - Sneaky Klaus{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<header>
|
||||
<h1>Admin Dashboard</h1>
|
||||
</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 %}
|
||||
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 %}
|
||||
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
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