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.
270 lines
9.8 KiB
Python
270 lines
9.8 KiB
Python
"""TEN-WP-0006-T03: one guardrail persistence contract, both store backends.
|
|
|
|
Parametrised over the in-memory and SQLite stores for the same reason the
|
|
lifecycle conformance suite is: the divergences that matter here -- atomicity
|
|
of override + audit + receipt, and the retired-tenant guard -- are 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 Tenant, TenantRetiredError
|
|
from tenant_engine.guardrail import (
|
|
UNLIMITED,
|
|
LimitKind,
|
|
LimitValue,
|
|
Provenance,
|
|
UnknownLimitKeyError,
|
|
resolve_limit,
|
|
)
|
|
from tenant_engine.sqlite_store import SQLiteTenantStore
|
|
from tenant_engine.store import (
|
|
IdempotencyConflictError,
|
|
InMemoryTenantStore,
|
|
VersionConflictError,
|
|
)
|
|
|
|
NOW = datetime(2026, 8, 16, 12, 0, tzinfo=UTC)
|
|
KEY = "spend.monthly"
|
|
|
|
|
|
@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:
|
|
t = Tenant.create(tenant_id="t-1", identifier="tenant:small:acme", created_at=NOW)
|
|
store.create_tenant(t)
|
|
return t
|
|
|
|
|
|
def eur(amount) -> LimitValue:
|
|
return LimitValue(kind=LimitKind.SPEND, amount=amount, currency="EUR", period="P1M")
|
|
|
|
|
|
def set_override(store, tenant, value, *, version=1, key=KEY, **kwargs):
|
|
params = dict(
|
|
tenant_id=tenant.tenant_id,
|
|
expected_version=version,
|
|
limit_key=key,
|
|
value=value,
|
|
change_id="c-1",
|
|
changed_by="ops",
|
|
reason="test",
|
|
correlation_id="corr-1",
|
|
idempotency_key="idem-1",
|
|
request_fingerprint="fp-1",
|
|
at=NOW,
|
|
)
|
|
params.update(kwargs)
|
|
return store.set_guardrail_override(**params)
|
|
|
|
|
|
# --- No backfill needed ---------------------------------------------------
|
|
|
|
|
|
def test_a_tenant_with_no_override_resolves_to_its_grouping_default(store, tenant):
|
|
# Grouping defaults are resolved, never materialised into rows. That is
|
|
# why this workplan needs no backfill migration: a tenant created before
|
|
# guardrails existed and one created after take the identical path, so an
|
|
# existing tenant cannot silently end up looser than a fresh one.
|
|
assert store.guardrail_overrides(tenant.tenant_id) == {}
|
|
effective = resolve_limit(KEY, tenant=store.get_tenant(tenant.tenant_id))
|
|
assert effective.provenance is Provenance.GROUPING
|
|
|
|
|
|
def test_the_migration_is_idempotent(tmp_path):
|
|
path = str(tmp_path / "tenant.db")
|
|
first = SQLiteTenantStore(path)
|
|
t = Tenant.create(tenant_id="t-1", identifier="tenant:small:acme", created_at=NOW)
|
|
first.create_tenant(t)
|
|
set_override(first, t, eur(9_000))
|
|
|
|
# forward-only and idempotent: reopening runs the same schema script
|
|
second = SQLiteTenantStore(path)
|
|
assert second.guardrail_overrides("t-1")[KEY].amount == 9_000
|
|
assert second.get_tenant("t-1").version == 2
|
|
|
|
|
|
# --- Write, read back, audit ---------------------------------------------
|
|
|
|
|
|
def test_setting_an_override_persists_it_and_bumps_the_version(store, tenant):
|
|
updated, change, replayed = set_override(store, tenant, eur(9_000))
|
|
assert not replayed
|
|
assert updated.version == 2
|
|
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000
|
|
assert change.previous is None
|
|
assert change.current.amount == 9_000
|
|
|
|
|
|
def test_the_override_wins_over_the_grouping_default(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
effective = resolve_limit(
|
|
KEY,
|
|
tenant=store.get_tenant(tenant.tenant_id),
|
|
overrides=store.guardrail_overrides(tenant.tenant_id),
|
|
)
|
|
assert effective.provenance is Provenance.OVERRIDE
|
|
assert effective.value.amount == 9_000
|
|
|
|
|
|
def test_the_change_is_audited_with_actor_reason_and_correlation(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
changes = store.guardrail_changes(tenant.tenant_id)
|
|
assert len(changes) == 1
|
|
assert (changes[0].changed_by, changes[0].reason, changes[0].correlation_id) == (
|
|
"ops",
|
|
"test",
|
|
"corr-1",
|
|
)
|
|
|
|
|
|
def test_the_audit_trail_is_append_only(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
set_override(store, tenant, eur(1_000), version=2, change_id="c-2", idempotency_key="idem-2")
|
|
changes = store.guardrail_changes(tenant.tenant_id)
|
|
assert [c.change_id for c in changes] == ["c-1", "c-2"]
|
|
# the earlier value survives in the record, not just the current one
|
|
assert changes[1].previous.amount == 9_000
|
|
|
|
|
|
def test_a_guardrail_change_emits_a_domain_event(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
events = [e for e in store.events() if e.event_type == "guardrail_changed"]
|
|
assert len(events) == 1
|
|
assert events[0].payload["limit_key"] == KEY
|
|
assert events[0].payload["correlation_id"] == "corr-1"
|
|
|
|
|
|
def test_clearing_an_override_falls_back_to_the_grouping_default(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
_, change, _ = set_override(
|
|
store, tenant, None, version=2, change_id="c-2", idempotency_key="idem-2"
|
|
)
|
|
assert change.is_clear
|
|
assert store.guardrail_overrides(tenant.tenant_id) == {}
|
|
effective = resolve_limit(KEY, tenant=store.get_tenant(tenant.tenant_id))
|
|
assert effective.provenance is Provenance.GROUPING
|
|
|
|
|
|
def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
|
|
entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
|
|
# spend.monthly is the only registered key, so use it to prove the
|
|
# sentinel serialises; the kind check lives in the domain tests
|
|
set_override(store, tenant, LimitValue(
|
|
kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"
|
|
))
|
|
stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
|
|
assert stored.is_unlimited
|
|
assert entity.is_unlimited
|
|
|
|
|
|
# --- Concurrency, idempotency, unknown keys ------------------------------
|
|
|
|
|
|
def test_a_stale_version_conflicts(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
with pytest.raises(VersionConflictError):
|
|
set_override(store, tenant, eur(1_000), version=1, change_id="c-2",
|
|
idempotency_key="idem-2")
|
|
|
|
|
|
def test_replay_returns_the_original_result_without_reapplying(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
updated, change, replayed = set_override(store, tenant, eur(9_000))
|
|
assert replayed
|
|
assert updated.version == 2 # not bumped a second time
|
|
assert change.change_id == "c-1"
|
|
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
|
|
|
|
|
|
def test_reusing_a_key_for_a_different_request_conflicts(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
with pytest.raises(IdempotencyConflictError):
|
|
set_override(store, tenant, eur(1_000), version=2, request_fingerprint="fp-2")
|
|
|
|
|
|
def test_an_unregistered_key_is_rejected_before_anything_is_written(store, tenant):
|
|
with pytest.raises(UnknownLimitKeyError):
|
|
set_override(store, tenant, eur(9_000), key="spend.weekly")
|
|
assert store.get_tenant(tenant.tenant_id).version == 1
|
|
assert store.guardrail_changes(tenant.tenant_id) == []
|
|
|
|
|
|
def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant):
|
|
set_override(store, tenant, eur(9_000))
|
|
with pytest.raises(VersionConflictError):
|
|
set_override(store, tenant, eur(1), version=99, change_id="c-2",
|
|
idempotency_key="idem-2")
|
|
assert store.get_tenant(tenant.tenant_id).version == 2
|
|
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
|
|
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000
|
|
|
|
|
|
# --- Retired tenants ------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def retired(store, tenant) -> Tenant:
|
|
updated, _ = store.mutate_tenant(
|
|
tenant_id=tenant.tenant_id,
|
|
expected_version=1,
|
|
mutate=lambda t: t.retire(at=NOW),
|
|
event_type="tenant_retired",
|
|
evidence={"reason": "test"},
|
|
idempotency_key="retire-1",
|
|
request_fingerprint="fp-retire",
|
|
)
|
|
return updated
|
|
|
|
|
|
def test_a_retired_tenants_limits_clamp_to_the_floor_but_stay_readable(store, retired):
|
|
effective = resolve_limit(
|
|
KEY,
|
|
tenant=store.get_tenant(retired.tenant_id),
|
|
overrides=store.guardrail_overrides(retired.tenant_id),
|
|
)
|
|
assert effective.provenance is Provenance.LIFECYCLE
|
|
assert effective.value.amount == 0
|
|
|
|
|
|
def test_loosening_a_retired_tenants_guardrail_is_refused(store, retired):
|
|
# small's default is 25_000; 90_000 would take effect on reactivation
|
|
with pytest.raises(TenantRetiredError):
|
|
set_override(store, retired, eur(90_000), version=2)
|
|
assert store.guardrail_overrides(retired.tenant_id) == {}
|
|
|
|
|
|
def test_tightening_a_retired_tenants_guardrail_is_allowed(store, retired):
|
|
# reduce-privilege operations stay available while retired -- refusing
|
|
# them would be the fail-open choice
|
|
_, change, _ = set_override(store, retired, eur(100), version=2)
|
|
assert change.current.amount == 100
|
|
|
|
|
|
def test_clearing_an_override_that_would_loosen_is_refused_while_retired(store, tenant):
|
|
set_override(store, tenant, eur(100))
|
|
store.mutate_tenant(
|
|
tenant_id=tenant.tenant_id,
|
|
expected_version=2,
|
|
mutate=lambda t: t.retire(at=NOW),
|
|
event_type="tenant_retired",
|
|
evidence={},
|
|
idempotency_key="retire-1",
|
|
request_fingerprint="fp-retire",
|
|
)
|
|
# clearing would fall back to small's 25_000 default -- a loosening
|
|
with pytest.raises(TenantRetiredError):
|
|
set_override(store, tenant, None, version=3, change_id="c-3",
|
|
idempotency_key="idem-3")
|
|
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100
|