user-engine/tests/test_tenant_management_adapter.py
tegwick 47a58d5cc2
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Report tenant grouping from the authority record
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>
2026-08-18 10:56:54 +02:00

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()