Delegate tenant lifecycle to the tenant authority
TEN-WP-0005 landed the authoritative metadata update and reversible retirement contract, so USER-WP-0021-T01's deferred tenant operations are now implementable without user-engine inventing lifecycle semantics. TenantManagementPort gains read, update, retire, and reactivate. The HTTP adapter echoes the record version as an If-Match ETag (never `*`), sends an Idempotency-Key plus actor/reason/correlation_id, and surfaces Idempotent-Replay. Authority failures map to redacted domain errors carrying only the contract's stable error_code; its detail text never crosses the boundary. Platform operators get the matching API routes and a CSRF-protected browser screen that reads the record before mutating it and hides the metadata form for a retired tenant. Portal OpenAPI moves to 0.3.0 with TenantRecord, UpdateTenant, and TenantLifecycleChange. Full suite: 145 tests, 3 external-provider skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
db8769cc8c
commit
667ea694c2
8 changed files with 952 additions and 11 deletions
|
|
@ -2,11 +2,32 @@ 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
|
||||
|
||||
|
|
@ -14,6 +35,13 @@ class _Response(io.BytesIO):
|
|||
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({
|
||||
|
|
@ -40,5 +68,130 @@ class TenantManagementAdapterTests(unittest.TestCase):
|
|||
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",
|
||||
)
|
||||
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_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": "tenant-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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue