"""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"