Files
StarPunk/tests/test_routes_dev_auth.py
Phil Skentelbery 0cca8169ce feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:53 -07:00

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("/admin/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("/admin/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 "/admin/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("/admin/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