"""Issues #299, #300, #301 (tg12): Sentinel proxy routes must require local-operator auth. Before the fix, three Sentinel proxy routes in ``backend/routers/tools.py`` were decorated only with ``@limiter.limit(...)`` — no ``Depends(require_local_operator)``: * ``POST /api/sentinel/token`` — Copernicus CDSE OAuth relay for caller-supplied client_id + client_secret. Anonymous access made the backend a free OAuth-mint relay for any Sentinel account. * ``POST /api/sentinel/tile`` — Sentinel Hub Process API relay. Caller supplies their own credentials, backend mints a token if needed and relays the PNG. Anonymous access was a bandwidth + quota relay for any Copernicus account. * ``GET /api/sentinel2/search`` — Planetary Computer STAC search with Esri imagery fallback. No caller credentials are involved, but the route is still an anonymous external-search relay. The fix adds ``dependencies=[Depends(require_local_operator)]`` to each. The parameterized regression in ``test_control_surface_auth.py`` covers the basic 403 path. This file adds the harder property: when the auth gate fires, **the underlying upstream HTTP call never happens** — no outbound Copernicus token mint, no Sentinel Hub Process call, no Planetary Computer STAC search. The egress-on-403 property is what separates a real gate from a route that returns 403 *after* burning a quota. """ from __future__ import annotations import asyncio from unittest.mock import patch, MagicMock import pytest from httpx import ASGITransport, AsyncClient # --------------------------------------------------------------------------- # Remote client fixture — same shape as test_control_surface_auth.py, but # inlined here so this file doesn't depend on the shared remote_client # fixture order. Uses 1.2.3.4 as the peer IP so loopback auth bypass # doesn't accidentally let the request through. # --------------------------------------------------------------------------- class _PeerClient: """Raw ASGI client with a configurable peer IP. FastAPI's ``TestClient`` reports ``request.client.host`` as ``"testclient"`` which isn't on the loopback allowlist — we need to set the peer explicitly to exercise the real ``require_local_operator`` path. """ def __init__(self, peer_ip: str): from main import app self._loop = asyncio.new_event_loop() self._transport = ASGITransport(app=app, client=(peer_ip, 12345)) self._base = f"http://{peer_ip}:8000" def _do(self, method: str, url: str, **kw): async def go(): async with AsyncClient(transport=self._transport, base_url=self._base) as ac: return await ac.request(method, url, **kw) return self._loop.run_until_complete(go()) def get(self, url, **kw): return self._do("GET", url, **kw) def post(self, url, **kw): return self._do("POST", url, **kw) def close(self): self._loop.close() @pytest.fixture def remote(): """Untrusted remote caller (1.2.3.4) — must hit the auth gate.""" client = _PeerClient("1.2.3.4") yield client client.close() @pytest.fixture def loopback(): """127.0.0.1 caller — must pass the gate exactly like the operator.""" client = _PeerClient("127.0.0.1") yield client client.close() # --------------------------------------------------------------------------- # /api/sentinel/token — issue #299 # --------------------------------------------------------------------------- class TestSentinelTokenAuthGate: def test_anonymous_caller_is_rejected(self, remote): """A remote (non-loopback, non-bridge) caller MUST be rejected.""" r = remote.post( "/api/sentinel/token", data={"client_id": "anything", "client_secret": "anything"}, ) assert r.status_code == 403 def test_no_upstream_token_mint_on_403(self, remote): """The Copernicus token endpoint must NOT be contacted when the auth gate fires. This is what makes the gate real — without it, a 403 returned *after* the upstream call still burns quota. We patch ``requests.post`` at the module level so any outbound token request would be intercepted. The mock is asserted to have ZERO calls. """ fake_post = MagicMock() # If the gate is broken, the route would call requests.post; we # want this MagicMock to make that fact loud. fake_post.side_effect = AssertionError( "requests.post was called despite auth-gate 403 — the gate is bypassable" ) with patch("requests.post", fake_post): r = remote.post( "/api/sentinel/token", data={"client_id": "anything", "client_secret": "anything"}, ) assert r.status_code == 403 assert fake_post.call_count == 0 def test_loopback_caller_passes_auth(self, loopback): """A 127.0.0.1 caller must pass the gate. We don't care about the upstream response shape — just that the request reaches the handler (which would then try to talk to Copernicus). We patch ``requests.post`` to return a 401 so the test doesn't hit the real network. Note: FastAPI's ``TestClient`` reports ``request.client.host`` as ``"testclient"`` by default, which is NOT on the loopback allowlist (``127.0.0.1`` / ``::1`` / ``localhost``). The ``loopback`` fixture below uses raw ASGI with an explicit ``127.0.0.1`` peer IP so the auth gate sees real loopback. """ fake_resp = MagicMock() fake_resp.status_code = 401 fake_resp.content = b'{"error": "invalid_client"}' with patch("requests.post", return_value=fake_resp): r = loopback.post( "/api/sentinel/token", data={"client_id": "anything", "client_secret": "anything"}, ) # 200 (relayed), 401 (upstream said no), or 502 (upstream blew up) # are all acceptable — what matters is we got past the auth gate # (no 403). The route relays the upstream response status. assert r.status_code != 403 # --------------------------------------------------------------------------- # /api/sentinel/tile — issue #300 # --------------------------------------------------------------------------- class TestSentinelTileAuthGate: _VALID_BODY = { "client_id": "anything", "client_secret": "anything", "preset": "TRUE-COLOR", "date": "2026-01-01", "z": 6, "x": 30, "y": 20, } def test_anonymous_caller_is_rejected(self, remote): r = remote.post("/api/sentinel/tile", json=self._VALID_BODY) assert r.status_code == 403 def test_no_upstream_call_on_403(self, remote): """When the gate fires, neither the token mint nor the Process API call should happen.""" fake_post = MagicMock(side_effect=AssertionError( "requests.post was called despite auth-gate 403 — gate bypassable" )) with patch("requests.post", fake_post): r = remote.post("/api/sentinel/tile", json=self._VALID_BODY) assert r.status_code == 403 assert fake_post.call_count == 0 # --------------------------------------------------------------------------- # /api/sentinel2/search — issue #301 # --------------------------------------------------------------------------- class TestSentinel2SearchAuthGate: def test_anonymous_caller_is_rejected(self, remote): r = remote.get("/api/sentinel2/search?lat=0&lng=0") assert r.status_code == 403 def test_no_upstream_search_on_403(self, remote): """The Planetary Computer STAC search MUST NOT be called when the gate fires.""" fake = MagicMock(side_effect=AssertionError( "search_sentinel2_scene was called despite 403 — gate bypassable" )) # Patch the underlying service function — that's the network # surface. If the auth dep fires first, the handler body never # runs and this stays uncalled. with patch("services.sentinel_search.search_sentinel2_scene", fake): r = remote.get("/api/sentinel2/search?lat=0&lng=0") assert r.status_code == 403 assert fake.call_count == 0 def test_loopback_caller_reaches_handler(self, loopback): """127.0.0.1 must pass the gate and reach the search function. Uses raw ASGI peer IP via the ``loopback`` fixture — TestClient would set ``request.client.host`` to ``"testclient"`` which isn't on the loopback allowlist.""" fake = MagicMock(return_value={"ok": True, "results": []}) with patch("services.sentinel_search.search_sentinel2_scene", fake): r = loopback.get("/api/sentinel2/search?lat=0&lng=0") assert r.status_code == 200 assert fake.call_count == 1 # Note: an earlier draft included a static dependency walker that # inspected the FastAPI route table to assert require_local_operator # was wired in. It was deleted because FastAPI's internal route # representation varies across minor versions — the walker was brittle # and the behavioral pair (anonymous → 403 with no upstream egress; # loopback → handler reached) gives stronger end-to-end evidence than # any structural check.