"""TEN-WP-0005-T03/T04: HTTP contract for the lifecycle surface.""" import pytest from fastapi.testclient import TestClient from helpers import AllowAllAuthorizer, ScopedAuthorizer from tenant_engine.app import create_app from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'} BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"} class _BrokenStore(InMemoryTenantStore): def mutate_tenant(self, **kwargs): raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused") def get_tenant(self, tenant_id: str): raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused") @pytest.fixture def client() -> TestClient: app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer()) test_client = TestClient(app) test_client.post( "/tenants", json={ "tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops", "display_name": "Binky", }, ) return test_client def _patch(client, *, headers=None, metadata=None, **overrides): return client.patch( "/tenants/t-1", headers={**HEADERS, **(headers or {})}, json={ **BODY, "metadata": {"display_name": "Binky Ltd"} if metadata is None else metadata, **overrides, }, ) # -- read --------------------------------------------------------------- def test_get_tenant_returns_record_and_etag(client) -> None: response = client.get("/tenants/t-1", params={"actor": "tenant-engine"}) assert response.status_code == 200 assert response.headers["ETag"] == '"1"' body = response.json() assert body["identifier"] == "tenant:friendly:binky" assert body["lifecycle"] == "active" assert body["version"] == 1 def test_get_tenant_resolves_by_identifier(client) -> None: response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}) assert response.status_code == 200 assert response.json()["tenant_id"] == "t-1" def test_get_unknown_tenant_is_404(client) -> None: assert client.get("/tenants/nope", params={"actor": "tenant-engine"}).status_code == 404 # -- update ------------------------------------------------------------- def test_update_succeeds_and_advances_the_etag(client) -> None: response = _patch(client) assert response.status_code == 200 assert response.headers["ETag"] == '"2"' assert response.headers["Idempotent-Replay"] == "false" assert response.json()["display_name"] == "Binky Ltd" persisted = client.get("/tenants/t-1", params={"actor": "tenant-engine"}) assert persisted.json()["display_name"] == "Binky Ltd" def test_update_rejects_unknown_field(client) -> None: response = _patch(client, metadata={"nickname": "binks"}) assert response.status_code == 422 # schema-level allow-list def test_update_rejects_identifier_mutation(client) -> None: response = _patch(client, metadata={"identifier": "tenant:large:other"}) assert response.status_code == 422 persisted = client.get("/tenants/t-1", params={"actor": "tenant-engine"}) assert persisted.json()["identifier"] == "tenant:friendly:binky" def test_update_rejects_empty_metadata(client) -> None: response = _patch(client, metadata={}) assert response.status_code == 400 assert response.json()["error_code"] == "invalid_update" def test_update_with_stale_version_is_409(client) -> None: _patch(client) response = _patch( client, headers={"Idempotency-Key": "idem-2"}, metadata={"display_name": "Third"} ) assert response.status_code == 409 assert response.json()["error_code"] == "version_conflict" assert response.json()["correlation_id"] == "corr-1" def test_duplicate_idempotency_key_replays(client) -> None: first = _patch(client) replay = _patch(client) assert replay.status_code == 200 assert replay.headers["Idempotent-Replay"] == "true" assert replay.json() == first.json() assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).json()["version"] == 2 def test_idempotency_key_reused_for_a_different_request_is_409(client) -> None: _patch(client) response = _patch(client, metadata={"display_name": "Different"}) assert response.status_code == 409 assert response.json()["error_code"] == "idempotency_key_conflict" def test_missing_idempotency_key_is_rejected(client) -> None: response = client.patch( "/tenants/t-1", headers={"If-Match": '"1"'}, json={**BODY, "metadata": {"display_name": "X"}}, ) assert response.status_code == 400 assert response.json()["error_code"] == "idempotency_key_required" def test_missing_if_match_is_rejected(client) -> None: response = client.patch( "/tenants/t-1", headers={"Idempotency-Key": "idem-1"}, json={**BODY, "metadata": {"display_name": "X"}}, ) assert response.status_code == 428 assert response.json()["error_code"] == "if_match_required" def test_wildcard_if_match_is_rejected(client) -> None: response = _patch(client, headers={"If-Match": "*"}) assert response.status_code == 400 assert response.json()["error_code"] == "invalid_if_match" def test_weak_etag_form_is_accepted(client) -> None: assert _patch(client, headers={"If-Match": 'W/"1"'}).status_code == 200 # -- retire / reactivate ------------------------------------------------- def _retire(client, *, key="idem-retire", version='"1"'): return client.post( "/tenants/t-1/retire", headers={"Idempotency-Key": key, "If-Match": version}, json=BODY, ) def test_retire_then_reactivate(client) -> None: retired = _retire(client) assert retired.status_code == 200 assert retired.json()["lifecycle"] == "retired" assert retired.json()["retired_at"] is not None reactivated = client.post( "/tenants/t-1/reactivate", headers={"Idempotency-Key": "idem-react", "If-Match": '"2"'}, json=BODY, ) assert reactivated.status_code == 200 assert reactivated.json()["lifecycle"] == "active" assert reactivated.json()["version"] == 3 def test_double_retirement_is_409(client) -> None: _retire(client) response = _retire(client, key="idem-retire-2", version='"2"') assert response.status_code == 409 assert response.json()["error_code"] == "invalid_lifecycle_transition" def test_retirement_replay_is_idempotent(client) -> None: first = _retire(client) replay = _retire(client) assert replay.status_code == 200 assert replay.headers["Idempotent-Replay"] == "true" assert replay.json() == first.json() def test_update_after_retirement_is_denied(client) -> None: _retire(client) response = _patch(client, headers={"If-Match": '"2"', "Idempotency-Key": "idem-x"}) assert response.status_code == 409 assert response.json()["error_code"] == "invalid_lifecycle_transition" def test_role_and_plan_mutations_denied_while_retired(client) -> None: _retire(client) granted = client.post( "/tenants/t-1/roles/grant", json={ "grant_id": "g-1", "role": "CUS", "grant_reason": "manual_grant", "granted_by": "ops", "correlation_id": "corr-1", "actor": "ops", }, ) plan = client.post("/tenants/t-1/plan", json={"plan_id": "plan-x", "actor": "ops"}) assert granted.status_code == 409 assert plan.status_code == 409 def test_lifecycle_mutation_on_unknown_tenant_is_404(client) -> None: response = client.post( "/tenants/nope/retire", headers={"Idempotency-Key": "k", "If-Match": '"1"'}, json=BODY ) assert response.status_code == 404 # -- authorization and redaction ----------------------------------------- def test_lifecycle_mutations_are_denied_by_default() -> None: client = TestClient(create_app(store=InMemoryTenantStore())) response = client.patch( "/tenants/t-1", headers=HEADERS, json={**BODY, "metadata": {"display_name": "X"}} ) assert response.status_code == 403 assert response.json()["error_code"] == "write_denied" def test_update_permission_does_not_imply_retire_permission() -> None: app = create_app( store=InMemoryTenantStore(), authorizer=ScopedAuthorizer("tenant.create", "tenant.update"), ) client = TestClient(app) client.post( "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"} ) assert _patch(client).status_code == 200 denied = _retire(client, version='"2"') assert denied.status_code == 403 assert denied.json()["action"] == "tenant.retire" def test_store_outage_is_a_redacted_503() -> None: client = TestClient(create_app(store=_BrokenStore(), authorizer=AllowAllAuthorizer())) read = client.get("/tenants/t-1", params={"actor": "tenant-engine"}) write = client.patch( "/tenants/t-1", headers=HEADERS, json={**BODY, "metadata": {"display_name": "X"}} ) for response in (read, write): assert response.status_code == 503 assert response.json()["error_code"] == "tenant_authority_unavailable" # No database path, driver text, or policy detail may reach a consumer. assert "tenant.db" not in response.text assert "refused" not in response.text