""" 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': '

HTML version

' }] } }) 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