T101: web backend proxy (auth, catalog, wallet, governance) + tests
This commit is contained in:
parent
e896a60bc0
commit
e77165ed72
11 changed files with 738 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
18
web/backend/pyproject.toml
Normal file
18
web/backend/pyproject.toml
Normal file
|
|
@ -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"]
|
||||
1
web/backend/src/m2_market_web/__init__.py
Normal file
1
web/backend/src/m2_market_web/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""M2 Market web backend."""
|
||||
65
web/backend/src/m2_market_web/auth.py
Normal file
65
web/backend/src/m2_market_web/auth.py
Normal file
|
|
@ -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)
|
||||
117
web/backend/src/m2_market_web/catalog.py
Normal file
117
web/backend/src/m2_market_web/catalog.py
Normal file
|
|
@ -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")
|
||||
21
web/backend/src/m2_market_web/http.py
Normal file
21
web/backend/src/m2_market_web/http.py
Normal file
|
|
@ -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(),
|
||||
)
|
||||
104
web/backend/src/m2_market_web/ledger.py
Normal file
104
web/backend/src/m2_market_web/ledger.py
Normal file
|
|
@ -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")
|
||||
41
web/backend/src/m2_market_web/main.py
Normal file
41
web/backend/src/m2_market_web/main.py
Normal file
|
|
@ -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()
|
||||
97
web/backend/src/m2_market_web/registry.py
Normal file
97
web/backend/src/m2_market_web/registry.py
Normal file
|
|
@ -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")
|
||||
6
web/backend/tests/conftest.py
Normal file
6
web/backend/tests/conftest.py
Normal file
|
|
@ -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"))
|
||||
267
web/backend/tests/test_backend.py
Normal file
267
web/backend/tests/test_backend.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue