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:
@@ -6,7 +6,6 @@ import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from starpunk import create_app
|
||||
from starpunk.database import init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -18,14 +17,14 @@ def app():
|
||||
|
||||
# Test configuration
|
||||
config = {
|
||||
'TESTING': True,
|
||||
'DEBUG': False,
|
||||
'DATA_PATH': temp_path,
|
||||
'NOTES_PATH': temp_path / 'notes',
|
||||
'DATABASE_PATH': temp_path / 'test.db',
|
||||
'SESSION_SECRET': 'test-secret-key',
|
||||
'ADMIN_ME': 'https://test.example.com',
|
||||
'SITE_URL': 'http://localhost:5000',
|
||||
"TESTING": True,
|
||||
"DEBUG": False,
|
||||
"DATA_PATH": temp_path,
|
||||
"NOTES_PATH": temp_path / "notes",
|
||||
"DATABASE_PATH": temp_path / "test.db",
|
||||
"SESSION_SECRET": "test-secret-key",
|
||||
"ADMIN_ME": "https://test.example.com",
|
||||
"SITE_URL": "http://localhost:5000",
|
||||
}
|
||||
|
||||
# Create app with test config
|
||||
|
||||
@@ -515,7 +515,7 @@ class TestRequireAuthDecorator:
|
||||
return "Protected content"
|
||||
|
||||
# Manually set cookie header
|
||||
environ = {"HTTP_COOKIE": f"session={session_token}"}
|
||||
environ = {"HTTP_COOKIE": f"starpunk_session={session_token}"}
|
||||
|
||||
with app.test_request_context(environ_base=environ):
|
||||
result = protected_route()
|
||||
@@ -562,7 +562,7 @@ class TestRequireAuthDecorator:
|
||||
return "Protected content"
|
||||
|
||||
# Call protected route with expired session
|
||||
environ = {"HTTP_COOKIE": f"session={token}"}
|
||||
environ = {"HTTP_COOKIE": f"starpunk_session={token}"}
|
||||
|
||||
with app.test_request_context(environ_base=environ):
|
||||
with patch("starpunk.auth.redirect") as mock_redirect:
|
||||
|
||||
@@ -25,7 +25,7 @@ from starpunk.notes import (
|
||||
NoteNotFoundError,
|
||||
InvalidNoteDataError,
|
||||
NoteSyncError,
|
||||
_get_existing_slugs
|
||||
_get_existing_slugs,
|
||||
)
|
||||
from starpunk.database import get_db
|
||||
|
||||
@@ -147,7 +147,7 @@ class TestCreateNote:
|
||||
"""Test that file is created on disk"""
|
||||
with app.app_context():
|
||||
note = create_note("Test content")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
assert note_path.exists()
|
||||
@@ -158,10 +158,12 @@ class TestCreateNote:
|
||||
with app.app_context():
|
||||
note = create_note("Test content")
|
||||
db = get_db(app)
|
||||
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
|
||||
assert row is not None
|
||||
assert row['slug'] == note.slug
|
||||
assert row["slug"] == note.slug
|
||||
|
||||
def test_create_content_hash_calculated(self, app, client):
|
||||
"""Test that content hash is calculated"""
|
||||
@@ -176,7 +178,7 @@ class TestCreateNote:
|
||||
with pytest.raises(InvalidNoteDataError) as exc:
|
||||
create_note("")
|
||||
|
||||
assert 'content' in str(exc.value).lower()
|
||||
assert "content" in str(exc.value).lower()
|
||||
|
||||
def test_create_whitespace_content_fails(self, app, client):
|
||||
"""Test whitespace-only content raises error"""
|
||||
@@ -340,7 +342,7 @@ class TestListNotes:
|
||||
note2 = create_note("Second", created_at=datetime(2024, 1, 2))
|
||||
|
||||
# Newest first (default)
|
||||
notes = list_notes(order_by='created_at', order_dir='DESC')
|
||||
notes = list_notes(order_by="created_at", order_dir="DESC")
|
||||
assert notes[0].slug == note2.slug
|
||||
assert notes[1].slug == note1.slug
|
||||
|
||||
@@ -351,7 +353,7 @@ class TestListNotes:
|
||||
note2 = create_note("Second", created_at=datetime(2024, 1, 2))
|
||||
|
||||
# Oldest first
|
||||
notes = list_notes(order_by='created_at', order_dir='ASC')
|
||||
notes = list_notes(order_by="created_at", order_dir="ASC")
|
||||
assert notes[0].slug == note1.slug
|
||||
assert notes[1].slug == note2.slug
|
||||
|
||||
@@ -364,22 +366,22 @@ class TestListNotes:
|
||||
# Update first note (will have newer updated_at)
|
||||
update_note(slug=note1.slug, content="Updated first")
|
||||
|
||||
notes = list_notes(order_by='updated_at', order_dir='DESC')
|
||||
notes = list_notes(order_by="updated_at", order_dir="DESC")
|
||||
assert notes[0].slug == note1.slug
|
||||
|
||||
def test_list_invalid_order_field(self, app, client):
|
||||
"""Test invalid order_by field raises error"""
|
||||
with app.app_context():
|
||||
with pytest.raises(ValueError) as exc:
|
||||
list_notes(order_by='malicious; DROP TABLE notes;')
|
||||
list_notes(order_by="malicious; DROP TABLE notes;")
|
||||
|
||||
assert 'Invalid order_by' in str(exc.value)
|
||||
assert "Invalid order_by" in str(exc.value)
|
||||
|
||||
def test_list_invalid_order_direction(self, app, client):
|
||||
"""Test invalid order direction raises error"""
|
||||
with app.app_context():
|
||||
with pytest.raises(ValueError) as exc:
|
||||
list_notes(order_dir='INVALID')
|
||||
list_notes(order_dir="INVALID")
|
||||
|
||||
assert "Must be 'ASC' or 'DESC'" in str(exc.value)
|
||||
|
||||
@@ -389,7 +391,7 @@ class TestListNotes:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
list_notes(limit=2000)
|
||||
|
||||
assert 'exceeds maximum' in str(exc.value)
|
||||
assert "exceeds maximum" in str(exc.value)
|
||||
|
||||
def test_list_negative_limit(self, app, client):
|
||||
"""Test negative limit raises error"""
|
||||
@@ -451,9 +453,7 @@ class TestUpdateNote:
|
||||
with app.app_context():
|
||||
note = create_note("Draft", published=False)
|
||||
updated = update_note(
|
||||
slug=note.slug,
|
||||
content="Published content",
|
||||
published=True
|
||||
slug=note.slug, content="Published content", published=True
|
||||
)
|
||||
|
||||
assert updated.content == "Published content"
|
||||
@@ -515,7 +515,7 @@ class TestUpdateNote:
|
||||
"""Test file is updated on disk"""
|
||||
with app.app_context():
|
||||
note = create_note("Original")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
update_note(slug=note.slug, content="Updated")
|
||||
@@ -559,17 +559,16 @@ class TestDeleteNote:
|
||||
# But record still in database with deleted_at set
|
||||
db = get_db(app)
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?",
|
||||
(note.slug,)
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row['deleted_at'] is not None
|
||||
assert row["deleted_at"] is not None
|
||||
|
||||
def test_hard_delete(self, app, client):
|
||||
"""Test hard deletion"""
|
||||
with app.app_context():
|
||||
note = create_note("To be deleted")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
delete_note(slug=note.slug, soft=False)
|
||||
@@ -577,8 +576,7 @@ class TestDeleteNote:
|
||||
# Note not in database
|
||||
db = get_db(app)
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?",
|
||||
(note.slug,)
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
@@ -630,7 +628,9 @@ class TestDeleteNote:
|
||||
|
||||
# Now completely gone
|
||||
db = get_db(app)
|
||||
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
def test_delete_both_slug_and_id_fails(self, app, client):
|
||||
@@ -649,7 +649,7 @@ class TestDeleteNote:
|
||||
"""Test soft delete moves file to trash directory"""
|
||||
with app.app_context():
|
||||
note = create_note("Test")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
delete_note(slug=note.slug, soft=True)
|
||||
@@ -674,21 +674,23 @@ class TestFileDatabaseSync:
|
||||
"""Test file and database are created together"""
|
||||
with app.app_context():
|
||||
note = create_note("Test content")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
# Both file and database record should exist
|
||||
assert note_path.exists()
|
||||
|
||||
db = get_db(app)
|
||||
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
|
||||
def test_update_file_and_db_in_sync(self, app, client):
|
||||
"""Test file and database are updated together"""
|
||||
with app.app_context():
|
||||
note = create_note("Original")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
update_note(slug=note.slug, content="Updated")
|
||||
@@ -698,14 +700,16 @@ class TestFileDatabaseSync:
|
||||
|
||||
# Database updated
|
||||
db = get_db(app)
|
||||
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
|
||||
assert row['updated_at'] > row['created_at']
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row["updated_at"] > row["created_at"]
|
||||
|
||||
def test_delete_file_and_db_in_sync(self, app, client):
|
||||
"""Test file and database are deleted together (hard delete)"""
|
||||
with app.app_context():
|
||||
note = create_note("Test")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
delete_note(slug=note.slug, soft=False)
|
||||
@@ -715,7 +719,9 @@ class TestFileDatabaseSync:
|
||||
|
||||
# Database deleted
|
||||
db = get_db(app)
|
||||
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
@@ -786,7 +792,7 @@ class TestErrorHandling:
|
||||
"""Test that missing file is logged but doesn't crash"""
|
||||
with app.app_context():
|
||||
note = create_note("Test content")
|
||||
data_dir = Path(app.config['DATA_PATH'])
|
||||
data_dir = Path(app.config["DATA_PATH"])
|
||||
note_path = data_dir / note.file_path
|
||||
|
||||
# Delete the file but leave database record
|
||||
@@ -893,7 +899,9 @@ class TestIntegration:
|
||||
|
||||
# Completely gone
|
||||
db = get_db(app)
|
||||
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
|
||||
row = db.execute(
|
||||
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
def test_create_list_paginate(self, app, client):
|
||||
|
||||
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
|
||||
366
tests/test_routes_dev_auth.py
Normal file
366
tests/test_routes_dev_auth.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
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
|
||||
277
tests/test_routes_public.py
Normal file
277
tests/test_routes_public.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Tests for public routes (homepage, note permalinks)
|
||||
|
||||
Tests cover:
|
||||
- Homepage rendering with notes list
|
||||
- Note permalink rendering
|
||||
- 404 behavior for missing/unpublished notes
|
||||
- Microformats2 markup
|
||||
- Flash message display
|
||||
- Error page rendering
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from starpunk import create_app
|
||||
from starpunk.notes import create_note
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path):
|
||||
"""Create test application with dev mode disabled"""
|
||||
# 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-for-testing-only",
|
||||
"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 for making requests"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_notes(app):
|
||||
"""Create sample notes for testing"""
|
||||
with app.app_context():
|
||||
notes = []
|
||||
for i in range(5):
|
||||
note = create_note(
|
||||
content=f"# Test Note {i}\n\nThis is test note number {i}.",
|
||||
published=(i % 2 == 0), # Even notes published, odd are drafts
|
||||
)
|
||||
notes.append(note)
|
||||
return notes
|
||||
|
||||
|
||||
class TestHomepage:
|
||||
"""Test homepage route (/)"""
|
||||
|
||||
def test_homepage_renders(self, client):
|
||||
"""Test homepage renders successfully"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"StarPunk" in response.data
|
||||
|
||||
def test_homepage_shows_published_notes(self, client, sample_notes):
|
||||
"""Test homepage shows only published notes"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Published notes should appear (notes 0, 2, 4)
|
||||
assert b"Test Note 0" in response.data
|
||||
assert b"Test Note 2" in response.data
|
||||
assert b"Test Note 4" in response.data
|
||||
|
||||
# Draft notes should not appear (notes 1, 3)
|
||||
assert b"Test Note 1" not in response.data
|
||||
assert b"Test Note 3" not in response.data
|
||||
|
||||
def test_homepage_empty_state(self, client):
|
||||
"""Test homepage with no notes"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# Should have some message about no notes
|
||||
assert b"No notes" in response.data or b"Welcome" in response.data
|
||||
|
||||
def test_homepage_has_feed_link(self, client):
|
||||
"""Test homepage has RSS feed link"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"feed.xml" in response.data or b"RSS" in response.data
|
||||
|
||||
def test_homepage_has_h_feed_microformat(self, client, sample_notes):
|
||||
"""Test homepage has h-feed microformat"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"h-feed" in response.data
|
||||
|
||||
def test_homepage_notes_have_h_entry(self, client, sample_notes):
|
||||
"""Test notes on homepage have h-entry microformat"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"h-entry" in response.data
|
||||
|
||||
|
||||
class TestNotePermalink:
|
||||
"""Test individual note permalink route (/note/<slug>)"""
|
||||
|
||||
def test_published_note_renders(self, client, sample_notes):
|
||||
"""Test published note permalink renders"""
|
||||
# Get a published note (note 0)
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
assert len(notes) > 0
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"Test Note" in response.data
|
||||
|
||||
def test_note_has_full_content(self, client, sample_notes):
|
||||
"""Test note page shows full content"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"test note number" in response.data
|
||||
|
||||
def test_note_has_h_entry_microformat(self, client, sample_notes):
|
||||
"""Test note page has h-entry microformat"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"h-entry" in response.data
|
||||
assert b"e-content" in response.data
|
||||
assert b"dt-published" in response.data
|
||||
|
||||
def test_note_has_permalink_url(self, client, sample_notes):
|
||||
"""Test note page has permalink URL"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"u-url" in response.data
|
||||
|
||||
def test_draft_note_returns_404(self, client, sample_notes):
|
||||
"""Test draft note returns 404"""
|
||||
# Get a draft note (note 1)
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
# Get all notes, then filter to drafts
|
||||
all_notes = list_notes()
|
||||
draft_notes = [n for n in all_notes if not n.published]
|
||||
assert len(draft_notes) > 0
|
||||
slug = draft_notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_missing_note_returns_404(self, client):
|
||||
"""Test missing note returns 404"""
|
||||
response = client.get("/note/nonexistent-slug")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_note_has_back_link(self, client, sample_notes):
|
||||
"""Test note page has link back to homepage"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
# Should have a link to home
|
||||
assert b'href="/"' in response.data
|
||||
|
||||
|
||||
class TestFlashMessages:
|
||||
"""Test flash message display"""
|
||||
|
||||
def test_flash_messages_display(self, client):
|
||||
"""Test flash messages are displayed"""
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [("success", "Test message")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"Test message" in response.data
|
||||
|
||||
def test_flash_message_categories(self, client):
|
||||
"""Test different flash message categories"""
|
||||
categories = ["success", "error", "warning", "info"]
|
||||
|
||||
for category in categories:
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [(category, f"{category} message")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert f"{category} message".encode() in response.data
|
||||
|
||||
|
||||
class TestErrorPages:
|
||||
"""Test error page rendering"""
|
||||
|
||||
def test_404_page_renders(self, client):
|
||||
"""Test 404 error page"""
|
||||
response = client.get("/nonexistent-page")
|
||||
assert response.status_code == 404
|
||||
assert b"404" in response.data or b"Not Found" in response.data
|
||||
|
||||
def test_404_has_home_link(self, client):
|
||||
"""Test 404 page has link to homepage"""
|
||||
response = client.get("/nonexistent-page")
|
||||
assert response.status_code == 404
|
||||
assert b'href="/"' in response.data
|
||||
|
||||
|
||||
class TestDevModeIndicator:
|
||||
"""Test dev mode warning display"""
|
||||
|
||||
def test_dev_mode_warning_not_shown_in_production(self, client):
|
||||
"""Test dev mode warning not shown when DEV_MODE=false"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"DEVELOPMENT MODE" not in response.data
|
||||
|
||||
def test_dev_mode_warning_shown_when_enabled(self, tmp_path):
|
||||
"""Test dev mode warning shown when DEV_MODE=true"""
|
||||
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)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"DEVELOPMENT MODE" in response.data or b"DEV MODE" in response.data
|
||||
|
||||
|
||||
class TestVersionDisplay:
|
||||
"""Test version number display"""
|
||||
|
||||
def test_version_in_footer(self, client):
|
||||
"""Test version number appears in footer"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"0.5.0" in response.data or b"StarPunk v" in response.data
|
||||
394
tests/test_templates.py
Normal file
394
tests/test_templates.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Tests for template rendering and structure
|
||||
|
||||
Tests cover:
|
||||
- Template inheritance
|
||||
- Microformats2 markup
|
||||
- HTML structure and validity
|
||||
- Template variables and context
|
||||
- Flash message rendering
|
||||
- Error templates
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from starpunk import create_app
|
||||
from starpunk.notes import create_note
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path):
|
||||
"""Create test application"""
|
||||
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",
|
||||
"SITE_URL": "http://localhost:5000",
|
||||
"ADMIN_ME": "https://test.example.com",
|
||||
"DEV_MODE": False,
|
||||
"SITE_NAME": "Test StarPunk",
|
||||
"VERSION": "0.5.0",
|
||||
}
|
||||
app = create_app(config=test_config)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestBaseTemplate:
|
||||
"""Test base.html template"""
|
||||
|
||||
def test_base_has_doctype(self, client):
|
||||
"""Test base template has HTML5 doctype"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert response.data.startswith(b"<!DOCTYPE html>")
|
||||
|
||||
def test_base_has_charset(self, client):
|
||||
"""Test base template has charset meta tag"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b'charset="UTF-8"' in response.data or b"charset=UTF-8" in response.data
|
||||
|
||||
def test_base_has_viewport(self, client):
|
||||
"""Test base template has viewport meta tag"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"viewport" in response.data
|
||||
assert b"width=device-width" in response.data
|
||||
|
||||
def test_base_has_title(self, client):
|
||||
"""Test base template has title"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"<title>" in response.data
|
||||
assert b"StarPunk" in response.data
|
||||
|
||||
def test_base_has_stylesheet(self, client):
|
||||
"""Test base template links to stylesheet"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"<link" in response.data
|
||||
assert b"stylesheet" in response.data
|
||||
assert b"style.css" in response.data
|
||||
|
||||
def test_base_has_rss_link(self, client):
|
||||
"""Test base template has RSS feed link"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"application/rss+xml" in response.data or b"feed.xml" in response.data
|
||||
|
||||
def test_base_has_header(self, client):
|
||||
"""Test base template has header"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"<header" in response.data
|
||||
|
||||
def test_base_has_main(self, client):
|
||||
"""Test base template has main content area"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"<main" in response.data
|
||||
|
||||
def test_base_has_footer(self, client):
|
||||
"""Test base template has footer"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"<footer" in response.data
|
||||
|
||||
def test_base_footer_has_version(self, client):
|
||||
"""Test footer shows version number"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"0.5.0" in response.data
|
||||
|
||||
def test_base_has_navigation(self, client):
|
||||
"""Test base has navigation"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"<nav" in response.data or b'href="/"' in response.data
|
||||
|
||||
|
||||
class TestHomepageTemplate:
|
||||
"""Test index.html template"""
|
||||
|
||||
def test_homepage_has_h_feed(self, client):
|
||||
"""Test homepage has h-feed microformat"""
|
||||
with client.application.test_request_context():
|
||||
create_note("# Test\n\nContent", published=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"h-feed" in response.data
|
||||
|
||||
def test_homepage_notes_have_h_entry(self, client):
|
||||
"""Test notes on homepage have h-entry"""
|
||||
with client.application.test_request_context():
|
||||
create_note("# Test\n\nContent", published=True)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"h-entry" in response.data
|
||||
|
||||
def test_homepage_empty_state(self, client):
|
||||
"""Test homepage shows message when no notes"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# Should have some indication of empty state
|
||||
data_lower = response.data.lower()
|
||||
assert (
|
||||
b"no notes" in data_lower
|
||||
or b"welcome" in data_lower
|
||||
or b"get started" in data_lower
|
||||
)
|
||||
|
||||
|
||||
class TestNoteTemplate:
|
||||
"""Test note.html template"""
|
||||
|
||||
def test_note_has_h_entry(self, client):
|
||||
"""Test note page has h-entry microformat"""
|
||||
with client.application.test_request_context():
|
||||
note = create_note("# Test Note\n\nContent here.", published=True)
|
||||
|
||||
response = client.get(f"/note/{note.slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"h-entry" in response.data
|
||||
|
||||
def test_note_has_e_content(self, client):
|
||||
"""Test note has e-content for content"""
|
||||
with client.application.test_request_context():
|
||||
note = create_note("# Test Note\n\nContent here.", published=True)
|
||||
|
||||
response = client.get(f"/note/{note.slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"e-content" in response.data
|
||||
|
||||
def test_note_has_dt_published(self, client):
|
||||
"""Test note has dt-published for date"""
|
||||
with client.application.test_request_context():
|
||||
note = create_note("# Test Note\n\nContent here.", published=True)
|
||||
|
||||
response = client.get(f"/note/{note.slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"dt-published" in response.data
|
||||
|
||||
def test_note_has_u_url(self, client):
|
||||
"""Test note has u-url for permalink"""
|
||||
with client.application.test_request_context():
|
||||
note = create_note("# Test Note\n\nContent here.", published=True)
|
||||
|
||||
response = client.get(f"/note/{note.slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"u-url" in response.data
|
||||
|
||||
def test_note_renders_markdown(self, client):
|
||||
"""Test note content is rendered as HTML"""
|
||||
with client.application.test_request_context():
|
||||
note = create_note("# Heading\n\n**Bold** text.", published=True)
|
||||
|
||||
response = client.get(f"/note/{note.slug}")
|
||||
assert response.status_code == 200
|
||||
# Should have HTML heading
|
||||
assert b"<h1" in response.data
|
||||
# Should have bold
|
||||
assert b"<strong>" in response.data or b"<b>" in response.data
|
||||
|
||||
|
||||
class TestAdminTemplates:
|
||||
"""Test admin templates"""
|
||||
|
||||
def test_login_template_has_form(self, client):
|
||||
"""Test login page has form"""
|
||||
response = client.get("/admin/login")
|
||||
assert response.status_code == 200
|
||||
assert b"<form" in response.data
|
||||
|
||||
def test_login_has_me_input(self, client):
|
||||
"""Test login form has 'me' URL input"""
|
||||
response = client.get("/admin/login")
|
||||
assert response.status_code == 200
|
||||
assert b'name="me"' in response.data or b'id="me"' in response.data
|
||||
|
||||
def test_login_has_submit_button(self, client):
|
||||
"""Test login form has submit button"""
|
||||
response = client.get("/admin/login")
|
||||
assert response.status_code == 200
|
||||
assert b'type="submit"' in response.data or b"<button" in response.data
|
||||
|
||||
def test_dashboard_extends_admin_base(self, client):
|
||||
"""Test dashboard uses admin base template"""
|
||||
from starpunk.auth import create_session
|
||||
|
||||
with client.application.test_request_context():
|
||||
token = create_session("https://test.example.com")
|
||||
|
||||
client.set_cookie("starpunk_session", token)
|
||||
response = client.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
# Should have admin-specific elements
|
||||
assert b"Dashboard" in response.data or b"Admin" in response.data
|
||||
|
||||
def test_new_note_form_has_textarea(self, client):
|
||||
"""Test new note form has textarea"""
|
||||
from starpunk.auth import create_session
|
||||
|
||||
with client.application.test_request_context():
|
||||
token = create_session("https://test.example.com")
|
||||
|
||||
client.set_cookie("starpunk_session", token)
|
||||
response = client.get("/admin/new")
|
||||
assert response.status_code == 200
|
||||
assert b"<textarea" in response.data
|
||||
|
||||
def test_new_note_form_has_published_checkbox(self, client):
|
||||
"""Test new note form has published checkbox"""
|
||||
from starpunk.auth import create_session
|
||||
|
||||
with client.application.test_request_context():
|
||||
token = create_session("https://test.example.com")
|
||||
|
||||
client.set_cookie("starpunk_session", token)
|
||||
response = client.get("/admin/new")
|
||||
assert response.status_code == 200
|
||||
assert b'type="checkbox"' in response.data
|
||||
|
||||
def test_edit_form_prefilled(self, client):
|
||||
"""Test edit form is prefilled with content"""
|
||||
from starpunk.auth import create_session
|
||||
|
||||
with client.application.test_request_context():
|
||||
token = create_session("https://test.example.com")
|
||||
note = create_note("# Edit Test\n\nContent.", published=True)
|
||||
|
||||
client.set_cookie("starpunk_session", token)
|
||||
response = client.get(f"/admin/edit/{note.id}")
|
||||
assert response.status_code == 200
|
||||
assert b"Edit Test" in response.data
|
||||
|
||||
|
||||
class TestFlashMessages:
|
||||
"""Test flash message rendering"""
|
||||
|
||||
def test_flash_message_success(self, client):
|
||||
"""Test success flash message renders"""
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [("success", "Operation successful")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"Operation successful" in response.data
|
||||
assert b"flash" in response.data or b"success" in response.data
|
||||
|
||||
def test_flash_message_error(self, client):
|
||||
"""Test error flash message renders"""
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [("error", "An error occurred")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"An error occurred" in response.data
|
||||
assert b"flash" in response.data or b"error" in response.data
|
||||
|
||||
def test_flash_message_warning(self, client):
|
||||
"""Test warning flash message renders"""
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [("warning", "Be careful")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"Be careful" in response.data
|
||||
|
||||
def test_multiple_flash_messages(self, client):
|
||||
"""Test multiple flash messages render"""
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [
|
||||
("success", "First message"),
|
||||
("error", "Second message"),
|
||||
]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"First message" in response.data
|
||||
assert b"Second message" in response.data
|
||||
|
||||
|
||||
class TestErrorTemplates:
|
||||
"""Test error page templates"""
|
||||
|
||||
def test_404_template(self, client):
|
||||
"""Test 404 error page"""
|
||||
response = client.get("/nonexistent")
|
||||
assert response.status_code == 404
|
||||
assert b"404" in response.data or b"Not Found" in response.data
|
||||
|
||||
def test_404_has_home_link(self, client):
|
||||
"""Test 404 page has link to homepage"""
|
||||
response = client.get("/nonexistent")
|
||||
assert response.status_code == 404
|
||||
assert b'href="/"' in response.data
|
||||
|
||||
|
||||
class TestDevModeIndicator:
|
||||
"""Test dev mode warning in templates"""
|
||||
|
||||
def test_dev_mode_warning_shown(self, tmp_path):
|
||||
"""Test dev mode warning appears when 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)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"DEVELOPMENT MODE" in response.data or b"DEV MODE" in response.data
|
||||
|
||||
def test_dev_mode_warning_not_shown(self, client):
|
||||
"""Test dev mode warning not shown in production"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"DEVELOPMENT MODE" not in response.data
|
||||
|
||||
|
||||
class TestTemplateVariables:
|
||||
"""Test template variables are available"""
|
||||
|
||||
def test_config_available(self, client):
|
||||
"""Test config is available in templates"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# VERSION should be rendered
|
||||
assert b"0.5.0" in response.data
|
||||
|
||||
def test_site_name_available(self, client):
|
||||
"""Test SITE_NAME is available"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# Should have site name in title or header
|
||||
assert b"<title>" in response.data
|
||||
|
||||
def test_url_for_works(self, client):
|
||||
"""Test url_for generates correct URLs"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# Should have URLs like /admin, /admin/login, etc.
|
||||
assert b"href=" in response.data
|
||||
Reference in New Issue
Block a user