Skip to content

Commit b453116

Browse files
Added server-side image validation and tests
1 parent a22d44c commit b453116

File tree

6 files changed

+247
-91
lines changed

6 files changed

+247
-91
lines changed

docs/installation.qmd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ If you use VSCode with Docker to develop in a container, the following VSCode De
1010
{
1111
"name": "Python 3",
1212
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
13-
"postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync",
13+
"postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && uv venv && uv sync",
1414
"features": {
1515
"ghcr.io/va-h/devcontainers-features/uv:1": {
1616
"version": "latest"
@@ -61,7 +61,7 @@ Install Docker Desktop and Docker Compose for your operating system by following
6161
For Ubuntu/Debian:
6262

6363
``` bash
64-
sudo apt update && sudo apt install -y python3-dev libpq-dev
64+
sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev
6565
```
6666

6767
For macOS:

index.qmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Install Docker Desktop and Coker Compose for your operating system by following
8484
For Ubuntu/Debian:
8585

8686
``` bash
87-
sudo apt update && sudo apt install -y python3-dev libpq-dev
87+
sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev
8888
```
8989

9090
For macOS:

routers/user.py

Lines changed: 8 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,15 @@
1-
from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException
1+
from fastapi import APIRouter, Depends, Form, UploadFile, File
22
from fastapi.responses import RedirectResponse, Response
33
from pydantic import BaseModel, EmailStr
44
from sqlmodel import Session
55
from typing import Optional
66
from utils.models import User, DataIntegrityError
77
from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError
8-
from PIL import Image
9-
import io
8+
from utils.images import validate_and_process_image
109

1110
router = APIRouter(prefix="/user", tags=["user"])
1211

1312

14-
# --- Constants ---
15-
16-
17-
# 2MB in bytes
18-
MAX_FILE_SIZE = 2 * 1024 * 1024
19-
ALLOWED_CONTENT_TYPES = {
20-
'image/jpeg',
21-
'image/png',
22-
'image/webp'
23-
}
24-
MIN_DIMENSION = 100
25-
MAX_DIMENSION = 2000
26-
TARGET_SIZE = 500
27-
28-
29-
# --- Custom Exceptions ---
30-
31-
32-
class InvalidImageError(HTTPException):
33-
"""Raised when an invalid image is uploaded"""
34-
35-
def __init__(self, message: str = "Invalid image file"):
36-
super().__init__(status_code=400, detail=message)
37-
38-
3913
# --- Server Request and Response Models ---
4014

4115

@@ -90,55 +64,12 @@ async def update_profile(
9064
):
9165
# Handle avatar update
9266
if user_profile.avatar_file:
93-
# Check file size
94-
if len(user_profile.avatar_file) > MAX_FILE_SIZE:
95-
raise InvalidImageError(
96-
message="File too large (max 2MB)"
97-
)
98-
99-
# Check file type
100-
if user_profile.avatar_content_type not in ALLOWED_CONTENT_TYPES:
101-
raise InvalidImageError(
102-
message="Invalid file type. Must be JPEG, PNG, or WebP"
103-
)
104-
105-
try:
106-
# Open and validate image
107-
image = Image.open(io.BytesIO(user_profile.avatar_file))
108-
width, height = image.size
109-
110-
# Check minimum dimensions
111-
if width < MIN_DIMENSION or height < MIN_DIMENSION:
112-
raise InvalidImageError(
113-
message=f"Image too small. Minimum dimension is {MIN_DIMENSION}px"
114-
)
115-
116-
# Check maximum dimensions
117-
if width > MAX_DIMENSION or height > MAX_DIMENSION:
118-
raise InvalidImageError(
119-
message=f"Image too large. Maximum dimension is {MAX_DIMENSION}px"
120-
)
121-
122-
# Crop to square and resize
123-
min_dim = min(width, height)
124-
left = (width - min_dim) // 2
125-
top = (height - min_dim) // 2
126-
right = left + min_dim
127-
bottom = top + min_dim
128-
129-
image = image.crop((left, top, right, bottom))
130-
image = image.resize((TARGET_SIZE, TARGET_SIZE), Image.Resampling.LANCZOS)
131-
132-
# Convert back to bytes
133-
output = io.BytesIO()
134-
image.save(output, format='PNG')
135-
user_profile.avatar_file = output.getvalue()
136-
user_profile.avatar_content_type = 'image/png'
137-
138-
except Exception as e:
139-
raise InvalidImageError(
140-
message="Invalid image file"
141-
)
67+
processed_image, content_type = validate_and_process_image(
68+
user_profile.avatar_file,
69+
user_profile.avatar_content_type
70+
)
71+
user_profile.avatar_file = processed_image
72+
user_profile.avatar_content_type = content_type
14273

14374
# Update user details
14475
user.name = user_profile.name

tests/test_images.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import pytest
2+
from PIL import Image
3+
import io
4+
from utils.images import (
5+
validate_and_process_image,
6+
InvalidImageError,
7+
MAX_FILE_SIZE,
8+
MIN_DIMENSION,
9+
MAX_DIMENSION,
10+
TARGET_SIZE
11+
)
12+
13+
def create_test_image(width: int, height: int, format: str = 'PNG') -> bytes:
14+
"""Helper function to create test images"""
15+
image = Image.new('RGB', (width, height), color='red')
16+
output = io.BytesIO()
17+
image.save(output, format=format)
18+
return output.getvalue()
19+
20+
def test_webp_dependencies_are_installed():
21+
"""Test that webp dependencies are installed"""
22+
assert '.webp' in Image.registered_extensions(), "WebP dependencies are not installed (e.g., libwebp-dev on Linux)"
23+
24+
def test_valid_square_image():
25+
"""Test processing a valid square image"""
26+
image_data = create_test_image(500, 500)
27+
processed_data, content_type = validate_and_process_image(image_data, 'image/png')
28+
29+
# Verify the processed image
30+
processed_image = Image.open(io.BytesIO(processed_data))
31+
assert processed_image.size == (TARGET_SIZE, TARGET_SIZE)
32+
assert content_type == 'image/png'
33+
34+
def test_valid_rectangular_image():
35+
"""Test processing a valid rectangular image"""
36+
image_data = create_test_image(800, 600)
37+
processed_data, content_type = validate_and_process_image(image_data, 'image/png')
38+
39+
# Verify the processed image
40+
processed_image = Image.open(io.BytesIO(processed_data))
41+
assert processed_image.size == (TARGET_SIZE, TARGET_SIZE)
42+
assert content_type == 'image/png'
43+
44+
def test_minimum_size_image():
45+
"""Test processing an image with minimum allowed dimensions"""
46+
image_data = create_test_image(MIN_DIMENSION, MIN_DIMENSION)
47+
processed_data, content_type = validate_and_process_image(image_data, 'image/png')
48+
49+
processed_image = Image.open(io.BytesIO(processed_data))
50+
assert processed_image.size == (TARGET_SIZE, TARGET_SIZE)
51+
52+
def test_too_small_image():
53+
"""Test that too small images are rejected"""
54+
image_data = create_test_image(MIN_DIMENSION - 1, MIN_DIMENSION - 1)
55+
with pytest.raises(InvalidImageError) as exc_info:
56+
validate_and_process_image(image_data, 'image/png')
57+
assert "Image too small" in str(exc_info.value.detail)
58+
59+
def test_too_large_image():
60+
"""Test that too large images are rejected"""
61+
image_data = create_test_image(MAX_DIMENSION + 1, MAX_DIMENSION + 1)
62+
with pytest.raises(InvalidImageError) as exc_info:
63+
validate_and_process_image(image_data, 'image/png')
64+
assert "Image too large" in str(exc_info.value.detail)
65+
66+
def test_invalid_file_type():
67+
"""Test that invalid file types are rejected"""
68+
image_data = create_test_image(500, 500)
69+
with pytest.raises(InvalidImageError) as exc_info:
70+
validate_and_process_image(image_data, 'image/gif')
71+
assert "Invalid file type" in str(exc_info.value.detail)
72+
73+
def test_file_too_large():
74+
"""Test that files exceeding MAX_FILE_SIZE are rejected"""
75+
# Create a large file that exceeds MAX_FILE_SIZE
76+
large_image_data = b'0' * (MAX_FILE_SIZE + 1)
77+
with pytest.raises(InvalidImageError) as exc_info:
78+
validate_and_process_image(large_image_data, 'image/png')
79+
assert "File too large" in str(exc_info.value.detail)
80+
81+
def test_corrupt_image_data():
82+
"""Test that corrupt image data is rejected"""
83+
corrupt_data = b'not an image'
84+
with pytest.raises(InvalidImageError) as exc_info:
85+
validate_and_process_image(corrupt_data, 'image/png')
86+
assert "Invalid image file" in str(exc_info.value.detail)
87+
88+
def test_different_image_formats():
89+
"""Test processing different valid image formats"""
90+
formats = [
91+
('JPEG', 'image/jpeg'),
92+
('PNG', 'image/png'),
93+
('WEBP', 'image/webp')
94+
]
95+
96+
for format_name, content_type in formats:
97+
image_data = create_test_image(500, 500, format_name)
98+
processed_data, result_type = validate_and_process_image(image_data, content_type)
99+
100+
# Verify the processed image
101+
processed_image = Image.open(io.BytesIO(processed_data))
102+
assert processed_image.size == (TARGET_SIZE, TARGET_SIZE)
103+
# Output should match input format
104+
assert result_type == content_type

tests/test_user.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from fastapi.testclient import TestClient
22
from httpx import Response
33
from sqlmodel import Session
4+
from unittest.mock import patch
45

56
from main import app
67
from utils.models import User
8+
from utils.images import InvalidImageError
9+
10+
# Mock data for consistent testing
11+
MOCK_IMAGE_DATA = b"processed fake image data"
12+
MOCK_CONTENT_TYPE = "image/png"
713

814

915
def test_update_profile_unauthorized(unauth_client: TestClient):
@@ -23,11 +29,12 @@ def test_update_profile_unauthorized(unauth_client: TestClient):
2329
assert response.headers["location"] == app.url_path_for("read_login")
2430

2531

26-
def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session):
32+
@patch('routers.user.validate_and_process_image')
33+
def test_update_profile_authorized(mock_validate, auth_client: TestClient, test_user: User, session: Session):
2734
"""Test that authorized users can edit their profile"""
2835

29-
# Create test image data
30-
test_image_data = b"fake image data"
36+
# Configure mock to return processed image data
37+
mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE)
3138

3239
# Update profile
3340
response: Response = auth_client.post(
@@ -37,7 +44,7 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses
3744
"email": "[email protected]",
3845
},
3946
files={
40-
"avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg")
47+
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
4148
},
4249
follow_redirects=False
4350
)
@@ -48,8 +55,11 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses
4855
session.refresh(test_user)
4956
assert test_user.name == "Updated Name"
5057
assert test_user.email == "[email protected]"
51-
assert test_user.avatar_data == test_image_data
52-
assert test_user.avatar_content_type == "image/jpeg"
58+
assert test_user.avatar_data == MOCK_IMAGE_DATA
59+
assert test_user.avatar_content_type == MOCK_CONTENT_TYPE
60+
61+
# Verify mock was called correctly
62+
mock_validate.assert_called_once()
5363

5464

5565
def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session):
@@ -110,18 +120,21 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio
110120
assert user is None
111121

112122

113-
def test_get_avatar_authorized(auth_client: TestClient, test_user: User):
123+
@patch('routers.user.validate_and_process_image')
124+
def test_get_avatar_authorized(mock_validate, auth_client: TestClient, test_user: User):
114125
"""Test getting user avatar"""
126+
# Configure mock to return processed image data
127+
mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE)
128+
115129
# First upload an avatar
116-
test_image_data = b"fake image data"
117130
auth_client.post(
118131
app.url_path_for("update_profile"),
119132
data={
120133
"name": test_user.name,
121134
"email": test_user.email,
122135
},
123136
files={
124-
"avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg")
137+
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
125138
},
126139
)
127140

@@ -130,8 +143,8 @@ def test_get_avatar_authorized(auth_client: TestClient, test_user: User):
130143
app.url_path_for("get_avatar")
131144
)
132145
assert response.status_code == 200
133-
assert response.content == test_image_data
134-
assert response.headers["content-type"] == "image/jpeg"
146+
assert response.content == MOCK_IMAGE_DATA
147+
assert response.headers["content-type"] == MOCK_CONTENT_TYPE
135148

136149

137150
def test_get_avatar_unauthorized(unauth_client: TestClient):
@@ -142,3 +155,24 @@ def test_get_avatar_unauthorized(unauth_client: TestClient):
142155
)
143156
assert response.status_code == 303
144157
assert response.headers["location"] == app.url_path_for("read_login")
158+
159+
160+
# Add new test for invalid image
161+
@patch('routers.user.validate_and_process_image')
162+
def test_update_profile_invalid_image(mock_validate, auth_client: TestClient):
163+
"""Test that invalid images are rejected"""
164+
# Configure mock to raise InvalidImageError
165+
mock_validate.side_effect = InvalidImageError("Invalid test image")
166+
167+
response: Response = auth_client.post(
168+
app.url_path_for("update_profile"),
169+
data={
170+
"name": "Updated Name",
171+
"email": "[email protected]",
172+
},
173+
files={
174+
"avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg")
175+
},
176+
)
177+
assert response.status_code == 400
178+
assert "Invalid test image" in response.text

0 commit comments

Comments
 (0)