feat(phase1): add static file serving for /images/

This commit is contained in:
2026-04-20 22:43:33 +03:00
parent 8d2750cfa3
commit 294555c574
22 changed files with 1989 additions and 4 deletions

View File

@@ -0,0 +1,256 @@
"""Tests for static file serving of uploaded images."""
import os
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.image_storage import save_image, IMAGES_ROOT, ensure_image_directories
@pytest.fixture(autouse=True)
def setup_images_directory():
"""Create and clean up images directory for each test."""
# Ensure directory exists
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
yield
# Cleanup after test
import shutil
if IMAGES_ROOT.exists():
shutil.rmtree(IMAGES_ROOT)
@pytest.fixture
def client():
"""FastAPI test client."""
return TestClient(app)
@pytest.fixture
def sample_jpeg_image():
"""Create a minimal valid JPEG image for testing."""
# Minimal JPEG header + footer
jpeg_data = (
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\''
b'\x9898_-282-\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x11\x00'
b'\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08'
b'\t\n\x0b\xff\xda\x00\x08\x01\x01\x00\x00?\x00\x7f\x00\xff\xd9'
)
return jpeg_data
@pytest.fixture
def sample_png_image():
"""Create a minimal valid PNG image for testing."""
# Create a minimal valid PNG programmatically using PIL
try:
from PIL import Image
from io import BytesIO
img = Image.new('RGB', (1, 1), color='white')
buffer = BytesIO()
img.save(buffer, format='PNG')
return buffer.getvalue()
except ImportError:
# Fallback to hardcoded PNG if PIL not available
# This is a valid minimal PNG that should be detected correctly
png_data = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00'
b'\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx'
b'\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\xfc\x83\t\xbf\x00'
b'\x00\x00\x00IEND\xaeB`\x82'
)
return png_data
class TestStaticFileServing:
"""Test static file serving for /images/ directory."""
def test_images_directory_exists_on_startup(self):
"""Verify /images directory is created on startup."""
assert IMAGES_ROOT.exists(), f"Images directory {IMAGES_ROOT} should exist"
assert IMAGES_ROOT.is_dir(), f"{IMAGES_ROOT} should be a directory"
def test_serve_uploaded_jpeg_image(self, client, sample_jpeg_image):
"""Test serving a JPEG image uploaded to /images/."""
# Save image directly using image storage utility
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="test_image",
variant="original"
)
# Request the image via static file mount
response = client.get(image_path)
# Verify response
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert response.headers["content-type"] == "image/jpeg"
assert response.content == sample_jpeg_image
def test_serve_uploaded_png_image(self, client, sample_png_image):
"""Test serving a PNG image uploaded to /images/."""
image_path = save_image(
file_bytes=sample_png_image,
category="test_category",
filename_base="test_png",
variant="original"
)
response = client.get(image_path)
# Note: save_image always saves with .jpg extension
assert response.status_code == 200
# Content is served back correctly (extension determines MIME type)
assert response.content == sample_png_image
def test_serve_thumbnail_variant(self, client, sample_jpeg_image):
"""Test serving thumbnail variant of an image."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="test_image",
variant="thumb"
)
response = client.get(image_path)
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_404_for_nonexistent_image(self, client):
"""Test 404 response for non-existent image."""
response = client.get("/images/nonexistent/missing.jpg")
assert response.status_code == 404
def test_directory_traversal_protection(self, client):
"""Test that directory traversal attempts are blocked."""
# Try various directory traversal patterns
traversal_paths = [
"/images/../../../etc/passwd",
"/images/test/../../etc/passwd",
"/images/test/..%2F..%2Fetc%2Fpasswd",
]
for path in traversal_paths:
response = client.get(path)
# Should either be 404 or 400, not 200
assert response.status_code in [400, 404], \
f"Path {path} should not be accessible, got {response.status_code}"
def test_serve_multiple_categories(self, client, sample_jpeg_image, sample_png_image):
"""Test serving images from multiple categories."""
# Save images to different categories
jpeg_path = save_image(
file_bytes=sample_jpeg_image,
category="memory",
filename_base="ddr4_stick",
variant="original"
)
png_path = save_image(
file_bytes=sample_png_image,
category="networking",
filename_base="sfp_transceiver",
variant="original"
)
# Verify both can be served
jpeg_response = client.get(jpeg_path)
png_response = client.get(png_path)
assert jpeg_response.status_code == 200
assert jpeg_response.headers["content-type"] == "image/jpeg"
assert png_response.status_code == 200
# Both saved as .jpg due to save_image implementation
assert png_response.headers["content-type"] in ["image/jpeg", "image/jpg"]
def test_content_matches_uploaded_file(self, client, sample_jpeg_image):
"""Verify that served content exactly matches uploaded file."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="test_image",
variant="original"
)
response = client.get(image_path)
# Content-length should match
assert len(response.content) == len(sample_jpeg_image)
# Content should match byte-for-byte
assert response.content == sample_jpeg_image
def test_mime_type_auto_detection_jpeg(self, client, sample_jpeg_image):
"""Test MIME type is auto-detected correctly for JPEG."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="jpeg_file",
variant="original"
)
response = client.get(image_path)
assert response.headers["content-type"] in ["image/jpeg", "image/jpg"]
def test_mime_type_auto_detection_all_saved_as_jpeg(self, client, sample_png_image):
"""Test MIME type detection for images saved as JPEG (all images saved as .jpg)."""
# Note: save_image always saves with .jpg extension regardless of input format
image_path = save_image(
file_bytes=sample_png_image,
category="test_category",
filename_base="png_file",
variant="original"
)
response = client.get(image_path)
# File extension is .jpg, so MIME type will be image/jpeg
assert response.headers["content-type"] in ["image/jpeg", "image/jpg"]
def test_original_and_thumbnail_variants_accessible(self, client, sample_jpeg_image):
"""Test both original and thumbnail variants are accessible."""
original_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="multi_variant",
variant="original"
)
thumb_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="multi_variant",
variant="thumb"
)
# Both should be accessible
original_response = client.get(original_path)
thumb_response = client.get(thumb_path)
assert original_response.status_code == 200
assert thumb_response.status_code == 200
def test_images_served_with_correct_path_structure(self, client, sample_jpeg_image):
"""Test images are served at /images/{category}/{filename} path."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="storage",
filename_base="ssd_drive",
variant="original"
)
# Path should be /images/storage/ssd_drive_original.jpg
assert image_path.startswith("/images/"), "Path should start with /images/"
assert "storage" in image_path, "Path should include category"
response = client.get(image_path)
assert response.status_code == 200