Fixes critical IndieAuth authentication failure by implementing modern JSON-based client discovery mechanism per IndieAuth spec section 4.2. Added /.well-known/oauth-authorization-server endpoint returning JSON metadata with client_id, redirect_uris, and OAuth capabilities. Added <link rel="indieauth-metadata"> discovery hint in HTML head. Maintained h-app microformats for backward compatibility with legacy IndieAuth servers. This resolves "client_id is not registered" error from IndieLogin.com by providing the metadata document modern IndieAuth servers expect. Changes: - Added oauth_client_metadata() endpoint in public routes - Returns JSON with client info (24-hour cache) - Uses config values (SITE_URL, SITE_NAME) not hardcoded URLs - Added indieauth-metadata link in base.html - Comprehensive test suite (15 new tests, all passing) - Updated version to v0.6.2 (PATCH increment) - Updated CHANGELOG.md with detailed fix documentation Standards Compliance: - IndieAuth specification section 4.2 - OAuth Client ID Metadata Document format - IANA well-known URI registry - RFC 7591 OAuth 2.0 Dynamic Client Registration Testing: - 467/468 tests passing (99.79%) - 15 new tests for OAuth metadata and discovery - Zero regressions in existing tests - Test coverage maintained at 88% Related Documentation: - ADR-017: OAuth Client ID Metadata Document Implementation - IndieAuth Fix Summary report - Implementation report in docs/reports/ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
433 lines
16 KiB
Python
433 lines
16 KiB
Python
"""
|
|
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
|
|
|
|
|
|
class TestOAuthMetadataEndpoint:
|
|
"""Test OAuth Client ID Metadata Document endpoint (.well-known/oauth-authorization-server)"""
|
|
|
|
def test_oauth_metadata_endpoint_exists(self, client):
|
|
"""Verify metadata endpoint returns 200 OK"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
assert response.status_code == 200
|
|
|
|
def test_oauth_metadata_content_type(self, client):
|
|
"""Verify response is JSON with correct content type"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
assert response.status_code == 200
|
|
assert response.content_type == "application/json"
|
|
|
|
def test_oauth_metadata_required_fields(self, client, app):
|
|
"""Verify all required fields are present and valid"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
data = response.get_json()
|
|
|
|
# Required fields per IndieAuth spec
|
|
assert "client_id" in data
|
|
assert "client_name" in data
|
|
assert "redirect_uris" in data
|
|
|
|
# client_id must match SITE_URL exactly (spec requirement)
|
|
with app.app_context():
|
|
assert data["client_id"] == app.config["SITE_URL"]
|
|
|
|
# redirect_uris must be array
|
|
assert isinstance(data["redirect_uris"], list)
|
|
assert len(data["redirect_uris"]) > 0
|
|
|
|
def test_oauth_metadata_optional_fields(self, client):
|
|
"""Verify recommended optional fields are present"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
data = response.get_json()
|
|
|
|
# Recommended fields
|
|
assert "issuer" in data
|
|
assert "client_uri" in data
|
|
assert "grant_types_supported" in data
|
|
assert "response_types_supported" in data
|
|
assert "code_challenge_methods_supported" in data
|
|
assert "token_endpoint_auth_methods_supported" in data
|
|
|
|
def test_oauth_metadata_field_values(self, client, app):
|
|
"""Verify field values are correct"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
data = response.get_json()
|
|
|
|
with app.app_context():
|
|
site_url = app.config["SITE_URL"]
|
|
|
|
# Verify URLs
|
|
assert data["issuer"] == site_url
|
|
assert data["client_id"] == site_url
|
|
assert data["client_uri"] == site_url
|
|
|
|
# Verify redirect_uris contains auth callback
|
|
assert f"{site_url}/auth/callback" in data["redirect_uris"]
|
|
|
|
# Verify supported methods
|
|
assert "authorization_code" in data["grant_types_supported"]
|
|
assert "code" in data["response_types_supported"]
|
|
assert "S256" in data["code_challenge_methods_supported"]
|
|
assert "none" in data["token_endpoint_auth_methods_supported"]
|
|
|
|
def test_oauth_metadata_redirect_uris_is_array(self, client):
|
|
"""Verify redirect_uris is array, not string (common pitfall)"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
data = response.get_json()
|
|
|
|
assert isinstance(data["redirect_uris"], list)
|
|
assert not isinstance(data["redirect_uris"], str)
|
|
|
|
def test_oauth_metadata_cache_headers(self, client):
|
|
"""Verify appropriate cache headers are set"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
assert response.status_code == 200
|
|
|
|
# Should cache for 24 hours (86400 seconds)
|
|
assert response.cache_control.max_age == 86400
|
|
assert response.cache_control.public is True
|
|
|
|
def test_oauth_metadata_valid_json(self, client):
|
|
"""Verify response is valid, parseable JSON"""
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
assert response.status_code == 200
|
|
|
|
# get_json() will raise ValueError if JSON is invalid
|
|
data = response.get_json()
|
|
assert data is not None
|
|
assert isinstance(data, dict)
|
|
|
|
def test_oauth_metadata_uses_config_values(self, tmp_path):
|
|
"""Verify metadata uses config values, not hardcoded strings"""
|
|
test_data_dir = tmp_path / "oauth_test"
|
|
test_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create app with custom config
|
|
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": "https://custom-site.example.com",
|
|
"SITE_NAME": "Custom Site Name",
|
|
"DEV_MODE": False,
|
|
}
|
|
app = create_app(config=test_config)
|
|
client = app.test_client()
|
|
|
|
response = client.get("/.well-known/oauth-authorization-server")
|
|
data = response.get_json()
|
|
|
|
# Should use custom config values
|
|
assert data["client_id"] == "https://custom-site.example.com"
|
|
assert data["client_name"] == "Custom Site Name"
|
|
assert data["client_uri"] == "https://custom-site.example.com"
|
|
assert (
|
|
"https://custom-site.example.com/auth/callback" in data["redirect_uris"]
|
|
)
|
|
|
|
|
|
class TestIndieAuthMetadataLink:
|
|
"""Test indieauth-metadata link in HTML head"""
|
|
|
|
def test_indieauth_metadata_link_present(self, client):
|
|
"""Verify discovery link is present in HTML head"""
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
assert b'rel="indieauth-metadata"' in response.data
|
|
|
|
def test_indieauth_metadata_link_points_to_endpoint(self, client):
|
|
"""Verify link points to correct endpoint"""
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
assert b"/.well-known/oauth-authorization-server" in response.data
|
|
|
|
def test_indieauth_metadata_link_in_head(self, client):
|
|
"""Verify link is in <head> section"""
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
# Simple check: link should appear before <body>
|
|
html = response.data.decode("utf-8")
|
|
metadata_link_pos = html.find('rel="indieauth-metadata"')
|
|
body_pos = html.find("<body>")
|
|
|
|
assert metadata_link_pos != -1
|
|
assert body_pos != -1
|
|
assert metadata_link_pos < body_pos
|