a1-ai 2.8.0

A1 — The cryptographic identity and authorization layer that turns anonymous AI agents into accountable, verifiable entities. One Identity. Full Provenance.
Documentation
"""
Tests for a1 Python SDK.

Uses respx to mock httpx transport — no live gateway required.
"""
from __future__ import annotations

import json
import pytest
import respx
import httpx

from a1.client import (
    AsyncA1Client,
    A1Client,
    A1Error,
    IntentSpec,
    IssuedCert,
    AuthorizeResult,
)

GATEWAY = "http://a1-test.local"

MOCK_CHAIN = {
    "version": 1,
    "principal_pk": "aa" * 32,
    "principal_scope": "bb" * 32,
    "certs": [],
}


# ── A1Error ──────────────────────────────────────────────────────────────────

def test_a1_error_carries_code_and_status():
    err = A1Error("chain expired", code="CHAIN_EXPIRED", status=403)
    assert str(err) == "chain expired"
    assert err.code == "CHAIN_EXPIRED"
    assert err.status == 403


def test_a1_error_optional_fields():
    err = A1Error("generic error")
    assert err.code is None
    assert err.status is None


# ── IntentSpec ────────────────────────────────────────────────────────────────

def test_intent_spec_to_dict_no_params():
    spec = IntentSpec("trade.equity")
    d = spec.to_dict()
    assert d == {"name": "trade.equity"}


def test_intent_spec_to_dict_with_params():
    spec = IntentSpec("trade.equity", {"symbol": "AAPL", "qty": "10"})
    d = spec.to_dict()
    assert d["name"] == "trade.equity"
    assert d["params"] == {"symbol": "AAPL", "qty": "10"}


# ── A1Client — sync ──────────────────────────────────────────────────────────

class TestA1ClientSync:
    def client(self) -> A1Client:
        return A1Client(GATEWAY)

    @respx.mock
    def test_health(self):
        respx.get(f"{GATEWAY}/health").mock(
            return_value=httpx.Response(200, json={"status": "ok", "version": "2.0.0"})
        )
        result = self.client().health()
        assert result["status"] == "ok"
        assert result["version"] == "2.0.0"

    @respx.mock
    def test_issue_cert_success(self):
        respx.post(f"{GATEWAY}/v1/cert/issue").mock(
            return_value=httpx.Response(
                200,
                json={
                    "cert": {"version": 1},
                    "fingerprint_hex": "aabbcc",
                    "scope_root_hex": "ddeeff",
                },
            )
        )
        cert = self.client().issue_cert(
            delegate_pk_hex="cc" * 32,
            intents=[IntentSpec("trade.equity")],
            ttl_seconds=3600,
        )
        assert isinstance(cert, IssuedCert)
        assert cert.fingerprint_hex == "aabbcc"
        assert cert.scope_root_hex == "ddeeff"

    @respx.mock
    def test_authorize_success(self):
        respx.post(f"{GATEWAY}/v1/authorize").mock(
            return_value=httpx.Response(
                200,
                json={
                    "authorized": True,
                    "chain_depth": 1,
                    "chain_fingerprint": "ff" * 32,
                    "verified_at_unix": 1_700_000_000,
                },
            )
        )
        result = self.client().authorize(
            chain=MOCK_CHAIN,
            intent_name="trade.equity",
            executor_pk_hex="dd" * 32,
        )
        assert isinstance(result, AuthorizeResult)
        assert result.authorized is True
        assert result.chain_depth == 1

    @respx.mock
    def test_authorize_raises_a1_error_on_403(self):
        respx.post(f"{GATEWAY}/v1/authorize").mock(
            return_value=httpx.Response(
                403,
                json={"error": "cert revoked", "code": "CERT_REVOKED"},
            )
        )
        with pytest.raises(A1Error) as exc_info:
            self.client().authorize(
                chain=MOCK_CHAIN,
                intent_name="trade.equity",
                executor_pk_hex="dd" * 32,
            )
        err = exc_info.value
        assert err.code == "CERT_REVOKED"
        assert err.status == 403

    @respx.mock
    def test_authorize_batch_all_authorized(self):
        respx.post(f"{GATEWAY}/v1/authorize/batch").mock(
            return_value=httpx.Response(
                200,
                json={
                    "all_authorized": True,
                    "authorized_count": 2,
                    "total_count": 2,
                    "results": [
                        {"intent_name": "query.portfolio", "authorized": True},
                        {"intent_name": "trade.equity", "authorized": True},
                    ],
                },
            )
        )
        result = self.client().authorize_batch(
            chain=MOCK_CHAIN,
            executor_pk_hex="dd" * 32,
            intents=[IntentSpec("query.portfolio"), IntentSpec("trade.equity")],
        )
        assert result["all_authorized"] is True
        assert result["authorized_count"] == 2

    @respx.mock
    def test_revoke_success(self):
        respx.post(f"{GATEWAY}/v1/cert/revoke").mock(
            return_value=httpx.Response(200, json={})
        )
        # Should not raise
        self.client().revoke("aabbcc")

    @respx.mock
    def test_revoke_batch_success(self):
        respx.post(f"{GATEWAY}/v1/cert/revoke-batch").mock(
            return_value=httpx.Response(
                200, json={"revoked_count": 2, "failed": []}
            )
        )
        result = self.client().revoke_batch(["aabb", "ccdd"])
        assert result["revoked_count"] == 2
        assert result["failed"] == []

    @respx.mock
    def test_non_json_500_raises_a1_error(self):
        respx.post(f"{GATEWAY}/v1/authorize").mock(
            return_value=httpx.Response(502, text="<html>Bad Gateway</html>")
        )
        with pytest.raises(A1Error) as exc_info:
            self.client().authorize(
                chain=MOCK_CHAIN,
                intent_name="trade.equity",
                executor_pk_hex="dd" * 32,
            )
        assert exc_info.value.status == 502


