feat: v1.2.0-rc.2 - Media display fixes and feed enhancements
## Added - Feed Media Enhancement with Media RSS namespace support - RSS enclosure, media:content, media:thumbnail elements - JSON Feed image field for first image - ADR-059: Full feed media standardization roadmap ## Fixed - Media display on homepage (was only showing on note pages) - Responsive image sizing with CSS constraints - Caption display (now alt text only, not visible) - Logging correlation ID crash in non-request contexts ## Documentation - Feed media design documents and implementation reports - Media display fixes design and validation reports - Updated ROADMAP with v1.3.0/v1.4.0 media plans 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
571
tests/test_feeds_rss.py
Normal file
571
tests/test_feeds_rss.py
Normal file
@@ -0,0 +1,571 @@
|
||||
"""
|
||||
Tests for RSS 2.0 with Media RSS extension
|
||||
|
||||
Tests cover:
|
||||
- Media RSS namespace declaration
|
||||
- RSS enclosure element (first image only)
|
||||
- Media RSS content elements (all images)
|
||||
- Media RSS thumbnail (first image)
|
||||
- Items without media have no media elements
|
||||
- JSON Feed image field
|
||||
- JSON Feed omits image when no media
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree import ElementTree as ET
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
from starpunk import create_app
|
||||
from starpunk.feeds.rss import generate_rss, generate_rss_streaming
|
||||
from starpunk.feeds.json_feed import generate_json_feed, generate_json_feed_streaming
|
||||
from starpunk.notes import create_note, list_notes
|
||||
from starpunk.media import save_media, attach_media_to_note
|
||||
|
||||
|
||||
def create_test_image(width=800, height=600, format='PNG'):
|
||||
"""
|
||||
Generate test image using PIL
|
||||
|
||||
Args:
|
||||
width: Image width in pixels
|
||||
height: Image height in pixels
|
||||
format: Image format (PNG, JPEG, GIF, WEBP)
|
||||
|
||||
Returns:
|
||||
Bytes of image data
|
||||
"""
|
||||
img = Image.new('RGB', (width, height), color='blue')
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format=format)
|
||||
buffer.seek(0)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path):
|
||||
"""Create test application"""
|
||||
test_data_dir = tmp_path / "data"
|
||||
test_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create media directory
|
||||
test_media_dir = test_data_dir / "media"
|
||||
test_media_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",
|
||||
"MEDIA_PATH": test_media_dir,
|
||||
"SESSION_SECRET": "test-secret-key",
|
||||
"ADMIN_ME": "https://test.example.com",
|
||||
"SITE_URL": "https://example.com",
|
||||
"SITE_NAME": "Test Blog",
|
||||
"SITE_DESCRIPTION": "A test blog",
|
||||
"DEV_MODE": False,
|
||||
}
|
||||
app = create_app(config=test_config)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def note_with_single_media(app):
|
||||
"""Create note with single image attachment"""
|
||||
from starpunk.media import get_note_media
|
||||
|
||||
with app.app_context():
|
||||
# Create note
|
||||
note = create_note(
|
||||
content="# Test Note\n\nNote with one image.",
|
||||
published=True
|
||||
)
|
||||
|
||||
# Create and attach media
|
||||
image_data = create_test_image(800, 600, 'JPEG')
|
||||
media_info = save_media(image_data, 'test-image.jpg')
|
||||
attach_media_to_note(note.id, [media_info['id']], ['Test caption'])
|
||||
|
||||
# Reload note and attach media
|
||||
notes = list_notes(published_only=True, limit=1)
|
||||
note = notes[0]
|
||||
media = get_note_media(note.id)
|
||||
object.__setattr__(note, 'media', media)
|
||||
return note
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def note_with_multiple_media(app):
|
||||
"""Create note with multiple image attachments"""
|
||||
from starpunk.media import get_note_media
|
||||
|
||||
with app.app_context():
|
||||
# Create note
|
||||
note = create_note(
|
||||
content="# Gallery Note\n\nNote with three images.",
|
||||
published=True
|
||||
)
|
||||
|
||||
# Create and attach multiple media
|
||||
media_ids = []
|
||||
captions = []
|
||||
|
||||
for i in range(3):
|
||||
image_data = create_test_image(800, 600, 'JPEG')
|
||||
media_info = save_media(image_data, f'image-{i}.jpg')
|
||||
media_ids.append(media_info['id'])
|
||||
captions.append(f'Caption {i}')
|
||||
|
||||
attach_media_to_note(note.id, media_ids, captions)
|
||||
|
||||
# Reload note and attach media
|
||||
notes = list_notes(published_only=True, limit=1)
|
||||
note = notes[0]
|
||||
media = get_note_media(note.id)
|
||||
object.__setattr__(note, 'media', media)
|
||||
return note
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def note_without_media(app):
|
||||
"""Create note without media"""
|
||||
with app.app_context():
|
||||
note = create_note(
|
||||
content="# Plain Note\n\nNote without images.",
|
||||
published=True
|
||||
)
|
||||
notes = list_notes(published_only=True, limit=1)
|
||||
return notes[0]
|
||||
|
||||
|
||||
class TestRSSMediaNamespace:
|
||||
"""Test RSS feed has Media RSS namespace"""
|
||||
|
||||
def test_rss_has_media_namespace(self, app, note_with_single_media):
|
||||
"""RSS feed should declare Media RSS namespace"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
# Check for Media RSS namespace declaration
|
||||
assert 'xmlns:media="http://search.yahoo.com/mrss/"' in feed_xml
|
||||
|
||||
def test_rss_streaming_has_media_namespace(self, app, note_with_single_media):
|
||||
"""Streaming RSS feed should declare Media RSS namespace"""
|
||||
with app.app_context():
|
||||
generator = generate_rss_streaming(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
feed_xml = ''.join(generator)
|
||||
|
||||
# Check for Media RSS namespace declaration
|
||||
assert 'xmlns:media="http://search.yahoo.com/mrss/"' in feed_xml
|
||||
|
||||
|
||||
class TestRSSEnclosure:
|
||||
"""Test RSS enclosure element for first image"""
|
||||
|
||||
def test_rss_enclosure_for_single_media(self, app, note_with_single_media):
|
||||
"""RSS item should include enclosure element for first image"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
# Parse XML
|
||||
root = ET.fromstring(feed_xml)
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
enclosure = item.find("enclosure")
|
||||
|
||||
# Should have enclosure
|
||||
assert enclosure is not None
|
||||
|
||||
# Check attributes
|
||||
assert enclosure.get("url") is not None
|
||||
assert "https://example.com/media/" in enclosure.get("url")
|
||||
assert enclosure.get("type") == "image/jpeg"
|
||||
assert enclosure.get("length") is not None
|
||||
|
||||
def test_rss_enclosure_first_image_only(self, app, note_with_multiple_media):
|
||||
"""RSS item should include only one enclosure (first image)"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_multiple_media]
|
||||
)
|
||||
|
||||
# Parse XML
|
||||
root = ET.fromstring(feed_xml)
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
enclosures = item.findall("enclosure")
|
||||
|
||||
# Should have exactly one enclosure (RSS 2.0 spec)
|
||||
assert len(enclosures) == 1
|
||||
|
||||
def test_rss_no_enclosure_without_media(self, app, note_without_media):
|
||||
"""RSS item without media should have no enclosure"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_without_media]
|
||||
)
|
||||
|
||||
# Parse XML
|
||||
root = ET.fromstring(feed_xml)
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
enclosure = item.find("enclosure")
|
||||
|
||||
# Should have no enclosure
|
||||
assert enclosure is None
|
||||
|
||||
|
||||
class TestRSSMediaContent:
|
||||
"""Test Media RSS content elements"""
|
||||
|
||||
def test_rss_media_content_for_single_image(self, app, note_with_single_media):
|
||||
"""RSS item should include media:content for image"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
# Parse XML with namespace
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
media_contents = item.findall("media:content", namespaces)
|
||||
|
||||
# Should have one media:content element
|
||||
assert len(media_contents) == 1
|
||||
|
||||
# Check attributes
|
||||
media_content = media_contents[0]
|
||||
assert media_content.get("url") is not None
|
||||
assert "https://example.com/media/" in media_content.get("url")
|
||||
assert media_content.get("type") == "image/jpeg"
|
||||
assert media_content.get("medium") == "image"
|
||||
|
||||
def test_rss_media_content_for_multiple_images(self, app, note_with_multiple_media):
|
||||
"""RSS item should include media:content for each image"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_multiple_media]
|
||||
)
|
||||
|
||||
# Parse XML with namespace
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
media_contents = item.findall("media:content", namespaces)
|
||||
|
||||
# Should have three media:content elements
|
||||
assert len(media_contents) == 3
|
||||
|
||||
def test_rss_no_media_content_without_media(self, app, note_without_media):
|
||||
"""RSS item without media should have no media:content elements"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_without_media]
|
||||
)
|
||||
|
||||
# Parse XML with namespace
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
media_contents = item.findall("media:content", namespaces)
|
||||
|
||||
# Should have no media:content elements
|
||||
assert len(media_contents) == 0
|
||||
|
||||
|
||||
class TestRSSMediaThumbnail:
|
||||
"""Test Media RSS thumbnail element"""
|
||||
|
||||
def test_rss_media_thumbnail_for_first_image(self, app, note_with_single_media):
|
||||
"""RSS item should include media:thumbnail for first image"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
# Parse XML with namespace
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
media_thumbnail = item.find("media:thumbnail", namespaces)
|
||||
|
||||
# Should have media:thumbnail
|
||||
assert media_thumbnail is not None
|
||||
assert media_thumbnail.get("url") is not None
|
||||
assert "https://example.com/media/" in media_thumbnail.get("url")
|
||||
|
||||
def test_rss_media_thumbnail_only_one(self, app, note_with_multiple_media):
|
||||
"""RSS item should include only one media:thumbnail (first image)"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_multiple_media]
|
||||
)
|
||||
|
||||
# Parse XML with namespace
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
media_thumbnails = item.findall("media:thumbnail", namespaces)
|
||||
|
||||
# Should have exactly one media:thumbnail
|
||||
assert len(media_thumbnails) == 1
|
||||
|
||||
def test_rss_no_media_thumbnail_without_media(self, app, note_without_media):
|
||||
"""RSS item without media should have no media:thumbnail"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_without_media]
|
||||
)
|
||||
|
||||
# Parse XML with namespace
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
media_thumbnail = item.find("media:thumbnail", namespaces)
|
||||
|
||||
# Should have no media:thumbnail
|
||||
assert media_thumbnail is None
|
||||
|
||||
|
||||
class TestRSSStreamingMedia:
|
||||
"""Test streaming RSS generation includes media elements"""
|
||||
|
||||
def test_rss_streaming_includes_enclosure(self, app, note_with_single_media):
|
||||
"""Streaming RSS should include enclosure element"""
|
||||
with app.app_context():
|
||||
generator = generate_rss_streaming(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
feed_xml = ''.join(generator)
|
||||
|
||||
# Parse and check for enclosure
|
||||
root = ET.fromstring(feed_xml)
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
enclosure = item.find("enclosure")
|
||||
|
||||
assert enclosure is not None
|
||||
|
||||
def test_rss_streaming_includes_media_elements(self, app, note_with_single_media):
|
||||
"""Streaming RSS should include media:content and media:thumbnail"""
|
||||
with app.app_context():
|
||||
generator = generate_rss_streaming(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
feed_xml = ''.join(generator)
|
||||
|
||||
# Parse and check for media elements
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
|
||||
media_content = item.find("media:content", namespaces)
|
||||
media_thumbnail = item.find("media:thumbnail", namespaces)
|
||||
|
||||
assert media_content is not None
|
||||
assert media_thumbnail is not None
|
||||
|
||||
|
||||
class TestJSONFeedImage:
|
||||
"""Test JSON Feed image field"""
|
||||
|
||||
def test_json_feed_has_image_field(self, app, note_with_single_media):
|
||||
"""JSON Feed item should include image field for first image"""
|
||||
with app.app_context():
|
||||
feed_json = generate_json_feed(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
feed = json.loads(feed_json)
|
||||
item = feed["items"][0]
|
||||
|
||||
# Should have image field
|
||||
assert "image" in item
|
||||
assert item["image"] is not None
|
||||
assert "https://example.com/media/" in item["image"]
|
||||
|
||||
def test_json_feed_image_uses_first_media(self, app, note_with_multiple_media):
|
||||
"""JSON Feed image field should use first media item URL"""
|
||||
from starpunk.media import get_note_media
|
||||
|
||||
with app.app_context():
|
||||
feed_json = generate_json_feed(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_multiple_media]
|
||||
)
|
||||
|
||||
feed = json.loads(feed_json)
|
||||
item = feed["items"][0]
|
||||
|
||||
# Should have image field with first image
|
||||
assert "image" in item
|
||||
|
||||
# Verify it's the first media item from the note
|
||||
# (Media is saved with UUID filenames, so we can't check for "image-0.jpg")
|
||||
media = note_with_multiple_media.media
|
||||
first_media_path = media[0]['path']
|
||||
assert first_media_path in item["image"]
|
||||
|
||||
def test_json_feed_no_image_field_without_media(self, app, note_without_media):
|
||||
"""JSON Feed item without media should not have image field"""
|
||||
with app.app_context():
|
||||
feed_json = generate_json_feed(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_without_media]
|
||||
)
|
||||
|
||||
feed = json.loads(feed_json)
|
||||
item = feed["items"][0]
|
||||
|
||||
# Should NOT have image field (per Q7: absent, not null)
|
||||
assert "image" not in item
|
||||
|
||||
def test_json_feed_streaming_has_image_field(self, app, note_with_single_media):
|
||||
"""Streaming JSON Feed should include image field"""
|
||||
with app.app_context():
|
||||
generator = generate_json_feed_streaming(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
feed_json = ''.join(generator)
|
||||
feed = json.loads(feed_json)
|
||||
item = feed["items"][0]
|
||||
|
||||
# Should have image field
|
||||
assert "image" in item
|
||||
assert "https://example.com/media/" in item["image"]
|
||||
|
||||
def test_json_feed_streaming_no_image_without_media(self, app, note_without_media):
|
||||
"""Streaming JSON Feed without media should omit image field"""
|
||||
with app.app_context():
|
||||
generator = generate_json_feed_streaming(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_without_media]
|
||||
)
|
||||
|
||||
feed_json = ''.join(generator)
|
||||
feed = json.loads(feed_json)
|
||||
item = feed["items"][0]
|
||||
|
||||
# Should NOT have image field
|
||||
assert "image" not in item
|
||||
|
||||
|
||||
class TestFeedMediaIntegration:
|
||||
"""Integration tests for media in feeds"""
|
||||
|
||||
def test_rss_media_and_html_both_present(self, app, note_with_single_media):
|
||||
"""RSS should include both media elements AND HTML img tags"""
|
||||
with app.app_context():
|
||||
feed_xml = generate_rss(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
# Parse XML
|
||||
root = ET.fromstring(feed_xml)
|
||||
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
channel = root.find("channel")
|
||||
item = channel.find("item")
|
||||
|
||||
# Should have media:content
|
||||
media_content = item.find("media:content", namespaces)
|
||||
assert media_content is not None
|
||||
|
||||
# Should also have HTML img in description
|
||||
description = item.find("description").text
|
||||
assert '<img' in description
|
||||
|
||||
def test_json_feed_image_and_attachments_both_present(self, app, note_with_single_media):
|
||||
"""JSON Feed should include both image field AND attachments array"""
|
||||
with app.app_context():
|
||||
feed_json = generate_json_feed(
|
||||
site_url="https://example.com",
|
||||
site_name="Test Blog",
|
||||
site_description="A test blog",
|
||||
notes=[note_with_single_media]
|
||||
)
|
||||
|
||||
feed = json.loads(feed_json)
|
||||
item = feed["items"][0]
|
||||
|
||||
# Should have image field
|
||||
assert "image" in item
|
||||
|
||||
# Should also have attachments array
|
||||
assert "attachments" in item
|
||||
assert len(item["attachments"]) > 0
|
||||
@@ -157,7 +157,7 @@ class TestImageOptimization:
|
||||
class TestMediaSave:
|
||||
"""Test save_media function"""
|
||||
|
||||
def test_save_valid_image(self, app, db):
|
||||
def test_save_valid_image(self, app):
|
||||
"""Test saving valid image"""
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
@@ -175,7 +175,7 @@ class TestMediaSave:
|
||||
media_path = Path(app.config['DATA_PATH']) / 'media' / media_info['path']
|
||||
assert media_path.exists()
|
||||
|
||||
def test_uuid_filename(self, app, db):
|
||||
def test_uuid_filename(self, app):
|
||||
"""Test UUID-based filename generation (per Q5)"""
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
@@ -192,7 +192,7 @@ class TestMediaSave:
|
||||
assert len(parts[0]) == 4 # Year
|
||||
assert len(parts[1]) == 2 # Month
|
||||
|
||||
def test_auto_resize_on_save(self, app, db):
|
||||
def test_auto_resize_on_save(self, app):
|
||||
"""Test image >2048px is automatically resized"""
|
||||
large_image = create_test_image(3000, 2000, 'PNG')
|
||||
|
||||
@@ -207,7 +207,7 @@ class TestMediaSave:
|
||||
class TestMediaAttachment:
|
||||
"""Test attach_media_to_note function"""
|
||||
|
||||
def test_attach_single_image(self, app, db, sample_note):
|
||||
def test_attach_single_image(self, app, sample_note):
|
||||
"""Test attaching single image to note"""
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
@@ -225,7 +225,7 @@ class TestMediaAttachment:
|
||||
assert media_list[0]['caption'] == 'Test caption'
|
||||
assert media_list[0]['display_order'] == 0
|
||||
|
||||
def test_attach_multiple_images(self, app, db, sample_note):
|
||||
def test_attach_multiple_images(self, app, sample_note):
|
||||
"""Test attaching multiple images (up to 4)"""
|
||||
with app.app_context():
|
||||
media_ids = []
|
||||
@@ -247,7 +247,7 @@ class TestMediaAttachment:
|
||||
assert media_item['display_order'] == i
|
||||
assert media_item['caption'] == f'Caption {i}'
|
||||
|
||||
def test_reject_more_than_4_images(self, app, db, sample_note):
|
||||
def test_reject_more_than_4_images(self, app, sample_note):
|
||||
"""Test rejection of 5th image (per Q6)"""
|
||||
with app.app_context():
|
||||
media_ids = []
|
||||
@@ -264,7 +264,7 @@ class TestMediaAttachment:
|
||||
|
||||
assert "Maximum 4 images" in str(exc_info.value)
|
||||
|
||||
def test_optional_captions(self, app, db, sample_note):
|
||||
def test_optional_captions(self, app, sample_note):
|
||||
"""Test captions are optional (per Q7)"""
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
@@ -281,7 +281,7 @@ class TestMediaAttachment:
|
||||
class TestMediaDeletion:
|
||||
"""Test delete_media function"""
|
||||
|
||||
def test_delete_media_file(self, app, db):
|
||||
def test_delete_media_file(self, app):
|
||||
"""Test deletion of media file and record"""
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
@@ -299,7 +299,7 @@ class TestMediaDeletion:
|
||||
# Verify file deleted
|
||||
assert not media_path.exists()
|
||||
|
||||
def test_delete_orphaned_associations(self, app, db, sample_note):
|
||||
def test_delete_orphaned_associations(self, app, sample_note):
|
||||
"""Test cascade deletion of note_media associations"""
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
@@ -315,8 +315,118 @@ class TestMediaDeletion:
|
||||
assert len(media_list) == 0
|
||||
|
||||
|
||||
class TestMediaSecurityEscaping:
|
||||
"""Test HTML/JavaScript escaping in media display (per media-display-fixes.md)"""
|
||||
|
||||
def test_caption_html_escaped_in_alt_attribute(self, app, sample_note):
|
||||
"""
|
||||
Test that captions containing HTML are properly escaped in alt attributes
|
||||
|
||||
Per media-display-fixes.md Security Considerations:
|
||||
"Alt text must be HTML-escaped in templates"
|
||||
|
||||
This prevents XSS attacks via malicious caption content.
|
||||
"""
|
||||
from starpunk.media import attach_media_to_note, save_media
|
||||
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
# Create caption with HTML tags that should be escaped
|
||||
malicious_caption = '<script>alert("XSS")</script><img src=x onerror=alert(1)>'
|
||||
|
||||
with app.app_context():
|
||||
# Save media with malicious caption
|
||||
media_info = save_media(image_data, 'test.png')
|
||||
attach_media_to_note(sample_note.id, [media_info['id']], [malicious_caption])
|
||||
|
||||
# Get the rendered note page
|
||||
client = app.test_client()
|
||||
response = client.get(f'/note/{sample_note.slug}')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the HTML is escaped in the alt attribute
|
||||
# The caption should appear as escaped HTML entities, not raw HTML
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
# Should NOT contain unescaped HTML tags
|
||||
assert '<script>alert("XSS")</script>' not in html
|
||||
assert '<img src=x onerror=alert(1)>' not in html
|
||||
|
||||
# Should NOT have onerror as an actual HTML attribute (i.e., outside quotes)
|
||||
# Pattern: onerror= followed by something that isn't part of an alt value
|
||||
assert 'onerror=' not in html or 'alt=' in html.split('onerror=')[0]
|
||||
|
||||
# Should contain escaped versions (Jinja2 auto-escapes by default)
|
||||
# The HTML tags should be escaped
|
||||
assert '<script>' in html
|
||||
assert '<img' in html
|
||||
|
||||
def test_caption_quotes_escaped_in_alt_attribute(self, app, sample_note):
|
||||
"""
|
||||
Test that captions containing quotes are properly escaped in alt attributes
|
||||
|
||||
This prevents breaking out of the alt attribute with malicious quotes.
|
||||
"""
|
||||
from starpunk.media import attach_media_to_note, save_media
|
||||
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
|
||||
# Create caption with quotes that could break alt attribute
|
||||
caption_with_quotes = 'Image" onload="alert(\'XSS\')'
|
||||
|
||||
with app.app_context():
|
||||
# Save media with caption containing quotes
|
||||
media_info = save_media(image_data, 'test.png')
|
||||
attach_media_to_note(sample_note.id, [media_info['id']], [caption_with_quotes])
|
||||
|
||||
# Get the rendered note page
|
||||
client = app.test_client()
|
||||
response = client.get(f'/note/{sample_note.slug}')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
# Should NOT contain unescaped onload event
|
||||
assert 'onload="alert' not in html
|
||||
|
||||
# The quote should be properly escaped
|
||||
# Jinja2 should escape quotes in attributes
|
||||
assert '"' in html or '"' in html or ''' in html
|
||||
|
||||
def test_caption_displayed_on_homepage(self, app, sample_note):
|
||||
"""
|
||||
Test that media with captions are properly escaped on homepage too
|
||||
|
||||
Per media-display-fixes.md, homepage also displays media using the same macro.
|
||||
"""
|
||||
from starpunk.media import attach_media_to_note, save_media
|
||||
|
||||
image_data = create_test_image(800, 600, 'PNG')
|
||||
malicious_caption = '<img src=x onerror=alert(1)>'
|
||||
|
||||
with app.app_context():
|
||||
media_info = save_media(image_data, 'test.png')
|
||||
attach_media_to_note(sample_note.id, [media_info['id']], [malicious_caption])
|
||||
|
||||
# Get the homepage
|
||||
client = app.test_client()
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
# Should NOT contain unescaped HTML tag
|
||||
assert '<img src=x onerror=alert(1)>' not in html
|
||||
|
||||
# Should contain escaped version
|
||||
assert '<img' in html
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_note(app, db):
|
||||
def sample_note(app):
|
||||
"""Create a sample note for testing"""
|
||||
from starpunk.notes import create_note
|
||||
|
||||
|
||||
Reference in New Issue
Block a user