Files
StarPunk/tests/test_routes_admin.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

464 lines
16 KiB
Python

"""
Tests for admin routes (dashboard, note management)
Tests cover:
- Authentication requirement for all admin routes
- Dashboard rendering with note list
- Create note flow
- Edit note flow
- Delete note flow
- Logout functionality
"""
import pytest
from starpunk import create_app
from starpunk.notes import create_note
from starpunk.auth import create_session
@pytest.fixture
def app(tmp_path):
"""Create test application"""
# Create test-specific data directory
test_data_dir = tmp_path / "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-key",
"ADMIN_ME": "https://test.example.com",
"SITE_URL": "http://localhost:5000",
"DEV_MODE": False,
}
app = create_app(config=test_config)
yield app
@pytest.fixture
def client(app):
"""Test client"""
return app.test_client()
@pytest.fixture
def authenticated_client(app, client):
"""Client with authenticated session"""
with app.test_request_context():
# Create a session for the test user
session_token = create_session("https://test.example.com")
# Set session cookie
client.set_cookie("starpunk_session", session_token)
return client
@pytest.fixture
def sample_notes(app):
"""Create sample notes"""
with app.app_context():
notes = []
for i in range(3):
note = create_note(
content=f"# Admin Test Note {i}\n\nContent {i}.",
published=(i != 1), # Note 1 is draft
)
notes.append(note)
return notes
class TestAuthenticationRequirement:
"""Test that all admin routes require authentication"""
def test_dashboard_requires_auth(self, client):
"""Test /admin requires authentication"""
response = client.get("/admin/", follow_redirects=False)
assert response.status_code == 302
assert "/admin/login" in response.location
def test_new_note_form_requires_auth(self, client):
"""Test /admin/new requires authentication"""
response = client.get("/admin/new", follow_redirects=False)
assert response.status_code == 302
def test_edit_note_form_requires_auth(self, client, sample_notes):
"""Test /admin/edit/<id> requires authentication"""
with client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = client.get(f"/admin/edit/{note_id}", follow_redirects=False)
assert response.status_code == 302
def test_create_note_submit_requires_auth(self, client):
"""Test POST /admin/new requires authentication"""
response = client.post(
"/admin/new",
data={"content": "Test content", "published": "on"},
follow_redirects=False,
)
assert response.status_code == 302
def test_update_note_submit_requires_auth(self, client, sample_notes):
"""Test POST /admin/edit/<id> requires authentication"""
with client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = client.post(
f"/admin/edit/{note_id}",
data={"content": "Updated content", "published": "on"},
follow_redirects=False,
)
assert response.status_code == 302
def test_delete_note_requires_auth(self, client, sample_notes):
"""Test POST /admin/delete/<id> requires authentication"""
with client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = client.post(
f"/admin/delete/{note_id}", data={"confirm": "yes"}, follow_redirects=False
)
assert response.status_code == 302
class TestDashboard:
"""Test admin dashboard"""
def test_dashboard_renders(self, authenticated_client):
"""Test dashboard renders for authenticated user"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
assert b"Dashboard" in response.data or b"Admin" in response.data
def test_dashboard_shows_all_notes(self, authenticated_client, sample_notes):
"""Test dashboard shows both published and draft notes"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
# All notes should appear
assert b"Admin Test Note 0" in response.data
assert b"Admin Test Note 1" in response.data
assert b"Admin Test Note 2" in response.data
def test_dashboard_shows_note_status(self, authenticated_client, sample_notes):
"""Test dashboard shows published/draft status"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
# Should indicate status
assert (
b"published" in response.data.lower() or b"draft" in response.data.lower()
)
def test_dashboard_has_new_note_button(self, authenticated_client):
"""Test dashboard has new note button"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
assert b"/admin/new" in response.data or b"New Note" in response.data
def test_dashboard_has_edit_links(self, authenticated_client, sample_notes):
"""Test dashboard has edit links for notes"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
assert b"/admin/edit/" in response.data or b"Edit" in response.data
def test_dashboard_has_delete_buttons(self, authenticated_client, sample_notes):
"""Test dashboard has delete buttons"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
assert b"delete" in response.data.lower()
def test_dashboard_has_logout_link(self, authenticated_client):
"""Test dashboard has logout link"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
assert b"logout" in response.data.lower()
def test_dashboard_shows_user_identity(self, authenticated_client):
"""Test dashboard shows logged in user identity"""
response = authenticated_client.get("/admin/")
assert response.status_code == 200
assert b"test.example.com" in response.data
class TestCreateNote:
"""Test note creation flow"""
def test_new_note_form_renders(self, authenticated_client):
"""Test new note form renders"""
response = authenticated_client.get("/admin/new")
assert response.status_code == 200
assert b"<form" in response.data
assert b"<textarea" in response.data
def test_new_note_form_has_content_field(self, authenticated_client):
"""Test form has content textarea"""
response = authenticated_client.get("/admin/new")
assert response.status_code == 200
assert b'name="content"' in response.data or b'id="content"' in response.data
def test_new_note_form_has_published_checkbox(self, authenticated_client):
"""Test form has published checkbox"""
response = authenticated_client.get("/admin/new")
assert response.status_code == 200
assert b'type="checkbox"' in response.data
assert b'name="published"' in response.data or b"published" in response.data
def test_create_note_success(self, authenticated_client):
"""Test creating a note successfully"""
response = authenticated_client.post(
"/admin/new",
data={"content": "# New Test Note\n\nThis is a test.", "published": "on"},
follow_redirects=True,
)
assert response.status_code == 200
assert (
b"created" in response.data.lower() or b"success" in response.data.lower()
)
# Verify note was created
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
assert any("New Test Note" in n.content for n in notes)
def test_create_draft_note(self, authenticated_client):
"""Test creating a draft note (published unchecked)"""
response = authenticated_client.post(
"/admin/new",
data={
"content": "# Draft Note\n\nThis is a draft."
# published checkbox not checked
},
follow_redirects=True,
)
assert response.status_code == 200
# Verify draft was created
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
# Get all notes and filter for drafts
all_notes = list_notes()
drafts = [n for n in all_notes if not n.published]
assert any("Draft Note" in n.content for n in drafts)
def test_create_note_with_empty_content_fails(self, authenticated_client):
"""Test creating note with empty content fails"""
response = authenticated_client.post(
"/admin/new", data={"content": "", "published": "on"}, follow_redirects=True
)
# Should show error
assert b"error" in response.data.lower() or b"required" in response.data.lower()
def test_create_note_redirects_to_dashboard(self, authenticated_client):
"""Test successful create redirects to dashboard"""
response = authenticated_client.post(
"/admin/new",
data={"content": "# Test\n\nContent.", "published": "on"},
follow_redirects=False,
)
assert response.status_code == 302
assert "/admin/" in response.location
class TestEditNote:
"""Test note editing flow"""
def test_edit_note_form_renders(self, authenticated_client, sample_notes):
"""Test edit form renders"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.get(f"/admin/edit/{note_id}")
assert response.status_code == 200
assert b"<form" in response.data
assert b"<textarea" in response.data
def test_edit_form_has_existing_content(self, authenticated_client, sample_notes):
"""Test edit form is pre-filled with existing content"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.get(f"/admin/edit/{note_id}")
assert response.status_code == 200
assert b"Admin Test Note" in response.data
def test_edit_form_has_delete_button(self, authenticated_client, sample_notes):
"""Test edit form has delete button"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.get(f"/admin/edit/{note_id}")
assert response.status_code == 200
assert b"delete" in response.data.lower()
def test_update_note_success(self, authenticated_client, sample_notes):
"""Test updating a note successfully"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.post(
f"/admin/edit/{note_id}",
data={"content": "# Updated Note\n\nUpdated content.", "published": "on"},
follow_redirects=True,
)
assert response.status_code == 200
assert (
b"updated" in response.data.lower() or b"success" in response.data.lower()
)
# Verify note was updated
with authenticated_client.application.app_context():
from starpunk.notes import get_note
note = get_note(id=note_id)
assert "Updated Note" in note.content
def test_update_note_change_published_status(
self, authenticated_client, sample_notes
):
"""Test changing published status"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes(published_only=True)
note_id = notes[0].id
# Unpublish the note
response = authenticated_client.post(
f"/admin/edit/{note_id}",
data={
"content": "# Test\n\nContent."
# published not checked
},
follow_redirects=True,
)
assert response.status_code == 200
# Verify status changed
with authenticated_client.application.app_context():
from starpunk.notes import get_note
note = get_note(id=note_id)
assert not note.published
def test_edit_nonexistent_note_404(self, authenticated_client):
"""Test editing nonexistent note returns 404"""
response = authenticated_client.get("/admin/edit/99999")
assert response.status_code == 404
def test_update_nonexistent_note_404(self, authenticated_client):
"""Test updating nonexistent note returns 404"""
response = authenticated_client.post(
"/admin/edit/99999", data={"content": "Test", "published": "on"}
)
assert response.status_code == 404
class TestDeleteNote:
"""Test note deletion"""
def test_delete_note_with_confirmation(self, authenticated_client, sample_notes):
"""Test deleting note with confirmation"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.post(
f"/admin/delete/{note_id}", data={"confirm": "yes"}, follow_redirects=True
)
assert response.status_code == 200
assert (
b"deleted" in response.data.lower() or b"success" in response.data.lower()
)
# Verify note was deleted
with authenticated_client.application.app_context():
from starpunk.notes import get_note
note = get_note(id=note_id)
assert note is None or note.deleted_at is not None
def test_delete_without_confirmation_cancels(
self, authenticated_client, sample_notes
):
"""Test deleting without confirmation cancels operation"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.post(
f"/admin/delete/{note_id}", data={"confirm": "no"}, follow_redirects=True
)
assert response.status_code == 200
assert (
b"cancelled" in response.data.lower() or b"cancel" in response.data.lower()
)
# Verify note still exists
with authenticated_client.application.app_context():
from starpunk.notes import get_note
note = get_note(id=note_id)
assert note is not None
assert note.deleted_at is None
def test_delete_nonexistent_note_shows_error(self, authenticated_client):
"""Test deleting nonexistent note returns 404"""
response = authenticated_client.post(
"/admin/delete/99999", data={"confirm": "yes"}
)
assert response.status_code == 404
def test_delete_redirects_to_dashboard(self, authenticated_client, sample_notes):
"""Test delete redirects to dashboard"""
with authenticated_client.application.app_context():
from starpunk.notes import list_notes
notes = list_notes()
note_id = notes[0].id
response = authenticated_client.post(
f"/admin/delete/{note_id}", data={"confirm": "yes"}, follow_redirects=False
)
assert response.status_code == 302
assert "/admin/" in response.location