Following design in /docs/design/micropub-endpoint-design.md and /docs/decisions/ADR-028-micropub-implementation.md Micropub Module (starpunk/micropub.py): - Property normalization for form-encoded and JSON requests - Content/title/tags extraction from Micropub properties - Bearer token extraction from Authorization header or form - Create action handler integrating with notes.py CRUD - Query endpoints (config, source, syndicate-to) - OAuth 2.0 compliant error responses Micropub Route (starpunk/routes/micropub.py): - Main /micropub endpoint handling GET and POST - Bearer token authentication and validation - Content-type handling (form-encoded and JSON) - Action routing (create supported, update/delete return V1 error) - Comprehensive error handling Integration: - Registered micropub blueprint in routes/__init__.py - Maps Micropub properties to StarPunk note format - Returns 201 Created with Location header per spec - V1 limitations clearly documented (no update/delete) All 23 Phase 3 tests pass Total: 77 tests pass (21 Phase 1 + 33 Phase 2 + 23 Phase 3) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
451 lines
14 KiB
Python
451 lines
14 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.
|
|
"""
|
|
|
|
import pytest
|
|
from starpunk.tokens import create_access_token
|
|
from starpunk.notes import get_note
|
|
|
|
|
|
# Helper function to create a valid access token for testing
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_token(app):
|
|
"""Create a valid access token with create scope"""
|
|
with app.app_context():
|
|
return create_access_token(
|
|
me="https://user.example",
|
|
client_id="https://client.example",
|
|
scope="create"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def read_only_token(app):
|
|
"""Create a token without create scope"""
|
|
with app.app_context():
|
|
return create_access_token(
|
|
me="https://user.example",
|
|
client_id="https://client.example",
|
|
scope="read" # Not a valid scope, but tests scope checking
|
|
)
|
|
|
|
|
|
# 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 'access token' in data['error_description'].lower()
|
|
|
|
|
|
def test_micropub_invalid_token(client):
|
|
"""Test Micropub endpoint rejects invalid tokens"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': 'Bearer invalid_token_12345'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'Test post'
|
|
})
|
|
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert data['error'] == 'unauthorized'
|
|
assert 'invalid' in data['error_description'].lower() or 'expired' in data['error_description'].lower()
|
|
|
|
|
|
def test_micropub_insufficient_scope(client, app, read_only_token):
|
|
"""Test Micropub endpoint rejects tokens without create scope"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {read_only_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'Test post'
|
|
})
|
|
|
|
assert response.status_code == 403
|
|
data = response.get_json()
|
|
assert data['error'] == 'insufficient_scope'
|
|
|
|
|
|
# Create Action - Form-Encoded Tests
|
|
|
|
|
|
def test_micropub_create_form_encoded(client, app, valid_token):
|
|
"""Test creating a note with form-encoded request"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'This is a test post from Micropub'
|
|
},
|
|
content_type='application/x-www-form-urlencoded')
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
location = response.headers['Location']
|
|
assert '/notes/' in location
|
|
|
|
# Verify note was created
|
|
with app.app_context():
|
|
slug = location.split('/')[-1]
|
|
note = get_note(slug)
|
|
assert note is not None
|
|
assert note.content == 'This is a test post from Micropub'
|
|
assert note.published is True
|
|
|
|
|
|
def test_micropub_create_with_title(client, app, valid_token):
|
|
"""Test creating note with explicit title (name property)"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'name': 'My Test Title',
|
|
'content': 'Content of the post'
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
|
|
with app.app_context():
|
|
slug = response.headers['Location'].split('/')[-1]
|
|
note = get_note(slug)
|
|
# Note: Current create_note doesn't support title, this may need adjustment
|
|
assert note.content == 'Content of the post'
|
|
|
|
|
|
def test_micropub_create_with_categories(client, app, valid_token):
|
|
"""Test creating note with categories (tags)"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'Post with tags',
|
|
'category[]': ['indieweb', 'micropub', 'testing']
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
|
|
with app.app_context():
|
|
slug = response.headers['Location'].split('/')[-1]
|
|
note = get_note(slug)
|
|
# Note: Need to verify tag storage format in notes.py
|
|
assert note.content == 'Post with tags'
|
|
|
|
|
|
def test_micropub_create_missing_content(client, valid_token):
|
|
"""Test Micropub rejects posts without content"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry'
|
|
})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
assert 'content' in data['error_description'].lower()
|
|
|
|
|
|
def test_micropub_create_empty_content(client, valid_token):
|
|
"""Test Micropub rejects posts with empty content"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': ' ' # Only whitespace
|
|
})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
|
|
|
|
# Create Action - JSON Tests
|
|
|
|
|
|
def test_micropub_create_json(client, app, valid_token):
|
|
"""Test creating note with JSON request"""
|
|
response = client.post('/micropub',
|
|
headers={
|
|
'Authorization': f'Bearer {valid_token}',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'content': ['This is a JSON test post']
|
|
}
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
with app.app_context():
|
|
slug = response.headers['Location'].split('/')[-1]
|
|
note = get_note(slug)
|
|
assert note.content == 'This is a JSON test post'
|
|
|
|
|
|
def test_micropub_create_json_with_name_and_categories(client, app, valid_token):
|
|
"""Test creating note with JSON including name and categories"""
|
|
response = client.post('/micropub',
|
|
headers={
|
|
'Authorization': f'Bearer {valid_token}',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'name': ['Test Note Title'],
|
|
'content': ['JSON post content'],
|
|
'category': ['test', 'json', 'micropub']
|
|
}
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
|
|
with app.app_context():
|
|
slug = response.headers['Location'].split('/')[-1]
|
|
note = get_note(slug)
|
|
assert note.content == 'JSON post content'
|
|
|
|
|
|
def test_micropub_create_json_structured_content(client, app, valid_token):
|
|
"""Test creating note with structured content (html/text object)"""
|
|
response = client.post('/micropub',
|
|
headers={
|
|
'Authorization': f'Bearer {valid_token}',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
json={
|
|
'type': ['h-entry'],
|
|
'properties': {
|
|
'content': [{
|
|
'text': 'Plain text version',
|
|
'html': '<p>HTML version</p>'
|
|
}]
|
|
}
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
|
|
with app.app_context():
|
|
slug = response.headers['Location'].split('/')[-1]
|
|
note = get_note(slug)
|
|
# Should prefer text over html
|
|
assert note.content == 'Plain text version'
|
|
|
|
|
|
# Token Location Tests
|
|
|
|
|
|
def test_micropub_token_in_form_parameter(client, app, valid_token):
|
|
"""Test token can be provided as form parameter"""
|
|
response = client.post('/micropub',
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'Test with form token',
|
|
'access_token': valid_token
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
|
|
|
|
def test_micropub_token_in_query_parameter(client, app, valid_token):
|
|
"""Test token in query parameter for GET requests"""
|
|
response = client.get(f'/micropub?q=config&access_token={valid_token}')
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
# V1 Limitation Tests
|
|
|
|
|
|
def test_micropub_update_not_supported(client, valid_token):
|
|
"""Test update action returns error in V1"""
|
|
response = client.post('/micropub',
|
|
headers={
|
|
'Authorization': f'Bearer {valid_token}',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
json={
|
|
'action': 'update',
|
|
'url': 'https://example.com/notes/test',
|
|
'replace': {
|
|
'content': ['Updated content']
|
|
}
|
|
})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
assert 'not supported' in data['error_description']
|
|
|
|
|
|
def test_micropub_delete_not_supported(client, valid_token):
|
|
"""Test delete action returns error in V1"""
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'action': 'delete',
|
|
'url': 'https://example.com/notes/test'
|
|
})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
assert 'not supported' in data['error_description']
|
|
|
|
|
|
# Query Endpoint Tests
|
|
|
|
|
|
def test_micropub_query_config(client, valid_token):
|
|
"""Test q=config query endpoint"""
|
|
response = client.get('/micropub?q=config',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
# Check required fields
|
|
assert 'media-endpoint' in data
|
|
assert 'syndicate-to' in data
|
|
assert data['media-endpoint'] is None # V1 has no media endpoint
|
|
assert data['syndicate-to'] == [] # V1 has no syndication
|
|
|
|
|
|
def test_micropub_query_syndicate_to(client, valid_token):
|
|
"""Test q=syndicate-to query endpoint"""
|
|
response = client.get('/micropub?q=syndicate-to',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert 'syndicate-to' in data
|
|
assert data['syndicate-to'] == [] # V1 has no syndication targets
|
|
|
|
|
|
def test_micropub_query_source(client, app, valid_token):
|
|
"""Test q=source query endpoint"""
|
|
# First create a post
|
|
with app.app_context():
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'Test post for source query'
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
note_url = response.headers['Location']
|
|
|
|
# Query the source
|
|
response = client.get(f'/micropub?q=source&url={note_url}',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
|
|
# Check Microformats2 structure
|
|
assert data['type'] == ['h-entry']
|
|
assert 'properties' in data
|
|
assert 'content' in data['properties']
|
|
assert data['properties']['content'][0] == 'Test post for source query'
|
|
|
|
|
|
def test_micropub_query_source_missing_url(client, valid_token):
|
|
"""Test q=source without URL parameter returns error"""
|
|
response = client.get('/micropub?q=source',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
assert 'url' in data['error_description'].lower()
|
|
|
|
|
|
def test_micropub_query_source_not_found(client, valid_token):
|
|
"""Test q=source with non-existent URL returns error"""
|
|
response = client.get('/micropub?q=source&url=https://example.com/notes/nonexistent',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert 'not found' in data['error_description'].lower()
|
|
|
|
|
|
def test_micropub_query_unknown(client, valid_token):
|
|
"""Test unknown query parameter returns error"""
|
|
response = client.get('/micropub?q=unknown',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data['error'] == 'invalid_request'
|
|
assert 'unknown' in data['error_description'].lower()
|
|
|
|
|
|
# Integration Tests
|
|
|
|
|
|
def test_micropub_end_to_end_flow(client, app, valid_token):
|
|
"""Test complete flow: create post, query config, query source"""
|
|
# 1. Get config
|
|
response = client.get('/micropub?q=config',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
assert response.status_code == 200
|
|
|
|
# 2. Create post
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': 'End-to-end test post',
|
|
'category[]': ['test', 'integration']
|
|
})
|
|
assert response.status_code == 201
|
|
note_url = response.headers['Location']
|
|
|
|
# 3. Query source
|
|
response = client.get(f'/micropub?q=source&url={note_url}',
|
|
headers={'Authorization': f'Bearer {valid_token}'})
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data['properties']['content'][0] == 'End-to-end test post'
|
|
|
|
|
|
def test_micropub_multiple_posts(client, app, valid_token):
|
|
"""Test creating multiple posts in sequence"""
|
|
for i in range(3):
|
|
response = client.post('/micropub',
|
|
headers={'Authorization': f'Bearer {valid_token}'},
|
|
data={
|
|
'h': 'entry',
|
|
'content': f'Test post number {i+1}'
|
|
})
|
|
|
|
assert response.status_code == 201
|
|
assert 'Location' in response.headers
|
|
|
|
# Verify all notes were created
|
|
with app.app_context():
|
|
from starpunk.notes import list_notes
|
|
notes = list_notes()
|
|
# Filter to published notes with our test content
|
|
test_notes = [n for n in notes if n.published and 'Test post number' in n.content]
|
|
assert len(test_notes) == 3
|