Fix bug where custom slugs (mp-slug) were being ignored because they were extracted from normalized properties after being filtered out. The root cause: normalize_properties() filters out all mp-* parameters (line 139) because they're Micropub server extensions, not properties. The old code tried to extract mp-slug from the normalized properties dict, but it had already been removed. The fix: Extract mp-slug directly from raw request data BEFORE calling normalize_properties(). This preserves the custom slug through to create_note(). Changes: - Move mp-slug extraction to before property normalization (line 290-299) - Handle both form-encoded (list) and JSON (string or list) formats - Add comprehensive tests for custom slug with both request formats - All 13 Micropub tests pass Fixes the issue reported in production where Quill-specified slugs were being replaced with auto-generated ones. References: - docs/reports/custom-slug-bug-diagnosis.md (architect's analysis) - Micropub spec: mp-slug is a server extension parameter Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
345 lines
11 KiB
Python
345 lines
11 KiB
Python
"""
|
|
Tests for Micropub endpoint
|
|
|
|
Tests the /micropub endpoint for creating posts via IndieWeb clients.
|
|
Covers both form-encoded and JSON requests, authentication, and error handling.
|
|
|
|
Note: After Phase 4 (ADR-030), StarPunk no longer issues tokens. Tests mock
|
|
external token verification responses.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
from starpunk.notes import get_note
|
|
|
|
|
|
# Mock token verification responses
|
|
|
|
@pytest.fixture
|
|
def mock_valid_token():
|
|
"""Mock response from external token verification (valid token)"""
|
|
def verify_token(token):
|
|
if token == "valid_token":
|
|
return {
|
|
"me": "https://user.example",
|
|
"client_id": "https://client.example",
|
|
"scope": "create"
|
|
}
|
|
return None
|
|
return verify_token
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_invalid_token():
|
|
"""Mock response from external token verification (invalid token)"""
|
|
def verify_token(token):
|
|
return None
|
|
return verify_token
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_read_only_token():
|
|
"""Mock response for token without create scope"""
|
|
def verify_token(token):
|
|
if token == "read_only_token":
|
|
return {
|
|
"me": "https://user.example",
|
|
"client_id": "https://client.example",
|
|
"scope": "read"
|
|
}
|
|
return None
|
|
return verify_token
|
|
|
|
|
|
# Authentication Tests
|
|
|
|
|
|
def test_micropub_no_token(client):
|
|
"""Test Micropub endpoint rejects requests without token"""
|
|
response = client.post('/micropub', data={
|
|
'h': 'entry',
|
|
'content': 'Test post'
|
|
})
|
|
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert data['error'] == 'unauthorized'
|
|
assert 'No access token' in data['error_description']
|
|
|
|
|
|
def test_micropub_invalid_token(client, mock_invalid_token):
|
|
"""Test Micropub endpoint rejects invalid tokens"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_invalid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
data={'h': 'entry', 'content': 'Test post'},
|
|
headers={'Authorization': 'Bearer invalid_token'}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert data['error'] == 'unauthorized'
|
|
assert 'Invalid or expired' in data['error_description']
|
|
|
|
|
|
def test_micropub_insufficient_scope(client, mock_read_only_token):
|
|
"""Test Micropub endpoint rejects token without create scope"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_read_only_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
data={'h': 'entry', 'content': 'Test post'},
|
|
headers={'Authorization': 'Bearer read_only_token'}
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
data = response.get_json()
|
|
assert data['error'] == 'insufficient_scope'
|
|
|
|
|
|
# Create Post Tests
|
|
|
|
|
|
def test_micropub_create_note_form(client, app, mock_valid_token):
|
|
"""Test creating a note via form-encoded request"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'This is a test note from Micropub',
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
# Verify note was created
|
|
location = response.headers['Location']
|
|
slug = location.split('/')[-1]
|
|
with app.app_context():
|
|
note = get_note(slug)
|
|
assert note is not None
|
|
assert note.content == 'This is a test note from Micropub'
|
|
assert note.published is True
|
|
|
|
|
|
def test_micropub_create_note_json(client, app, mock_valid_token):
|
|
"""Test creating a note via JSON request"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'content': ['JSON test note']
|
|
}
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
location = response.headers['Location']
|
|
slug = location.split('/')[-1]
|
|
with app.app_context():
|
|
note = get_note(slug)
|
|
assert note.content == 'JSON test note'
|
|
|
|
|
|
def test_micropub_create_with_name(client, app, mock_valid_token):
|
|
"""Test creating a note with a title (name property)"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'name': ['My Test Title'],
|
|
'content': ['Content goes here']
|
|
}
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
# Verify note was created successfully
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
|
|
def test_micropub_create_with_categories(client, app, mock_valid_token):
|
|
"""Test creating a note with tags (category property)"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'content': ['Tagged post'],
|
|
'category': ['test', 'micropub', 'indieweb']
|
|
}
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
# Verify note was created successfully
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
|
|
def test_micropub_create_with_custom_slug_form(client, app, mock_valid_token):
|
|
"""Test creating a note with custom slug via form-encoded request"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'This is a test for custom slugs',
|
|
'mp-slug': 'my-custom-slug'
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
# Verify the custom slug was used
|
|
location = response.headers['Location']
|
|
assert location.endswith('/notes/my-custom-slug')
|
|
|
|
# Verify note exists with the custom slug
|
|
with app.app_context():
|
|
note = get_note('my-custom-slug')
|
|
assert note is not None
|
|
assert note.slug == 'my-custom-slug'
|
|
assert note.content == 'This is a test for custom slugs'
|
|
|
|
|
|
def test_micropub_create_with_custom_slug_json(client, app, mock_valid_token):
|
|
"""Test creating a note with custom slug via JSON request"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'content': ['JSON test with custom slug']
|
|
},
|
|
'mp-slug': 'json-custom-slug'
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
# Verify the custom slug was used
|
|
location = response.headers['Location']
|
|
assert location.endswith('/notes/json-custom-slug')
|
|
|
|
# Verify note exists with the custom slug
|
|
with app.app_context():
|
|
note = get_note('json-custom-slug')
|
|
assert note is not None
|
|
assert note.slug == 'json-custom-slug'
|
|
assert note.content == 'JSON test with custom slug'
|
|
|
|
|
|
# Query Tests
|
|
|
|
|
|
def test_micropub_query_config(client, mock_valid_token):
|
|
"""Test q=config endpoint"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.get(
|
|
'/micropub?q=config',
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
# Config endpoint returns server capabilities
|
|
# Check that it's a valid config response
|
|
assert isinstance(data, dict)
|
|
|
|
|
|
def test_micropub_query_source(client, mock_valid_token):
|
|
"""Test q=source endpoint"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
# First create a note
|
|
create_response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'content': ['Source test']
|
|
}
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
location = create_response.headers['Location']
|
|
|
|
# Query for source
|
|
response = client.get(
|
|
f'/micropub?q=source&url={location}',
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert 'properties' in data
|
|
assert data['properties']['content'][0] == 'Source test'
|
|
|
|
|
|
# Error Handling Tests
|
|
|
|
|
|
def test_micropub_missing_content(client, mock_valid_token):
|
|
"""Test creating note without content fails"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {}
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
|
|
|
|
def test_micropub_unsupported_action(client, mock_valid_token):
|
|
"""Test unsupported actions (update, delete) return error"""
|
|
with patch('starpunk.routes.micropub.verify_external_token', mock_valid_token):
|
|
# Test update
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'action': 'update',
|
|
'url': 'https://example.com/note/123'
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert 'not supported' in data['error_description']
|
|
|
|
# Test delete
|
|
response = client.post(
|
|
'/micropub',
|
|
json={
|
|
'action': 'delete',
|
|
'url': 'https://example.com/note/123'
|
|
},
|
|
headers={'Authorization': 'Bearer valid_token'}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert 'not supported' in data['error_description']
|