Files
StarPunk/tests/test_media_upload.py
Phil Skentelbery 07f351fef7 feat(media): Add comprehensive logging for media uploads - v1.4.1
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>
2025-12-16 17:22:22 -07:00

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 '&lt;script&gt;' in html
assert '&lt;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 '&#34;' in html or '&quot;' in html or '&#39;' 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 '&lt;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