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>
This commit is contained in:
463
tests/test_routes_admin.py
Normal file
463
tests/test_routes_admin.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user