Spaces:
Build error
Build error
Commit Β·
6fbc8ca
1
Parent(s): 84013a9
Upload folder using huggingface_hub
Browse files- app/api/routes_admin.py +40 -0
- app/api/routes_governance.py +5 -4
- app/api/routes_history.py +1 -1
- app/api/routes_incidents.py +9 -8
- app/api/routes_intents.py +7 -2
- app/api/routes_payments.py +6 -2
- app/api/routes_risk.py +7 -2
- app/core/storage.py +16 -2
- app/main.py +15 -1
- tests/test_routes_admin.py +34 -0
app/api/routes_admin.py
CHANGED
|
@@ -123,6 +123,46 @@ async def update_key_tier(
|
|
| 123 |
return {"message": f"Tier updated to {req.tier}"}
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
|
| 127 |
async def deactivate_api_key(
|
| 128 |
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")):
|
|
|
|
| 123 |
return {"message": f"Tier updated to {req.tier}"}
|
| 124 |
|
| 125 |
|
| 126 |
+
@router.post("/keys/{key_id}/rotate", dependencies=[Depends(verify_admin)])
|
| 127 |
+
async def rotate_api_key(
|
| 128 |
+
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)"),
|
| 129 |
+
):
|
| 130 |
+
"""Atomically deactivate a key and issue a new one on the same tenant
|
| 131 |
+
and tier -- the single action a leaked/compromised key actually needs.
|
| 132 |
+
Doing this as get-old + deactivate + create separately (the only option
|
| 133 |
+
before this endpoint existed) risks losing track of the tenant_id
|
| 134 |
+
partway through, or leaving the old key active if a later step fails.
|
| 135 |
+
The new plaintext key is returned exactly once, like create_api_key's."""
|
| 136 |
+
with tracker._get_pg_conn() as conn:
|
| 137 |
+
old_row = tracker._pg_execute(
|
| 138 |
+
conn, "SELECT tenant_id, tier FROM api_keys WHERE lookup_hash = %s", (key_id,)
|
| 139 |
+
).fetchone()
|
| 140 |
+
if not old_row:
|
| 141 |
+
conn.rollback()
|
| 142 |
+
raise HTTPException(status_code=404, detail="API key not found")
|
| 143 |
+
|
| 144 |
+
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 145 |
+
salt = secrets.token_hex(16)
|
| 146 |
+
tracker._pg_execute(
|
| 147 |
+
conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
|
| 148 |
+
tracker._pg_execute(
|
| 149 |
+
conn,
|
| 150 |
+
"INSERT INTO api_keys "
|
| 151 |
+
"(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
|
| 152 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 153 |
+
(old_row["tenant_id"], old_row["tier"], datetime.utcnow(), True,
|
| 154 |
+
salt, tracker._salted_hash(new_key, salt), tracker._lookup_hash(new_key)),
|
| 155 |
+
)
|
| 156 |
+
conn.commit()
|
| 157 |
+
|
| 158 |
+
return {
|
| 159 |
+
"api_key": new_key,
|
| 160 |
+
"tenant_id": old_row["tenant_id"],
|
| 161 |
+
"tier": old_row["tier"],
|
| 162 |
+
"deactivated_key_id": key_id,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
|
| 167 |
async def deactivate_api_key(
|
| 168 |
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")):
|
app/api/routes_governance.py
CHANGED
|
@@ -366,7 +366,7 @@ async def evaluate_intent_endpoint(
|
|
| 366 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 367 |
span.record_exception(e)
|
| 368 |
span.end()
|
| 369 |
-
raise HTTPException(status_code=500, detail=
|
| 370 |
|
| 371 |
|
| 372 |
# --------------------------------------------------------------------------
|
|
@@ -413,8 +413,9 @@ async def record_outcome_endpoint(
|
|
| 413 |
logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}")
|
| 414 |
|
| 415 |
return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
|
| 416 |
-
except Exception
|
| 417 |
-
|
|
|
|
| 418 |
|
| 419 |
|
| 420 |
# --------------------------------------------------------------------------
|
|
@@ -553,4 +554,4 @@ async def evaluate_healing_decision_endpoint(
|
|
| 553 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 554 |
span.record_exception(e)
|
| 555 |
span.end()
|
| 556 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
| 366 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 367 |
span.record_exception(e)
|
| 368 |
span.end()
|
| 369 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 370 |
|
| 371 |
|
| 372 |
# --------------------------------------------------------------------------
|
|
|
|
| 413 |
logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}")
|
| 414 |
|
| 415 |
return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
|
| 416 |
+
except Exception:
|
| 417 |
+
logger.exception("Error recording outcome")
|
| 418 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 419 |
|
| 420 |
|
| 421 |
# --------------------------------------------------------------------------
|
|
|
|
| 554 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 555 |
span.record_exception(e)
|
| 556 |
span.end()
|
| 557 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
app/api/routes_history.py
CHANGED
|
@@ -7,4 +7,4 @@ router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
|
| 7 |
|
| 8 |
@router.get("/history")
|
| 9 |
async def get_history():
|
| 10 |
-
return {"incidents": incident_history}
|
|
|
|
| 7 |
|
| 8 |
@router.get("/history")
|
| 9 |
async def get_history():
|
| 10 |
+
return {"incidents": list(incident_history)}
|
app/api/routes_incidents.py
CHANGED
|
@@ -30,23 +30,20 @@ from agentic_reliability_framework.core.models.event import (
|
|
| 30 |
ReliabilityEvent,
|
| 31 |
)
|
| 32 |
|
|
|
|
| 33 |
from app.causal_explainer import CausalExplainer
|
|
|
|
| 34 |
from app.core.usage_tracker import UsageRecord, enforce_quota, tracker
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
|
| 38 |
router = APIRouter()
|
| 39 |
|
| 40 |
-
# ---------------------------------------------------------------------------
|
| 41 |
-
# Inβmemory incident store (for auditing / debugging only)
|
| 42 |
-
# ---------------------------------------------------------------------------
|
| 43 |
-
incident_history: list[dict] = []
|
| 44 |
-
|
| 45 |
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
# POST /api/v1/report_incident
|
| 48 |
# ---------------------------------------------------------------------------
|
| 49 |
-
@router.post("/report_incident")
|
| 50 |
async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
| 51 |
"""
|
| 52 |
Record a ``ReliabilityEvent`` in the inβmemory incident history.
|
|
@@ -54,7 +51,10 @@ async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
|
| 54 |
This endpoint is used by internal monitoring tools to feed incident
|
| 55 |
data into the causal explainer and downstream analysis. The event
|
| 56 |
is stored as a JSONβsafe dictionary and is **not** persisted across
|
| 57 |
-
API restarts.
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
Parameters
|
| 60 |
----------
|
|
@@ -249,6 +249,7 @@ async def evaluate_incident(
|
|
| 249 |
raise
|
| 250 |
except Exception as exc:
|
| 251 |
error_msg = str(exc)
|
|
|
|
| 252 |
if tracker:
|
| 253 |
record = UsageRecord(
|
| 254 |
api_key=api_key,
|
|
@@ -260,4 +261,4 @@ async def evaluate_incident(
|
|
| 260 |
processing_ms=(time.time() - start_time) * 1000,
|
| 261 |
)
|
| 262 |
await tracker.increment_usage_async(record, background_tasks)
|
| 263 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
| 30 |
ReliabilityEvent,
|
| 31 |
)
|
| 32 |
|
| 33 |
+
from app.api.deps import verify_internal_key
|
| 34 |
from app.causal_explainer import CausalExplainer
|
| 35 |
+
from app.core.storage import incident_history
|
| 36 |
from app.core.usage_tracker import UsageRecord, enforce_quota, tracker
|
| 37 |
|
| 38 |
logger = logging.getLogger(__name__)
|
| 39 |
|
| 40 |
router = APIRouter()
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# ---------------------------------------------------------------------------
|
| 44 |
# POST /api/v1/report_incident
|
| 45 |
# ---------------------------------------------------------------------------
|
| 46 |
+
@router.post("/report_incident", dependencies=[Depends(verify_internal_key)])
|
| 47 |
async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
| 48 |
"""
|
| 49 |
Record a ``ReliabilityEvent`` in the inβmemory incident history.
|
|
|
|
| 51 |
This endpoint is used by internal monitoring tools to feed incident
|
| 52 |
data into the causal explainer and downstream analysis. The event
|
| 53 |
is stored as a JSONβsafe dictionary and is **not** persisted across
|
| 54 |
+
API restarts. Requires the same ``X-Internal-Key`` header every other
|
| 55 |
+
data-bearing route in this API requires -- previously this endpoint had
|
| 56 |
+
no auth dependency at all, so anyone could write into the incident
|
| 57 |
+
history that feeds the causal explainer and ``GET /history``.
|
| 58 |
|
| 59 |
Parameters
|
| 60 |
----------
|
|
|
|
| 249 |
raise
|
| 250 |
except Exception as exc:
|
| 251 |
error_msg = str(exc)
|
| 252 |
+
logger.exception("Error in evaluate_incident (deprecated endpoint)")
|
| 253 |
if tracker:
|
| 254 |
record = UsageRecord(
|
| 255 |
api_key=api_key,
|
|
|
|
| 261 |
processing_ms=(time.time() - start_time) * 1000,
|
| 262 |
)
|
| 263 |
await tracker.increment_usage_async(record, background_tasks)
|
| 264 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
app/api/routes_intents.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -11,5 +15,6 @@ async def simulate_intent_endpoint(intent: IntentSimulation):
|
|
| 11 |
try:
|
| 12 |
result = simulate_intent(intent)
|
| 13 |
return IntentSimulationResponse(**result)
|
| 14 |
-
except Exception
|
| 15 |
-
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
from app.api.deps import verify_internal_key
|
| 5 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 6 |
from app.services.intent_service import simulate_intent
|
| 7 |
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 11 |
|
| 12 |
|
|
|
|
| 15 |
try:
|
| 16 |
result = simulate_intent(intent)
|
| 17 |
return IntentSimulationResponse(**result)
|
| 18 |
+
except Exception:
|
| 19 |
+
logger.exception("simulate_intent failed")
|
| 20 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
app/api/routes_payments.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
Payment endpoints β Stripe Checkout integration.
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import stripe
|
| 7 |
from fastapi import APIRouter, HTTPException
|
|
@@ -9,6 +10,8 @@ from pydantic import BaseModel
|
|
| 9 |
|
| 10 |
from app.core.usage_tracker import tracker, Tier
|
| 11 |
|
|
|
|
|
|
|
| 12 |
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 13 |
|
| 14 |
# Set Stripe API key (from environment)
|
|
@@ -59,5 +62,6 @@ async def create_checkout_session(req: CheckoutRequest):
|
|
| 59 |
subscription_data={"metadata": {"api_key": req.api_key}},
|
| 60 |
)
|
| 61 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 62 |
-
except Exception
|
| 63 |
-
|
|
|
|
|
|
| 2 |
Payment endpoints β Stripe Checkout integration.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
from fastapi import APIRouter, HTTPException
|
|
|
|
| 10 |
|
| 11 |
from app.core.usage_tracker import tracker, Tier
|
| 12 |
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 16 |
|
| 17 |
# Set Stripe API key (from environment)
|
|
|
|
| 62 |
subscription_data={"metadata": {"api_key": req.api_key}},
|
| 63 |
)
|
| 64 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 65 |
+
except Exception:
|
| 66 |
+
logger.exception("create_checkout_session failed")
|
| 67 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
app/api/routes_risk.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -14,8 +18,9 @@ async def get_risk():
|
|
| 14 |
raise HTTPException(
|
| 15 |
status_code=501,
|
| 16 |
detail="This endpoint is deprecated and not implemented")
|
| 17 |
-
except Exception
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
if risk < 0.3:
|
| 21 |
status = "low"
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
from app.api.deps import verify_internal_key
|
| 5 |
from app.models.risk_models import RiskResponse
|
| 6 |
from app.services.risk_service import get_system_risk
|
| 7 |
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 11 |
|
| 12 |
|
|
|
|
| 18 |
raise HTTPException(
|
| 19 |
status_code=501,
|
| 20 |
detail="This endpoint is deprecated and not implemented")
|
| 21 |
+
except Exception:
|
| 22 |
+
logger.exception("get_risk failed")
|
| 23 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 24 |
|
| 25 |
if risk < 0.3:
|
| 26 |
status = "low"
|
app/core/storage.py
CHANGED
|
@@ -1,2 +1,16 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory store for recent incident reports.
|
| 2 |
+
|
| 3 |
+
Bounded (maxlen), not persisted across restarts -- exists to give the
|
| 4 |
+
causal explainer and GET /history recent context, not as a durable audit
|
| 5 |
+
trail. The cap protects against unbounded memory growth from
|
| 6 |
+
POST /report_incident, which can be called repeatedly by anything holding
|
| 7 |
+
a valid internal key.
|
| 8 |
+
|
| 9 |
+
Shared by app.api.routes_incidents (writes, via report_incident) and
|
| 10 |
+
app.api.routes_history (reads, via GET /history) -- both must import this
|
| 11 |
+
same object rather than declaring their own list, or writes and reads
|
| 12 |
+
silently operate on two different lists.
|
| 13 |
+
"""
|
| 14 |
+
from collections import deque
|
| 15 |
+
|
| 16 |
+
incident_history: deque = deque(maxlen=10_000)
|
app/main.py
CHANGED
|
@@ -35,8 +35,9 @@ import time as _time
|
|
| 35 |
from contextlib import asynccontextmanager
|
| 36 |
from typing import Dict
|
| 37 |
|
| 38 |
-
from fastapi import FastAPI
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 40 |
|
| 41 |
# ββ Optional: Prometheus metrics βββββββββββββββββββββββββββββ
|
| 42 |
try:
|
|
@@ -371,6 +372,19 @@ def create_app() -> FastAPI:
|
|
| 371 |
)
|
| 372 |
logger.debug("CORS middleware configured")
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
# ββ Rate limiter ββββββββββββββββββββββββββββββββββββββββββ
|
| 375 |
if SLOWAPI_AVAILABLE:
|
| 376 |
app.state.limiter = limiter
|
|
|
|
| 35 |
from contextlib import asynccontextmanager
|
| 36 |
from typing import Dict
|
| 37 |
|
| 38 |
+
from fastapi import FastAPI, Request
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
+
from fastapi.responses import JSONResponse
|
| 41 |
|
| 42 |
# ββ Optional: Prometheus metrics βββββββββββββββββββββββββββββ
|
| 43 |
try:
|
|
|
|
| 372 |
)
|
| 373 |
logger.debug("CORS middleware configured")
|
| 374 |
|
| 375 |
+
# ββ Generic exception handler ββββββββββββββββββββββββββββ
|
| 376 |
+
# Defense-in-depth for anything that escapes a route's own try/except
|
| 377 |
+
# uncaught (HTTPException instances are unaffected -- FastAPI's own,
|
| 378 |
+
# more specific handler for those still takes precedence). Logs the
|
| 379 |
+
# real exception server-side and returns a generic message: routes
|
| 380 |
+
# that catch their own exceptions have each been fixed to do the same,
|
| 381 |
+
# but this exists so a bug that skips that pattern doesn't leak raw
|
| 382 |
+
# exception text (paths, internals, third-party SDK details) to callers.
|
| 383 |
+
@app.exception_handler(Exception)
|
| 384 |
+
async def unhandled_exception_handler(request: Request, exc: Exception):
|
| 385 |
+
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
| 386 |
+
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
| 387 |
+
|
| 388 |
# ββ Rate limiter ββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
if SLOWAPI_AVAILABLE:
|
| 390 |
app.state.limiter = limiter
|
tests/test_routes_admin.py
CHANGED
|
@@ -81,3 +81,37 @@ def test_update_nonexistent_key_returns_404(client):
|
|
| 81 |
json={"tier": "pro"},
|
| 82 |
)
|
| 83 |
assert resp.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
json={"tier": "pro"},
|
| 82 |
)
|
| 83 |
assert resp.status_code == 404
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_rotate_key_deactivates_old_and_creates_new_on_same_tenant(client):
|
| 87 |
+
create_resp = client.post(
|
| 88 |
+
"/admin/keys",
|
| 89 |
+
params={"admin_key": TEST_ADMIN_KEY},
|
| 90 |
+
json={"tier": "pro", "org_name": "Rotate Test Org"},
|
| 91 |
+
)
|
| 92 |
+
assert create_resp.status_code == 200
|
| 93 |
+
old_body = create_resp.json()
|
| 94 |
+
old_key_id = _key_id(old_body["api_key"])
|
| 95 |
+
tenant_id = old_body["tenant_id"]
|
| 96 |
+
|
| 97 |
+
rotate_resp = client.post(
|
| 98 |
+
f"/admin/keys/{old_key_id}/rotate", params={"admin_key": TEST_ADMIN_KEY})
|
| 99 |
+
assert rotate_resp.status_code == 200
|
| 100 |
+
rotated = rotate_resp.json()
|
| 101 |
+
assert rotated["tenant_id"] == tenant_id
|
| 102 |
+
assert rotated["tier"] == "pro"
|
| 103 |
+
assert rotated["deactivated_key_id"] == old_key_id
|
| 104 |
+
new_key_id = _key_id(rotated["api_key"])
|
| 105 |
+
assert new_key_id != old_key_id
|
| 106 |
+
|
| 107 |
+
list_resp = client.get("/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 108 |
+
keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]}
|
| 109 |
+
assert keys_by_id[old_key_id]["is_active"] is False
|
| 110 |
+
assert keys_by_id[new_key_id]["is_active"] is True
|
| 111 |
+
assert keys_by_id[new_key_id]["tier"] == "pro"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_rotate_nonexistent_key_returns_404(client):
|
| 115 |
+
resp = client.post(
|
| 116 |
+
"/admin/keys/does-not-exist/rotate", params={"admin_key": TEST_ADMIN_KEY})
|
| 117 |
+
assert resp.status_code == 404
|