## 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>
464 lines
16 KiB
Python
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
|