import asyncio import http.server import json import threading from typing import List, Tuple from unittest.mock import MagicMock, patch import pytest from starlette.requests import Request from starlette.exceptions import HTTPException from core.database import SessionLocal, ModelEndpoint from routes.gallery.gallery_routes import setup_gallery_routes, _visible_image_endpoint_for_base class TrackingPollHandler(http.server.BaseHTTPRequestHandler): requests_received: List[str] = [] def do_GET(self): TrackingPollHandler.requests_received.append(self.path) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"available": true, "progress": 50}') def do_POST(self): TrackingPollHandler.requests_received.append(self.path) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"images": ["data:image/png;base64,AAAA"]}') def log_message(self, format, *args): pass @pytest.fixture def mock_provider_listener(): TrackingPollHandler.requests_received = [] server = http.server.HTTPServer(("127.0.0.1", 0), TrackingPollHandler) port = server.server_address[1] thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() yield f"http://127.0.0.1:{port}" server.shutdown() server.server_close() def create_gallery_request(method: str, path: str, query_params: dict = None, body: dict = None, user: str = "alice"): scope = { "type": "http", "method": method, "path": path, "headers": [(b"content-type", b"application/json")], "query_string": "&".join(f"{k}={v}" for k, v in (query_params or {}).items()).encode("utf-8"), "app": MagicMock(), } req = Request(scope) async def mock_json(): return body or {} req.json = mock_json return req def test_edit_progress_disabled_endpoint_causes_no_outbound_polling(mock_provider_listener): """GET /api/image/edit-progress with disabled endpoint causes zero outbound requests.""" db = SessionLocal() ep_id = "ep-test-disabled-progress" try: ep = ModelEndpoint( id=ep_id, name="Disabled Endpoint", base_url=mock_provider_listener, model_type="image", is_enabled=False, owner="alice", cached_models=json.dumps(["test-diff"]), ) db.merge(ep) db.commit() router = setup_gallery_routes() route = next(r for r in router.routes if r.path == "/api/image/edit-progress") req = create_gallery_request( "GET", "/api/image/edit-progress", query_params={"request_id": "req-123", "endpoint_id": ep_id}, user="alice", ) with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"): resp = asyncio.run(route.endpoint(req)) assert isinstance(resp, dict) assert resp["available"] is False assert resp.get("reason") == "endpoint not found or disabled" # Zero outbound polling to the provider! assert len(TrackingPollHandler.requests_received) == 0 finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close() def test_edit_progress_foreign_owned_endpoint_causes_no_outbound_polling(mock_provider_listener): """GET /api/image/edit-progress with foreign-owned endpoint causes zero outbound requests.""" db = SessionLocal() ep_id = "ep-test-foreign-progress" try: ep = ModelEndpoint( id=ep_id, name="Victim Endpoint", base_url=mock_provider_listener, model_type="image", is_enabled=True, owner="victim_user", cached_models=json.dumps(["test-diff"]), ) db.merge(ep) db.commit() router = setup_gallery_routes() route = next(r for r in router.routes if r.path == "/api/image/edit-progress") # Attacker user 'bob' requests victim's endpoint req = create_gallery_request( "GET", "/api/image/edit-progress", query_params={"request_id": "req-456", "endpoint_id": ep_id}, user="bob", ) with patch("routes.gallery.gallery_routes.require_privilege", return_value="bob"): resp = asyncio.run(route.endpoint(req)) assert isinstance(resp, dict) assert resp["available"] is False assert resp.get("reason") == "endpoint not found or disabled" assert len(TrackingPollHandler.requests_received) == 0 finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close() def test_edit_progress_rejects_chat_only_endpoint_causes_no_polling(mock_provider_listener): """GET /api/image/edit-progress rejects chat-only endpoint ID with zero outbound requests.""" db = SessionLocal() ep_id = "ep-test-chat-progress" try: ep = ModelEndpoint( id=ep_id, name="Chat Endpoint", base_url=mock_provider_listener, model_type="llm", # chat only! is_enabled=True, owner="alice", cached_models=json.dumps(["llama-3-8b"]), ) db.merge(ep) db.commit() router = setup_gallery_routes() route = next(r for r in router.routes if r.path == "/api/image/edit-progress") req = create_gallery_request( "GET", "/api/image/edit-progress", query_params={"request_id": "req-789", "endpoint_id": ep_id}, user="alice", ) with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"): resp = asyncio.run(route.endpoint(req)) assert isinstance(resp, dict) assert resp["available"] is False assert resp.get("reason") == "endpoint not found or disabled" # Zero outbound polling to the chat provider! assert len(TrackingPollHandler.requests_received) == 0 finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close() def test_edit_progress_blocks_private_ip_when_policy_enabled(mock_provider_listener, monkeypatch): """GET /api/image/edit-progress enforces IMAGE_BLOCK_PRIVATE_IPS policy.""" monkeypatch.setenv("IMAGE_BLOCK_PRIVATE_IPS", "true") db = SessionLocal() ep_id = "ep-test-private-progress" try: ep = ModelEndpoint( id=ep_id, name="Private Image Endpoint", base_url=mock_provider_listener, # 127.0.0.1 loopback model_type="image", is_enabled=True, owner="alice", cached_models=json.dumps(["sd-v1"]), ) db.merge(ep) db.commit() router = setup_gallery_routes() route = next(r for r in router.routes if r.path == "/api/image/edit-progress") req = create_gallery_request( "GET", "/api/image/edit-progress", query_params={"request_id": "req-priv", "endpoint_id": ep_id}, user="alice", ) with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"): resp = asyncio.run(route.endpoint(req)) assert isinstance(resp, dict) assert resp["available"] is False assert "rejected" in str(resp.get("reason")) # Zero outbound polling because private IP was blocked by policy! assert len(TrackingPollHandler.requests_received) == 0 finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close() def test_harmonize_rejects_chat_only_endpoint(mock_provider_listener): """POST /api/image/harmonize rejects a chat-only endpoint that has no image capability.""" db = SessionLocal() ep_id = "ep-test-chat-only" try: ep = ModelEndpoint( id=ep_id, name="Chat Only Llama", base_url=mock_provider_listener, model_type="llm", # chat only is_enabled=True, owner="alice", cached_models=json.dumps(["meta-llama/Llama-3-8B-Instruct", "mistral-7b"]), ) db.merge(ep) db.commit() router = setup_gallery_routes() route = next(r for r in router.routes if r.path == "/api/image/harmonize") payload = { "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "_endpoint": ep_id, "_model": "meta-llama/Llama-3-8B-Instruct", } req = create_gallery_request("POST", "/api/image/harmonize", body=payload, user="alice") with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"): with pytest.raises(HTTPException) as exc_info: asyncio.run(route.endpoint(req)) assert exc_info.value.status_code == 403 assert "Choose a registered image endpoint" in exc_info.value.detail # Zero outbound requests to chat provider assert len(TrackingPollHandler.requests_received) == 0 finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close() def test_harmonize_accepts_eligible_image_model_on_mixed_endpoint(mock_provider_listener): """POST /api/image/harmonize accepts a mixed endpoint when an image-capable model is selected.""" db = SessionLocal() ep_id = "ep-test-mixed-endpoint" try: ep = ModelEndpoint( id=ep_id, name="Mixed Gateway", base_url=mock_provider_listener, model_type="llm", # mixed gateway is_enabled=True, owner="alice", cached_models=json.dumps(["gpt-4o", "qwen-image-edit"]), ) db.merge(ep) db.commit() # Testing capability helper directly with image model ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "qwen-image-edit") assert ep_resolved is not None assert ep_resolved.id == ep_id # Testing with chat model fails ep_chat = _visible_image_endpoint_for_base(db, ep_id, "alice", "gpt-4o") assert ep_chat is None finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close() def test_harmonize_preserves_legitimate_image_capable_endpoint(mock_provider_listener): """Legitimate image endpoint (model_type == 'image') resolves and works properly.""" db = SessionLocal() ep_id = "ep-test-sd-diffusion" try: ep = ModelEndpoint( id=ep_id, name="Forge SD WebUI", base_url=mock_provider_listener, model_type="image", is_enabled=True, owner="alice", cached_models=json.dumps(["sd_xl_base_1.0.safetensors"]), ) db.merge(ep) db.commit() ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "sd_xl_base_1.0.safetensors") assert ep_resolved is not None assert ep_resolved.id == ep_id finally: db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete() db.commit() db.close()