All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Engine/PIP declaration is now checkable (layer.yaml plus a Tooling-client scan). Writes persist a decision record or the published fail-closed stance, live-lookup freshness is published, events_for is tenant-scoped, and mutation evidence drains to audit-core from a local outbox without blocking the mutation. Sender registration is requested as AUDIT-IN-0002. Boundary-contract amendment is requested as NET-IN-0002. Assistant: grok Assistant-Session: 01a04cea-e5e8-7081-a0fc-808ebbc35fa9
278 lines
9.4 KiB
Python
278 lines
9.4 KiB
Python
"""TEN-WP-0010-T03/T04: reclassification, and what it moves.
|
|
|
|
Grouping resolves spend ceilings, so these tests care as much about the
|
|
guardrail and grant consequences of a reclassification as about the mutation
|
|
itself.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from helpers import AllowAllAuthorizer, ScopedAuthorizer
|
|
|
|
from tenant_engine.app import create_app
|
|
from tenant_engine.domain import (
|
|
CapabilityRole,
|
|
EmptyUpdateError,
|
|
ImmutableFieldError,
|
|
InvalidLifecycleTransitionError,
|
|
InvalidTenantIdentifierError,
|
|
Tenant,
|
|
create_role_grant,
|
|
)
|
|
from tenant_engine.guardrail import Provenance, resolve_limit
|
|
from tenant_engine.store import InMemoryTenantStore
|
|
|
|
NOW = datetime(2026, 8, 17, 12, 0, tzinfo=UTC)
|
|
KEY = "spend.monthly"
|
|
BODY = {"actor": "ops", "reason": "grew past the band", "correlation_id": "corr-1"}
|
|
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
|
|
|
|
|
def tenant(identifier="tenant:small:acme") -> Tenant:
|
|
return Tenant.create(tenant_id="t-1", identifier=identifier, created_at=NOW)
|
|
|
|
|
|
# --- Domain -------------------------------------------------------------
|
|
|
|
|
|
def test_reclassification_changes_the_field_not_the_identifier():
|
|
moved = tenant().with_grouping("large", at=NOW)
|
|
assert moved.grouping == "large"
|
|
# the identifier's segment is historical and stays put
|
|
assert moved.identifier == "tenant:small:acme"
|
|
assert moved.version == 2
|
|
|
|
|
|
def test_the_field_and_the_identifier_are_allowed_to_diverge():
|
|
# this is the whole point of the change -- ADR-0013 amendment, T01
|
|
moved = tenant().with_grouping("enterprise", at=NOW)
|
|
assert moved.identifier.split(":")[1] != moved.grouping
|
|
|
|
|
|
def test_reserved_tenants_cannot_be_reclassified():
|
|
# giving tenant:platform a grouping would move the platform's own identity
|
|
# onto the grouping ladder and off the reserved guardrail profile
|
|
for identifier in ("tenant:platform", "tenant:coulomb"):
|
|
with pytest.raises(ImmutableFieldError):
|
|
tenant(identifier).with_grouping("large", at=NOW)
|
|
|
|
|
|
def test_an_unknown_grouping_is_rejected():
|
|
with pytest.raises(InvalidTenantIdentifierError):
|
|
tenant().with_grouping("enormous", at=NOW)
|
|
|
|
|
|
def test_a_no_op_reclassification_is_rejected():
|
|
with pytest.raises(EmptyUpdateError):
|
|
tenant().with_grouping("small", at=NOW)
|
|
|
|
|
|
def test_a_retired_tenant_cannot_be_reclassified():
|
|
with pytest.raises(InvalidLifecycleTransitionError):
|
|
tenant().retire(at=NOW).with_grouping("large", at=NOW)
|
|
|
|
|
|
# --- Guardrail consequences (T04) ---------------------------------------
|
|
|
|
|
|
def test_the_spend_ceiling_follows_the_new_grouping():
|
|
before = resolve_limit(KEY, tenant=tenant())
|
|
after = resolve_limit(KEY, tenant=tenant().with_grouping("enterprise", at=NOW))
|
|
assert before.value.amount == 25_000
|
|
assert after.value.amount == 2_000_000
|
|
assert after.provenance is Provenance.GROUPING
|
|
|
|
|
|
def test_reclassification_off_trial_lifts_the_zero_ceiling():
|
|
# the defect this workplan exists to fix: a trial tenant was stuck at zero
|
|
trial = tenant("tenant:trial:acme")
|
|
assert resolve_limit(KEY, tenant=trial).value.amount == 0
|
|
grown = trial.with_grouping("medium", at=NOW)
|
|
assert resolve_limit(KEY, tenant=grown).value.amount == 100_000
|
|
|
|
|
|
def test_an_override_still_wins_after_reclassification():
|
|
# precedence is unchanged: reclassification moves the grouping layer only
|
|
from tenant_engine.guardrail import LimitKind, LimitValue
|
|
|
|
override = {KEY: LimitValue(kind=LimitKind.SPEND, amount=7_000, currency="EUR", period="P1M")}
|
|
effective = resolve_limit(
|
|
KEY, tenant=tenant().with_grouping("enterprise", at=NOW), overrides=override
|
|
)
|
|
assert effective.provenance is Provenance.OVERRIDE
|
|
assert effective.value.amount == 7_000
|
|
|
|
|
|
# --- Grant consequences (T04) -------------------------------------------
|
|
|
|
|
|
def test_existing_platform_default_grants_survive_a_move_off_trial():
|
|
trial = tenant("tenant:trial:acme")
|
|
grant = create_role_grant(
|
|
tenant=trial,
|
|
grant_id="g-1",
|
|
role=CapabilityRole.PLTF,
|
|
grant_reason="platform_default",
|
|
plan_id=None,
|
|
granted_by="ops",
|
|
correlation_id="corr-0",
|
|
granted_at=NOW,
|
|
)
|
|
store = InMemoryTenantStore()
|
|
store.create_tenant(trial)
|
|
store.grant_role(grant)
|
|
|
|
# the trail is append-only; reclassifying does not revoke history
|
|
assert store.active_roles("t-1") == frozenset({CapabilityRole.PLTF})
|
|
|
|
|
|
def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
|
|
from tenant_engine.domain import InvalidGrantError
|
|
|
|
grown = tenant("tenant:trial:acme").with_grouping("medium", at=NOW)
|
|
with pytest.raises(InvalidGrantError):
|
|
create_role_grant(
|
|
tenant=grown,
|
|
grant_id="g-2",
|
|
role=CapabilityRole.PLTF,
|
|
grant_reason="platform_default",
|
|
plan_id=None,
|
|
granted_by="ops",
|
|
correlation_id="corr-1",
|
|
granted_at=NOW,
|
|
)
|
|
|
|
|
|
# --- API ----------------------------------------------------------------
|
|
|
|
|
|
def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient:
|
|
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer())
|
|
client = TestClient(app)
|
|
client.post(
|
|
"/tenants",
|
|
json={"tenant_id": "t-1", "identifier": identifier, "actor": "ops"},
|
|
)
|
|
return client
|
|
|
|
|
|
def post_grouping(client, grouping="large", headers=None):
|
|
return client.post(
|
|
"/tenants/t-1/grouping",
|
|
json={"grouping": grouping, **BODY},
|
|
headers=headers or HEADERS,
|
|
)
|
|
|
|
|
|
def test_the_route_reclassifies_and_bumps_the_version():
|
|
client = make_client()
|
|
response = post_grouping(client)
|
|
assert response.status_code == 200
|
|
assert response.json()["grouping"] == "large"
|
|
assert response.json()["identifier"] == "tenant:small:acme"
|
|
assert response.headers["ETag"] == '"2"'
|
|
|
|
|
|
def test_the_new_ceiling_is_visible_through_the_guardrail_read():
|
|
client = make_client(identifier="tenant:trial:acme")
|
|
assert (
|
|
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
|
|
"amount"
|
|
]
|
|
== 0
|
|
)
|
|
post_grouping(client, "medium")
|
|
assert (
|
|
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
|
|
"amount"
|
|
]
|
|
== 100_000
|
|
)
|
|
|
|
|
|
def test_reclassification_is_authorized_separately_from_a_rename():
|
|
# policy can permit a display-name edit without permitting a move that
|
|
# changes the spend ceiling
|
|
client = make_client(ScopedAuthorizer("tenant.create", "tenant.update"))
|
|
assert post_grouping(client).status_code == 403
|
|
assert (
|
|
client.patch(
|
|
"/tenants/t-1",
|
|
json={"metadata": {"display_name": "Acme"}, **BODY},
|
|
headers=HEADERS,
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
def test_renaming_permission_is_not_conferred_by_reclassification_permission():
|
|
client = make_client(ScopedAuthorizer("tenant.create", "tenant.grouping.set"))
|
|
assert post_grouping(client).status_code == 200
|
|
assert (
|
|
client.patch(
|
|
"/tenants/t-1",
|
|
json={"metadata": {"display_name": "Acme"}, **BODY},
|
|
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_an_unknown_grouping_is_a_distinct_error_code():
|
|
response = post_grouping(make_client(), "enormous")
|
|
assert response.status_code == 400
|
|
assert response.json()["error_code"] == "invalid_grouping"
|
|
|
|
|
|
def test_a_no_op_reclassification_is_rejected_over_http():
|
|
response = post_grouping(make_client(), "small")
|
|
assert response.status_code == 400
|
|
assert response.json()["error_code"] == "invalid_update"
|
|
|
|
|
|
def test_reclassifying_a_reserved_tenant_is_refused():
|
|
client = make_client(identifier="tenant:platform")
|
|
assert post_grouping(client).status_code == 400
|
|
|
|
|
|
def test_reclassification_replays_idempotently():
|
|
client = make_client()
|
|
first = post_grouping(client)
|
|
second = post_grouping(client)
|
|
assert second.headers["Idempotent-Replay"] == "true"
|
|
assert second.json()["version"] == first.json()["version"] == 2
|
|
|
|
|
|
def test_a_stale_if_match_conflicts():
|
|
client = make_client()
|
|
post_grouping(client)
|
|
conflict = post_grouping(client, "medium", {"Idempotency-Key": "idem-2", "If-Match": '"1"'})
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["error_code"] == "version_conflict"
|
|
|
|
|
|
def test_reclassifying_a_retired_tenant_is_refused():
|
|
client = make_client()
|
|
client.post(
|
|
"/tenants/t-1/retire",
|
|
json=BODY,
|
|
headers={"Idempotency-Key": "retire-1", "If-Match": '"1"'},
|
|
)
|
|
response = post_grouping(client, "large", {"Idempotency-Key": "idem-3", "If-Match": '"2"'})
|
|
assert response.status_code == 409
|
|
assert response.json()["error_code"] == "invalid_lifecycle_transition"
|
|
|
|
|
|
def test_the_change_is_auditable_as_its_own_event():
|
|
# a ceiling that moves with no guardrail write against it must still be
|
|
# reconstructible -- this event is how
|
|
client = make_client()
|
|
post_grouping(client)
|
|
store = client.app.state.store
|
|
events = [e for e in store.events_for("t-1") if e.event_type == "tenant_grouping_changed"]
|
|
assert len(events) == 1
|
|
assert events[0].payload["actor"] == "ops"
|
|
assert events[0].payload["reason"] == "grew past the band"
|
|
assert events[0].payload["correlation_id"] == "corr-1"
|