Implement tenant update and reversible retirement API (TEN-WP-0005 T01-T04)
Add an explicit tenant lifecycle (active/retired), allow-listed mutable
metadata, record versioning, and lifecycle timestamps to the tenant authority.
- domain: TenantLifecycle, with_metadata/retire/reactivate, immutability and
transition invariants. Identifier stays immutable -- it is the IAM Profile
`tenant` claim key-cape mints into tokens.
- store: mutate_tenant() commits idempotency replay, version CAS, mutation,
and audit event together; durable receipts survive restart. Retired tenants
refuse new grants and plan changes but keep their history.
- sqlite: forward-only idempotent migration; existing rows default to active
at version 1. Reads now take the write lock -- the concurrent-writer test
caught unguarded reads on the shared connection observing mid-transaction
state as a spurious tenant_not_found.
- api: GET/PATCH /tenants/{id}, POST retire|reactivate. Idempotency-Key and
If-Match required, distinct flex-auth actions per operation, stable error
schema, redacted 503s.
- docs/tenant-lifecycle-api.md: consumer contract for user-engine.
Implemented against SQLite, not PostgreSQL as the workplan assumed --
TEN-WP-0004 shipped SQLite on a PVC as the production store.
124 tests pass (was 66); no breaking change to existing endpoints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7dcccafc03
commit
d6fd73bd42
10 changed files with 1625 additions and 28 deletions
303
tests/test_api_lifecycle.py
Normal file
303
tests/test_api_lifecycle.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""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")
|
||||
|
||||
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")
|
||||
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").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"
|
||||
assert client.get("/tenants/t-1").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
|
||||
assert client.get("/tenants/t-1").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").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")
|
||||
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
|
||||
88
tests/test_lifecycle_domain.py
Normal file
88
tests/test_lifecycle_domain.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from tenant_engine.domain import (
|
||||
EmptyUpdateError,
|
||||
ImmutableFieldError,
|
||||
InvalidLifecycleTransitionError,
|
||||
Tenant,
|
||||
TenantLifecycle,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 10, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _tenant() -> Tenant:
|
||||
return Tenant.create(
|
||||
tenant_id="t-1", identifier="tenant:friendly:binky", display_name="Binky", created_at=NOW
|
||||
)
|
||||
|
||||
|
||||
def test_new_tenant_is_active_at_version_one() -> None:
|
||||
tenant = _tenant()
|
||||
assert tenant.lifecycle is TenantLifecycle.ACTIVE
|
||||
assert tenant.version == 1
|
||||
assert tenant.is_active
|
||||
|
||||
|
||||
def test_metadata_update_bumps_version_and_keeps_identity() -> None:
|
||||
tenant = _tenant().with_metadata({"display_name": "Binky Ltd"}, at=NOW)
|
||||
|
||||
assert tenant.display_name == "Binky Ltd"
|
||||
assert tenant.version == 2
|
||||
assert tenant.updated_at == NOW
|
||||
assert tenant.tenant_id == "t-1"
|
||||
assert tenant.identifier == "tenant:friendly:binky"
|
||||
assert tenant.grouping == "friendly"
|
||||
|
||||
|
||||
def test_update_rejects_identifier_mutation() -> None:
|
||||
with pytest.raises(ImmutableFieldError):
|
||||
_tenant().with_metadata({"identifier": "tenant:large:other"}, at=NOW)
|
||||
|
||||
|
||||
def test_update_rejects_unknown_field() -> None:
|
||||
with pytest.raises(ImmutableFieldError):
|
||||
_tenant().with_metadata({"nickname": "binks"}, at=NOW)
|
||||
|
||||
|
||||
def test_update_rejects_empty_change_set() -> None:
|
||||
with pytest.raises(EmptyUpdateError):
|
||||
_tenant().with_metadata({}, at=NOW)
|
||||
|
||||
|
||||
def test_update_rejects_no_op_change() -> None:
|
||||
with pytest.raises(EmptyUpdateError):
|
||||
_tenant().with_metadata({"display_name": "Binky"}, at=NOW)
|
||||
|
||||
|
||||
def test_retire_is_reversible_and_preserves_identity() -> None:
|
||||
retired = _tenant().retire(at=NOW)
|
||||
assert retired.lifecycle is TenantLifecycle.RETIRED
|
||||
assert retired.retired_at == NOW
|
||||
assert retired.version == 2
|
||||
assert retired.identifier == "tenant:friendly:binky"
|
||||
|
||||
reactivated = retired.reactivate(at=NOW)
|
||||
assert reactivated.lifecycle is TenantLifecycle.ACTIVE
|
||||
assert reactivated.reactivated_at == NOW
|
||||
assert reactivated.retired_at == NOW # retirement history is not erased
|
||||
assert reactivated.version == 3
|
||||
|
||||
|
||||
def test_double_retirement_is_an_invalid_transition() -> None:
|
||||
retired = _tenant().retire(at=NOW)
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
retired.retire(at=NOW)
|
||||
|
||||
|
||||
def test_reactivating_an_active_tenant_is_an_invalid_transition() -> None:
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
_tenant().reactivate(at=NOW)
|
||||
|
||||
|
||||
def test_metadata_update_denied_while_retired() -> None:
|
||||
retired = _tenant().retire(at=NOW)
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
retired.with_metadata({"display_name": "Binky Ltd"}, at=NOW)
|
||||
296
tests/test_lifecycle_store_conformance.py
Normal file
296
tests/test_lifecycle_store_conformance.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""TEN-WP-0005-T02/T04: one lifecycle contract, both store backends.
|
||||
|
||||
Every test here is parametrised over the in-memory and SQLite stores so the
|
||||
durable backend cannot silently diverge from the reference semantics -- the
|
||||
divergence that matters (CAS, idempotency, retirement guards) is exactly the
|
||||
kind a single-backend suite would miss.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
InvalidLifecycleTransitionError,
|
||||
PlanAssignment,
|
||||
Tenant,
|
||||
TenantLifecycle,
|
||||
TenantRetiredError,
|
||||
create_role_grant,
|
||||
)
|
||||
from tenant_engine.sqlite_store import SQLiteTenantStore
|
||||
from tenant_engine.store import (
|
||||
IdempotencyConflictError,
|
||||
InMemoryTenantStore,
|
||||
TenantNotFoundError,
|
||||
VersionConflictError,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 10, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "sqlite"])
|
||||
def store(request, tmp_path):
|
||||
if request.param == "memory":
|
||||
return InMemoryTenantStore()
|
||||
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant(store) -> Tenant:
|
||||
record = Tenant.create(
|
||||
tenant_id="t-1", identifier="tenant:friendly:binky", display_name="Binky", created_at=NOW
|
||||
)
|
||||
store.create_tenant(record)
|
||||
return record
|
||||
|
||||
|
||||
def _retire(store, *, key: str = "idem-retire", version: int = 1):
|
||||
return store.mutate_tenant(
|
||||
tenant_id="t-1",
|
||||
expected_version=version,
|
||||
mutate=lambda t: t.retire(at=NOW),
|
||||
event_type="tenant_retired",
|
||||
evidence={"actor": "ops", "reason": "offboarded", "correlation_id": "corr-1"},
|
||||
idempotency_key=key,
|
||||
request_fingerprint="fp-retire",
|
||||
)
|
||||
|
||||
|
||||
def _rename(store, *, key: str, version: int, name: str = "Binky Ltd", fingerprint: str = "fp-a"):
|
||||
return store.mutate_tenant(
|
||||
tenant_id="t-1",
|
||||
expected_version=version,
|
||||
mutate=lambda t: t.with_metadata({"display_name": name}, at=NOW),
|
||||
event_type="tenant_updated",
|
||||
evidence={"actor": "ops", "reason": "rename", "correlation_id": "corr-1"},
|
||||
idempotency_key=key,
|
||||
request_fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
|
||||
def test_update_persists_and_bumps_version(store, tenant) -> None:
|
||||
updated, replayed = _rename(store, key="k1", version=1)
|
||||
|
||||
assert replayed is False
|
||||
assert updated.version == 2
|
||||
assert store.get_tenant("t-1").display_name == "Binky Ltd"
|
||||
|
||||
|
||||
def test_stale_version_is_rejected(store, tenant) -> None:
|
||||
_rename(store, key="k1", version=1)
|
||||
|
||||
with pytest.raises(VersionConflictError) as exc:
|
||||
_rename(store, key="k2", version=1, name="Binky GmbH")
|
||||
|
||||
assert exc.value.actual == 2
|
||||
assert store.get_tenant("t-1").display_name == "Binky Ltd"
|
||||
|
||||
|
||||
def test_duplicate_idempotency_key_replays_the_original_result(store, tenant) -> None:
|
||||
first, _ = _rename(store, key="k1", version=1)
|
||||
replay, replayed = _rename(store, key="k1", version=1)
|
||||
|
||||
assert replayed is True
|
||||
assert replay == first
|
||||
# The replay must not apply the mutation a second time.
|
||||
assert store.get_tenant("t-1").version == 2
|
||||
|
||||
|
||||
def test_conflicting_idempotency_key_reuse_is_rejected(store, tenant) -> None:
|
||||
_rename(store, key="k1", version=1, fingerprint="fp-a")
|
||||
|
||||
with pytest.raises(IdempotencyConflictError):
|
||||
_rename(store, key="k1", version=1, name="Something Else", fingerprint="fp-b")
|
||||
|
||||
|
||||
def test_mutating_an_unknown_tenant_raises(store) -> None:
|
||||
with pytest.raises(TenantNotFoundError):
|
||||
_rename(store, key="k1", version=1)
|
||||
|
||||
|
||||
def test_retire_then_reactivate_round_trip(store, tenant) -> None:
|
||||
retired, _ = _retire(store)
|
||||
assert retired.lifecycle is TenantLifecycle.RETIRED
|
||||
assert store.get_tenant("t-1").lifecycle is TenantLifecycle.RETIRED
|
||||
|
||||
reactivated, _ = store.mutate_tenant(
|
||||
tenant_id="t-1",
|
||||
expected_version=2,
|
||||
mutate=lambda t: t.reactivate(at=NOW),
|
||||
event_type="tenant_reactivated",
|
||||
evidence={"actor": "ops", "reason": "returned", "correlation_id": "corr-2"},
|
||||
idempotency_key="idem-reactivate",
|
||||
request_fingerprint="fp-reactivate",
|
||||
)
|
||||
assert reactivated.lifecycle is TenantLifecycle.ACTIVE
|
||||
assert store.get_tenant("t-1").version == 3
|
||||
|
||||
|
||||
def test_double_retirement_with_a_new_key_is_an_invalid_transition(store, tenant) -> None:
|
||||
_retire(store)
|
||||
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
_retire(store, key="idem-retire-2", version=2)
|
||||
|
||||
|
||||
def test_failed_mutation_leaves_no_version_bump_and_no_receipt(store, tenant) -> None:
|
||||
with pytest.raises(InvalidLifecycleTransitionError):
|
||||
store.mutate_tenant(
|
||||
tenant_id="t-1",
|
||||
expected_version=1,
|
||||
mutate=lambda t: t.reactivate(at=NOW), # already active
|
||||
event_type="tenant_reactivated",
|
||||
evidence={"actor": "ops", "reason": "x", "correlation_id": "c"},
|
||||
idempotency_key="k-fail",
|
||||
request_fingerprint="fp",
|
||||
)
|
||||
|
||||
assert store.get_tenant("t-1").version == 1
|
||||
# The failed key must be reusable -- a rolled-back attempt is not a receipt.
|
||||
updated, replayed = _rename(store, key="k-fail", version=1)
|
||||
assert replayed is False
|
||||
assert updated.version == 2
|
||||
|
||||
|
||||
def test_retired_tenant_refuses_new_grants_and_plan_changes(store, tenant) -> None:
|
||||
_retire(store)
|
||||
|
||||
grant = create_role_grant(
|
||||
tenant=tenant,
|
||||
grant_id="g-1",
|
||||
role=CapabilityRole.CUS,
|
||||
grant_reason="manual_grant",
|
||||
plan_id=None,
|
||||
granted_by="ops",
|
||||
correlation_id="corr-1",
|
||||
granted_at=NOW,
|
||||
)
|
||||
with pytest.raises(TenantRetiredError):
|
||||
store.grant_role(grant)
|
||||
with pytest.raises(TenantRetiredError):
|
||||
store.assign_plan(PlanAssignment(tenant_id="t-1", plan_id="plan-x", assigned_at=NOW))
|
||||
|
||||
|
||||
def test_retirement_preserves_existing_grant_and_plan_history(store, tenant) -> None:
|
||||
store.grant_role(
|
||||
create_role_grant(
|
||||
tenant=tenant,
|
||||
grant_id="g-1",
|
||||
role=CapabilityRole.CUS,
|
||||
grant_reason="manual_grant",
|
||||
plan_id=None,
|
||||
granted_by="ops",
|
||||
correlation_id="corr-1",
|
||||
granted_at=NOW,
|
||||
)
|
||||
)
|
||||
store.assign_plan(PlanAssignment(tenant_id="t-1", plan_id="plan-x", assigned_at=NOW))
|
||||
|
||||
_retire(store)
|
||||
|
||||
# Retirement is not a revocation: history stays queryable for audit and
|
||||
# so reactivation does not have to reconstruct anything.
|
||||
assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS})
|
||||
assert any(event.event_type == "plan_assigned" for event in store.events())
|
||||
|
||||
|
||||
def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None:
|
||||
_rename(store, key="k1", version=1)
|
||||
|
||||
event = [e for e in store.events() if e.event_type == "tenant_updated"][-1]
|
||||
assert event.payload["actor"] == "ops"
|
||||
assert event.payload["reason"] == "rename"
|
||||
assert event.payload["correlation_id"] == "corr-1"
|
||||
assert event.payload["version"] == 2
|
||||
|
||||
|
||||
def test_lifecycle_survives_reopening_the_database(tmp_path) -> None:
|
||||
path = str(tmp_path / "tenant.db")
|
||||
store = SQLiteTenantStore(path)
|
||||
store.create_tenant(
|
||||
Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky", created_at=NOW)
|
||||
)
|
||||
_retire(store)
|
||||
|
||||
reopened = SQLiteTenantStore(path)
|
||||
assert reopened.get_tenant("t-1").lifecycle is TenantLifecycle.RETIRED
|
||||
assert reopened.get_tenant("t-1").version == 2
|
||||
|
||||
# Restart-safe idempotency: the receipt outlives the process.
|
||||
replay, replayed = _retire(reopened, version=1)
|
||||
assert replayed is True
|
||||
assert replay.version == 2
|
||||
|
||||
|
||||
def test_migration_defaults_pre_lifecycle_rows_to_active(tmp_path) -> None:
|
||||
"""A database written by the pre-TEN-WP-0005 schema must open and read."""
|
||||
import sqlite3
|
||||
|
||||
path = str(tmp_path / "legacy.db")
|
||||
legacy = sqlite3.connect(path)
|
||||
with legacy:
|
||||
legacy.executescript("""
|
||||
CREATE TABLE tenants (
|
||||
tenant_id TEXT PRIMARY KEY, identifier TEXT UNIQUE NOT NULL, grouping_name TEXT
|
||||
);
|
||||
CREATE TABLE grants (
|
||||
grant_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||
grant_reason TEXT NOT NULL, plan_id TEXT, granted_by TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL, correlation_id TEXT NOT NULL, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE plans (
|
||||
tenant_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL, assigned_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO tenants VALUES ('t-legacy', 'tenant:friendly:legacy', 'friendly');
|
||||
INSERT INTO grants VALUES ('g-legacy', 't-legacy', 'CUS', 'manual_grant', NULL,
|
||||
'ops', '2026-08-01T00:00:00+00:00', 'corr-legacy', NULL);
|
||||
INSERT INTO plans VALUES ('t-legacy', 'plan-legacy', '2026-08-01T00:00:00+00:00');
|
||||
""")
|
||||
legacy.close()
|
||||
|
||||
migrated = SQLiteTenantStore(path)
|
||||
tenant = migrated.get_tenant("t-legacy")
|
||||
|
||||
assert tenant.lifecycle is TenantLifecycle.ACTIVE
|
||||
assert tenant.version == 1
|
||||
assert tenant.identifier == "tenant:friendly:legacy"
|
||||
assert migrated.active_roles("t-legacy") == frozenset({CapabilityRole.CUS})
|
||||
# And the migrated row is immediately mutable under the new contract.
|
||||
updated, _ = migrated.mutate_tenant(
|
||||
tenant_id="t-legacy",
|
||||
expected_version=1,
|
||||
mutate=lambda t: t.with_metadata({"display_name": "Legacy"}, at=NOW),
|
||||
event_type="tenant_updated",
|
||||
evidence={"actor": "ops", "reason": "backfill", "correlation_id": "c"},
|
||||
idempotency_key="k",
|
||||
request_fingerprint="fp",
|
||||
)
|
||||
assert updated.version == 2
|
||||
|
||||
|
||||
def test_concurrent_writers_only_one_wins(tmp_path) -> None:
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
store = SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
||||
store.create_tenant(
|
||||
Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky", created_at=NOW)
|
||||
)
|
||||
|
||||
def attempt(n: int):
|
||||
try:
|
||||
return _rename(store, key=f"k{n}", version=1, name=f"Name {n}", fingerprint=f"fp{n}")[0]
|
||||
except VersionConflictError:
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(attempt, range(8)))
|
||||
|
||||
winners = [r for r in results if r is not None]
|
||||
assert len(winners) == 1
|
||||
assert store.get_tenant("t-1").version == 2
|
||||
Loading…
Add table
Add a link
Reference in a new issue