All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""TEN-WP-0005-T03/T04: HTTP contract for the lifecycle 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
|
|
|
|
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
|
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"}
|
|
|
|
|
|
class _AllowAllAuthorizer(WriteAuthorizer):
|
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
return None
|
|
|
|
|
|
class _ScopedAuthorizer(WriteAuthorizer):
|
|
"""Allows only the listed actions -- stands in for a flex-auth policy that
|
|
grants an operator metadata edits but not retirement."""
|
|
|
|
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 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
|