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.
315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""TEN-WP-0005-T02/T04: one lifecycle contract, every store backend.
|
|
|
|
Every test here is parametrised over the in-memory, SQLite, and PostgreSQL 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 postgres_backend import clean_postgres_store
|
|
|
|
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", "postgres"])
|
|
def store(request, tmp_path):
|
|
if request.param == "memory":
|
|
return InMemoryTenantStore()
|
|
if request.param == "sqlite":
|
|
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
|
|
return clean_postgres_store(tmp_path)
|
|
|
|
|
|
@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_grouping_mutation_persists(store, tenant) -> None:
|
|
updated, replayed = store.mutate_tenant(
|
|
tenant_id="t-1",
|
|
expected_version=1,
|
|
mutate=lambda current: current.with_grouping("large", at=NOW),
|
|
event_type="tenant_grouping_changed",
|
|
evidence={"actor": "ops", "reason": "growth", "correlation_id": "corr-grouping"},
|
|
idempotency_key="grouping-large",
|
|
request_fingerprint="fp-grouping-large",
|
|
)
|
|
|
|
assert replayed is False
|
|
assert updated.grouping == "large"
|
|
assert store.get_tenant("t-1").grouping == "large"
|
|
|
|
|
|
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
|