"""TEN-WP-0006-T04: HTTP contract for the guardrail surface.""" import pytest from fastapi.testclient import TestClient from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny from tenant_engine.app import create_app from tenant_engine.authz import AuthorizationOutcome 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 _TenantScopedAuthorizer: """Permits guardrail work on exactly one tenant.""" def __init__(self, permitted_tenant: str) -> None: self._permitted = permitted_tenant def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome: if action == "tenant.create": return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor) if tenant_id != self._permitted: raise deny(action, tenant_id, actor, "not permitted for this tenant") return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor) class _BrokenStore(InMemoryTenantStore): def get_tenant(self, tenant_id: str): raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused") class _BrokenWriteStore(InMemoryTenantStore): def set_guardrail_override(self, **kwargs): 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 # --- Cross-tenant denial and write-path outage ---------------------------- def test_a_caller_cannot_reach_across_tenants(): client = make_client(_TenantScopedAuthorizer("t-own")) client.post( "/tenants", json={"tenant_id": "t-own", "identifier": "tenant:small:own", "actor": "ops"}, ) assert client.get("/tenants/t-own/guardrails", params={"actor": "ops"}).status_code == 200 assert read(client, actor="ops").status_code == 403 assert put(client).status_code == 403 def test_a_cross_tenant_write_changes_nothing(): client = make_client(_TenantScopedAuthorizer("t-own")) assert put(client).status_code == 403 # the store was never touched: the version is untouched assert client.app.state.store.get_tenant("t-1").version == 1 def test_a_store_outage_fails_closed_on_write(): client = make_client(store=_BrokenWriteStore()) response = put(client) assert response.status_code == 503 assert response.json()["error_code"] == "tenant_authority_unavailable" assert "tenant.db" not in response.text assert response.json()["correlation_id"] == "corr-1" def test_errors_never_reflect_policy_internals(): client = make_client(ScopedAuthorizer("tenant.create")) body = put(client).json() assert "tenant.db" not in str(body) assert body["error_code"] == "write_denied" # --- Compatibility -------------------------------------------------------- def test_existing_endpoints_are_unaffected(client): assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).status_code == 200 assert client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"}).status_code == 200 assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).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