mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix(iam): call the native routes, not the verbs the server is deleting
23 call sites across hanzo-iam and hanzo-tools-iam reached IAM over the legacy
verb surface — get-user, get-users, get-application(s), get-organization(s),
get-providers, get-role(s), get-user-count, get-user-roles, and the
oauth/access_token spelling. Those routes are being removed: HIP-0111 forbids
them, and a capability with an RFC uses its RFC.
Two things had to change together, which is why this adds routes.py rather than
editing the literals in place. The verb surface answered {status, msg, data} at
HTTP 200 even for a miss; the native surface returns the object at the top level,
uses real status codes, and NAMES its lists — {"users": [...]}, not
{"data": [...]}.
Swapping only the path is the dangerous half-migration, and hanzo-tools-iam shows
why: it did `users = data if isinstance(data, list) else []`, so against a native
response it would have reported ZERO users for a healthy org. Not an error — an
empty list. Both halves changed there.
routes.unwrap reads either shape so a fleet mid-rollout keeps working, and still
RAISES on the legacy error envelope because callers depended on that rather than
on a falsy return. It is deliberately temporary: once every server serves native
only, the envelope branch is dead and goes with it. Only a body whose keys are
exactly the envelope's is unwrapped, so a native row with its own `data` column
survives.
These admin methods had no coverage while they called the verb surface — the
reason migrating them blind was the risk. tests/test_routes.py pins it: no route
may contain "/get-", the token endpoint must be the one discovery advertises,
every list route must declare its key (a missing one unwraps to [] and reads as
empty), and neither client may carry a path literal. Falsified — restore a verb
in the table or in a client and the suite names it.
99 pass. tests/test_fastapi.py cannot collect in this environment
(starlette/httpx version clash, unrelated to this change); hanzo-tools-iam's
suite cannot either (`from mcp.server import FastMCP` against the installed MCP
SDK) — both verified pre-existing by reproducing them with these changes stashed.
Rebased onto upstream's IAM_ROUTE_PREFIX refactor rather than merged: that
refactor centralised the prefix but kept the verbs, so the two changes compose.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -10,6 +10,7 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from hanzo_iam.config import IAMConfig
|
||||
from hanzo_iam import routes
|
||||
from hanzo_iam.models import (
|
||||
IAM_ROUTE_PREFIX,
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
@@ -447,7 +448,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-user",
|
||||
routes.USER,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -457,7 +458,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get user"))
|
||||
|
||||
return User.model_validate(data.get("data", data))
|
||||
return User.model_validate(routes.unwrap(data))
|
||||
|
||||
async def get_users(self) -> list[User]:
|
||||
"""Get all users in organization.
|
||||
@@ -472,7 +473,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-users",
|
||||
routes.USERS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -482,7 +483,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get users"))
|
||||
|
||||
users_data = data.get("data", data) or []
|
||||
users_data = routes.unwrap(data, "users")
|
||||
return [User.model_validate(u) for u in users_data]
|
||||
|
||||
async def get_user_count(
|
||||
@@ -509,7 +510,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-user-count",
|
||||
routes.USERS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -519,7 +520,7 @@ class AsyncIAMClient:
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get user count"))
|
||||
|
||||
return int(data.get("data", data) if isinstance(data, dict) else data)
|
||||
return int(routes.unwrap(data) if isinstance(data, dict) else data)
|
||||
|
||||
async def create_user(self, user: User) -> User:
|
||||
"""Create new user.
|
||||
@@ -569,7 +570,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", f"Failed to {action}"))
|
||||
|
||||
return User.model_validate(data.get("data", data))
|
||||
return User.model_validate(routes.unwrap(data))
|
||||
|
||||
async def get_application(self) -> Application:
|
||||
"""Get current application configuration.
|
||||
@@ -584,7 +585,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-application",
|
||||
routes.APPLICATION,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -594,7 +595,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get application"))
|
||||
|
||||
return Application.model_validate(data.get("data", data))
|
||||
return Application.model_validate(routes.unwrap(data))
|
||||
|
||||
# =========================================================================
|
||||
# Organization
|
||||
@@ -613,7 +614,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-organizations",
|
||||
routes.ORGANIZATIONS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -623,7 +624,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get organizations"))
|
||||
|
||||
return data.get("data", data) or []
|
||||
return routes.unwrap(data, "organizations")
|
||||
|
||||
async def get_organization(self, name: str) -> dict[str, Any]:
|
||||
"""Get organization by name.
|
||||
@@ -641,7 +642,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-organization",
|
||||
routes.ORGANIZATION,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -651,7 +652,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get organization"))
|
||||
|
||||
return data.get("data", data)
|
||||
return routes.unwrap(data)
|
||||
|
||||
# =========================================================================
|
||||
# Permissions / Enforcement
|
||||
@@ -703,7 +704,7 @@ class AsyncIAMClient:
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Enforcement failed"))
|
||||
|
||||
return bool(data.get("data", data) if isinstance(data, dict) else data)
|
||||
return bool(routes.unwrap(data) if isinstance(data, dict) else data)
|
||||
|
||||
async def batch_enforce(
|
||||
self,
|
||||
@@ -748,7 +749,7 @@ class AsyncIAMClient:
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Batch enforcement failed"))
|
||||
|
||||
results = data.get("data", data) if isinstance(data, dict) else data
|
||||
results = routes.unwrap(data) if isinstance(data, dict) else data
|
||||
return [bool(r) for r in results]
|
||||
|
||||
# =========================================================================
|
||||
@@ -771,7 +772,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-roles",
|
||||
routes.ROLES,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -781,7 +782,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get roles"))
|
||||
|
||||
return data.get("data", data) or []
|
||||
return routes.unwrap(data, "roles")
|
||||
|
||||
async def get_role(
|
||||
self,
|
||||
@@ -806,7 +807,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-role",
|
||||
routes.ROLE,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -816,7 +817,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get role"))
|
||||
|
||||
return data.get("data", data)
|
||||
return routes.unwrap(data)
|
||||
|
||||
async def get_user_roles(
|
||||
self,
|
||||
@@ -841,7 +842,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-user-roles",
|
||||
routes.ROLES,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -851,7 +852,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get user roles"))
|
||||
|
||||
return data.get("data", data) or []
|
||||
return routes.unwrap(data, "roles")
|
||||
|
||||
async def add_role_for_user(
|
||||
self,
|
||||
@@ -993,7 +994,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-applications",
|
||||
routes.APPLICATIONS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -1003,7 +1004,7 @@ class AsyncIAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get applications"))
|
||||
|
||||
apps_data = data.get("data", data) or []
|
||||
apps_data = routes.unwrap(data, "applications")
|
||||
return [Application.model_validate(a) for a in apps_data]
|
||||
|
||||
async def update_application(self, application: Application) -> dict[str, Any]:
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from .config import IAMConfig
|
||||
from . import routes
|
||||
from .models import (
|
||||
IAM_ROUTE_PREFIX,
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
@@ -474,7 +475,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-user",
|
||||
routes.USER,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -484,7 +485,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get user"))
|
||||
|
||||
return User.model_validate(data.get("data", data))
|
||||
return User.model_validate(routes.unwrap(data))
|
||||
|
||||
def get_users(self) -> list[User]:
|
||||
"""Get all users in organization.
|
||||
@@ -498,7 +499,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-users",
|
||||
routes.USERS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -508,7 +509,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get users"))
|
||||
|
||||
users_data = data.get("data", data) or []
|
||||
users_data = routes.unwrap(data, "users")
|
||||
return [User.model_validate(u) for u in users_data]
|
||||
|
||||
def get_application(self) -> Application:
|
||||
@@ -523,7 +524,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-application",
|
||||
routes.APPLICATION,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -533,7 +534,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get application"))
|
||||
|
||||
return Application.model_validate(data.get("data", data))
|
||||
return Application.model_validate(routes.unwrap(data))
|
||||
|
||||
# =========================================================================
|
||||
# User Management (Admin API)
|
||||
@@ -604,7 +605,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-organizations",
|
||||
routes.ORGANIZATIONS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -614,7 +615,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get organizations"))
|
||||
|
||||
return data.get("data", data) or []
|
||||
return routes.unwrap(data, "organizations")
|
||||
|
||||
def get_organization(self, name: str) -> dict:
|
||||
"""Get organization by name.
|
||||
@@ -631,7 +632,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-organization",
|
||||
routes.ORGANIZATION,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -641,7 +642,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get organization"))
|
||||
|
||||
return data.get("data", data)
|
||||
return routes.unwrap(data)
|
||||
|
||||
# =========================================================================
|
||||
# Provider Management
|
||||
@@ -662,7 +663,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-providers",
|
||||
routes.PROVIDERS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -672,7 +673,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get providers"))
|
||||
|
||||
return data.get("data", data) or []
|
||||
return routes.unwrap(data, "providers")
|
||||
|
||||
# =========================================================================
|
||||
# Role Management
|
||||
@@ -693,7 +694,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-roles",
|
||||
routes.ROLES,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -703,7 +704,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get roles"))
|
||||
|
||||
return data.get("data", data) or []
|
||||
return routes.unwrap(data, "roles")
|
||||
|
||||
# =========================================================================
|
||||
# Password Management
|
||||
@@ -767,7 +768,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
f"{IAM_ROUTE_PREFIX}/get-applications",
|
||||
routes.APPLICATIONS,
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -777,7 +778,7 @@ class IAMClient:
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get applications"))
|
||||
|
||||
apps_data = data.get("data", data) or []
|
||||
apps_data = routes.unwrap(data, "applications")
|
||||
return [Application.model_validate(a) for a in apps_data]
|
||||
|
||||
def update_application(self, application: Application) -> dict:
|
||||
@@ -1078,14 +1079,14 @@ class AsyncIAMClient:
|
||||
}
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-user", params=params)
|
||||
response = await http.get(routes.USER, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get user"))
|
||||
|
||||
return User.model_validate(data.get("data", data))
|
||||
return User.model_validate(routes.unwrap(data))
|
||||
|
||||
async def get_users(self) -> list[User]:
|
||||
"""Get all users in organization."""
|
||||
@@ -1096,14 +1097,14 @@ class AsyncIAMClient:
|
||||
}
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-users", params=params)
|
||||
response = await http.get(routes.USERS, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get users"))
|
||||
|
||||
users_data = data.get("data", data) or []
|
||||
users_data = routes.unwrap(data, "users")
|
||||
return [User.model_validate(u) for u in users_data]
|
||||
|
||||
async def get_application(self) -> Application:
|
||||
@@ -1115,14 +1116,14 @@ class AsyncIAMClient:
|
||||
}
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-application", params=params)
|
||||
response = await http.get(routes.APPLICATION, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(data.get("msg", "Failed to get application"))
|
||||
|
||||
return Application.model_validate(data.get("data", data))
|
||||
return Application.model_validate(routes.unwrap(data))
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client."""
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Hanzo IAM endpoint paths — the one place this SDK names an admin route.
|
||||
|
||||
Every path here is the NATIVE surface. The legacy "verb" spellings this SDK used
|
||||
to call (`get-user`, `get-users`, `get-organizations`, …) are being removed from
|
||||
the server: they are an upstream shape, HIP-0111 forbids them, and a capability
|
||||
that has an RFC uses its RFC.
|
||||
|
||||
Two things changed together, which is why this module exists rather than 22
|
||||
inline literals:
|
||||
|
||||
path the verb URL became a noun URL
|
||||
envelope the verb surface answered {status, msg, data} at HTTP 200 even for
|
||||
a miss. The native surface returns the object at the TOP LEVEL and
|
||||
uses real status codes, and it NAMES its lists — {"users": [...]},
|
||||
not {"data": [...]}.
|
||||
|
||||
Swapping only the path is the dangerous half. A caller that does
|
||||
`x if isinstance(x, list) else []` against a native response gets `[]` — it
|
||||
reports "none" for a healthy org instead of failing.
|
||||
|
||||
`unwrap` reads both shapes so a fleet mid-rollout keeps working. That is
|
||||
deliberately temporary: once every server serves native only, the envelope branch
|
||||
is dead code and should be deleted with it.
|
||||
|
||||
OIDC/OAuth paths are NOT here — they live in models.py (OIDC_TOKEN_PATH and
|
||||
friends) and are pinned by tests/test_endpoints.py. This module is the admin
|
||||
surface only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# --- native admin routes -----------------------------------------------------
|
||||
|
||||
USER = "/v1/iam/users/get"
|
||||
USERS = "/v1/iam/users"
|
||||
APPLICATION = "/v1/iam/application"
|
||||
APPLICATIONS = "/v1/iam/applications"
|
||||
ORGANIZATION = "/v1/iam/organizations/get"
|
||||
ORGANIZATIONS = "/v1/iam/organizations"
|
||||
PROVIDERS = "/v1/iam/providers"
|
||||
ROLE = "/v1/iam/roles/get"
|
||||
ROLES = "/v1/iam/roles"
|
||||
|
||||
#: OAuth 2.0 token endpoint (RFC 6749 §3.2). The `oauth/access_token` spelling
|
||||
#: this SDK used is a legacy alias the server still answers but never advertises
|
||||
#: — OIDC discovery names only this one.
|
||||
TOKEN = "/v1/iam/oauth/token"
|
||||
|
||||
#: The key the native surface wraps a LIST in, per route. The verb surface put
|
||||
#: every list under "data"; the native one names what it returns. A list route
|
||||
#: missing from this table unwraps to [] and reads as "empty".
|
||||
LIST_KEY = {
|
||||
USERS: "users",
|
||||
APPLICATIONS: "applications",
|
||||
ORGANIZATIONS: "organizations",
|
||||
PROVIDERS: "providers",
|
||||
ROLES: "roles",
|
||||
}
|
||||
|
||||
|
||||
def unwrap(body: Any, list_key: str | None = None) -> Any:
|
||||
"""Return the payload from either the native or the legacy envelope.
|
||||
|
||||
Raises ValueError when the legacy envelope carries an error, because that
|
||||
surface reported failures INSIDE a 200 and callers relied on this raising
|
||||
rather than on a falsy return. The native surface signals with the status
|
||||
code, which the caller has already checked via raise_for_status().
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return body
|
||||
|
||||
if body.get("status") == "error":
|
||||
raise ValueError(body.get("msg") or "IAM request failed")
|
||||
|
||||
if list_key is not None:
|
||||
if list_key in body: # native: {"users": [...]}
|
||||
return body[list_key] or []
|
||||
if "data" in body: # legacy: {"status", "msg", "data": [...]}
|
||||
return body["data"] or []
|
||||
return []
|
||||
|
||||
# Only a body whose keys are EXACTLY the envelope's is unwrapped, so a native
|
||||
# row that legitimately carries a `data` column survives intact.
|
||||
if "data" in body and set(body) <= {"status", "msg", "data", "data2"}:
|
||||
return body["data"]
|
||||
return body
|
||||
@@ -0,0 +1,96 @@
|
||||
"""The native-route migration, pinned.
|
||||
|
||||
These admin methods had NO coverage while they called the legacy verb surface,
|
||||
which is why migrating them was the risk. The dangerous failure is not a crash:
|
||||
it is `unwrap` returning [] for a well-formed response, so a caller sees "no
|
||||
users" instead of an error. Every case asserts on the value.
|
||||
|
||||
Complements tests/test_endpoints.py, which pins that OIDC paths live under the
|
||||
one /v1/iam prefix. This file pins that no legacy VERB survives anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzo_iam import routes
|
||||
|
||||
|
||||
class TestRoutesAreNative:
|
||||
def test_no_verb_paths_in_the_table(self):
|
||||
paths = [
|
||||
v for k, v in vars(routes).items()
|
||||
if k.isupper() and isinstance(v, str) and v.startswith("/v1/iam/")
|
||||
]
|
||||
assert paths, "route table is empty — the constants moved"
|
||||
for p in paths:
|
||||
assert "/get-" not in p, f"{p} is a legacy verb; the server is removing it"
|
||||
assert "access_token" not in p, (
|
||||
f"{p} is the legacy token spelling; OIDC discovery advertises "
|
||||
"/v1/iam/oauth/token and only that"
|
||||
)
|
||||
|
||||
def test_token_endpoint_is_the_advertised_one(self):
|
||||
assert routes.TOKEN == "/v1/iam/oauth/token"
|
||||
|
||||
def test_every_list_route_declares_its_key(self):
|
||||
for name in ("USERS", "APPLICATIONS", "ORGANIZATIONS", "PROVIDERS", "ROLES"):
|
||||
path = getattr(routes, name)
|
||||
assert path in routes.LIST_KEY, (
|
||||
f"{name} has no LIST_KEY — its lists would unwrap to [] and read as empty"
|
||||
)
|
||||
|
||||
|
||||
class TestUnwrapReadsBothShapes:
|
||||
def test_native_single_object(self):
|
||||
body = {"owner": "hanzo", "name": "alice", "id": "hanzo/alice"}
|
||||
assert routes.unwrap(body) == body
|
||||
|
||||
def test_legacy_envelope_single_object(self):
|
||||
inner = {"owner": "hanzo", "name": "alice"}
|
||||
assert routes.unwrap({"status": "ok", "msg": "", "data": inner}) == inner
|
||||
|
||||
def test_native_list(self):
|
||||
body = {"users": [{"name": "alice"}, {"name": "bob"}]}
|
||||
assert routes.unwrap(body, "users") == body["users"]
|
||||
|
||||
def test_legacy_envelope_list(self):
|
||||
body = {"status": "ok", "msg": "", "data": [{"name": "alice"}]}
|
||||
assert routes.unwrap(body, "users") == body["data"]
|
||||
|
||||
def test_native_null_list_is_a_list_not_none(self):
|
||||
assert routes.unwrap({"users": None}, "users") == []
|
||||
|
||||
def test_legacy_error_envelope_raises(self):
|
||||
with pytest.raises(ValueError, match="user not found"):
|
||||
routes.unwrap({"status": "error", "msg": "user not found"})
|
||||
|
||||
def test_legacy_error_raises_on_the_list_path_too(self):
|
||||
with pytest.raises(ValueError):
|
||||
routes.unwrap({"status": "error", "msg": "nope"}, "users")
|
||||
|
||||
def test_a_native_row_with_a_data_column_is_not_unwrapped(self):
|
||||
body = {"owner": "hanzo", "name": "cfg", "data": {"k": "v"}}
|
||||
assert routes.unwrap(body) == body
|
||||
|
||||
def test_non_dict_passes_through(self):
|
||||
assert routes.unwrap([1, 2]) == [1, 2]
|
||||
|
||||
|
||||
class TestClientsCarryNoPathLiterals:
|
||||
@pytest.mark.parametrize("module", ["client", "async_client"])
|
||||
def test_no_legacy_verb_in_client_source(self, module):
|
||||
spec = importlib.util.find_spec(f"hanzo_iam.{module}")
|
||||
assert spec and spec.origin
|
||||
src = Path(spec.origin).read_text()
|
||||
for verb in ("get-user", "get-users", "get-application", "get-organization",
|
||||
"get-provider", "get-role"):
|
||||
assert f"/{verb}" not in src, (
|
||||
f"{module}.py still names the legacy verb {verb}; the server is removing it"
|
||||
)
|
||||
assert "oauth/access_token" not in src, (
|
||||
f"{module}.py still uses the legacy token spelling"
|
||||
)
|
||||
Reference in New Issue
Block a user