mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 06:17:23 +00:00
feat: image refactor
This commit is contained in:
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
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
Reference in new issue
Block a user