tenant-engine/tests/test_lifecycle_domain.py
tegwick d6fd73bd42 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>
2026-08-10 20:00:43 +02:00

88 lines
2.7 KiB
Python

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)