Finish TEN-WP-0006-T04: expose guardrail read and write APIs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f631224ab5
commit
b6d016869f
5 changed files with 677 additions and 10 deletions
292
tests/test_api_guardrails.py
Normal file
292
tests/test_api_guardrails.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""TEN-WP-0006-T04: HTTP contract for the guardrail surface."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
||||
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
||||
|
||||
KEY = "spend.monthly"
|
||||
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
||||
LIMIT = {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"}
|
||||
BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
|
||||
|
||||
|
||||
class _AllowAllAuthorizer(WriteAuthorizer):
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _ScopedAuthorizer(WriteAuthorizer):
|
||||
def __init__(self, *allowed: str) -> None:
|
||||
self._allowed = set(allowed)
|
||||
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
if action not in self._allowed:
|
||||
raise WriteAuthorizationDeniedError(action, "not permitted")
|
||||
|
||||
|
||||
class _BrokenStore(InMemoryTenantStore):
|
||||
def get_tenant(self, tenant_id: str):
|
||||
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
||||
|
||||
|
||||
def make_client(authorizer=None, store=None) -> TestClient:
|
||||
app = create_app(
|
||||
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
|
||||
)
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/tenants",
|
||||
json={"tenant_id": "t-1", "identifier": "tenant:small:acme", "actor": "ops"},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return make_client()
|
||||
|
||||
|
||||
def read(client, actor="flex-auth"):
|
||||
return client.get("/tenants/t-1/guardrails", params={"actor": actor})
|
||||
|
||||
|
||||
def put(client, *, limit=None, headers=None, body=None, key=KEY):
|
||||
return client.put(
|
||||
f"/tenants/t-1/guardrails/{key}",
|
||||
json={"limit": limit or LIMIT, **(body or BODY)},
|
||||
headers=headers or HEADERS,
|
||||
)
|
||||
|
||||
|
||||
# --- Read -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_returns_effective_limits_with_provenance(client):
|
||||
response = read(client)
|
||||
assert response.status_code == 200
|
||||
limit = response.json()["limits"][KEY]
|
||||
assert limit["provenance"] == "grouping"
|
||||
assert limit["amount"] == 25_000
|
||||
assert limit["currency"] == "EUR"
|
||||
|
||||
|
||||
def test_a_trial_tenant_reads_a_zero_spend_ceiling(client):
|
||||
client.post(
|
||||
"/tenants",
|
||||
json={"tenant_id": "t-2", "identifier": "tenant:trial:pilot", "actor": "ops"},
|
||||
)
|
||||
response = client.get("/tenants/t-2/guardrails", params={"actor": "flex-auth"})
|
||||
assert response.json()["limits"][KEY]["amount"] == 0
|
||||
|
||||
|
||||
def test_read_is_authorized_separately_from_write():
|
||||
# a PDP gets the read and nothing else
|
||||
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
|
||||
assert read(client).status_code == 200
|
||||
assert put(client).status_code == 403
|
||||
|
||||
|
||||
def test_write_permission_does_not_confer_read_permission():
|
||||
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
|
||||
assert read(client).status_code == 403
|
||||
assert put(client).status_code == 200
|
||||
|
||||
|
||||
def test_an_unauthorized_read_cannot_probe_tenant_existence():
|
||||
client = make_client(_ScopedAuthorizer("tenant.create"))
|
||||
known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
|
||||
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
|
||||
assert known.status_code == unknown.status_code == 403
|
||||
assert known.json() == unknown.json()
|
||||
|
||||
|
||||
def test_a_store_outage_fails_closed_on_read():
|
||||
client = make_client(store=_BrokenStore())
|
||||
response = read(client)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error_code"] == "tenant_authority_unavailable"
|
||||
assert "tenant.db" not in response.text
|
||||
|
||||
|
||||
# --- Write ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setting_an_override_returns_the_new_effective_limit(client):
|
||||
response = put(client)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["effective"]["amount"] == 9_000
|
||||
assert body["effective"]["provenance"] == "override"
|
||||
assert body["version"] == 2
|
||||
assert response.headers["ETag"] == '"2"'
|
||||
assert response.headers["Idempotent-Replay"] == "false"
|
||||
|
||||
|
||||
def test_the_override_shows_up_in_a_subsequent_read(client):
|
||||
put(client)
|
||||
assert read(client).json()["limits"][KEY]["provenance"] == "override"
|
||||
|
||||
|
||||
def test_clearing_falls_back_to_the_grouping_default(client):
|
||||
put(client)
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
f"/tenants/t-1/guardrails/{KEY}",
|
||||
json=BODY,
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["cleared"] is True
|
||||
assert response.json()["effective"]["provenance"] == "grouping"
|
||||
|
||||
|
||||
def test_replay_returns_the_same_result_without_reapplying(client):
|
||||
first = put(client)
|
||||
second = put(client)
|
||||
assert second.headers["Idempotent-Replay"] == "true"
|
||||
assert second.json()["version"] == first.json()["version"] == 2
|
||||
assert second.json()["change_id"] == first.json()["change_id"]
|
||||
|
||||
|
||||
def test_reusing_a_key_for_a_different_limit_conflicts(client):
|
||||
put(client)
|
||||
response = put(
|
||||
client,
|
||||
limit={**LIMIT, "amount": "1000"},
|
||||
headers={"Idempotency-Key": "idem-1", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error_code"] == "idempotency_key_conflict"
|
||||
|
||||
|
||||
def test_a_stale_if_match_conflicts(client):
|
||||
put(client)
|
||||
response = put(client, headers={"Idempotency-Key": "idem-2", "If-Match": '"1"'})
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error_code"] == "version_conflict"
|
||||
|
||||
|
||||
def test_if_match_and_idempotency_key_are_required(client):
|
||||
assert put(client, headers={"Idempotency-Key": "idem-9"}).status_code == 428
|
||||
missing_key = put(client, headers={"If-Match": '"1"'})
|
||||
assert missing_key.status_code == 400
|
||||
assert missing_key.json()["error_code"] == "idempotency_key_required"
|
||||
|
||||
|
||||
def test_if_match_star_is_rejected(client):
|
||||
response = put(client, headers={"Idempotency-Key": "idem-9", "If-Match": "*"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error_code"] == "invalid_if_match"
|
||||
|
||||
|
||||
def test_an_unregistered_limit_key_is_rejected(client):
|
||||
response = put(client, key="spend.weekly")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error_code"] == "unknown_limit_key"
|
||||
|
||||
|
||||
def test_a_malformed_limit_is_rejected(client):
|
||||
# a spend limit without a currency is not a spend limit
|
||||
response = put(client, limit={"kind": "spend", "amount": "100", "period": "P1M"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error_code"] == "invalid_limit"
|
||||
|
||||
|
||||
def test_a_negative_amount_is_rejected(client):
|
||||
response = put(client, limit={**LIMIT, "amount": "-1"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_unknown_body_fields_are_rejected_by_the_schema(client):
|
||||
response = client.put(
|
||||
f"/tenants/t-1/guardrails/{KEY}",
|
||||
json={"limit": LIMIT, **BODY, "surprise": 1},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_reason_and_correlation_id_are_required(client):
|
||||
response = client.put(
|
||||
f"/tenants/t-1/guardrails/{KEY}",
|
||||
json={"limit": LIMIT, "actor": "ops", "reason": "", "correlation_id": "c"},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_an_unlimited_override_must_be_stated_explicitly(client):
|
||||
response = put(client, limit={**LIMIT, "amount": "unlimited"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["effective"]["amount"] == "unlimited"
|
||||
|
||||
|
||||
def test_an_unknown_tenant_is_not_found(client):
|
||||
response = client.put(
|
||||
f"/tenants/t-404/guardrails/{KEY}",
|
||||
json={"limit": LIMIT, **BODY},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error_code"] == "tenant_not_found"
|
||||
|
||||
|
||||
# --- Retired tenants ------------------------------------------------------
|
||||
|
||||
|
||||
def retire(client):
|
||||
return client.post(
|
||||
"/tenants/t-1/retire",
|
||||
json={"actor": "ops", "reason": "test", "correlation_id": "corr-r"},
|
||||
headers={"Idempotency-Key": "retire-1", "If-Match": '"1"'},
|
||||
)
|
||||
|
||||
|
||||
def test_a_retired_tenant_still_reads_its_guardrails(client):
|
||||
retire(client)
|
||||
response = read(client)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["limits"][KEY]["provenance"] == "lifecycle"
|
||||
assert response.json()["limits"][KEY]["amount"] == 0
|
||||
|
||||
|
||||
def test_loosening_a_retired_tenants_guardrail_is_refused(client):
|
||||
retire(client)
|
||||
# 90 000 is above small's 25 000 default -- inert while retired, but it
|
||||
# would take effect the moment the tenant is reactivated
|
||||
response = put(
|
||||
client,
|
||||
limit={**LIMIT, "amount": "90000"},
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error_code"] == "guardrail_loosening_denied"
|
||||
|
||||
|
||||
def test_tightening_a_retired_tenants_guardrail_is_allowed(client):
|
||||
retire(client)
|
||||
response = put(
|
||||
client,
|
||||
limit={**LIMIT, "amount": "100"},
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# --- Compatibility --------------------------------------------------------
|
||||
|
||||
|
||||
def test_existing_endpoints_are_unaffected(client):
|
||||
assert client.get("/tenants/t-1").status_code == 200
|
||||
assert client.get("/tenants/t-1/roles").status_code == 200
|
||||
assert client.get("/tenants/t-1/roles/live").status_code == 200
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_the_guardrail_routes_are_in_the_openapi_document(client):
|
||||
paths = client.get("/openapi.json").json()["paths"]
|
||||
assert "/tenants/{tenant_id}/guardrails" in paths
|
||||
assert "/tenants/{tenant_id}/guardrails/{limit_key}" in paths
|
||||
Loading…
Add table
Add a link
Reference in a new issue