Implements media upload logging per docs/design/v1.4.1/media-logging-design.md Changes: - Add logging to save_media() in starpunk/media.py: * INFO: Successful uploads with file details * WARNING: Validation/optimization/variant failures * ERROR: Unexpected system errors - Remove duplicate logging in Micropub media endpoint - Add 5 comprehensive logging tests in TestMediaLogging class - Bump version to 1.4.1 - Update CHANGELOG.md All media upload operations now logged for debugging and observability. Validation errors, optimization failures, and variant generation issues are tracked at appropriate log levels. Original functionality unchanged. Test results: 28/28 media tests pass, 5 new logging tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
561 lines
20 KiB
Python
561 lines
20 KiB
Python
"""
|
|
Tests for media upload functionality (v1.2.0 Phase 3)
|
|
|
|
Tests media upload, validation, optimization, and display per ADR-057 and ADR-058.
|
|
Uses generated test images (PIL Image.new()) per Q31.
|
|
"""
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
import io
|
|
from pathlib import Path
|
|
|
|
from starpunk.media import (
|
|
validate_image,
|
|
optimize_image,
|
|
save_media,
|
|
attach_media_to_note,
|
|
get_note_media,
|
|
delete_media,
|
|
MAX_FILE_SIZE,
|
|
MAX_DIMENSION,
|
|
RESIZE_DIMENSION,
|
|
MAX_IMAGES_PER_NOTE,
|
|
)
|
|
|
|
|
|
def create_test_image(width=800, height=600, format='PNG'):
|
|
"""
|
|
Generate test image using PIL
|
|
|
|
Per Q31: Use generated test images, not real files
|
|
|
|
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='red')
|
|
buffer = io.BytesIO()
|
|
img.save(buffer, format=format)
|
|
buffer.seek(0)
|
|
return buffer.getvalue()
|
|
|
|
|
|
class TestImageValidation:
|
|
"""Test validate_image function"""
|
|
|
|
def test_valid_jpeg(self):
|
|
"""Test validation of valid JPEG image"""
|
|
image_data = create_test_image(800, 600, 'JPEG')
|
|
mime_type, width, height = validate_image(image_data, 'test.jpg')
|
|
|
|
assert mime_type == 'image/jpeg'
|
|
assert width == 800
|
|
assert height == 600
|
|
|
|
def test_valid_png(self):
|
|
"""Test validation of valid PNG image"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
mime_type, width, height = validate_image(image_data, 'test.png')
|
|
|
|
assert mime_type == 'image/png'
|
|
assert width == 800
|
|
assert height == 600
|
|
|
|
def test_valid_gif(self):
|
|
"""Test validation of valid GIF image"""
|
|
image_data = create_test_image(800, 600, 'GIF')
|
|
mime_type, width, height = validate_image(image_data, 'test.gif')
|
|
|
|
assert mime_type == 'image/gif'
|
|
assert width == 800
|
|
assert height == 600
|
|
|
|
def test_valid_webp(self):
|
|
"""Test validation of valid WebP image"""
|
|
image_data = create_test_image(800, 600, 'WEBP')
|
|
mime_type, width, height = validate_image(image_data, 'test.webp')
|
|
|
|
assert mime_type == 'image/webp'
|
|
assert width == 800
|
|
assert height == 600
|
|
|
|
def test_file_too_large(self):
|
|
"""Test rejection of >10MB file (per Q6)"""
|
|
# Create data larger than MAX_FILE_SIZE
|
|
large_data = b'x' * (MAX_FILE_SIZE + 1)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_image(large_data, 'large.jpg')
|
|
|
|
assert "File too large" in str(exc_info.value)
|
|
|
|
def test_dimensions_too_large(self):
|
|
"""Test rejection of >4096px image (per ADR-058)"""
|
|
large_image = create_test_image(5000, 5000, 'PNG')
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_image(large_image, 'huge.png')
|
|
|
|
assert "dimensions too large" in str(exc_info.value).lower()
|
|
|
|
def test_corrupted_image(self):
|
|
"""Test rejection of corrupted image data"""
|
|
corrupted_data = b'not an image'
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_image(corrupted_data, 'corrupt.jpg')
|
|
|
|
assert "Invalid or corrupted" in str(exc_info.value)
|
|
|
|
|
|
class TestImageOptimization:
|
|
"""Test optimize_image function"""
|
|
|
|
def test_no_resize_needed(self):
|
|
"""Test image within limits is not resized"""
|
|
image_data = create_test_image(1024, 768, 'PNG')
|
|
optimized, width, height, optimized_bytes = optimize_image(image_data)
|
|
|
|
assert width == 1024
|
|
assert height == 768
|
|
assert optimized_bytes is not None
|
|
assert len(optimized_bytes) > 0
|
|
|
|
def test_resize_large_image(self):
|
|
"""Test auto-resize of >2048px image (per ADR-058)"""
|
|
large_image = create_test_image(3000, 2000, 'PNG')
|
|
optimized, width, height, optimized_bytes = optimize_image(large_image)
|
|
|
|
# Should be resized to 2048px on longest edge
|
|
assert width == RESIZE_DIMENSION
|
|
# Height should be proportionally scaled
|
|
assert height == int(2000 * (RESIZE_DIMENSION / 3000))
|
|
assert optimized_bytes is not None
|
|
|
|
def test_aspect_ratio_preserved(self):
|
|
"""Test aspect ratio is maintained during resize"""
|
|
image_data = create_test_image(3000, 1500, 'PNG')
|
|
optimized, width, height, optimized_bytes = optimize_image(image_data)
|
|
|
|
# Original aspect ratio: 2:1
|
|
# After resize: should still be 2:1
|
|
assert width / height == pytest.approx(2.0, rel=0.01)
|
|
assert optimized_bytes is not None
|
|
|
|
def test_gif_animation_preserved(self):
|
|
"""Test GIF animation preservation (per Q12)"""
|
|
# For v1.2.0: Just verify GIF is handled without error
|
|
# Full animation preservation is complex
|
|
gif_data = create_test_image(800, 600, 'GIF')
|
|
optimized, width, height, optimized_bytes = optimize_image(gif_data)
|
|
|
|
assert width > 0
|
|
assert height > 0
|
|
assert optimized_bytes is not None
|
|
|
|
|
|
class TestMediaSave:
|
|
"""Test save_media function"""
|
|
|
|
def test_save_valid_image(self, app):
|
|
"""Test saving valid image"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
media_info = save_media(image_data, 'test.png')
|
|
|
|
assert media_info['id'] > 0
|
|
assert media_info['filename'] == 'test.png'
|
|
assert media_info['mime_type'] == 'image/png'
|
|
assert media_info['width'] == 800
|
|
assert media_info['height'] == 600
|
|
assert media_info['size'] > 0
|
|
|
|
# Check file was created
|
|
media_path = Path(app.config['DATA_PATH']) / 'media' / media_info['path']
|
|
assert media_path.exists()
|
|
|
|
def test_uuid_filename(self, app):
|
|
"""Test UUID-based filename generation (per Q5)"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
media_info = save_media(image_data, 'original-name.png')
|
|
|
|
# Stored filename should be different from original
|
|
assert media_info['stored_filename'] != 'original-name.png'
|
|
# Should end with .png
|
|
assert media_info['stored_filename'].endswith('.png')
|
|
# Path should be YYYY/MM/uuid.ext (per Q2)
|
|
parts = media_info['path'].split('/')
|
|
assert len(parts) == 3 # year/month/filename
|
|
assert len(parts[0]) == 4 # Year
|
|
assert len(parts[1]) == 2 # Month
|
|
|
|
def test_auto_resize_on_save(self, app):
|
|
"""Test image >2048px is automatically resized"""
|
|
large_image = create_test_image(3000, 2000, 'PNG')
|
|
|
|
with app.app_context():
|
|
media_info = save_media(large_image, 'large.png')
|
|
|
|
# Should be resized
|
|
assert media_info['width'] == RESIZE_DIMENSION
|
|
assert media_info['height'] < 2000
|
|
|
|
|
|
class TestMediaAttachment:
|
|
"""Test attach_media_to_note function"""
|
|
|
|
def test_attach_single_image(self, app, sample_note):
|
|
"""Test attaching single image to note"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
# Save media
|
|
media_info = save_media(image_data, 'test.png')
|
|
|
|
# Attach to note
|
|
attach_media_to_note(sample_note.id, [media_info['id']], ['Test caption'])
|
|
|
|
# Verify attachment
|
|
media_list = get_note_media(sample_note.id)
|
|
assert len(media_list) == 1
|
|
assert media_list[0]['id'] == media_info['id']
|
|
assert media_list[0]['caption'] == 'Test caption'
|
|
assert media_list[0]['display_order'] == 0
|
|
|
|
def test_attach_multiple_images(self, app, sample_note):
|
|
"""Test attaching multiple images (up to 4)"""
|
|
with app.app_context():
|
|
media_ids = []
|
|
captions = []
|
|
|
|
for i in range(4):
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
media_info = save_media(image_data, f'test{i}.png')
|
|
media_ids.append(media_info['id'])
|
|
captions.append(f'Caption {i}')
|
|
|
|
attach_media_to_note(sample_note.id, media_ids, captions)
|
|
|
|
media_list = get_note_media(sample_note.id)
|
|
assert len(media_list) == 4
|
|
|
|
# Verify order
|
|
for i, media_item in enumerate(media_list):
|
|
assert media_item['display_order'] == i
|
|
assert media_item['caption'] == f'Caption {i}'
|
|
|
|
def test_reject_more_than_4_images(self, app, sample_note):
|
|
"""Test rejection of 5th image (per Q6)"""
|
|
with app.app_context():
|
|
media_ids = []
|
|
captions = []
|
|
|
|
for i in range(5):
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
media_info = save_media(image_data, f'test{i}.png')
|
|
media_ids.append(media_info['id'])
|
|
captions.append('')
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
attach_media_to_note(sample_note.id, media_ids, captions)
|
|
|
|
assert "Maximum 4 images" in str(exc_info.value)
|
|
|
|
def test_optional_captions(self, app, sample_note):
|
|
"""Test captions are optional (per Q7)"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
media_info = save_media(image_data, 'test.png')
|
|
|
|
# Attach without caption
|
|
attach_media_to_note(sample_note.id, [media_info['id']], [''])
|
|
|
|
media_list = get_note_media(sample_note.id)
|
|
assert media_list[0]['caption'] is None or media_list[0]['caption'] == ''
|
|
|
|
|
|
class TestMediaDeletion:
|
|
"""Test delete_media function"""
|
|
|
|
def test_delete_media_file(self, app):
|
|
"""Test deletion of media file and record"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
media_info = save_media(image_data, 'test.png')
|
|
media_id = media_info['id']
|
|
media_path = Path(app.config['DATA_PATH']) / 'media' / media_info['path']
|
|
|
|
# Verify file exists
|
|
assert media_path.exists()
|
|
|
|
# Delete media
|
|
delete_media(media_id)
|
|
|
|
# Verify file deleted
|
|
assert not media_path.exists()
|
|
|
|
def test_delete_orphaned_associations(self, app, sample_note):
|
|
"""Test cascade deletion of note_media associations"""
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
media_info = save_media(image_data, 'test.png')
|
|
attach_media_to_note(sample_note.id, [media_info['id']], ['Test'])
|
|
|
|
# Delete media
|
|
delete_media(media_info['id'])
|
|
|
|
# Verify association also deleted
|
|
media_list = get_note_media(sample_note.id)
|
|
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
|
|
|
|
|
|
class TestMediaLogging:
|
|
"""Test media upload logging (v1.4.1)"""
|
|
|
|
def test_save_media_logs_success(self, app, caplog):
|
|
"""Test successful upload logs at INFO level"""
|
|
import logging
|
|
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
with caplog.at_level(logging.INFO):
|
|
media_info = save_media(image_data, 'test.png')
|
|
|
|
# Check success log exists
|
|
assert "Media upload successful" in caplog.text
|
|
assert 'filename="test.png"' in caplog.text
|
|
assert f'stored="{media_info["stored_filename"]}"' in caplog.text
|
|
assert f'size={media_info["size"]}b' in caplog.text
|
|
# optimized flag should be present
|
|
assert 'optimized=' in caplog.text
|
|
# variants count should be present
|
|
assert 'variants=' in caplog.text
|
|
|
|
def test_save_media_logs_validation_failure(self, app, caplog):
|
|
"""Test validation failure logs at WARNING level"""
|
|
import logging
|
|
|
|
# Create invalid data (corrupted image)
|
|
invalid_data = b'not an image'
|
|
|
|
with app.app_context():
|
|
with caplog.at_level(logging.WARNING):
|
|
with pytest.raises(ValueError):
|
|
save_media(invalid_data, 'corrupt.jpg')
|
|
|
|
# Check validation failure log
|
|
assert "Media upload validation failed" in caplog.text
|
|
assert 'filename="corrupt.jpg"' in caplog.text
|
|
assert f'size={len(invalid_data)}b' in caplog.text
|
|
assert 'error=' in caplog.text
|
|
|
|
def test_save_media_logs_optimization_failure(self, app, caplog, monkeypatch):
|
|
"""Test optimization failure logs at WARNING level"""
|
|
import logging
|
|
from starpunk import media
|
|
|
|
# Mock optimize_image to raise ValueError
|
|
def mock_optimize_image(image_data, original_size=None):
|
|
raise ValueError("Image cannot be optimized to target size. Please use a smaller or lower-resolution image.")
|
|
|
|
monkeypatch.setattr(media, 'optimize_image', mock_optimize_image)
|
|
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
with caplog.at_level(logging.WARNING):
|
|
with pytest.raises(ValueError):
|
|
save_media(image_data, 'test.png')
|
|
|
|
# Check optimization failure log
|
|
assert "Media upload optimization failed" in caplog.text
|
|
assert 'filename="test.png"' in caplog.text
|
|
assert f'size={len(image_data)}b' in caplog.text
|
|
assert 'error=' in caplog.text
|
|
|
|
def test_save_media_logs_variant_failure(self, app, caplog, monkeypatch):
|
|
"""Test variant generation failure logs at WARNING level but continues"""
|
|
import logging
|
|
from starpunk import media
|
|
|
|
# Mock generate_all_variants to raise an exception
|
|
def mock_generate_all_variants(*args, **kwargs):
|
|
raise RuntimeError("Variant generation failed")
|
|
|
|
monkeypatch.setattr(media, 'generate_all_variants', mock_generate_all_variants)
|
|
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
with caplog.at_level(logging.INFO): # Need INFO level to capture success log
|
|
# Should succeed despite variant failure
|
|
media_info = save_media(image_data, 'test.png')
|
|
|
|
# Check variant failure log
|
|
assert "Media upload variant generation failed" in caplog.text
|
|
assert 'filename="test.png"' in caplog.text
|
|
assert f'media_id={media_info["id"]}' in caplog.text
|
|
assert 'error=' in caplog.text
|
|
|
|
# But success log should also be present
|
|
assert "Media upload successful" in caplog.text
|
|
# And variants should be 0
|
|
assert 'variants=0' in caplog.text
|
|
|
|
def test_save_media_logs_unexpected_error(self, app, caplog, monkeypatch):
|
|
"""Test unexpected error logs at ERROR level"""
|
|
import logging
|
|
from starpunk import media
|
|
from pathlib import Path as OriginalPath
|
|
|
|
# Mock Path.write_bytes to raise OSError (simulating disk full)
|
|
def mock_write_bytes(self, data):
|
|
raise OSError("[Errno 28] No space left on device")
|
|
|
|
monkeypatch.setattr(Path, 'write_bytes', mock_write_bytes)
|
|
|
|
image_data = create_test_image(800, 600, 'PNG')
|
|
|
|
with app.app_context():
|
|
with caplog.at_level(logging.ERROR):
|
|
with pytest.raises(OSError):
|
|
save_media(image_data, 'test.png')
|
|
|
|
# Check unexpected error log
|
|
assert "Media upload failed unexpectedly" in caplog.text
|
|
assert 'filename="test.png"' in caplog.text
|
|
assert 'error_type="OSError"' in caplog.text
|
|
assert 'error=' in caplog.text
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_note(app):
|
|
"""Create a sample note for testing"""
|
|
from starpunk.notes import create_note
|
|
|
|
with app.app_context():
|
|
note = create_note("Test note content", published=True)
|
|
yield note
|