Spaces:
Build error
Build error
Commit ·
0fea413
1
Parent(s): 8aeaefb
Upload folder using huggingface_hub
Browse files- Dockerfile +47 -4
- README.md +18 -6
- app/api/routes_history.py +3 -2
- app/api/routes_intents.py +3 -2
- app/api/routes_memory.py +3 -2
- app/api/routes_pricing.py +22 -44
- app/api/routes_risk.py +3 -2
- docs/authentication.md +26 -10
- tests/test_deps.py +75 -1
- tests/test_routes_pricing.py +47 -0
Dockerfile
CHANGED
|
@@ -1,16 +1,52 @@
|
|
|
|
|
| 1 |
# ---- deps stage: needs git + a credentialed clone of the private ARF repos
|
| 2 |
# (agentic_reliability_framework, ARF-Bayesian-Pricing-Calculator).
|
| 3 |
# This stage is discarded after build -- the credential never reaches
|
| 4 |
# the final image's layers, env, or git config. ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
FROM python:3.12-slim AS deps
|
| 6 |
-
ARG GH_PAT
|
| 7 |
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
| 8 |
-
RUN
|
|
|
|
| 9 |
RUN python -m venv /opt/venv
|
| 10 |
ENV PATH="/opt/venv/bin:$PATH"
|
| 11 |
WORKDIR /app
|
| 12 |
COPY requirements.txt .
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# ---- final stage: just the built venv + app code, no git, no credential ----
|
| 16 |
FROM python:3.12-slim
|
|
@@ -18,4 +54,11 @@ COPY --from=deps /opt/venv /opt/venv
|
|
| 18 |
ENV PATH="/opt/venv/bin:$PATH"
|
| 19 |
WORKDIR /app
|
| 20 |
COPY . .
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.2
|
| 2 |
# ---- deps stage: needs git + a credentialed clone of the private ARF repos
|
| 3 |
# (agentic_reliability_framework, ARF-Bayesian-Pricing-Calculator).
|
| 4 |
# This stage is discarded after build -- the credential never reaches
|
| 5 |
# the final image's layers, env, or git config. ----
|
| 6 |
+
#
|
| 7 |
+
# GH_PAT is read via a BuildKit secret mount, not `ARG` -- an ARG's value is
|
| 8 |
+
# printed in plaintext as part of the logged RUN command that uses it (this
|
| 9 |
+
# is exactly how a real, live token ended up visible in a Render deploy log
|
| 10 |
+
# this session). A secret mount's value is never written to a log line or
|
| 11 |
+
# an image layer. REQUIRES a matching setup step in Render's dashboard
|
| 12 |
+
# before this will build: Render's Docker service settings -> Secret Files
|
| 13 |
+
# -> add a file named exactly `gh_pat` containing the token value (nothing
|
| 14 |
+
# else in the file). The old `GH_PAT` environment variable is no longer
|
| 15 |
+
# read by this Dockerfile and can be removed once this is confirmed working.
|
| 16 |
FROM python:3.12-slim AS deps
|
|
|
|
| 17 |
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
| 18 |
+
RUN --mount=type=secret,id=gh_pat,dst=/etc/secrets/gh_pat \
|
| 19 |
+
git config --global url."https://$(cat /etc/secrets/gh_pat)@github.com/".insteadOf "https://github.com/"
|
| 20 |
RUN python -m venv /opt/venv
|
| 21 |
ENV PATH="/opt/venv/bin:$PATH"
|
| 22 |
WORKDIR /app
|
| 23 |
COPY requirements.txt .
|
| 24 |
+
# torch has no explicit pin anywhere in this dependency tree -- it's pulled in
|
| 25 |
+
# transitively by sentence-transformers (for agentic_reliability_framework's
|
| 26 |
+
# RAG/semantic-memory features) and, left to the default PyPI index, resolves
|
| 27 |
+
# to the CUDA-enabled build (nvidia-cusparselt, cuda-toolkit, nvidia-nccl, ...)
|
| 28 |
+
# even though this service runs on CPU-only Render instances. That variant's
|
| 29 |
+
# extra weight is a real contributor to out-of-memory deploy failures.
|
| 30 |
+
#
|
| 31 |
+
# A separate `pip install torch==... --index-url .../cpu` RUN before this one
|
| 32 |
+
# does NOT work: it's a distinct resolve that only knows about the CPU wheel;
|
| 33 |
+
# the very next `pip install -r requirements.txt`, seeing no --index-url, only
|
| 34 |
+
# has the default PyPI index in view and re-resolves torch from there,
|
| 35 |
+
# silently replacing the CPU build with the CUDA one at the same version
|
| 36 |
+
# number (confirmed happening in a real deploy -- final `pip install` log
|
| 37 |
+
# showed plain `torch-2.13.0` plus the full nvidia/cuda-toolkit/triton stack,
|
| 38 |
+
# not `torch-2.13.0+cpu`). Putting torch and -r requirements.txt in one
|
| 39 |
+
# `pip install` call, with the CPU wheelhouse as the primary --index-url and
|
| 40 |
+
# PyPI as --extra-index-url, makes it a single resolve: torch is satisfied
|
| 41 |
+
# from the CPU index and nothing later re-derives a different build for it.
|
| 42 |
+
# Version pinned to 2.13.0 to match exactly what pip's resolver already chose
|
| 43 |
+
# for this dependency tree (confirmed available on the CPU index for
|
| 44 |
+
# cp312/manylinux before pinning it here, not assumed).
|
| 45 |
+
RUN pip install --no-cache-dir \
|
| 46 |
+
--index-url https://download.pytorch.org/whl/cpu \
|
| 47 |
+
--extra-index-url https://pypi.org/simple \
|
| 48 |
+
torch==2.13.0 \
|
| 49 |
+
-r requirements.txt
|
| 50 |
|
| 51 |
# ---- final stage: just the built venv + app code, no git, no credential ----
|
| 52 |
FROM python:3.12-slim
|
|
|
|
| 54 |
ENV PATH="/opt/venv/bin:$PATH"
|
| 55 |
WORKDIR /app
|
| 56 |
COPY . .
|
| 57 |
+
# Shell form (not exec/JSON-array form) deliberately -- ${PORT:-7860} only
|
| 58 |
+
# expands with a real shell interpreting the command; exec form passes
|
| 59 |
+
# arguments literally with no variable substitution at all. Render injects
|
| 60 |
+
# PORT and expects the app to bind to it (its deploy log explicitly failed
|
| 61 |
+
# port-scanning for it: "Bind your service to at least one port"); the
|
| 62 |
+
# Hugging Face Space mirror sets no such variable and expects the
|
| 63 |
+
# conventional default, 7860. One image, correct on both targets.
|
| 64 |
+
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
|
README.md
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# arf-api
|
| 2 |
|
| 3 |
ARF API Control Plane (FastAPI)
|
| 4 |
|
| 5 |
## Live Demo
|
| 6 |
|
| 7 |
-
|
| 8 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
- **Interactive Documentation**: [https://arf-ai-agentic-reliability-framework-api.hf.space/docs](https://arf-ai-agentic-reliability-framework-api.hf.space/docs)
|
| 10 |
|
| 11 |
## Quick Start (Local Development)
|
| 12 |
|
| 13 |
1. **Install dependencies**:
|
|
|
|
| 14 |
```bash
|
| 15 |
pip install -r requirements.txt
|
| 16 |
```
|
|
@@ -91,9 +106,7 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content-
|
|
| 91 |
"effect": -90,
|
| 92 |
"explanation_text": "If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.",
|
| 93 |
"is_model_based": false,
|
| 94 |
-
"warnings": [
|
| 95 |
-
"Using heuristic causal model (no fitted SCM)."
|
| 96 |
-
]
|
| 97 |
},
|
| 98 |
"utility_decision": {
|
| 99 |
"best_action": "restart_container",
|
|
@@ -120,4 +133,3 @@ Notes
|
|
| 120 |
|
| 121 |
- The governance endpoints use an in-process `RiskEngine` initialized at startup.
|
| 122 |
- Outcomes are recorded via `POST /api/v1/intents/outcome` (`app/api/routes_governance.py`), tenant-scoped and auth-protected.
|
| 123 |
-
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ARF API
|
| 3 |
+
emoji: 🛡️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
# arf-api
|
| 11 |
|
| 12 |
ARF API Control Plane (FastAPI)
|
| 13 |
|
| 14 |
## Live Demo
|
| 15 |
|
| 16 |
+
**Render is the primary deployment target** (custom domain, real scaling, standard secrets
|
| 17 |
+
management -- the multi-stage Docker build in this repo was purpose-built for it). The Hugging
|
| 18 |
+
Face Space below is a secondary, publicly-browsable mirror of the same code, not the primary
|
| 19 |
+
integration target -- point real pilot/customer integrations at Render once its URL is
|
| 20 |
+
confirmed live, not at the Space URL.
|
| 21 |
+
|
| 22 |
+
- **HF Space (public mirror)**: [https://arf-ai-agentic-reliability-framework-api.hf.space](https://arf-ai-agentic-reliability-framework-api.hf.space)
|
| 23 |
- **Interactive Documentation**: [https://arf-ai-agentic-reliability-framework-api.hf.space/docs](https://arf-ai-agentic-reliability-framework-api.hf.space/docs)
|
| 24 |
|
| 25 |
## Quick Start (Local Development)
|
| 26 |
|
| 27 |
1. **Install dependencies**:
|
| 28 |
+
|
| 29 |
```bash
|
| 30 |
pip install -r requirements.txt
|
| 31 |
```
|
|
|
|
| 106 |
"effect": -90,
|
| 107 |
"explanation_text": "If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.",
|
| 108 |
"is_model_based": false,
|
| 109 |
+
"warnings": ["Using heuristic causal model (no fitted SCM)."]
|
|
|
|
|
|
|
| 110 |
},
|
| 111 |
"utility_decision": {
|
| 112 |
"best_action": "restart_container",
|
|
|
|
| 133 |
|
| 134 |
- The governance endpoints use an in-process `RiskEngine` initialized at startup.
|
| 135 |
- Outcomes are recorded via `POST /api/v1/intents/outcome` (`app/api/routes_governance.py`), tenant-scoped and auth-protected.
|
|
|
app/api/routes_history.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
|
|
|
| 2 |
from app.core.storage import incident_history
|
| 3 |
|
| 4 |
-
router = APIRouter()
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("/history")
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from app.api.deps import verify_internal_key
|
| 3 |
from app.core.storage import incident_history
|
| 4 |
|
| 5 |
+
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 6 |
|
| 7 |
|
| 8 |
@router.get("/history")
|
app/api/routes_intents.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 2 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 3 |
from app.services.intent_service import simulate_intent
|
| 4 |
|
| 5 |
-
router = APIRouter()
|
| 6 |
|
| 7 |
|
| 8 |
@router.post("/simulate_intent", response_model=IntentSimulationResponse)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from app.api.deps import verify_internal_key
|
| 3 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 4 |
from app.services.intent_service import simulate_intent
|
| 5 |
|
| 6 |
+
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 7 |
|
| 8 |
|
| 9 |
@router.post("/simulate_intent", response_model=IntentSimulationResponse)
|
app/api/routes_memory.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
from fastapi import APIRouter, Request
|
|
|
|
| 2 |
|
| 3 |
-
router = APIRouter()
|
| 4 |
|
| 5 |
|
| 6 |
@router.get("/stats")
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Request
|
| 2 |
+
from app.api.deps import verify_internal_key
|
| 3 |
|
| 4 |
+
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("/stats")
|
app/api/routes_pricing.py
CHANGED
|
@@ -57,48 +57,26 @@ async def run_pricing(
|
|
| 57 |
quota: dict = Depends(enforce_quota),
|
| 58 |
):
|
| 59 |
"""
|
| 60 |
-
Multi‑run pricing with cooldown and buffer persistence.
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
for i in range(req.runs):
|
| 73 |
-
if not req.force and is_cooldown_active(
|
| 74 |
-
req.customer_id, req.cooldown_hours):
|
| 75 |
-
raise HTTPException(status_code=429,
|
| 76 |
-
detail=f"Cooldown active after {i} runs")
|
| 77 |
-
|
| 78 |
-
pricing_input = parse_input_dict(req.input)
|
| 79 |
-
engine = PricingEngine(calibration_buffer=buffer)
|
| 80 |
-
out = engine.estimate(pricing_input)
|
| 81 |
-
|
| 82 |
-
# Simulate an outcome (in real use, this would come from the actual
|
| 83 |
-
# deal)
|
| 84 |
-
import random
|
| 85 |
-
outcome = "success" if random.random() > out.risk_score else "failure" # nosec B311
|
| 86 |
-
|
| 87 |
-
event = {
|
| 88 |
-
"run_id": out.run_history_id,
|
| 89 |
-
"customer_id": req.customer_id,
|
| 90 |
-
"outcome": outcome,
|
| 91 |
-
"price": out.recommended_price,
|
| 92 |
-
"value": out.expected_value,
|
| 93 |
-
"risk_score": out.risk_score,
|
| 94 |
-
"run_number": i + 1,
|
| 95 |
-
}
|
| 96 |
-
add_event(event)
|
| 97 |
-
buffer = load_buffer() # reload after update
|
| 98 |
-
|
| 99 |
-
outputs.append(out)
|
| 100 |
-
|
| 101 |
-
if i < req.runs - 1:
|
| 102 |
-
enforce_cooldown(req.customer_id, req.cooldown_hours)
|
| 103 |
-
|
| 104 |
-
return outputs
|
|
|
|
| 57 |
quota: dict = Depends(enforce_quota),
|
| 58 |
):
|
| 59 |
"""
|
| 60 |
+
Multi‑run pricing with cooldown and buffer persistence. TEMPORARILY DISABLED.
|
| 61 |
+
|
| 62 |
+
This endpoint used to persist each run's "outcome" as
|
| 63 |
+
`random.random() > risk_score` -- a fabricated result, not a real deal
|
| 64 |
+
outcome -- into a calibration buffer with no customer_id scoping, so
|
| 65 |
+
every customer's calls read and wrote the same file. Net effect: every
|
| 66 |
+
customer's price was shaped by every other customer's randomly-generated
|
| 67 |
+
outcomes, not just their own. Disabled until both are fixed: (1) a real
|
| 68 |
+
outcome-ingestion path (this endpoint must not invent one), and (2) the
|
| 69 |
+
buffer scoped per customer. See AUDIT_arf-bayesian-pricing-calculator.md
|
| 70 |
+
and AUDIT_arf-api.md (workspace root) for the original findings and
|
| 71 |
+
recommended fix. `Depends(enforce_quota)` stays active so this still
|
| 72 |
+
requires the same auth it always did -- only authenticated callers reach
|
| 73 |
+
the disabled-notice below; everyone else still gets the normal 401/403.
|
| 74 |
"""
|
| 75 |
+
raise HTTPException(
|
| 76 |
+
status_code=503,
|
| 77 |
+
detail=(
|
| 78 |
+
"This endpoint is temporarily disabled while a data-integrity issue is "
|
| 79 |
+
"fixed. Use POST /api/v1/pricing/estimate for a single price estimate "
|
| 80 |
+
"with no persisted learning in the meantime."
|
| 81 |
+
),
|
| 82 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_risk.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 2 |
from app.models.risk_models import RiskResponse
|
| 3 |
from app.services.risk_service import get_system_risk
|
| 4 |
|
| 5 |
-
router = APIRouter()
|
| 6 |
|
| 7 |
|
| 8 |
@router.get("/get_risk", response_model=RiskResponse)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from app.api.deps import verify_internal_key
|
| 3 |
from app.models.risk_models import RiskResponse
|
| 4 |
from app.services.risk_service import get_system_risk
|
| 5 |
|
| 6 |
+
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 7 |
|
| 8 |
|
| 9 |
@router.get("/get_risk", response_model=RiskResponse)
|
docs/authentication.md
CHANGED
|
@@ -2,20 +2,36 @@
|
|
| 2 |
|
| 3 |
This page describes how to authenticate with the ARF API.
|
| 4 |
|
| 5 |
-
Current status
|
| 6 |
-
|
| 7 |
-
- `routes_governance.py`
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
What the code provides
|
| 12 |
|
| 13 |
-
- `app/core/config.py` exposes an `api_key` setting read from `.env`, but no current route
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
- Add `dependencies=[Depends(verify_internal_key)]` (or a purpose-built dependency) to the `APIRouter(...)` construction in the files listed above, following the pattern already used in `routes_governance.py`.
|
| 18 |
|
| 19 |
Notes
|
| 20 |
|
| 21 |
- Tests run against a real Postgres connection (`tests/conftest.py`), not SQLite; see the top-level README's Tests section.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
This page describes how to authenticate with the ARF API.
|
| 4 |
|
| 5 |
+
Current status
|
| 6 |
+
|
| 7 |
+
- `routes_governance.py`, `routes_risk.py`, `routes_intents.py`, `routes_history.py`,
|
| 8 |
+
`routes_memory.py`: the entire router requires the `X-Internal-Key` header, verified against
|
| 9 |
+
`ARF_INTERNAL_API_KEY` (`app/api/deps.py::verify_internal_key`). This fails closed — requests
|
| 10 |
+
are rejected with 401 if the env var is unset, if the header is missing, or if it doesn't
|
| 11 |
+
match (constant-time comparison). This is the header arf-gateway injects when proxying to
|
| 12 |
+
this service. The last four were unauthenticated until this was fixed — see
|
| 13 |
+
`tests/test_deps.py` for the tests that verify the dependency itself actually rejects what
|
| 14 |
+
it should, not just that it's wired in.
|
| 15 |
+
- `routes_admin.py`: individual `/admin/*` endpoints require an `admin_key` query parameter,
|
| 16 |
+
verified against `ARF_ADMIN_API_KEY` (`app/api/deps.py`, or the local `verify_admin`
|
| 17 |
+
dependency in that router). Also fails closed if unset.
|
| 18 |
+
- `routes_pricing.py`: individual `/pricing/*` endpoints require a real per-customer API key
|
| 19 |
+
(`Authorization: Bearer <key>` or `?api_key=`), verified against the tracked/tenant-scoped
|
| 20 |
+
`enforce_quota` dependency (`app/core/usage_tracker.py`) — a different mechanism from
|
| 21 |
+
`X-Internal-Key`, since pricing estimates are meant to be reachable by a customer directly,
|
| 22 |
+
not only via the gateway.
|
| 23 |
|
| 24 |
What the code provides
|
| 25 |
|
| 26 |
+
- `app/core/config.py` exposes an `api_key` setting read from `.env`, but no current route
|
| 27 |
+
checks it — it is not the mechanism in use. The real mechanisms are `X-Internal-Key`,
|
| 28 |
+
`ARF_ADMIN_API_KEY`, and per-customer API keys (`enforce_quota`), all checked in
|
| 29 |
+
`app/api/deps.py` / `app/core/usage_tracker.py`.
|
|
|
|
| 30 |
|
| 31 |
Notes
|
| 32 |
|
| 33 |
- Tests run against a real Postgres connection (`tests/conftest.py`), not SQLite; see the top-level README's Tests section.
|
| 34 |
+
- `tests/conftest.py` globally overrides `verify_internal_key` for the test suite (so routes
|
| 35 |
+
behind it can be exercised without the gateway-injected header) — this means the app-level
|
| 36 |
+
test suite alone can't confirm the dependency actually fails closed. `tests/test_deps.py`
|
| 37 |
+
calls it directly, bypassing that override, specifically to verify that.
|
tests/test_deps.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
-
from
|
|
|
|
|
|
|
| 3 |
from app.api.deps import get_db
|
| 4 |
|
| 5 |
|
|
@@ -13,3 +18,72 @@ def test_get_db_closes_session():
|
|
| 13 |
with pytest.raises(Exception):
|
| 14 |
db_gen.throw(Exception("test error"))
|
| 15 |
mock_session.close.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib
|
| 2 |
+
from unittest.mock import MagicMock, patch
|
| 3 |
+
|
| 4 |
import pytest
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
|
| 7 |
+
import app.api.deps as deps
|
| 8 |
from app.api.deps import get_db
|
| 9 |
|
| 10 |
|
|
|
|
| 18 |
with pytest.raises(Exception):
|
| 19 |
db_gen.throw(Exception("test error"))
|
| 20 |
mock_session.close.assert_called_once()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# verify_internal_key tests below are called directly, not through
|
| 24 |
+
# TestClient: tests/conftest.py globally overrides verify_internal_key
|
| 25 |
+
# (`fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key`)
|
| 26 |
+
# so that already-protected routes (routes_governance.py) can be exercised
|
| 27 |
+
# in tests without the gateway-injected X-Internal-Key header. That override
|
| 28 |
+
# makes the real fail-closed behavior untestable through the app for any
|
| 29 |
+
# router that uses it -- this is the only place it's actually verified to
|
| 30 |
+
# reject what it should reject, rather than just trusted to work because
|
| 31 |
+
# it's wired in.
|
| 32 |
+
#
|
| 33 |
+
# Newly relevant as of the auth fix to routes_risk.py, routes_intents.py,
|
| 34 |
+
# routes_history.py, routes_memory.py (see docs/authentication.md) -- those
|
| 35 |
+
# four routers now depend on this function passing correctly.
|
| 36 |
+
|
| 37 |
+
_NEWLY_PROTECTED_ROUTER_MODULES = [
|
| 38 |
+
"app.api.routes_risk",
|
| 39 |
+
"app.api.routes_intents",
|
| 40 |
+
"app.api.routes_history",
|
| 41 |
+
"app.api.routes_memory",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.asyncio
|
| 46 |
+
async def test_verify_internal_key_rejects_missing_header(monkeypatch):
|
| 47 |
+
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 48 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 49 |
+
await deps.verify_internal_key(x_internal_key=None)
|
| 50 |
+
assert exc_info.value.status_code == 401
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest.mark.asyncio
|
| 54 |
+
async def test_verify_internal_key_rejects_wrong_key(monkeypatch):
|
| 55 |
+
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 56 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 57 |
+
await deps.verify_internal_key(x_internal_key="wrong-key")
|
| 58 |
+
assert exc_info.value.status_code == 401
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.mark.asyncio
|
| 62 |
+
async def test_verify_internal_key_fails_closed_when_unset(monkeypatch):
|
| 63 |
+
"""The env var being unset must reject every request, not let them
|
| 64 |
+
through -- this is the specific property that makes this safe to add
|
| 65 |
+
to a router without also needing to guarantee the env var is always
|
| 66 |
+
set."""
|
| 67 |
+
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "")
|
| 68 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 69 |
+
await deps.verify_internal_key(x_internal_key="anything")
|
| 70 |
+
assert exc_info.value.status_code == 401
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@pytest.mark.asyncio
|
| 74 |
+
async def test_verify_internal_key_accepts_correct_key(monkeypatch):
|
| 75 |
+
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 76 |
+
result = await deps.verify_internal_key(x_internal_key="real-secret")
|
| 77 |
+
assert result is None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.parametrize("router_module_name", _NEWLY_PROTECTED_ROUTER_MODULES)
|
| 81 |
+
def test_router_requires_verify_internal_key(router_module_name):
|
| 82 |
+
"""Structural check, independent of the function-level tests above:
|
| 83 |
+
proves each router actually declares verify_internal_key as a
|
| 84 |
+
router-level dependency, not just that the function itself works in
|
| 85 |
+
isolation. Mirrors the pattern routes_governance.py already uses
|
| 86 |
+
(`APIRouter(dependencies=[Depends(verify_internal_key)])`)."""
|
| 87 |
+
module = importlib.import_module(router_module_name)
|
| 88 |
+
dependency_callables = [d.dependency for d in module.router.dependencies]
|
| 89 |
+
assert deps.verify_internal_key in dependency_callables
|
tests/test_routes_pricing.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from app.main import app
|
| 3 |
+
|
| 4 |
+
client = TestClient(app)
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_run_pricing_is_temporarily_disabled():
|
| 8 |
+
"""/pricing/run was disabled after AUDIT_arf-bayesian-pricing-calculator.md's
|
| 9 |
+
Critical finding: it persisted a fabricated random.random() outcome into a
|
| 10 |
+
calibration buffer with no customer_id scoping, so every customer's price
|
| 11 |
+
was shaped by every other customer's fabricated outcomes. This asserts the
|
| 12 |
+
disabled state itself, not the old (buggy) behavior -- update this test
|
| 13 |
+
when the endpoint is actually fixed and re-enabled, not before."""
|
| 14 |
+
response = client.post(
|
| 15 |
+
"/api/v1/pricing/run",
|
| 16 |
+
json={"input": {}, "customer_id": "test-customer", "runs": 1},
|
| 17 |
+
)
|
| 18 |
+
assert response.status_code == 503
|
| 19 |
+
assert "temporarily disabled" in response.json()["detail"].lower()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_run_pricing_still_requires_auth():
|
| 23 |
+
"""Disabling the endpoint must not also disable its auth -- conftest.py's
|
| 24 |
+
mock_enforce_quota makes every request "authenticated" for this test
|
| 25 |
+
client, so this only confirms the Depends(enforce_quota) dependency is
|
| 26 |
+
still declared and still runs before the handler body (i.e. it wasn't
|
| 27 |
+
accidentally dropped along with the rest of the function body); it does
|
| 28 |
+
not exercise the real 401/403 paths, which are covered by test_deps.py
|
| 29 |
+
and usage_tracker's own tests."""
|
| 30 |
+
response = client.post(
|
| 31 |
+
"/api/v1/pricing/run",
|
| 32 |
+
json={"input": {}, "customer_id": "test-customer", "runs": 1},
|
| 33 |
+
)
|
| 34 |
+
# Reaching the 503 (not erroring before it) proves enforce_quota resolved
|
| 35 |
+
# successfully -- if the dependency were missing or broken, this would be
|
| 36 |
+
# a 401/422/500 instead.
|
| 37 |
+
assert response.status_code == 503
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_estimate_pricing_route_still_registered():
|
| 41 |
+
"""/pricing/estimate was not touched by the disable -- confirm it's still
|
| 42 |
+
a distinct, reachable route (a malformed request should 400, not 503/404),
|
| 43 |
+
so a caller following the "use /pricing/estimate instead" guidance in the
|
| 44 |
+
503 detail message actually has somewhere to go."""
|
| 45 |
+
response = client.post("/api/v1/pricing/estimate", json={"input": {}})
|
| 46 |
+
assert response.status_code != 503
|
| 47 |
+
assert response.status_code != 404
|