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