import copy from dataclasses import replace import time import pytest from informed_decision.approval_client import ApprovalEngineError from informed_decision.approval_http import ApprovalHTTPClient from informed_decision.http_transport import TransportError from informed_decision.oidc import HumanSession from informed_decision.provenance import Claim, Route ORIGIN = "https://approval.test" APPROVAL = {"id": "fixture", "binding": {"digest": "sha256:" + "a" * 64, "human_control": True}, "status": "approved", "updated_at": "2099-01-01T00:00:00Z", "entries": [ {"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10T00:00:00Z"}]} def session(): return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION), Claim("human", Route.AUTHENTICATION), {}, time.time() + 900, "synthetic-access-token") class Responses: def __init__(self, *responses): self.responses = list(responses) self.calls = [] def request(self, method, url, **kwargs): self.calls.append((method, url, kwargs)) result = self.responses.pop(0) if isinstance(result, Exception): raise result return copy.deepcopy(result) def test_entry_uses_access_token_empty_body_and_actual_entry_correlation(): transport = Responses((200, APPROVAL), (200, APPROVAL)) client = ApprovalHTTPClient(ORIGIN, session(), transport=transport) result = client.add_entry("fixture") assert result.correlation == ("fixture", "human-fixture", "2026-09-10T00:00:00Z") assert not result.duplicate assert [x[:2] for x in transport.calls] == [("GET", ORIGIN + "/v1/approvals/fixture"), ("POST", ORIGIN + "/v1/approvals/fixture/entries")] assert transport.calls[1][2]["body"] == b"{}" assert transport.calls[1][2]["headers"]["Authorization"] == "Bearer synthetic-access-token" assert not hasattr(client, "consume") def test_duplicate_recovers_original_entry_never_invents_time_or_reposts(): transport = Responses((200, APPROVAL), (409, {"error": "duplicate_approver"}), (200, APPROVAL)) result = ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") assert result.duplicate and result.approved_at == APPROVAL["entries"][0]["approved_at"] assert [x[0] for x in transport.calls] == ["GET", "POST", "GET"] @pytest.mark.parametrize("value", [False, None, "true", 1]) def test_undeclared_or_malformed_human_control_refused_before_mutation(value): data = copy.deepcopy(APPROVAL) data["binding"]["human_control"] = value transport = Responses((200, data)) with pytest.raises(ApprovalEngineError, match="invalid_approval_response"): ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") assert len(transport.calls) == 1 and transport.calls[0][0] == "GET" @pytest.mark.parametrize("value", [[], [{"subject_id": "other"}], [1], None, [{"subject_id": "human-fixture", "principal_type": "service", "approved_at": "2026-09-10T00:00:00Z"}], [{"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10"}], [{"subject_id": "human-fixture", "principal_type": "human", "approved_at": None}]]) def test_missing_corresponding_entry_cannot_be_reported_as_success(value): data = copy.deepcopy(APPROVAL) data["entries"] = value transport = Responses((200, APPROVAL), (200, data)) with pytest.raises(ApprovalEngineError, match="entry_correlation_missing"): ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") @pytest.mark.parametrize("response,reason", [ ((403, {"error": "forbidden", "message": "secret-sentinel"}), "forbidden"), ((409, {"error": "conflict"}), "conflict"), ((503, {"error": "store_unavailable"}), "store_unavailable"), ((500, {"error": ["secret-sentinel"]}), "upstream_refused"), (TransportError("secret-sentinel"), "upstream_unavailable"), ]) def test_refusal_and_uncertain_post_are_not_retried(response, reason): transport = Responses((200, APPROVAL), response) with pytest.raises(ApprovalEngineError) as error: ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture") assert error.value.reason == reason and "secret-sentinel" not in str(error.value) assert len(transport.calls) == 2 @pytest.mark.parametrize("identifier", ["../consume", "a/entries", "a?consume", "a%2Fentries", "", "a" * 129]) def test_identifier_cannot_inject_a_route(identifier): transport = Responses() with pytest.raises(ValueError): ApprovalHTTPClient(ORIGIN, session(), transport=transport).get_approval(identifier) assert not transport.calls def test_session_expiry_refused_before_request(): transport = Responses() with pytest.raises(ApprovalEngineError, match="session_expired"): ApprovalHTTPClient(ORIGIN, replace(session(), expires_at=1), transport=transport).get_approval("fixture") assert not transport.calls def test_read_current_state_each_time(): revoked = {**APPROVAL, "status": "revoked"} transport = Responses((200, APPROVAL), (200, revoked)) client = ApprovalHTTPClient(ORIGIN, session(), transport=transport) assert client.get_approval("fixture")["status"] == "approved" assert client.get_approval("fixture")["status"] == "revoked" def test_cluster_http_is_explicit_and_cannot_select_a_public_http_endpoint(): with pytest.raises(ValueError): ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session()) client = ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session(), allow_internal_http=True) assert client.origin.startswith("http:") with pytest.raises(ValueError): ApprovalHTTPClient("http://attacker.test", session(), allow_internal_http=True)