tenant-engine has made grouping mutable through its own reclassification route, so a tenant created as tenant:small:acme can report grouping "large". The identifier's grouping segment is now historical and must not be parsed. TenantRecord dropped the field entirely, so the portal read discarded the one safe source of a tenant's classification and left an operator with nothing but the identifier to infer from — exactly the mistake the change creates. The record and adapter now carry grouping, the operator screen shows it with a note that the identifier segment is not the grouping, and the OpenAPI schema documents where to read it. Also corrects the UpdateTenant description, which still claimed grouping was immutable. It is mutable, but never as metadata, because it resolves a tenant's spend ceiling. No reclassification control is offered here: that route is not deployed yet and, per tenant-engine, wants its own permission rather than riding on rename. Full suite: 149 tests, 3 provider-gated skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.5 KiB
Python
207 lines
8.5 KiB
Python
import io
|
|
import json
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from urllib.error import HTTPError
|
|
|
|
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
|
|
from user_engine.errors import (
|
|
AuthorizationDenied,
|
|
ConflictError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
|
|
RECORD = {
|
|
"tenant_id": "t-1",
|
|
"identifier": "tenant:friendly:binky",
|
|
"grouping": "friendly",
|
|
"display_name": "Binky",
|
|
"contact_email": None,
|
|
"lifecycle": "active",
|
|
"version": 1,
|
|
}
|
|
|
|
|
|
class _Response(io.BytesIO):
|
|
def __init__(self, payload, headers=None):
|
|
super().__init__(payload)
|
|
self.headers = headers or {}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
self.close()
|
|
|
|
|
|
def _http_error(code, body):
|
|
return HTTPError(
|
|
"http://tenant-engine/tenants/t-1", code, "error", {},
|
|
io.BytesIO(json.dumps(body).encode()),
|
|
)
|
|
|
|
|
|
class TenantManagementAdapterTests(unittest.TestCase):
|
|
def test_uses_tenant_engine_contract(self):
|
|
body = _Response(json.dumps({
|
|
"tenant_id": "tenant:friendly:new",
|
|
"identifier": "tenant:friendly:new",
|
|
"grouping": "friendly",
|
|
}).encode())
|
|
adapter = HTTPTenantManagementAdapter(
|
|
base_url="http://tenant-engine", bearer_token="opaque"
|
|
)
|
|
with patch("user_engine.adapters.tenant_management.urlopen", return_value=body) as call:
|
|
result = adapter.create_tenant(
|
|
tenant="tenant:friendly:new", display_name="New",
|
|
idempotency_key="tenant-create-123", correlation_id="corr-1",
|
|
)
|
|
request = call.call_args.args[0]
|
|
self.assertEqual(request.full_url, "http://tenant-engine/tenants")
|
|
self.assertEqual(json.loads(request.data), {
|
|
"tenant_id": "tenant:friendly:new",
|
|
"identifier": "tenant:friendly:new",
|
|
"actor": "user-engine",
|
|
})
|
|
self.assertEqual(result.status, "created")
|
|
self.assertEqual(result.external_ref, "tenant:friendly:new")
|
|
|
|
|
|
class TenantLifecycleAdapterTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.adapter = HTTPTenantManagementAdapter(
|
|
base_url="http://tenant-engine", bearer_token="opaque"
|
|
)
|
|
|
|
def _call(self, method, *, response=None, **kwargs):
|
|
body = _Response(json.dumps(response or RECORD).encode(), kwargs.pop("headers", None))
|
|
with patch("user_engine.adapters.tenant_management.urlopen", return_value=body) as call:
|
|
record = getattr(self.adapter, method)(**kwargs)
|
|
return call.call_args.args[0], record
|
|
|
|
def test_read_encodes_the_identifier_and_sends_no_mutation_headers(self):
|
|
request, record = self._call(
|
|
"tenant", tenant="tenant:friendly:binky", correlation_id="corr-1"
|
|
)
|
|
self.assertEqual(
|
|
request.full_url,
|
|
"http://tenant-engine/tenants/tenant%3Afriendly%3Abinky?actor=user-engine",
|
|
)
|
|
self.assertEqual(request.get_method(), "GET")
|
|
self.assertIsNone(request.data)
|
|
self.assertNotIn("If-match", request.headers)
|
|
self.assertNotIn("Idempotency-key", request.headers)
|
|
self.assertEqual(record.lifecycle, "active")
|
|
self.assertEqual(record.version, 1)
|
|
self.assertFalse(record.replayed)
|
|
|
|
def test_read_reports_the_authoritative_grouping(self):
|
|
"""Grouping must come from the record; the identifier segment is historical."""
|
|
request, record = self._call(
|
|
"tenant",
|
|
response={**RECORD, "identifier": "tenant:small:acme", "grouping": "large"},
|
|
tenant="tenant:small:acme", correlation_id="corr-1",
|
|
)
|
|
self.assertEqual(record.tenant, "tenant:small:acme")
|
|
self.assertEqual(record.grouping, "large")
|
|
|
|
def test_update_sends_a_version_etag_and_the_allow_listed_change(self):
|
|
request, record = self._call(
|
|
"update_tenant", response={**RECORD, "display_name": "Binky Ltd", "version": 2},
|
|
tenant="tenant:friendly:binky", metadata={"display_name": "Binky Ltd"},
|
|
expected_version=1, reason="operator rename",
|
|
idempotency_key="tenant-update-1", correlation_id="corr-1",
|
|
)
|
|
self.assertEqual(request.get_method(), "PATCH")
|
|
self.assertEqual(request.headers["If-match"], '"1"')
|
|
self.assertEqual(request.headers["Idempotency-key"], "tenant-update-1")
|
|
self.assertEqual(json.loads(request.data), {
|
|
"metadata": {"display_name": "Binky Ltd"},
|
|
"actor": "user-engine",
|
|
"reason": "operator rename",
|
|
"correlation_id": "corr-1",
|
|
})
|
|
self.assertEqual(record.display_name, "Binky Ltd")
|
|
self.assertEqual(record.version, 2)
|
|
|
|
def test_update_refuses_immutable_and_empty_change_sets_before_the_call(self):
|
|
with patch("user_engine.adapters.tenant_management.urlopen") as call:
|
|
for metadata in ({"identifier": "tenant:friendly:other"}, {}):
|
|
with self.assertRaises(ValidationError):
|
|
self.adapter.update_tenant(
|
|
tenant="tenant:friendly:binky", metadata=metadata,
|
|
expected_version=1, reason="rename",
|
|
idempotency_key="tenant-update-1", correlation_id="corr-1",
|
|
)
|
|
call.assert_not_called()
|
|
|
|
def test_lifecycle_transitions_target_their_own_endpoints(self):
|
|
for method, suffix, lifecycle in (
|
|
("retire_tenant", "/retire", "retired"),
|
|
("reactivate_tenant", "/reactivate", "active"),
|
|
):
|
|
request, record = self._call(
|
|
method, response={**RECORD, "lifecycle": lifecycle, "version": 2},
|
|
headers={"Idempotent-Replay": "true"},
|
|
tenant="tenant:friendly:binky", expected_version=1,
|
|
reason="contract change", idempotency_key="tenant-change-1",
|
|
correlation_id="corr-1",
|
|
)
|
|
self.assertTrue(request.full_url.endswith(suffix))
|
|
self.assertEqual(request.get_method(), "POST")
|
|
self.assertEqual(json.loads(request.data)["reason"], "contract change")
|
|
self.assertEqual(record.lifecycle, lifecycle)
|
|
self.assertTrue(record.replayed)
|
|
|
|
def test_a_reason_is_required_for_every_mutation(self):
|
|
with patch("user_engine.adapters.tenant_management.urlopen") as call:
|
|
with self.assertRaises(ValidationError):
|
|
self.adapter.retire_tenant(
|
|
tenant="tenant:friendly:binky", expected_version=1, reason=" ",
|
|
idempotency_key="tenant-retire-1", correlation_id="corr-1",
|
|
)
|
|
call.assert_not_called()
|
|
|
|
def test_authority_failures_are_redacted_to_stable_codes(self):
|
|
cases = (
|
|
(403, "write_denied", AuthorizationDenied),
|
|
(404, "tenant_not_found", NotFoundError),
|
|
(409, "version_conflict", ConflictError),
|
|
(409, "invalid_lifecycle_transition", ConflictError),
|
|
(428, "if_match_required", ValidationError),
|
|
(422, "unknown_field", ValidationError),
|
|
(503, "tenant_authority_unavailable", RuntimeError),
|
|
)
|
|
for code, error_code, expected in cases:
|
|
error = _http_error(code, {
|
|
"error_code": error_code,
|
|
"detail": "record lives at /var/lib/tenant-engine/tenants.db",
|
|
"correlation_id": "corr-1",
|
|
})
|
|
with patch("user_engine.adapters.tenant_management.urlopen", side_effect=error):
|
|
with self.assertRaises(expected) as caught:
|
|
self.adapter.retire_tenant(
|
|
tenant="tenant:friendly:binky", expected_version=1,
|
|
reason="contract ended", idempotency_key="tenant-retire-1",
|
|
correlation_id="corr-1",
|
|
)
|
|
message = str(caught.exception)
|
|
self.assertNotIn("tenant-engine/tenants.db", message)
|
|
self.assertNotIn("record lives", message)
|
|
if code in {409}:
|
|
self.assertEqual(error_code, message)
|
|
|
|
def test_an_unroutable_authority_is_not_mistaken_for_a_rejection(self):
|
|
with patch(
|
|
"user_engine.adapters.tenant_management.urlopen",
|
|
side_effect=OSError("connection refused"),
|
|
):
|
|
with self.assertRaises(OSError):
|
|
self.adapter.tenant(
|
|
tenant="tenant:friendly:binky", correlation_id="corr-1"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|