user-engine/tests/test_tenant_management_adapter.py
tegwick 4622b64061 Authenticate flex-auth calls with a rotating caller token
Closes the caller side of FLEX-WP-0015. FlexAuthHTTPAdapter reads the
audience-scoped projected ServiceAccount token from a file on every decision,
so hourly rotation needs no restart, and runtime configuration now requires
USER_ENGINE_FLEX_AUTH_TOKEN_FILE.

A missing, empty, or unreadable token file fails closed as a denial without
reaching flex-auth: OSError joins the caught set and an empty read raises.
Coverage proves all three unusable-token cases deny before any request is
made, and that neither the deny reason nor the decision repr carries the
token value.

Tenant-authority reads now identify user-engine as actor `user-engine` under
the protected tenant.read action, keeping tenant ids opaque and URL-encoded.

Contract: docs/flex-auth-caller-identity.md. Full suite: 148 tests, 3
provider-gated skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:53:31 +02:00

197 lines
8 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_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()