feat: image refactor

This commit is contained in:
Alex committed 2026-08-12 12:36:43 +01:00
1 parent 1c2b0f506a
commit 4b1bc17c77
28 files changed
+1192 -266

No files matched your search

@@ -465,6 +465,28 @@ class TestUpdateAgent:
got = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert got["name"] == "new name"
def test_ignores_client_supplied_image_path(self, app, pg_conn):
from application.api.user.agents.routes import UpdateAgent
from application.storage.db.repositories.agents import AgentsRepository
user = "u-upd-image-path"
agent = _seed_agent(
pg_conn, user=user, status="draft", with_source=False,
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/update_agent/{agent['id']}", method="PUT",
json={"description": "safe update", "image": ".env"},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdateAgent().put(str(agent["id"]))
assert response.status_code == 200
got = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert got["description"] == "safe update"
assert got["image"] is None
def test_invalid_status_returns_400(self, app, pg_conn):
from application.api.user.agents.routes import UpdateAgent
@@ -258,3 +258,11 @@ class TestBuildCreateKwargs:
{}, image_url="/upload/img.png", agent_type="classic",
)
assert out.get("image") == "/upload/img.png"
def test_client_image_path_is_never_persisted(self):
from application.api.user.agents.routes import _build_create_kwargs
out = _build_create_kwargs(
{"image": ".env"}, image_url="", agent_type="classic",
)
assert "image" not in out
+211 -15
View File
@@ -1520,23 +1520,53 @@ class TestLiveSpeechToTextAdditional:
class TestServeImage:
"""Tests for ServeImage endpoint."""
agent_id = "00000000-0000-0000-0000-000000000001"
image_path = "inputs/user123/attachments/avatar.png"
@staticmethod
def _capability(agent_id, image_path, user_id="user123"):
from application.utils import generate_agent_image_capability
return generate_agent_image_capability(agent_id, image_path, user_id)
def test_serve_image_success(self, flask_app):
from application.api.user.attachments.routes import ServeImage
app = Flask(__name__)
mock_storage = MagicMock()
mock_file_obj = io.BytesIO(b"\x89PNG\r\n")
mock_storage.get_file_size.return_value = len(mock_file_obj.getvalue())
mock_storage.get_file.return_value = mock_file_obj
with patch("application.api.user.base.storage", mock_storage):
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": self.image_path,
}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch(
"application.api.user.attachments.routes.settings.STORAGE_TYPE", "local"
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, self.image_path)
with app.test_request_context(
"/api/images/test/image.png",
f"/api/images/{self.agent_id}/{capability}",
method="GET",
):
resource = ServeImage()
response = resource.get("test/image.png")
response = resource.get(self.agent_id, capability)
assert _get_response_status(response) == 200
assert response.headers.get("Content-Type") == "image/png"
assert response.headers.get("X-Content-Type-Options") == "nosniff"
assert response.is_streamed
assert b"".join(response.response) == b"\x89PNG\r\n"
mock_storage.get_file.assert_called_once_with(self.image_path)
def test_serve_image_jpg_content_type(self, flask_app):
from application.api.user.attachments.routes import ServeImage
@@ -1544,32 +1574,184 @@ class TestServeImage:
app = Flask(__name__)
mock_storage = MagicMock()
mock_file_obj = io.BytesIO(b"\xff\xd8\xff\xe0")
mock_storage.get_file_size.return_value = len(mock_file_obj.getvalue())
mock_storage.get_file.return_value = mock_file_obj
with patch("application.api.user.base.storage", mock_storage):
image_path = "inputs/user123/attachments/photo.jpg"
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": image_path,
}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch(
"application.api.user.attachments.routes.settings.STORAGE_TYPE", "local"
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, image_path)
with app.test_request_context(
"/api/images/test/photo.jpg",
f"/api/images/{self.agent_id}/{capability}",
method="GET",
):
resource = ServeImage()
response = resource.get("test/photo.jpg")
response = resource.get(self.agent_id, capability)
assert _get_response_status(response) == 200
assert response.headers.get("Content-Type") == "image/jpeg"
def test_serve_image_not_found(self, flask_app):
def test_s3_avatar_redirects_without_downloading_object(self, flask_app):
from application.api.user.attachments.routes import ServeImage
app = Flask(__name__)
mock_storage = MagicMock()
mock_storage.get_file.side_effect = FileNotFoundError("not found")
mock_storage.get_file_size.return_value = 1024
mock_storage.generate_presigned_url.return_value = (
"https://bucket.example/avatar.png?signature=short-lived"
)
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": self.image_path,
}
with patch("application.api.user.base.storage", mock_storage):
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch(
"application.api.user.attachments.routes.settings.STORAGE_TYPE", "s3"
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, self.image_path)
with app.test_request_context(
"/api/images/missing/image.png",
f"/api/images/{self.agent_id}/{capability}", method="GET"
):
response = ServeImage().get(self.agent_id, capability)
assert response.status_code == 302
assert response.headers["Location"].startswith("https://bucket.example/")
assert response.headers["Cache-Control"] == "private, max-age=240"
mock_storage.generate_presigned_url.assert_called_once_with(
self.image_path, expires_in=300, content_type="image/png"
)
mock_storage.get_file.assert_not_called()
def test_legacy_oversized_avatar_is_rejected_before_read(self, flask_app):
from application.api.user.attachments.routes import ServeImage
app = Flask(__name__)
mock_storage = MagicMock()
mock_storage.get_file_size.return_value = 5001
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": self.image_path,
}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch(
"application.api.user.attachments.routes.settings.AGENT_IMAGE_MAX_BYTES", 5000
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, self.image_path)
with app.test_request_context(
f"/api/images/{self.agent_id}/{capability}", method="GET"
):
response = ServeImage().get(self.agent_id, capability)
assert response.status_code == 404
mock_storage.get_file.assert_not_called()
mock_storage.generate_presigned_url.assert_not_called()
def test_unknown_capability_never_reads_storage(self, flask_app):
from application.api.user.attachments.routes import ServeImage
app = Flask(__name__)
mock_storage = MagicMock()
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": self.image_path,
}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch("application.api.user.base.storage", mock_storage):
with app.test_request_context(
f"/api/images/{self.agent_id}/{'0' * 64}",
method="GET",
):
resource = ServeImage()
response = resource.get("missing/image.png")
response = resource.get(self.agent_id, "0" * 64)
assert _get_response_status(response) == 404
mock_storage.get_file.assert_not_called()
def test_poisoned_agent_path_never_reads_storage(self, flask_app):
from application.api.user.attachments.routes import ServeImage
app = Flask(__name__)
mock_storage = MagicMock()
agent = {"id": self.agent_id, "user_id": "user123", "image": ".env"}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, ".env")
with app.test_request_context(
f"/api/images/{self.agent_id}/{capability}", method="GET"
):
response = ServeImage().get(self.agent_id, capability)
assert _get_response_status(response) == 404
mock_storage.get_file.assert_not_called()
def test_serve_image_file_not_found(self, flask_app):
from application.api.user.attachments.routes import ServeImage
app = Flask(__name__)
mock_storage = MagicMock()
mock_storage.get_file_size.side_effect = FileNotFoundError("not found")
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": self.image_path,
}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, self.image_path)
with app.test_request_context(
f"/api/images/{self.agent_id}/{capability}", method="GET"
):
response = ServeImage().get(self.agent_id, capability)
assert _get_response_status(response) == 404
def test_serve_image_generic_error(self, flask_app):
@@ -1577,15 +1759,29 @@ class TestServeImage:
app = Flask(__name__)
mock_storage = MagicMock()
mock_storage.get_file.side_effect = Exception("storage error")
mock_storage.get_file_size.side_effect = Exception("storage error")
with patch("application.api.user.base.storage", mock_storage):
agent = {
"id": self.agent_id,
"user_id": "user123",
"image": self.image_path,
}
with patch(
"application.utils.settings.JWT_SECRET_KEY", "test-image-secret"
), patch(
"application.api.user.attachments.routes.AgentsRepository.find_image_record",
return_value=agent,
), patch(
"application.api.user.attachments.routes.db_readonly", _fake_readonly
), patch("application.api.user.base.storage", mock_storage):
capability = self._capability(self.agent_id, self.image_path)
with app.test_request_context(
"/api/images/broken/image.png",
f"/api/images/{self.agent_id}/{capability}",
method="GET",
):
resource = ServeImage()
response = resource.get("broken/image.png")
response = resource.get(self.agent_id, capability)
assert _get_response_status(response) == 500
+164 -4
View File
@@ -4,6 +4,7 @@ from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
from PIL import Image
from werkzeug.datastructures import FileStorage
@@ -112,7 +113,19 @@ class TestGetVectorStore:
@pytest.mark.unit
class TestHandleImageUpload:
pass
@staticmethod
def _image_bytes(
width: int = 1, height: int = 1, image_format: str = "PNG"
) -> bytes:
buffer = io.BytesIO()
Image.new("RGB", (width, height), color="white").save(
buffer, format=image_format
)
return buffer.getvalue()
@classmethod
def _png_bytes(cls) -> bytes:
return cls._image_bytes()
def test_returns_existing_url_when_no_file(self, flask_app):
from application.api.user.base import handle_image_upload
@@ -135,7 +148,7 @@ class TestHandleImageUpload:
with flask_app.test_request_context():
mock_file = FileStorage(
stream=io.BytesIO(b"fake image data"), filename="test_image.png"
stream=io.BytesIO(self._png_bytes()), filename="test_image.png"
)
mock_request = Mock()
mock_request.files = {"image": mock_file}
@@ -152,6 +165,23 @@ class TestHandleImageUpload:
assert "user123" in url
mock_storage.save_file.assert_called_once()
def test_uploads_image_with_non_ascii_basename(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.test_request_context():
mock_file = FileStorage(
stream=io.BytesIO(self._png_bytes()), filename="上传.png"
)
mock_storage = Mock()
url, error = handle_image_upload(
Mock(files={"image": mock_file}), "", "user123", mock_storage
)
assert error is None
assert url.endswith("_avatar.png")
mock_storage.save_file.assert_called_once()
def test_ignores_empty_filename(self, flask_app):
from application.api.user.base import handle_image_upload
@@ -175,7 +205,9 @@ class TestHandleImageUpload:
from application.api.user.base import handle_image_upload
with flask_app.app_context():
mock_file = FileStorage(stream=io.BytesIO(b"data"), filename="test.png")
mock_file = FileStorage(
stream=io.BytesIO(self._png_bytes()), filename="test.png"
)
mock_request = Mock()
mock_request.files = {"image": mock_file}
mock_storage = Mock()
@@ -189,6 +221,135 @@ class TestHandleImageUpload:
assert error is not None
assert error.status_code == 400
def test_rejects_non_image_content(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.app_context():
mock_file = FileStorage(
stream=io.BytesIO(b"not an image"), filename="test.png"
)
mock_request = Mock(files={"image": mock_file})
mock_storage = Mock()
url, error = handle_image_upload(
mock_request, "old.jpg", "user123", mock_storage
)
assert url is None
assert error.status_code == 400
mock_storage.save_file.assert_not_called()
def test_accepts_image_at_encoded_byte_limit(self, flask_app):
from application.api.user.base import handle_image_upload
payload = self._png_bytes()
with patch(
"application.api.user.base.settings.AGENT_IMAGE_MAX_BYTES",
len(payload),
), flask_app.app_context():
mock_file = FileStorage(
stream=io.BytesIO(payload), filename="at-limit.png"
)
mock_storage = Mock()
url, error = handle_image_upload(
Mock(files={"image": mock_file}), "", "user123", mock_storage
)
assert error is None
assert url.endswith("_at-limit.png")
mock_storage.save_file.assert_called_once()
def test_rejects_image_over_encoded_byte_limit(self, flask_app):
from application.api.user.base import handle_image_upload
payload = self._png_bytes()
with patch(
"application.api.user.base.settings.AGENT_IMAGE_MAX_BYTES",
len(payload) - 1,
), flask_app.app_context():
mock_file = FileStorage(
stream=io.BytesIO(payload), filename="too-large.png"
)
mock_storage = Mock()
url, error = handle_image_upload(
Mock(files={"image": mock_file}), "old.png", "user123", mock_storage
)
assert url is None
assert error.status_code == 400
mock_storage.save_file.assert_not_called()
@pytest.mark.parametrize(
("width", "height", "expected_status"),
[(4, 4, None), (5, 4, 400)],
)
def test_enforces_decoded_pixel_limit(
self, flask_app, width, height, expected_status
):
from application.api.user.base import handle_image_upload
payload = self._image_bytes(width, height)
with patch(
"application.api.user.base.settings.AGENT_IMAGE_MAX_PIXELS", 16
), flask_app.app_context():
mock_file = FileStorage(
stream=io.BytesIO(payload), filename="dimensions.png"
)
mock_storage = Mock()
url, error = handle_image_upload(
Mock(files={"image": mock_file}), "", "user123", mock_storage
)
if expected_status is None:
assert error is None
assert url
mock_storage.save_file.assert_called_once()
else:
assert url is None
assert error.status_code == expected_status
mock_storage.save_file.assert_not_called()
def test_rejects_image_whose_content_does_not_match_extension(
self, flask_app
):
from application.api.user.base import handle_image_upload
with flask_app.app_context():
mock_file = FileStorage(
stream=io.BytesIO(self._png_bytes()), filename="disguised.jpg"
)
mock_storage = Mock()
url, error = handle_image_upload(
Mock(files={"image": mock_file}), "", "user123", mock_storage
)
assert url is None
assert error.status_code == 400
mock_storage.save_file.assert_not_called()
def test_sanitizes_user_directory_component(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.app_context():
mock_file = FileStorage(
stream=io.BytesIO(self._png_bytes()), filename="test.png"
)
mock_request = Mock(files={"image": mock_file})
mock_storage = Mock()
url, error = handle_image_upload(
mock_request, "", "../../secrets", mock_storage
)
assert error is None
assert ".." not in url
assert url.startswith("inputs/secrets-")
assert "/attachments/" in url
@pytest.mark.unit
class TestRequireAgentDecorator:
@@ -321,4 +482,3 @@ class TestRequireAgentPgConn:
with _patch_base_db(pg_conn), flask_app.app_context():
result = fn(webhook_token="webhook-123")
assert result["got"] == str(agent["id"])