# ── A1Client — async ─────────────────────────────────────────────────────────

class TestA1ClientAsync:
    def client(self) -> A1Client:
        return AsyncA1Client(GATEWAY)

    @pytest.mark.asyncio
    @respx.mock
    async def test_health_async(self):
        respx.get(f"{GATEWAY}/health").mock(
            return_value=httpx.Response(200, json={"status": "ok", "version": "2.0.0"})
        )
        result = await self.client().health()
        assert result["status"] == "ok"

    @pytest.mark.asyncio
    @respx.mock
    async def test_authorize_async_success(self):
        respx.post(f"{GATEWAY}/v1/authorize").mock(
            return_value=httpx.Response(
                200,
                json={
                    "authorized": True,
                    "chain_depth": 2,
                    "chain_fingerprint": "ee" * 32,
                    "verified_at_unix": 1_700_000_001,
                },
            )
        )
        result = await self.client().authorize(
            chain=MOCK_CHAIN,
            intent_name="trade.equity",
            executor_pk_hex="dd" * 32,
        )
        assert result.authorized is True
        assert result.chain_depth == 2

    @pytest.mark.asyncio
    @respx.mock
    async def test_authorize_async_raises_on_403(self):
        respx.post(f"{GATEWAY}/v1/authorize").mock(
            return_value=httpx.Response(
                403,
                json={"error": "scope too narrow", "code": "SCOPE_VIOLATION"},
            )
        )
        with pytest.raises(A1Error) as exc_info:
            await self.client().authorize(
                chain=MOCK_CHAIN,
                intent_name="trade.equity",
                executor_pk_hex="dd" * 32,
            )
        assert exc_info.value.code == "SCOPE_VIOLATION"

    @pytest.mark.asyncio
    @respx.mock
    async def test_revoke_batch_async(self):
        respx.post(f"{GATEWAY}/v1/cert/revoke-batch").mock(
            return_value=httpx.Response(
                200, json={"revoked_count": 3, "failed": []}
            )
        )
        result = await self.client().revoke_batch(["aa", "bb", "cc"])
        assert result["revoked_count"] == 3