"""Tests for HTTP endpoint routes — CRUD, no-leak, /test, LAN policy.""" from unittest.mock import MagicMock import httpx import pytest import respx from fastapi import FastAPI from fastapi.testclient import TestClient from ledgrab.api import dependencies as deps from ledgrab.api.routes.http_endpoints import router from ledgrab.storage.http_endpoint_store import HTTPEndpointStore @pytest.fixture def _route_db(tmp_path): from ledgrab.storage.database import Database db = Database(tmp_path / "test.db") yield db db.close() @pytest.fixture def http_store(_route_db): return HTTPEndpointStore(_route_db) @pytest.fixture def client(http_store): app = FastAPI() app.include_router(router) from ledgrab.api.auth import verify_api_key app.dependency_overrides[verify_api_key] = lambda: "test-user" app.dependency_overrides[deps.get_http_endpoint_store] = lambda: http_store # Stub fire_entity_event's processor_manager deps._deps["processor_manager"] = MagicMock() return TestClient(app, raise_server_exceptions=False) # --------------------------------------------------------------------------- # CRUD shape + no-leak guarantee # --------------------------------------------------------------------------- class TestCRUDRoutes: def test_create_and_list(self, client): r = client.post( "/api/v1/http/endpoints", json={ "name": "Plex", "url": "https://example.com/status/sessions", "auth_token": "very-secret", "headers": {"Accept": "application/json"}, }, ) assert r.status_code == 201 body = r.json() assert body["id"].startswith("htep_") assert body["auth_token_set"] is True assert "very-secret" not in r.text lst = client.get("/api/v1/http/endpoints").json() assert lst["count"] == 1 def test_response_never_exposes_auth_token(self, client): """Defence-in-depth: scan every CRUD response for the token string.""" token = "uniq-token-12345-no-collisions" post = client.post( "/api/v1/http/endpoints", json={ "name": "x", "url": "https://example.com", "auth_token": token, }, ) assert post.status_code == 201 endpoint_id = post.json()["id"] get = client.get(f"/api/v1/http/endpoints/{endpoint_id}") put = client.put(f"/api/v1/http/endpoints/{endpoint_id}", json={"name": "renamed"}) lst = client.get("/api/v1/http/endpoints") for resp in (post, get, put, lst): assert token not in resp.text, f"token leaked in {resp.url}" def test_method_allowlist_at_schema_layer(self, client): r = client.post( "/api/v1/http/endpoints", json={"name": "x", "url": "https://example.com", "method": "POST"}, ) assert r.status_code == 422 def test_crlf_in_header_rejected(self, client): r = client.post( "/api/v1/http/endpoints", json={ "name": "x", "url": "https://example.com", "headers": {"X-Inject": "value\r\nX-Smuggle: yes"}, }, ) assert r.status_code == 422 def test_invalid_header_name_rejected(self, client): r = client.post( "/api/v1/http/endpoints", json={ "name": "x", "url": "https://example.com", "headers": {"Bad Name With Space": "v"}, }, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # /test one-shot endpoint + LAN policy # --------------------------------------------------------------------------- class TestOneShotEndpoint: @respx.mock def test_test_success(self, client): respx.get("https://example.com/status").mock( return_value=httpx.Response(200, json={"ok": True}) ) r = client.post( "/api/v1/http/endpoints/test", json={"url": "https://example.com/status"}, ) assert r.status_code == 200 body = r.json() assert body["success"] is True assert body["status_code"] == 200 assert body["body_json"] == {"ok": True} @respx.mock def test_test_sends_auth_header(self, client): route = respx.get("https://example.com/secure").mock( return_value=httpx.Response(200, json={}) ) client.post( "/api/v1/http/endpoints/test", json={ "url": "https://example.com/secure", "auth_token": "tok-abc", }, ) sent = route.calls.last.request assert sent.headers.get("authorization") == "Bearer tok-abc" def test_test_rejects_loopback_url(self, client): # Loopback is always blocked, even with the relaxed polling policy. r = client.post( "/api/v1/http/endpoints/test", json={"url": "http://127.0.0.1:9000/"}, ) assert r.status_code == 400 def test_test_rejects_metadata_link_local(self, client): # AWS/GCP metadata at 169.254.169.254 stays blocked. r = client.post( "/api/v1/http/endpoints/test", json={"url": "http://169.254.169.254/latest/meta-data/"}, ) assert r.status_code == 400 @respx.mock def test_test_allows_private_lan_ip(self, client): """Relaxed polling policy MUST permit private IPs — primary use case.""" respx.get("http://192.168.1.100/status").mock( return_value=httpx.Response(200, json={"ok": True}) ) r = client.post( "/api/v1/http/endpoints/test", json={"url": "http://192.168.1.100/status"}, ) assert r.status_code == 200 assert r.json()["success"] is True