Add logout button to admin dashboard and test to verify its presence. This completes the missing acceptance criterion for Story 1.4: "Logout option available from admin interface" Changes: - Add logout form with CSRF protection to dashboard header - Add integration test to verify logout button is present - All tests pass (24/24) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
383 lines
13 KiB
Python
383 lines
13 KiB
Python
"""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()
|