"""Issue #298 (tg12): Sentinel credentials must live server-side. Before the fix, ``frontend/src/components/SettingsPanel.tsx`` stored ``client_id`` and ``client_secret`` in ``localStorage`` / ``sessionStorage`` via the privacy storage helper, and the proxy routes in ``backend/routers/tools.py`` REQUIRED those values to come in the request body. Any same-origin script (XSS, malicious extension, dev-tools HAR export) had read access to real third-party Sentinel credentials. After the fix: * ``SENTINEL_CLIENT_ID`` and ``SENTINEL_CLIENT_SECRET`` are entries in the ``api_settings.API_REGISTRY`` and are persisted via the existing ``/api/settings/api-keys`` flow (admin-gated, .env-backed, never returned to the browser). * The proxy routes prefer request-body values for back-compat but fall back to ``os.environ.get("SENTINEL_CLIENT_ID")`` / ``os.environ.get("SENTINEL_CLIENT_SECRET")`` when the body omits them. The dashboard's ``sentinelHub.ts`` no longer sends credentials in the body — every request now hits the env path. * When neither source has a value, the route returns a 400 with a pointer to the API Keys panel rather than a curt "client_id and client_secret required" message. These tests cover the resolution order and the registry surface. """ from __future__ import annotations from unittest.mock import patch, MagicMock import pytest # --------------------------------------------------------------------------- # Helper: import the routes module fresh per test so monkey-patched # environment variables are picked up by the route's os.environ.get call. # (The lookup is per-request, not at import time, so this isn't strictly # required — but it makes the test layout obvious.) # --------------------------------------------------------------------------- @pytest.fixture def loopback_client(): """ASGI client with peer IP 127.0.0.1 so the Sentinel routes' (post-#303) ``require_local_operator`` gate passes. Built without a context manager so the privacy-core lifespan check doesn't run in the test env. """ import asyncio from httpx import ASGITransport, AsyncClient from main import app class _Loop: def __init__(self): self._loop = asyncio.new_event_loop() self._transport = ASGITransport(app=app, client=("127.0.0.1", 12345)) self._base = "http://127.0.0.1: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 put(self, url, **kw): return self._do("PUT", url, **kw) def close(self): self._loop.close() c = _Loop() yield c c.close() # --------------------------------------------------------------------------- # API_REGISTRY surface # --------------------------------------------------------------------------- class TestApiRegistry: def test_sentinel_keys_registered(self): """Both Sentinel keys must be entries in API_REGISTRY so the existing /api/settings/api-keys PUT flow can write them to .env.""" from services.api_settings import API_REGISTRY, ALLOWED_ENV_KEYS ids = {row["id"] for row in API_REGISTRY} assert "sentinel_client_id" in ids assert "sentinel_client_secret" in ids # Critical: ALLOWED_ENV_KEYS is the gate on which .env keys the # API can mutate. If we forgot to add the env_key field on the # registry rows, callers couldn't actually save the values. assert "SENTINEL_CLIENT_ID" in ALLOWED_ENV_KEYS assert "SENTINEL_CLIENT_SECRET" in ALLOWED_ENV_KEYS def test_api_keys_put_accepts_sentinel_keys(self, loopback_client, monkeypatch, tmp_path): """End-to-end: PUT /api/settings/api-keys with SENTINEL_CLIENT_ID + SENTINEL_CLIENT_SECRET must persist to .env.""" import services.api_settings as api_settings # Redirect both .env paths to tmp so the test doesn't mutate # the developer's real backend .env. tmp_env = tmp_path / ".env" monkeypatch.setattr(api_settings, "ENV_PATH", tmp_env) monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", tmp_path / "operator_api_keys.env") r = loopback_client.put( "/api/settings/api-keys", json={ "SENTINEL_CLIENT_ID": "test-sentinel-id", "SENTINEL_CLIENT_SECRET": "test-sentinel-secret", }, ) assert r.status_code == 200, f"PUT failed: {r.text}" body = r.json() assert body.get("ok") is True # File on disk should now carry both keys. parsed = api_settings._parse_env_file(tmp_env) assert parsed.get("SENTINEL_CLIENT_ID") == "test-sentinel-id" assert parsed.get("SENTINEL_CLIENT_SECRET") == "test-sentinel-secret" # --------------------------------------------------------------------------- # Credential resolution — body wins, env is fallback, neither is 400 # --------------------------------------------------------------------------- class TestSentinelTokenCredResolution: def test_env_fallback_when_body_empty(self, loopback_client, monkeypatch): """No body credentials → backend reads .env values.""" monkeypatch.setenv("SENTINEL_CLIENT_ID", "env-id") monkeypatch.setenv("SENTINEL_CLIENT_SECRET", "env-secret") # Mock the upstream Copernicus call so we don't hit the network. # Capture what was sent so we can prove env values were used. captured: dict = {} fake_resp = MagicMock() fake_resp.status_code = 200 fake_resp.content = b'{"access_token": "stub", "expires_in": 300}' def fake_post(url, *args, **kwargs): captured["url"] = url captured["data"] = kwargs.get("data", {}) return fake_resp with patch("requests.post", side_effect=fake_post): r = loopback_client.post( "/api/sentinel/token", data={}, # ← deliberately empty body headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert r.status_code == 200 # The forwarded creds must come from env, not from a stale cache # or fallback string. assert captured.get("data", {}).get("client_id") == "env-id" assert captured.get("data", {}).get("client_secret") == "env-secret" def test_body_credentials_win_over_env(self, loopback_client, monkeypatch): """Body values (back-compat path) must win when both sources are present. This preserves the pre-#298 behavior for any legacy callers that still post credentials.""" monkeypatch.setenv("SENTINEL_CLIENT_ID", "env-id") monkeypatch.setenv("SENTINEL_CLIENT_SECRET", "env-secret") captured: dict = {} fake_resp = MagicMock() fake_resp.status_code = 200 fake_resp.content = b'{"access_token": "stub"}' def fake_post(url, *args, **kwargs): captured["data"] = kwargs.get("data", {}) return fake_resp with patch("requests.post", side_effect=fake_post): r = loopback_client.post( "/api/sentinel/token", data={"client_id": "body-id", "client_secret": "body-secret"}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert r.status_code == 200 assert captured["data"]["client_id"] == "body-id" assert captured["data"]["client_secret"] == "body-secret" def test_400_when_neither_source_has_credentials(self, loopback_client, monkeypatch): """If body is empty AND env is empty, return 400 with a friendly pointer to the API Keys panel — not a curt "required" message and not a 500.""" monkeypatch.delenv("SENTINEL_CLIENT_ID", raising=False) monkeypatch.delenv("SENTINEL_CLIENT_SECRET", raising=False) # If the route ever calls requests.post here, the gate is broken # — empty creds should never produce an outbound HTTP call. fake = MagicMock(side_effect=AssertionError( "requests.post should not be called when no credentials are configured" )) with patch("requests.post", fake): r = loopback_client.post( "/api/sentinel/token", data={}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) assert r.status_code == 400 detail = r.json().get("detail", "") # The pointer to the API Keys panel is what makes this non-hostile. assert "API Keys panel" in detail or "SENTINEL_CLIENT_ID" in detail assert fake.call_count == 0 class TestSentinelTileCredResolution: def test_env_fallback_when_body_omits_credentials(self, loopback_client, monkeypatch): """Tile route: no body credentials → uses env values.""" monkeypatch.setenv("SENTINEL_CLIENT_ID", "env-id") monkeypatch.setenv("SENTINEL_CLIENT_SECRET", "env-secret") token_resp = MagicMock() token_resp.status_code = 200 token_resp.json = MagicMock(return_value={"access_token": "stub", "expires_in": 300}) process_resp = MagicMock() process_resp.status_code = 200 process_resp.content = b"" process_resp.headers = {"content-type": "image/png"} captured: list = [] def fake_post(url, *args, **kwargs): captured.append({"url": url, "data": kwargs.get("data"), "json": kwargs.get("json")}) if "openid-connect/token" in url: return token_resp return process_resp with patch("requests.post", side_effect=fake_post): r = loopback_client.post( "/api/sentinel/tile", json={ # Note: no client_id / client_secret in body "preset": "TRUE-COLOR", "date": "2026-01-01", "z": 6, "x": 30, "y": 20, }, ) assert r.status_code == 200 # First call was the token mint; verify it used env creds. token_call = next(c for c in captured if "openid-connect/token" in c["url"]) assert token_call["data"]["client_id"] == "env-id" assert token_call["data"]["client_secret"] == "env-secret" def test_400_when_neither_source_has_credentials(self, loopback_client, monkeypatch): monkeypatch.delenv("SENTINEL_CLIENT_ID", raising=False) monkeypatch.delenv("SENTINEL_CLIENT_SECRET", raising=False) fake = MagicMock(side_effect=AssertionError( "requests.post should not be called when no credentials are configured" )) with patch("requests.post", fake): r = loopback_client.post( "/api/sentinel/tile", json={ "preset": "TRUE-COLOR", "date": "2026-01-01", "z": 6, "x": 30, "y": 20, }, ) assert r.status_code == 400 detail = r.json().get("detail", "") assert "API Keys panel" in detail or "SENTINEL_CLIENT_ID" in detail assert fake.call_count == 0