The auth routes were registered under /admin/* but the IndieAuth redirect_uri was configured as /auth/callback, causing 404 errors when providers redirected back after authentication. - Change auth blueprint url_prefix from "/admin" to "/auth" - Update test expectations for new auth route paths - Add ADR-022 documenting the architectural decision 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""
|
|
Tests for development authentication routes and security
|
|
|
|
Tests cover:
|
|
- Dev auth route availability based on DEV_MODE
|
|
- Session creation without authentication
|
|
- Security: 404 when DEV_MODE disabled
|
|
- Configuration validation
|
|
- Visual warning indicators
|
|
"""
|
|
|
|
import pytest
|
|
from starpunk import create_app
|
|
from starpunk.auth import verify_session
|
|
|
|
|
|
@pytest.fixture
|
|
def dev_app(tmp_path):
|
|
"""Create app with DEV_MODE enabled"""
|
|
test_data_dir = tmp_path / "dev_data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret",
|
|
"SITE_URL": "http://localhost:5000",
|
|
"DEV_MODE": True,
|
|
"DEV_ADMIN_ME": "https://dev.example.com",
|
|
}
|
|
app = create_app(config=test_config)
|
|
yield app
|
|
|
|
|
|
@pytest.fixture
|
|
def prod_app(tmp_path):
|
|
"""Create app with DEV_MODE disabled (production)"""
|
|
test_data_dir = tmp_path / "prod_data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret",
|
|
"SITE_URL": "http://localhost:5000",
|
|
"ADMIN_ME": "https://prod.example.com",
|
|
"DEV_MODE": False,
|
|
}
|
|
app = create_app(config=test_config)
|
|
yield app
|
|
|
|
|
|
class TestDevAuthRouteAvailability:
|
|
"""Test dev auth routes are only available when DEV_MODE enabled"""
|
|
|
|
def test_dev_login_available_when_enabled(self, dev_app):
|
|
"""Test /dev/login is available when DEV_MODE=true"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/dev/login", follow_redirects=False)
|
|
|
|
# Should redirect to dashboard (successful login)
|
|
assert response.status_code == 302
|
|
assert "/admin/" in response.location
|
|
|
|
def test_dev_login_404_when_disabled(self, prod_app):
|
|
"""Test /dev/login returns 404 when DEV_MODE=false"""
|
|
client = prod_app.test_client()
|
|
response = client.get("/dev/login")
|
|
|
|
# Should return 404 - route doesn't exist
|
|
assert response.status_code == 404
|
|
|
|
def test_dev_login_not_accessible_in_production(self, prod_app):
|
|
"""Test dev login cannot be accessed in production mode"""
|
|
client = prod_app.test_client()
|
|
|
|
# Try various paths
|
|
paths = ["/dev/login", "/dev/auth", "/dev-login"]
|
|
for path in paths:
|
|
response = client.get(path)
|
|
# Should be 404 (dev routes not registered) or redirect to login
|
|
assert response.status_code in [404, 302]
|
|
|
|
|
|
class TestDevAuthFunctionality:
|
|
"""Test dev auth creates sessions correctly"""
|
|
|
|
def test_dev_login_creates_session(self, dev_app):
|
|
"""Test dev login creates a valid session"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/dev/login", follow_redirects=False)
|
|
|
|
assert response.status_code == 302
|
|
|
|
# Check session cookie was set
|
|
cookies = response.headers.getlist("Set-Cookie")
|
|
assert any("session=" in cookie for cookie in cookies)
|
|
|
|
def test_dev_login_session_is_valid(self, dev_app):
|
|
"""Test dev login session can be verified"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/dev/login", follow_redirects=False)
|
|
|
|
# Extract session token from cookie
|
|
session_token = None
|
|
for cookie in response.headers.getlist("Set-Cookie"):
|
|
if "session=" in cookie:
|
|
session_token = cookie.split("session=")[1].split(";")[0]
|
|
break
|
|
|
|
assert session_token is not None
|
|
|
|
# Verify session is valid
|
|
with dev_app.app_context():
|
|
session_info = verify_session(session_token)
|
|
assert session_info is not None
|
|
assert session_info["me"] == "https://dev.example.com"
|
|
|
|
def test_dev_login_uses_dev_admin_me(self, dev_app):
|
|
"""Test dev login uses DEV_ADMIN_ME identity"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/dev/login", follow_redirects=False)
|
|
|
|
# Get session token
|
|
session_token = None
|
|
for cookie in response.headers.getlist("Set-Cookie"):
|
|
if "session=" in cookie:
|
|
session_token = cookie.split("session=")[1].split(";")[0]
|
|
break
|
|
|
|
# Verify identity
|
|
with dev_app.app_context():
|
|
session_info = verify_session(session_token)
|
|
assert session_info is not None
|
|
assert session_info["me"] == dev_app.config["DEV_ADMIN_ME"]
|
|
|
|
def test_dev_login_grants_admin_access(self, dev_app):
|
|
"""Test dev login grants access to admin routes"""
|
|
client = dev_app.test_client()
|
|
|
|
# Login via dev auth
|
|
response = client.get("/dev/login", follow_redirects=True)
|
|
assert response.status_code == 200
|
|
|
|
# Should now be able to access admin
|
|
response = client.get("/admin/")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestConfigurationValidation:
|
|
"""Test configuration validation for dev mode"""
|
|
|
|
def test_dev_mode_requires_dev_admin_me(self, tmp_path):
|
|
"""Test DEV_MODE=true requires DEV_ADMIN_ME"""
|
|
test_data_dir = tmp_path / "validation_data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret",
|
|
"SITE_URL": "http://localhost:5000",
|
|
"DEV_MODE": True,
|
|
# Missing DEV_ADMIN_ME
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="DEV_ADMIN_ME"):
|
|
app = create_app(config=test_config)
|
|
|
|
def test_production_mode_requires_admin_me(self, tmp_path):
|
|
"""Test production mode requires ADMIN_ME"""
|
|
test_data_dir = tmp_path / "prod_validation_data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret",
|
|
"SITE_URL": "http://localhost:5000",
|
|
"DEV_MODE": False,
|
|
"ADMIN_ME": None, # Explicitly set to None
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="ADMIN_ME"):
|
|
app = create_app(config=test_config)
|
|
|
|
def test_dev_mode_allows_missing_admin_me(self, tmp_path):
|
|
"""Test DEV_MODE=true doesn't require ADMIN_ME"""
|
|
test_data_dir = tmp_path / "dev_no_admin_data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret",
|
|
"SITE_URL": "http://localhost:5000",
|
|
"DEV_MODE": True,
|
|
"DEV_ADMIN_ME": "https://dev.example.com",
|
|
# ADMIN_ME not set - should be okay
|
|
}
|
|
|
|
# Should not raise
|
|
app = create_app(config=test_config)
|
|
assert app is not None
|
|
|
|
|
|
class TestDevModeWarnings:
|
|
"""Test dev mode warning indicators"""
|
|
|
|
def test_dev_mode_shows_warning_banner(self, dev_app):
|
|
"""Test dev mode shows warning banner on pages"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
# Should have dev mode warning
|
|
assert (
|
|
b"DEVELOPMENT MODE" in response.data
|
|
or b"DEV MODE" in response.data
|
|
or b"Development authentication" in response.data
|
|
)
|
|
|
|
def test_dev_mode_warning_on_admin_pages(self, dev_app):
|
|
"""Test dev mode warning appears on admin pages"""
|
|
client = dev_app.test_client()
|
|
|
|
# Login first
|
|
client.get("/dev/login")
|
|
|
|
# Check admin page
|
|
response = client.get("/admin/")
|
|
assert response.status_code == 200
|
|
assert b"DEVELOPMENT MODE" in response.data or b"DEV MODE" in response.data
|
|
|
|
def test_production_mode_no_warning(self, prod_app):
|
|
"""Test production mode doesn't show dev warning"""
|
|
client = prod_app.test_client()
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
# Should NOT have dev mode warning
|
|
assert b"DEVELOPMENT MODE" not in response.data
|
|
assert b"DEV MODE" not in response.data
|
|
|
|
def test_dev_login_page_shows_link(self, dev_app):
|
|
"""Test login page shows dev login link when DEV_MODE enabled"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/auth/login")
|
|
assert response.status_code == 200
|
|
|
|
# Should have link to dev login
|
|
assert b"/dev/login" in response.data or b"Dev Login" in response.data
|
|
|
|
def test_production_login_no_dev_link(self, prod_app):
|
|
"""Test login page doesn't show dev link in production"""
|
|
client = prod_app.test_client()
|
|
response = client.get("/auth/login")
|
|
assert response.status_code == 200
|
|
|
|
# Should NOT have dev login link
|
|
assert b"/dev/login" not in response.data
|
|
|
|
|
|
class TestSecuritySafeguards:
|
|
"""Test security safeguards for dev auth"""
|
|
|
|
def test_dev_mode_logs_warning(self, tmp_path, caplog):
|
|
"""Test dev mode logs warning on startup"""
|
|
import logging
|
|
|
|
caplog.set_level(logging.WARNING)
|
|
|
|
# Create new app to trigger startup logging
|
|
test_data_dir = tmp_path / "logging_data"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
test_config = {
|
|
"TESTING": True,
|
|
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
|
"DATA_PATH": test_data_dir,
|
|
"NOTES_PATH": test_data_dir / "notes",
|
|
"SESSION_SECRET": "test-secret",
|
|
"SITE_URL": "http://localhost:5000",
|
|
"DEV_MODE": True,
|
|
"DEV_ADMIN_ME": "https://dev.example.com",
|
|
}
|
|
app = create_app(config=test_config)
|
|
|
|
# Check logs
|
|
assert any("DEVELOPMENT" in record.message.upper() for record in caplog.records)
|
|
|
|
def test_dev_login_logs_session_creation(self, dev_app, caplog):
|
|
"""Test dev login logs session creation"""
|
|
import logging
|
|
|
|
caplog.set_level(logging.WARNING)
|
|
|
|
client = dev_app.test_client()
|
|
client.get("/dev/login")
|
|
|
|
# Should log the session creation
|
|
assert any("DEV MODE" in record.message for record in caplog.records)
|
|
|
|
def test_dev_mode_cookie_not_secure(self, dev_app):
|
|
"""Test dev mode session cookie is not marked secure (for localhost)"""
|
|
client = dev_app.test_client()
|
|
response = client.get("/dev/login", follow_redirects=False)
|
|
|
|
# Check cookie settings
|
|
cookies = response.headers.getlist("Set-Cookie")
|
|
session_cookie = [c for c in cookies if "session=" in c][0]
|
|
|
|
# Should have httponly but not secure (for localhost testing)
|
|
assert "HttpOnly" in session_cookie
|
|
# Note: 'Secure' might not be set for dev mode to work with http://localhost
|
|
|
|
|
|
class TestIntegrationFlow:
|
|
"""Test complete dev auth integration flow"""
|
|
|
|
def test_complete_dev_auth_flow(self, dev_app):
|
|
"""Test complete flow: dev login -> admin access -> logout"""
|
|
client = dev_app.test_client()
|
|
|
|
# Step 1: Access admin without auth (should redirect to login)
|
|
response = client.get("/admin/", follow_redirects=False)
|
|
assert response.status_code == 302
|
|
assert "/auth/login" in response.location
|
|
|
|
# Step 2: Use dev login
|
|
response = client.get("/dev/login", follow_redirects=True)
|
|
assert response.status_code == 200
|
|
|
|
# Step 3: Access admin (should work now)
|
|
response = client.get("/admin/")
|
|
assert response.status_code == 200
|
|
assert b"Dashboard" in response.data or b"Admin" in response.data
|
|
|
|
# Step 4: Create a note
|
|
response = client.post(
|
|
"/admin/new",
|
|
data={
|
|
"content": "# Dev Auth Test\n\nCreated via dev auth.",
|
|
"published": "on",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Step 5: Logout
|
|
response = client.post("/auth/logout", follow_redirects=True)
|
|
assert response.status_code == 200
|
|
|
|
# Step 6: Verify can't access admin anymore
|
|
response = client.get("/admin/", follow_redirects=False)
|
|
assert response.status_code == 302
|