diff --git a/pyproject.toml b/pyproject.toml index d5a456d..653b6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "M2 Marketplace monorepo — schemas, ledger, CLI, indexer (spec 0 requires-python = ">=3.12" [tool.uv.workspace] -members = ["ledger", "cli", "indexer"] +members = ["ledger", "cli", "indexer", "web/backend"] [tool.ruff] line-length = 100 diff --git a/web/backend/pyproject.toml b/web/backend/pyproject.toml new file mode 100644 index 0000000..d9e6b41 --- /dev/null +++ b/web/backend/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "m2-market-web" +version = "0.1.0" +description = "FastAPI backend proxy for the M2 Market web app" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.111", + "httpx>=0.27", + "itsdangerous>=2.2", + "uvicorn>=0.30", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/m2_market_web"] diff --git a/web/backend/src/m2_market_web/__init__.py b/web/backend/src/m2_market_web/__init__.py new file mode 100644 index 0000000..68498b5 --- /dev/null +++ b/web/backend/src/m2_market_web/__init__.py @@ -0,0 +1 @@ +"""M2 Market web backend.""" diff --git a/web/backend/src/m2_market_web/auth.py b/web/backend/src/m2_market_web/auth.py new file mode 100644 index 0000000..dc439b3 --- /dev/null +++ b/web/backend/src/m2_market_web/auth.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import hmac +import os +from typing import Annotated + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from pydantic import BaseModel + +COOKIE_NAME = "m2mw_session" +SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 + +router = APIRouter() + + +class LoginBody(BaseModel): + passcode: str + + +def _serializer() -> URLSafeTimedSerializer: + secret = os.environ.get("SESSION_SECRET") + if not secret: + raise HTTPException(status_code=500, detail="session_not_configured") + return URLSafeTimedSerializer(secret, salt="m2-market-web-session") + + +def _configured_passcode() -> str: + passcode = os.environ.get("FLEET_PASSCODE") + if not passcode: + raise HTTPException(status_code=500, detail="auth_not_configured") + return passcode + + +@router.post("/login", status_code=status.HTTP_204_NO_CONTENT) +def login(body: LoginBody, response: Response) -> None: + if not hmac.compare_digest(body.passcode, _configured_passcode()): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + token = _serializer().dumps({"scope": "fleet"}) + response.set_cookie( + COOKIE_NAME, + token, + max_age=SESSION_MAX_AGE_SECONDS, + httponly=True, + secure=True, + samesite="lax", + ) + + +def require_session( + session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None, +) -> None: + if not session_cookie: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + try: + payload = _serializer().loads(session_cookie, max_age=SESSION_MAX_AGE_SECONDS) + except (BadSignature, SignatureExpired): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized" + ) from None + if payload.get("scope") != "fleet": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + + +SessionDependency = Depends(require_session) diff --git a/web/backend/src/m2_market_web/catalog.py b/web/backend/src/m2_market_web/catalog.py new file mode 100644 index 0000000..c006a77 --- /dev/null +++ b/web/backend/src/m2_market_web/catalog.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import os +from typing import Any + +import httpx +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse + +from .auth import SessionDependency +from .http import client, upstream_error + +PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog") +TENANT_DEFAULT = "m2-core" + +router = APIRouter(dependencies=[SessionDependency]) + + +def _tenant() -> str: + return os.environ.get("TENANT", TENANT_DEFAULT) + + +def _visible(listing: dict[str, Any]) -> bool: + visibility = listing.get("tenant_visibility") or [] + return "*" in visibility or _tenant() in visibility + + +def _price(listing: dict[str, Any]) -> dict[str, Any]: + price = listing.get("price") + if isinstance(price, dict): + return { + "amount": price.get("amount", 0), + "currency": price.get("currency", "credits"), + "model": price.get("model", "fixed"), + } + return {"amount": listing.get("price_amount", 0), "currency": "credits", "model": "fixed"} + + +def _listing_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]: + return { + "listing_id": listing_id, + "name": listing.get("name") or listing_id, + "summary": listing.get("summary") or "", + "category": listing.get("category") or "", + "price": _price(listing), + "seller": listing.get("seller") or listing.get("seller_id") or "", + "stats": listing.get("stats") or {}, + "score": score, + } + + +def _detail_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]: + dto = _listing_dto(listing_id, listing, score) + dto.update( + { + "evidence_summary": listing.get("evidence_summary") or "", + "permissions": listing.get("permissions") or [], + "install_ref": listing.get("install_ref") or "", + "install_command": f"m2-market install {listing_id} --yes", + } + ) + return dto + + +async def _memory_search(query_text: str, limit: int) -> list[dict[str, Any]]: + base_url = os.environ["MEMORY_API_URL"] + api_key = os.environ["MEMORY_API_KEY"] + async with client(base_url, {"X-API-Key": api_key}) as http: + response = await http.post( + "/memory/search", + json={ + "query": query_text, + "agent_id": PARTITION, + "memory_types": ["semantic"], + "limit": max(limit * 3, limit), + }, + ) + response.raise_for_status() + items: list[dict[str, Any]] = [] + for result in response.json().get("results", []): + meta = result.get("metadata") or {} + listing = meta.get("listing") + listing_id = meta.get("listing_id") + if not isinstance(listing, dict) or not listing_id: + continue + if not _visible(listing): + continue + items.append( + {"listing_id": listing_id, "score": result.get("score"), "listing": listing} + ) + if len(items) >= limit: + break + return items + + +@router.get("/search", response_model=None) +async def search( + q: str = "", limit: int = Query(10, ge=1, le=50) +) -> list[dict[str, Any]] | JSONResponse: + try: + return [ + _listing_dto(i["listing_id"], i["listing"], i["score"]) + for i in await _memory_search(q, limit) + ] + except (httpx.HTTPError, KeyError): + return upstream_error("catalog") + + +@router.get("/listing/{listing_id}", response_model=None) +async def listing(listing_id: str) -> dict[str, Any] | JSONResponse: + try: + for item in await _memory_search(listing_id, 10): + if item["listing_id"] == listing_id: + return _detail_dto(item["listing_id"], item["listing"], item["score"]) + return upstream_error("catalog") + except (httpx.HTTPError, KeyError): + return upstream_error("catalog") diff --git a/web/backend/src/m2_market_web/http.py b/web/backend/src/m2_market_web/http.py new file mode 100644 index 0000000..8d2a5b6 --- /dev/null +++ b/web/backend/src/m2_market_web/http.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import httpx +from fastapi.responses import JSONResponse + + +def upstream_error(panel: str) -> JSONResponse: + return JSONResponse(status_code=502, content={"error": "upstream_failure", "panel": panel}) + + +def async_transport() -> httpx.AsyncBaseTransport | None: + return None + + +def client(base_url: str, headers: dict[str, str] | None = None) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=base_url.rstrip("/"), + headers=headers or {}, + timeout=20.0, + transport=async_transport(), + ) diff --git a/web/backend/src/m2_market_web/ledger.py b/web/backend/src/m2_market_web/ledger.py new file mode 100644 index 0000000..652e635 --- /dev/null +++ b/web/backend/src/m2_market_web/ledger.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import os +from datetime import date +from typing import Any + +import httpx +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from .auth import SessionDependency +from .http import client, upstream_error + +router = APIRouter(dependencies=[SessionDependency]) + + +def _headers() -> dict[str, str]: + return {"X-API-Key": os.environ["LEDGER_SERVICE_KEY"]} + + +def _tx_dto(tx: dict[str, Any]) -> dict[str, Any]: + return { + "ts": tx.get("ts"), + "from": tx.get("from"), + "to": tx.get("to"), + "amount": tx.get("amount"), + "reason": tx.get("reason"), + "ref": tx.get("ref"), + } + + +def _registry_raw_url(path: str) -> str: + forgejo = os.environ["FORGEJO_URL"].rstrip("/") + repo = os.environ["REGISTRY_REPO"].strip("/") + return f"{forgejo}/{repo}/raw/branch/main/{path}" + + +async def _latest_audit_date() -> str: + today = date.today().isoformat() + forgejo = os.environ["FORGEJO_URL"].rstrip("/") + repo = os.environ["REGISTRY_REPO"].strip("/") + token = os.environ.get("FORGEJO_TOKEN", "") + headers = {"Authorization": f"token {token}"} if token else {} + async with client(forgejo, headers) as http: + response = await http.get(f"/api/v1/repos/{repo}/contents/audit", params={"ref": "main"}) + response.raise_for_status() + dates = sorted( + item["name"].removesuffix(".json") + for item in response.json() + if isinstance(item, dict) and str(item.get("name", "")).endswith(".json") + ) + return today if today in dates else (dates[-1] if dates else today) + + +@router.get("/wallet/{operator_id}", response_model=None) +async def wallet(operator_id: str) -> dict[str, Any] | JSONResponse: + try: + async with client(os.environ["LEDGER_URL"], _headers()) as http: + balance = await http.get(f"/balance/{operator_id}") + balance.raise_for_status() + tx_log = await http.get("/tx", params={"operator_id": operator_id}) + tx_log.raise_for_status() + balance_body = balance.json() + transactions = tx_log.json().get("transactions", [])[:20] + return { + "operator_id": operator_id, + "balance": balance_body.get("balance", 0), + "as_of": balance_body.get("as_of"), + "transactions": [_tx_dto(tx) for tx in transactions], + } + except (httpx.HTTPError, KeyError): + return upstream_error("wallet") + + +@router.get("/economy", response_model=None) +async def economy() -> dict[str, Any] | JSONResponse: + try: + async with client(os.environ["LEDGER_URL"], _headers()) as http: + tx_log = await http.get("/tx") + tx_log.raise_for_status() + transactions = tx_log.json().get("transactions", []) + operators = sorted( + { + party + for tx in transactions + for party in (tx.get("from"), tx.get("to")) + if party and party != "mint" + } + ) + balances = [] + for operator_id in operators: + response = await http.get(f"/balance/{operator_id}") + response.raise_for_status() + body = response.json() + balances.append( + {"operator_id": operator_id, "balance": body.get("balance", 0)} + ) + audit_date = await _latest_audit_date() + return { + "operators": balances, + "audit": {"date": audit_date, "url": _registry_raw_url(f"audit/{audit_date}.json")}, + } + except (httpx.HTTPError, KeyError): + return upstream_error("economy") diff --git a/web/backend/src/m2_market_web/main.py b/web/backend/src/m2_market_web/main.py new file mode 100644 index 0000000..4dcaa74 --- /dev/null +++ b/web/backend/src/m2_market_web/main.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse + +from . import auth, catalog, ledger, registry + + +def create_app() -> FastAPI: + app = FastAPI(title="m2-market-web") + app.include_router(auth.router, prefix="/api") + app.include_router(catalog.router, prefix="/api") + app.include_router(ledger.router, prefix="/api") + app.include_router(registry.router, prefix="/api") + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "healthy"} + + dist = Path(__file__).resolve().parents[3] / "frontend" / "dist" + + @app.get("/{path:path}", include_in_schema=False) + def static_or_spa(path: str, request: Request): + if request.url.path.startswith("/api/"): + return JSONResponse(status_code=404, content={"error": "not_found"}) + if not dist.exists(): + return JSONResponse(status_code=404, content={"error": "frontend_not_built"}) + requested = (dist / path).resolve() + if requested.is_file() and dist.resolve() in requested.parents: + return FileResponse(requested) + index = dist / "index.html" + if index.is_file(): + return FileResponse(index) + return JSONResponse(status_code=404, content={"error": "frontend_not_built"}) + + return app + + +app = create_app() diff --git a/web/backend/src/m2_market_web/registry.py b/web/backend/src/m2_market_web/registry.py new file mode 100644 index 0000000..e24c882 --- /dev/null +++ b/web/backend/src/m2_market_web/registry.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import base64 +import json +import os +from datetime import UTC, datetime +from typing import Any + +import httpx +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from .auth import SessionDependency +from .http import client, upstream_error + +router = APIRouter(dependencies=[SessionDependency]) + + +def _repo() -> str: + return os.environ["REGISTRY_REPO"].strip("/") + + +def _headers() -> dict[str, str]: + token = os.environ["FORGEJO_TOKEN"] + return {"Authorization": f"token {token}"} + + +def _age_days(created_at: str | None) -> int: + if not created_at: + return 0 + created = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + return max(0, (datetime.now(UTC) - created).days) + + +def _listing_url(listing_id: str) -> str: + return ( + f"{os.environ['FORGEJO_URL'].rstrip('/')}/{_repo()}" + f"/src/branch/main/listings/{listing_id}" + ) + + +async def _listing_status(http: httpx.AsyncClient, listing_id: str) -> str: + response = await http.get( + f"/api/v1/repos/{_repo()}/contents/listings/{listing_id}/listing.json", + params={"ref": "main"}, + ) + response.raise_for_status() + body = response.json() + if not isinstance(body, dict): + return "published" + listing = body if "status" in body else body.get("content_json", {}) + if not listing and isinstance(body, dict) and body.get("content"): + content = str(body["content"]).replace("\n", "") + listing = json.loads(base64.b64decode(content).decode("utf-8")) + return listing.get("status", "published") if isinstance(listing, dict) else "published" + + +@router.get("/governance", response_model=None) +async def governance() -> dict[str, Any] | JSONResponse: + try: + async with client(os.environ["FORGEJO_URL"], _headers()) as http: + prs_response = await http.get( + f"/api/v1/repos/{_repo()}/pulls", params={"state": "open"} + ) + prs_response.raise_for_status() + open_prs = [ + { + "number": pr.get("number"), + "title": pr.get("title", ""), + "age_days": _age_days(pr.get("created_at")), + "labels": [label.get("name", "") for label in pr.get("labels", [])], + "url": pr.get("html_url") or pr.get("url"), + } + for pr in prs_response.json() + ] + + listings_response = await http.get( + f"/api/v1/repos/{_repo()}/contents/listings", params={"ref": "main"} + ) + listings_response.raise_for_status() + listings = [] + for item in listings_response.json(): + if not isinstance(item, dict) or item.get("type") not in {None, "dir"}: + continue + listing_id = item.get("name") + if not listing_id: + continue + listings.append( + { + "listing_id": listing_id, + "status": await _listing_status(http, listing_id), + "url": _listing_url(listing_id), + } + ) + return {"open_prs": open_prs, "listings": listings} + except (httpx.HTTPError, KeyError, ValueError): + return upstream_error("governance") diff --git a/web/backend/tests/conftest.py b/web/backend/tests/conftest.py new file mode 100644 index 0000000..5c8135e --- /dev/null +++ b/web/backend/tests/conftest.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) diff --git a/web/backend/tests/test_backend.py b/web/backend/tests/test_backend.py new file mode 100644 index 0000000..308182f --- /dev/null +++ b/web/backend/tests/test_backend.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx +import pytest +from fastapi.testclient import TestClient +from m2_market_web import http as web_http +from m2_market_web.main import create_app + +SECRET_VALUES = { + "fleet-passcode-secret", + "session-secret-value", + "mem-secret-key", + "ledger-secret-key", + "forgejo-secret-token", +} + + +@pytest.fixture(autouse=True) +def env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FLEET_PASSCODE", "fleet-passcode-secret") + monkeypatch.setenv("SESSION_SECRET", "session-secret-value") + monkeypatch.setenv("MEMORY_API_URL", "https://memory.example") + monkeypatch.setenv("MEMORY_API_KEY", "mem-secret-key") + monkeypatch.setenv("LEDGER_URL", "https://ledger.example") + monkeypatch.setenv("LEDGER_SERVICE_KEY", "ledger-secret-key") + monkeypatch.setenv("FORGEJO_URL", "https://git.example") + monkeypatch.setenv("FORGEJO_TOKEN", "forgejo-secret-token") + monkeypatch.setenv("REGISTRY_REPO", "m2/market-registry") + monkeypatch.setenv("TENANT", "m2-core") + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Callable[[httpx.MockTransport], TestClient]: + def build(transport: httpx.MockTransport) -> TestClient: + monkeypatch.setattr(web_http, "async_transport", lambda: transport) + return TestClient(create_app(), base_url="https://testserver") + + return build + + +def _authed(test_client: TestClient) -> None: + response = test_client.post("/api/login", json={"passcode": "fleet-passcode-secret"}) + assert response.status_code == 204 + + +def _listing() -> dict: + return { + "name": "PDF Export", + "summary": "Branded reports", + "category": "reporting", + "price": {"amount": 7, "currency": "credits", "model": "fixed"}, + "seller": "m2bd", + "stats": {"installs": 3}, + "tenant_visibility": ["*"], + "evidence_summary": "Used in one production workflow", + "permissions": ["filesystem:write"], + "install_ref": "lst_pdf-v1.0.0", + } + + +def _ok_transport(request: httpx.Request) -> httpx.Response: + if request.url.host == "memory.example": + assert request.headers["X-API-Key"] == "mem-secret-key" + body = json.loads(request.content) + assert body["agent_id"] == "market:catalog" + assert body["limit"] == 6 or body["limit"] == 30 + return httpx.Response( + 200, + json={ + "results": [ + { + "score": 0.9, + "metadata": {"listing_id": "lst_pdf", "listing": _listing()}, + }, + { + "score": 0.8, + "metadata": { + "listing_id": "lst_hidden", + "listing": {"name": "Hidden", "tenant_visibility": ["other"]}, + }, + }, + ] + }, + ) + if request.url.host == "ledger.example": + assert request.headers["X-API-Key"] == "ledger-secret-key" + if request.url.path == "/balance/m2bd": + return httpx.Response( + 200, + json={"operator_id": "m2bd", "balance": 42, "as_of": "2026-07-02T10:00:00Z"}, + ) + if request.url.path == "/balance/platform": + return httpx.Response(200, json={"operator_id": "platform", "balance": 8}) + if request.url.path == "/tx" and request.url.params.get("operator_id") == "m2bd": + return httpx.Response( + 200, + json={ + "transactions": [ + { + "ts": "2026-07-02T09:00:00Z", + "from": "mint", + "to": "m2bd", + "amount": 50, + "reason": "grant", + "ref": "starter", + "private": "ignored", + } + ] + }, + ) + if request.url.path == "/tx": + return httpx.Response( + 200, + json={ + "transactions": [ + {"from": "mint", "to": "m2bd", "amount": 50}, + {"from": "m2bd", "to": "platform", "amount": 8}, + ] + }, + ) + if request.url.host == "git.example": + assert request.headers["Authorization"] == "token forgejo-secret-token" + if request.url.path == "/api/v1/repos/m2/market-registry/contents/audit": + return httpx.Response(200, json=[{"name": "2026-07-01.json"}]) + if request.url.path == "/api/v1/repos/m2/market-registry/pulls": + return httpx.Response( + 200, + json=[ + { + "number": 12, + "title": "Publish PDF Export", + "created_at": "2026-07-01T10:00:00Z", + "labels": [{"name": "approved"}], + "html_url": "https://git.example/m2/market-registry/pulls/12", + } + ], + ) + if request.url.path == "/api/v1/repos/m2/market-registry/contents/listings": + return httpx.Response(200, json=[{"name": "lst_pdf", "type": "dir"}]) + if ( + request.url.path + == "/api/v1/repos/m2/market-registry/contents/listings/lst_pdf/listing.json" + ): + return httpx.Response(200, json={"content_json": {"status": "published"}}) + return httpx.Response(404, json={"unexpected": str(request.url)}) + + +def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]) -> None: + test_client = client(httpx.MockTransport(_ok_transport)) + assert test_client.get("/api/search?q=pdf").status_code == 401 + assert test_client.post("/api/login", json={"passcode": "wrong"}).status_code == 401 + _authed(test_client) + assert test_client.get("/api/search?q=pdf&limit=2").status_code == 200 + + +def test_catalog_wallet_economy_governance_happy_paths( + client: Callable[[httpx.MockTransport], TestClient], +) -> None: + test_client = client(httpx.MockTransport(_ok_transport)) + _authed(test_client) + + search = test_client.get("/api/search?q=pdf&limit=2") + assert search.status_code == 200 + assert search.json() == [ + { + "listing_id": "lst_pdf", + "name": "PDF Export", + "summary": "Branded reports", + "category": "reporting", + "price": {"amount": 7, "currency": "credits", "model": "fixed"}, + "seller": "m2bd", + "stats": {"installs": 3}, + "score": 0.9, + } + ] + + listing = test_client.get("/api/listing/lst_pdf") + assert listing.status_code == 200 + assert listing.json()["install_command"] == "m2-market install lst_pdf --yes" + assert listing.json()["permissions"] == ["filesystem:write"] + + wallet = test_client.get("/api/wallet/m2bd") + assert wallet.status_code == 200 + assert wallet.json()["balance"] == 42 + assert wallet.json()["transactions"][0] == { + "ts": "2026-07-02T09:00:00Z", + "from": "mint", + "to": "m2bd", + "amount": 50, + "reason": "grant", + "ref": "starter", + } + + economy = test_client.get("/api/economy") + assert economy.status_code == 200 + assert economy.json()["operators"] == [ + {"operator_id": "m2bd", "balance": 42}, + {"operator_id": "platform", "balance": 8}, + ] + assert economy.json()["audit"] == { + "date": "2026-07-01", + "url": "https://git.example/m2/market-registry/raw/branch/main/audit/2026-07-01.json", + } + + governance = test_client.get("/api/governance") + assert governance.status_code == 200 + assert governance.json()["open_prs"][0]["labels"] == ["approved"] + assert governance.json()["listings"] == [ + { + "listing_id": "lst_pdf", + "status": "published", + "url": "https://git.example/m2/market-registry/src/branch/main/listings/lst_pdf", + } + ] + + +@pytest.mark.parametrize( + ("path", "failed_host", "panel"), + [ + ("/api/search?q=pdf", "memory.example", "catalog"), + ("/api/wallet/m2bd", "ledger.example", "wallet"), + ("/api/economy", "ledger.example", "economy"), + ("/api/governance", "git.example", "governance"), + ], +) +def test_upstream_failures_are_panel_502( + client: Callable[[httpx.MockTransport], TestClient], + path: str, + failed_host: str, + panel: str, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == failed_host: + return httpx.Response(503, json={"secret": "upstream body ignored"}) + return _ok_transport(request) + + test_client = client(httpx.MockTransport(handler)) + _authed(test_client) + response = test_client.get(path) + assert response.status_code == 502 + assert response.json() == {"error": "upstream_failure", "panel": panel} + + +def test_secret_values_never_appear_in_response_bodies( + client: Callable[[httpx.MockTransport], TestClient], +) -> None: + test_client = client(httpx.MockTransport(_ok_transport)) + responses = [ + test_client.post("/api/login", json={"passcode": "fleet-passcode-secret"}), + ] + responses.extend( + [ + test_client.get("/health"), + test_client.get("/api/search?q=pdf"), + test_client.get("/api/listing/lst_pdf"), + test_client.get("/api/wallet/m2bd"), + test_client.get("/api/economy"), + test_client.get("/api/governance"), + ] + ) + for response in responses: + body = response.text + for secret in SECRET_VALUES: + assert secret not in body