mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.
Where this SDK was already right, the other two moved: search normalizes the
knowledge kind in both directions, `Page.total` stays the server's own count,
and `put` picks its method from the name. Where it was not:
- kb.Import carries what the handler answers. `/v1/knowledge/import` writes
{format, imported, pages} (knowledge/import.go); this modelled `imported`
alone, so a caller could not see which pages landed.
- Fact carries the server's three. `GET /v1/graph` serves twelve members
(graph/typed.go, wireFact) and no other route hands back an assertion's
content address, so projecting the nine an asserter states made a read lossy
with nothing to recover it from. `id`, `by` and `knowable` arrive read-only
and `write()` never sends them.
- graph.extract and graph.ingest take a Source. Both routes bind the same
{source, subject, text, at} (graph/semantic.go, graphSourceIn), so it is one
value — and the first positional argument is now the same thing here as it is
in the other two.
- import_(format, data, project) — the order the contract writes and the other
two take. It was (export, *, format).
- A body carries what somebody set. `send` dropped None from the query and left
it in the body, so `search.find` posted {"index": null, "offset": null,
"project": null} where the other two omit the key: three requests for one
call, and cloud asked to read a value where there is none. Audit's pageSize
and p go the same way — the route's own defaults are the same 100 and 1, so
an unset term is one the route decides identically, and `all` pins the size it
walks at because it needs to know the page length.
- A write body is field data and nothing else. `url` belongs to a source, and an
empty title is left out so the doctype names the required field that is
missing rather than being handed a blank one.
- kb.list takes order and kb.links takes a project. `/v1/framework/{doctype}`
serves order_by and `/v1/knowledge/graph` serves ?project=.
- policy_denied and entitlement_required do not exist. Neither string appears
anywhere in hanzoai/cloud, so REFUSED is the two codes it does emit. A 403
read as denied on an invented code would hand an unauthenticated caller a cure
for a refusal nobody made.
- One refusal reader, by named fallback: code from `code` else `error`, reason
from `detail` else `message`, cures from `cure`. The gate's own `reason` —
"unpaid", "unresolved" — names the admit leg that failed rather than
explaining anything, so it is dropped, and `title` is not a refusal member.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- The fourth outcome is Fault, not the generated client's ApiException. It is
the one shared concept whose name a reader could not carry between languages,
and an exception from the generated layer is not this layer's vocabulary.
- A client with no credential builds. It raised at construction, which is a
process that cannot start where the other two are a process whose first call
fails — and the first call is where the credential is actually needed.
HIP-0044 is amended where the code was right and the document was not.
ruff check, ruff format --check and mypy clean on every changed file; 109
passed. The two test_smoke failures are pre-existing (ToolsApi is absent from
the generated tree) and reproduce on a stashed working copy.
Co-authored-by: Hanzo Dev <[email protected]>
350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""One credential, one endpoint, N subjects.
|
|
|
|
Nothing here touches the network. The stub below has the same `request()`
|
|
signature as :class:`hanzoai.cloud.rest.RESTClientObject`, which is the only
|
|
thing the client and the two mints ever call, so these exercise the real code
|
|
path and only the socket is missing.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from hanzoai import Held, Fault, Grant, Token, Client
|
|
from hanzoai.cloud import IamApi
|
|
|
|
|
|
class Reply:
|
|
"""Shaped like `hanzoai.cloud.rest.RESTResponse`, without a socket."""
|
|
|
|
def __init__(self, status, payload=None, headers=None):
|
|
self.status = status
|
|
self.reason = "stub"
|
|
if isinstance(payload, (bytes, bytearray)):
|
|
self.data = bytes(payload)
|
|
elif payload is None:
|
|
self.data = b""
|
|
else:
|
|
self.data = json.dumps(payload).encode()
|
|
self.headers = headers or {"content-type": "application/json", "x-request-id": "req_1"}
|
|
|
|
def read(self):
|
|
return self.data
|
|
|
|
def getheaders(self):
|
|
return self.headers
|
|
|
|
def getheader(self, name, default=None):
|
|
return self.headers.get(name, default)
|
|
|
|
|
|
class Transport:
|
|
"""Answers each request with the next reply, and records what it was asked."""
|
|
|
|
def __init__(self, *replies):
|
|
self.replies = list(replies)
|
|
self.calls = []
|
|
|
|
def request(self, method, url, headers=None, body=None, post_params=None, _request_timeout=None):
|
|
self.calls.append((method, url, dict(headers or {}), body, post_params))
|
|
assert self.replies, "the client made more requests than the test staged"
|
|
return self.replies.pop(0)
|
|
|
|
|
|
def minted(token, expires_in=600):
|
|
"""IAM's oauth answer, in RFC 6749's casing."""
|
|
return Reply(200, {"access_token": token, "expires_in": expires_in, "token_type": "Bearer"})
|
|
|
|
|
|
def issued(token, expires_in=600):
|
|
"""IAM's act answer, in IAM's own casing."""
|
|
return Reply(200, {"accessToken": token, "expiresIn": expires_in})
|
|
|
|
|
|
def operator(mint, calls=None):
|
|
"""A client whose identity mint and whose API calls are both stubs.
|
|
|
|
Both mints ride the client's own pool in production, so with no separate
|
|
`calls` the client's transport is the mint's — which is what lets `as_`
|
|
reach the staged replies instead of a socket.
|
|
"""
|
|
client = Client(credential=Token("cli_1", "shh", transport=mint))
|
|
client.rest_client = mint if calls is None else calls
|
|
return client
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The identity — client credentials, never a bearer somebody pasted
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_mint_is_the_client_credentials_exchange_on_iam_s_own_host():
|
|
"""`POST https://hanzo.id/v1/iam/oauth/token`, client_secret_basic, form-encoded.
|
|
|
|
Four things this pins, each of which fails silently if it drifts: the host
|
|
is IAM's, not the platform API's; the grant is client_credentials; the
|
|
credential rides as HTTP Basic rather than in the form; and `resource`
|
|
names the server the token is for, so a token minted for api.hanzo.ai is
|
|
useless anywhere else.
|
|
"""
|
|
mint = Transport(minted("tok-1"))
|
|
assert Token("cli_1", "shh", transport=mint).token() == "tok-1"
|
|
|
|
method, url, headers, body, form = mint.calls[0]
|
|
assert (method, url) == ("POST", "https://hanzo.id/v1/iam/oauth/token")
|
|
assert headers["Authorization"] == "Basic Y2xpXzE6c2ho" # base64("cli_1:shh")
|
|
assert headers["Content-Type"] == "application/x-www-form-urlencoded"
|
|
assert form == [("grant_type", "client_credentials"), ("resource", "https://api.hanzo.ai")]
|
|
assert body is None
|
|
|
|
|
|
def test_the_resource_follows_the_endpoint_the_client_was_pointed_at():
|
|
mint = Transport(minted("tok-1"))
|
|
Token("cli_1", "shh", resource="https://api.acme.internal", transport=mint).token()
|
|
assert mint.calls[0][4] == [("grant_type", "client_credentials"), ("resource", "https://api.acme.internal")]
|
|
|
|
|
|
def test_the_oauth_answer_is_snake_case():
|
|
"""RFC 6749 spells it `access_token`. camelCase here reads as no token at all."""
|
|
mint = Transport(Reply(200, {"accessToken": "wrong", "expiresIn": 600}))
|
|
with pytest.raises(Fault) as caught:
|
|
Token("cli_1", "shh", transport=mint).token()
|
|
assert "cli_1" in str(caught.value)
|
|
|
|
|
|
def test_the_token_is_held_until_it_nears_expiry():
|
|
mint = Transport(minted("tok-1"))
|
|
identity = Token("cli_1", "shh", transport=mint)
|
|
|
|
assert [identity.token() for _ in range(5)] == ["tok-1"] * 5
|
|
assert len(mint.calls) == 1
|
|
|
|
|
|
def test_a_token_inside_the_skew_is_replaced_while_it_still_works():
|
|
"""A token that dies in flight is a 401 nobody can tell from a revoked identity.
|
|
|
|
`expires_in: 30` is inside the 60s window, so it is spent on arrival.
|
|
"""
|
|
mint = Transport(minted("tok-1", expires_in=30), minted("tok-2", expires_in=600))
|
|
identity = Token("cli_1", "shh", transport=mint)
|
|
|
|
assert identity.token() == "tok-1"
|
|
assert identity.token() == "tok-2"
|
|
|
|
|
|
def test_a_refused_mint_says_which_identity_was_refused():
|
|
"""A 401 reads the same whether the id is wrong, the secret is stale, or the
|
|
app may not use this grant — and the reader is holding none of those."""
|
|
mint = Transport(Reply(401, {"error": "invalid_client", "error_description": "client authentication required"}))
|
|
with pytest.raises(Fault) as caught:
|
|
Token("cli_1", "shh", transport=mint).token()
|
|
|
|
assert caught.value.status == 401
|
|
assert "cli_1" in str(caught.value)
|
|
assert "invalid_client" in str(caught.value)
|
|
|
|
|
|
def test_the_client_takes_no_bearer():
|
|
"""There is no way to hand this SDK a token. That is the point.
|
|
|
|
A credential the SDK is handed is a credential nobody rotates, and it says
|
|
nothing about who is calling.
|
|
"""
|
|
import inspect
|
|
|
|
taken = set(inspect.signature(Client.__init__).parameters)
|
|
assert not taken & {"token", "api_key", "access_token", "key", "bearer"}
|
|
|
|
|
|
def test_a_client_with_no_credential_builds_and_fails_at_the_first_call(monkeypatch):
|
|
"""Construction never fails on a missing credential; the first call does.
|
|
|
|
There is nothing to exchange, so nothing is sent to IAM and nothing is sent
|
|
unsigned to the gateway — an unsigned call comes back a bare 403, which
|
|
reads as a refusal of the caller rather than the absence of one.
|
|
"""
|
|
for name in ("HANZO_CLIENT_ID", "HANZO_CLIENT_SECRET"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
client = Client()
|
|
with pytest.raises(Fault) as caught:
|
|
client.credential.token()
|
|
assert caught.value.status == 0
|
|
assert "HANZO_CLIENT_ID" in str(caught.value)
|
|
|
|
|
|
def test_every_option_falls_back_to_its_environment_variable(monkeypatch):
|
|
monkeypatch.setenv("HANZO_CLIENT_ID", "cli_env")
|
|
monkeypatch.setenv("HANZO_CLIENT_SECRET", "shh_env")
|
|
monkeypatch.setenv("HANZO_BASE_URL", "https://api.acme.internal/")
|
|
monkeypatch.setenv("HANZO_ISSUER_URL", "https://id.acme.internal/")
|
|
|
|
client = Client()
|
|
assert client.base == "https://api.acme.internal"
|
|
assert client.issuer == "https://id.acme.internal"
|
|
assert client.resource == "https://api.acme.internal", "the audience follows the endpoint by default"
|
|
assert (client.credential.id, client.credential.secret) == ("cli_env", "shh_env")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The scope — the credential carries it, so no method takes a user id
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_as_mints_a_subject_token_from_the_client_s_own_token():
|
|
"""`POST https://hanzo.id/v1/iam/tokens/issue?id=<subject>`, no body.
|
|
|
|
The subject rides as the `id` query because IAM reads the act grant off the
|
|
token presented, and there is nothing to put in a body.
|
|
"""
|
|
mint = Transport(minted("tok-operator"), issued("tok-usr-7"))
|
|
scoped = operator(mint).as_("usr_7")
|
|
|
|
assert scoped.credential.token() == "tok-usr-7"
|
|
method, url, headers, body, _ = mint.calls[1]
|
|
assert (method, url) == ("POST", "https://hanzo.id/v1/iam/tokens/issue?id=usr_7")
|
|
assert headers["Authorization"] == "Bearer tok-operator"
|
|
assert body is None
|
|
|
|
|
|
def test_the_subject_is_url_encoded():
|
|
mint = Transport(minted("tok-operator"), issued("tok"))
|
|
operator(mint).as_("acct/42 x&y").credential.token()
|
|
assert mint.calls[1][1] == "https://hanzo.id/v1/iam/tokens/issue?id=acct%2F42+x%26y"
|
|
|
|
|
|
def test_the_act_answer_is_camel_case():
|
|
"""The two mints are different endpoints and each keeps its own wire."""
|
|
mint = Transport(minted("tok-operator"), Reply(200, {"access_token": "wrong", "expires_in": 600}))
|
|
with pytest.raises(Fault) as caught:
|
|
operator(mint).as_("usr_7").credential.token()
|
|
assert "usr_7" in str(caught.value)
|
|
|
|
|
|
def test_the_client_s_own_token_never_rides_a_scoped_call():
|
|
"""Two credentials on one request are two answers to who is calling."""
|
|
mint = Transport(minted("tok-operator"), issued("tok-usr-7"))
|
|
scoped = operator(mint).as_("usr_7")
|
|
|
|
_, _, headers, _, _ = IamApi(scoped)._get_iam_keys_serialize(
|
|
"org_1", _request_auth=None, _content_type=None, _headers=None, _host_index=0
|
|
)
|
|
assert headers["Authorization"] == "Bearer tok-usr-7"
|
|
assert "tok-operator" not in headers["Authorization"]
|
|
|
|
|
|
def test_scoping_does_not_disturb_the_client_it_came_from():
|
|
"""Two subjects and the operator hold three credentials, not one shared slot."""
|
|
mint = Transport(minted("tok-operator"), issued("tok-a"), issued("tok-b"))
|
|
boss = operator(mint)
|
|
a, b = boss.as_("usr_a"), boss.as_("usr_b")
|
|
|
|
assert a.credential.token() == "tok-a"
|
|
assert b.credential.token() == "tok-b"
|
|
assert boss.credential.token() == "tok-operator"
|
|
assert a.configuration is not b.configuration
|
|
|
|
|
|
def test_a_scoped_client_keeps_the_endpoint_and_the_issuer():
|
|
mint = Transport(minted("tok-operator"))
|
|
boss = Client(credential=Token("cli_1", "shh", transport=mint), base="https://api.acme.internal")
|
|
scoped = boss.as_("usr_7")
|
|
assert (scoped.base, scoped.issuer, scoped.resource) == (boss.base, boss.issuer, boss.resource)
|
|
|
|
|
|
def test_a_scoped_client_answers_the_same_six():
|
|
mint = Transport(minted("tok-operator"))
|
|
scoped = operator(mint).as_("usr_7")
|
|
for capability in ("budget", "policy", "audit", "search", "kb", "graph"):
|
|
assert getattr(scoped, capability).client is scoped
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The call
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_send_puts_the_endpoint_the_credential_and_the_query_on_one_request():
|
|
mint = Transport(minted("tok-1"))
|
|
calls = Transport(Reply(200, {"plan": "pro"}))
|
|
client = operator(mint, calls)
|
|
|
|
reply = client.send("GET", "/v1/allowance", query={"window": "day", "empty": "", "absent": None})
|
|
|
|
method, url, headers, body, _ = calls.calls[0]
|
|
assert (method, url) == ("GET", "https://api.hanzo.ai/v1/allowance?window=day")
|
|
assert headers["Authorization"] == "Bearer tok-1"
|
|
assert body is None
|
|
assert reply.status == 200
|
|
assert reply.body == {"plan": "pro"}
|
|
assert reply.request == "req_1", "x-request-id is what joins a call to its audit row"
|
|
|
|
|
|
def test_a_body_carries_its_media_type():
|
|
mint = Transport(minted("tok-1"))
|
|
calls = Transport(Reply(200, {"imported": 1}))
|
|
client = operator(mint, calls)
|
|
|
|
client.send("POST", "/v1/knowledge/import", body=b"PK\x03\x04", media="application/octet-stream")
|
|
_, _, headers, body, _ = calls.calls[0]
|
|
assert headers["Content-Type"] == "application/octet-stream"
|
|
assert body == b"PK\x03\x04"
|
|
|
|
|
|
def test_a_401_re_mints_once_and_replays():
|
|
"""A rotated token costs a round trip, not an error the caller has to handle."""
|
|
mint = Transport(minted("tok-stale"), minted("tok-fresh"))
|
|
calls = Transport(Reply(401, {"code": "unauthorized"}), Reply(200, {"plan": "pro"}))
|
|
client = operator(mint, calls)
|
|
|
|
assert client.send("GET", "/v1/allowance").status == 200
|
|
assert len(mint.calls) == 2, "the stale token was not dropped"
|
|
assert calls.calls[0][2]["Authorization"] == "Bearer tok-stale"
|
|
assert calls.calls[1][2]["Authorization"] == "Bearer tok-fresh"
|
|
|
|
|
|
def test_a_second_401_is_the_server_saying_no():
|
|
mint = Transport(minted("tok-1"), minted("tok-2"))
|
|
calls = Transport(Reply(401, {"code": "unauthorized"}), Reply(401, {"code": "unauthorized"}))
|
|
client = operator(mint, calls)
|
|
|
|
assert client.send("GET", "/v1/allowance").status == 401
|
|
assert len(calls.calls) == 2
|
|
|
|
|
|
def test_a_read_that_was_refused_raises_rather_than_answering_nothing():
|
|
mint = Transport(minted("tok-1"), minted("tok-1"))
|
|
calls = Transport(
|
|
Reply(401, {"code": "unauthorized", "detail": "sign in to view the audit trail"}),
|
|
Reply(401, {"code": "unauthorized", "detail": "sign in to view the audit trail"}),
|
|
)
|
|
client = operator(mint, calls)
|
|
|
|
with pytest.raises(Fault) as caught:
|
|
client.audit.list()
|
|
assert caught.value.status == 401
|
|
assert "sign in to view the audit trail" in str(caught.value)
|
|
|
|
|
|
def test_a_generated_operation_that_was_held_raises_rather_than_returning_nothing():
|
|
"""No operation declares a schema for the hold, so the generated deserializer
|
|
answers it with `None` — a queued call reading as one that succeeded and
|
|
returned nothing. The six answer the same hold as an arm."""
|
|
mint = Transport(minted("tok-1"))
|
|
client = operator(mint)
|
|
held = {"status": "held", "id": "apr_7f3", "clause": "iam.keys", "reason": "key reads are reviewed"}
|
|
|
|
with pytest.raises(Held) as caught:
|
|
client.response_deserialize(Reply(202, held), {"200": "object"})
|
|
|
|
assert (caught.value.id, caught.value.clause) == ("apr_7f3", "iam.keys")
|
|
assert caught.value.request == "req_1"
|
|
|
|
|
|
def test_a_202_that_is_not_a_hold_passes_through_to_the_generated_deserializer():
|
|
"""A dozen long-running operations answer 202 with their own schema."""
|
|
mint = Transport(minted("tok-1"))
|
|
response = operator(mint).response_deserialize(Reply(202, {"id": "dep_1", "status": "queued"}), {"202": "object"})
|
|
assert response.status_code == 202
|
|
assert response.data == {"id": "dep_1", "status": "queued"}
|