diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb68f4..447d024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ plus bug fixes and the first real HTTP-layer test coverage. `retry_backoff`. - New exceptions: `ArcNotSupportedError` (501) and `ArcLicenseError` (403 for a missing enterprise-license feature; subclass of `ArcAuthenticationError`). +- `ArcPermissionError` (403: the token lacks the permission the endpoint + requires), `ArcScopedAccessError` (403 from Arc's per-database check: the + token may read, but not the database asked for) and + `ArcPermissionDataUnavailableError` (403 because Arc could not load the + token's grants and failed closed — a server-side fault, not a statement + about the token). `ArcScopedAccessError` carries the refused database on + `.database`, empty or `"*"` when the call asked for every database. The + latter two subclass `ArcPermissionError`, which subclasses + `ArcAuthenticationError`, so existing `except` blocks keep working. - Real HTTP round-trip test coverage (via `pytest-httpx`) for headers, error mapping, compression on the wire, retries, msgpack decode, imports, databases, and enterprise wrappers — sync and async. Opt-in live integration tests under @@ -46,6 +55,13 @@ plus bug fixes and the first real HTTP-layer test coverage. from the payload's magic bytes. - `User-Agent` is now derived from the package version instead of a hardcoded string, and the package version is a single source of truth read by the build. +- HTTP 403 no longer collapses onto `ArcAuthenticationError`, which now means + only 401. The database listing endpoints need read permission and, where the + server restricts reads per database, a grant for the database — and Arc + refuses `databases.list()` for a tenant-scoped token rather than returning a + filtered list, matching `SHOW DATABASES`. That refusal is an expected state, + not a fault to retry: it raises `ArcScopedAccessError` naming the database, + while a token with no read permission raises `ArcPermissionError`. ### Fixed diff --git a/README.md b/README.md index 8a4b6da..961996e 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,30 @@ measurements = client.databases.list_measurements("metrics") client.databases.delete("metrics", confirm=True) ``` +The three read methods need a token with read permission. On a server that +restricts reads per database they also need a grant for the database in +question, and `list()` needs one covering every database — Arc refuses rather +than returning a filtered list, the same way `SHOW DATABASES` does: + +```python +from arc_client.exceptions import ArcScopedAccessError + +try: + databases = client.databases.list().databases +except ArcScopedAccessError: + # Expected for a tenant-scoped token: it cannot enumerate every database, + # so ask for the one it is granted instead of retrying. + measurements = client.databases.list_measurements("metrics") +``` + +`ArcScopedAccessError.database` carries the database the call was refused for +(empty or `"*"` when the call asked for every database). A token with no read +permission at all raises `ArcPermissionError` instead; Arc failing to load the +token's grants raises `ArcPermissionDataUnavailableError`, a server-side fault +rather than a statement about the token; and a database that does not exist +still raises `ArcNotFoundError` — "you may not read it" and "it is not there" +stay apart. + ## Querying Data ### JSON Response @@ -445,7 +469,10 @@ the codec from the payload's magic bytes, so no `Content-Encoding` header is sen from arc_client.exceptions import ( ArcError, # Base exception ArcConnectionError, # Connection failures - ArcAuthenticationError,# Auth failures (401/403) + ArcAuthenticationError,# Token missing, invalid or expired (401) + ArcPermissionError, # Token lacks the required permission (403) + ArcScopedAccessError, # Token may read, but not this database (403) + ArcPermissionDataUnavailableError, # Arc could not load the grants (403) ArcLicenseError, # Enterprise feature not licensed (403) ArcQueryError, # Query execution errors ArcIngestionError, # Write failures diff --git a/src/arc_client/__init__.py b/src/arc_client/__init__.py index 11e22c3..fcbf45b 100644 --- a/src/arc_client/__init__.py +++ b/src/arc_client/__init__.py @@ -17,8 +17,11 @@ ArcLicenseError, ArcNotFoundError, ArcNotSupportedError, + ArcPermissionDataUnavailableError, + ArcPermissionError, ArcQueryError, ArcRateLimitError, + ArcScopedAccessError, ArcServerError, ArcValidationError, ) @@ -42,6 +45,9 @@ "ArcServerError", "ArcNotSupportedError", "ArcLicenseError", + "ArcPermissionError", + "ArcPermissionDataUnavailableError", + "ArcScopedAccessError", # Version "__version__", ] diff --git a/src/arc_client/exceptions.py b/src/arc_client/exceptions.py index a7b36bf..a0244ff 100644 --- a/src/arc_client/exceptions.py +++ b/src/arc_client/exceptions.py @@ -79,3 +79,63 @@ class ArcLicenseError(ArcAuthenticationError): """ pass + + +class ArcPermissionError(ArcAuthenticationError): + """HTTP 403: the token does not carry the permission the endpoint needs. + + Distinct from :class:`ArcAuthenticationError`, which now means only HTTP + 401 — the token is missing, invalid or expired. A 401 is fixed by + re-authenticating; a 403 is not, and needs a token with different + permissions. + + Subclasses :class:`ArcAuthenticationError` so existing ``except`` blocks + that caught 403 as an authentication failure keep working. + """ + + pass + + +class ArcPermissionDataUnavailableError(ArcPermissionError): + """HTTP 403 because Arc could not load the token's grants. + + Arc fails closed when its permission data cannot be read, which arrives + as a 403 like a real refusal. Nothing about the token or the database + caused it, so it is raised separately: retrying with a different database + or a different token will not help, and the Arc server log is where the + cause is. + """ + + pass + + +class ArcScopedAccessError(ArcPermissionError): + """HTTP 403 from Arc's per-database permission check. + + The token may read, but not the database the call asked about. Raised by + the database listing endpoints: + + * :meth:`arc_client.management.databases.DatabaseClient.list` — Arc + refuses to list every database for a token restricted to particular + ones rather than returning a filtered list, matching ``SHOW + DATABASES``. ``database`` is then ``"*"``. + * :meth:`~arc_client.management.databases.DatabaseClient.get` and + :meth:`~arc_client.management.databases.DatabaseClient.list_measurements` + — the token holds no grant for the database named, which ``database`` + carries. + + This is a normal state for a tenant-scoped token, not a transient + failure: an identical retry is refused identically, and the way forward + is to name a database the token is granted. It is deliberately separate + from :class:`ArcPermissionError` so "you are scoped, name your database" + and "this token cannot read at all" can be handled differently. + + Attributes: + database: The database the call was refused for, or ``"*"`` when it + asked for every database. ``None`` if Arc's message did not name + one. + """ + + def __init__(self, message: str, database: Optional[str] = None) -> None: + super().__init__(message) + self.database = database diff --git a/src/arc_client/http/base.py b/src/arc_client/http/base.py index d92e359..ffa588b 100644 --- a/src/arc_client/http/base.py +++ b/src/arc_client/http/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from typing import Any, Optional import httpx @@ -15,7 +16,10 @@ ArcLicenseError, ArcNotFoundError, ArcNotSupportedError, + ArcPermissionDataUnavailableError, + ArcPermissionError, ArcRateLimitError, + ArcScopedAccessError, ArcServerError, ) @@ -38,8 +42,83 @@ def build_headers( return headers +# Arc sends HTTP 403 for three different things and distinguishes them only +# by message, so the message is what these read. The shapes its read +# middleware and handler gates produce: +# +# no permission for read on database 'x' -> scoped +# access denied: no read permission for database 'x' -> scoped +# permission data unavailable -> grants unreadable +# token does not have 'read' permission -> no read permission +# Permission denied: read required -> no read permission +# +# Only the first two are resolved by naming a different database, so they +# must not collapse onto one exception. The patterns match the stable parts +# of each message rather than the whole string, so a change to the permission +# word or the surrounding punctuation cannot silently reclassify one as +# another, and the database name is captured where Arc sends one. +_SCOPED_DENIAL_RES = ( + # The read middleware on the listing routes. The name is empty on the + # list-everything route, which sends no database. + re.compile( + r"^no permission for \w+ on database '(?P[^']*)'", + re.IGNORECASE, + ), + # The handler gates' own wording. + re.compile( + r"^access denied: no \w+ permission for database '(?P[^']*)'", + re.IGNORECASE, + ), +) + +# Arc could not load the token's grants and refused rather than guess. A 403 +# like the others, but it says nothing about the token or the database, so it +# must not be reported as a permission problem. Checked first. +_PERMISSION_DATA_UNAVAILABLE = "permission data unavailable" + + +def _scoped_denial_database(message: object) -> Optional[str]: + """Return the database named by a per-database denial, else None. + + An empty capture is returned as ``""`` — Arc sends an empty name on the + list-everything route — so callers must test against ``None``, not + truthiness, to tell "not a scoped denial" from "scoped, no name". + """ + if not isinstance(message, str): + return None + text = message.strip() + for pattern in _SCOPED_DENIAL_RES: + match = pattern.match(text) + if match is not None: + return match.group("database") + return None + + +def _is_permission_data_unavailable(message: object) -> bool: + """True if Arc failed closed because it could not load the grants.""" + return isinstance(message, str) and _PERMISSION_DATA_UNAVAILABLE in message.lower() + + def handle_response_error(response: httpx.Response) -> None: - """Handle HTTP error responses and raise appropriate exceptions.""" + """Handle HTTP error responses and raise appropriate exceptions. + + Raises: + ArcAuthenticationError: HTTP 401 — the token is missing, invalid or + expired. Re-authenticate. + ArcLicenseError: HTTP 403 naming an enterprise license. + ArcScopedAccessError: HTTP 403 from Arc's per-database permission + check — the token may read, but not the database asked for. A + subclass of ``ArcPermissionError``; carries ``database``. + ArcPermissionDataUnavailableError: HTTP 403 because Arc could not + load the token's grants and failed closed. A server-side fault, + not a statement about the token. + ArcPermissionError: any other HTTP 403 — the token does not carry the + permission the endpoint requires. + ArcNotFoundError: HTTP 404. + ArcRateLimitError: HTTP 429. + ArcNotSupportedError: HTTP 501. + ArcServerError: HTTP 5xx and any other non-2xx status. + """ if response.is_success: return @@ -58,7 +137,37 @@ def handle_response_error(response: httpx.Response) -> None: # ArcAuthenticationError, so plain permission-denied handling still works). if isinstance(message, str) and "enterprise license" in message.lower(): raise ArcLicenseError(f"Enterprise license required: {message}") - raise ArcAuthenticationError(f"Permission denied: {message}") + # Checked before the scoped shape: Arc failing to read its own + # permission data is a server fault, and reporting it as "your token + # is scoped" would send the caller after a database that was never + # the problem. + if _is_permission_data_unavailable(message): + raise ArcPermissionDataUnavailableError( + f"Arc could not read its own permission data and refused the request " + f"rather than guessing. This is a server-side fault, not a problem " + f"with this token — check the Arc server log. ({message})" + ) + # Arc's per-database check refuses a token that may read but holds no + # grant for the database asked for. Naming a database it is granted is + # the only thing that resolves it, so it gets its own exception rather + # than being reported as "this token cannot read". + database = _scoped_denial_database(message) + if database is not None: + if database in ("", "*"): + detail = ( + "this token is scoped to specific databases, so Arc will not list " + "them all. Name one with get() or list_measurements()." + ) + else: + detail = ( + f"this token is scoped to specific databases and holds no read " + f"grant for {database!r}. Name a database it is granted." + ) + raise ArcScopedAccessError( + f"Access denied: {detail} ({message})", + database=database, + ) + raise ArcPermissionError(f"Permission denied: {message}") elif status_code == 404: raise ArcNotFoundError(f"Resource not found: {message}") elif status_code == 429: diff --git a/src/arc_client/management/async_databases.py b/src/arc_client/management/async_databases.py index db61aa2..c3c6bb0 100644 --- a/src/arc_client/management/async_databases.py +++ b/src/arc_client/management/async_databases.py @@ -23,7 +23,21 @@ def __init__(self, http: AsyncHTTPClient, config: ClientConfig) -> None: self._config = config async def list(self) -> DatabaseListResponse: - """List all databases.""" + """List all databases. + + Needs a token with read permission. Where the server restricts reads + per database, it also needs a grant covering every database: Arc + refuses this call for a token scoped to particular databases rather + than returning a filtered list, the same way ``SHOW DATABASES`` does. + + Raises: + ArcScopedAccessError: The token is scoped to specific databases + (``database`` is ``"*"``). Expected for a tenant-scoped + token — name a database with :meth:`get` or + :meth:`list_measurements` instead of retrying. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + """ try: response = await self._http.get("/api/v1/databases") return DatabaseListResponse.model_validate(response.json()) @@ -33,7 +47,18 @@ async def list(self) -> DatabaseListResponse: raise ArcError(f"Failed to list databases: {e}") from e async def get(self, name: str) -> DatabaseInfo: - """Get a single database by name.""" + """Get a single database by name. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). It may be able to read others. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. Kept separate from the + permission errors: "you may not read it" and "it is not + there" are different answers. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = await self._http.get(f"/api/v1/databases/{name}") @@ -44,7 +69,16 @@ async def get(self, name: str) -> DatabaseInfo: raise ArcError(f"Failed to get database {name!r}: {e}") from e async def list_measurements(self, name: str) -> MeasurementListResponse: - """List measurements within a database.""" + """List measurements within a database. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). Ask for a database it is granted. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = await self._http.get(f"/api/v1/databases/{name}/measurements") diff --git a/src/arc_client/management/databases.py b/src/arc_client/management/databases.py index b89ccb5..262b138 100644 --- a/src/arc_client/management/databases.py +++ b/src/arc_client/management/databases.py @@ -21,6 +21,15 @@ class DatabaseClient: Create and delete require an admin token. Delete additionally requires the server to have delete operations enabled and an explicit ``confirm=True``. + The three read methods need a token with read permission. On a server that + restricts reads per database they also need a grant for the database in + question, and :meth:`list` needs one covering every database — Arc refuses + rather than returning a filtered list, matching ``SHOW DATABASES``. That + refusal raises :class:`~arc_client.exceptions.ArcScopedAccessError`, which + is a normal state for a tenant-scoped token rather than a fault to retry; + a token with no read permission at all raises + :class:`~arc_client.exceptions.ArcPermissionError`. + Example: >>> client.databases.create("metrics") >>> for db in client.databases.list().databases: @@ -32,7 +41,21 @@ def __init__(self, http: SyncHTTPClient, config: ClientConfig) -> None: self._config = config def list(self) -> DatabaseListResponse: - """List all databases.""" + """List all databases. + + Needs a token with read permission. Where the server restricts reads + per database, it also needs a grant covering every database: Arc + refuses this call for a token scoped to particular databases rather + than returning a filtered list, the same way ``SHOW DATABASES`` does. + + Raises: + ArcScopedAccessError: The token is scoped to specific databases + (``database`` is ``"*"``). Expected for a tenant-scoped + token — name a database with :meth:`get` or + :meth:`list_measurements` instead of retrying. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + """ try: response = self._http.get("/api/v1/databases") return DatabaseListResponse.model_validate(response.json()) @@ -42,7 +65,18 @@ def list(self) -> DatabaseListResponse: raise ArcError(f"Failed to list databases: {e}") from e def get(self, name: str) -> DatabaseInfo: - """Get a single database by name.""" + """Get a single database by name. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). It may be able to read others. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. Kept separate from the + permission errors: "you may not read it" and "it is not + there" are different answers. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = self._http.get(f"/api/v1/databases/{name}") @@ -53,7 +87,16 @@ def get(self, name: str) -> DatabaseInfo: raise ArcError(f"Failed to get database {name!r}: {e}") from e def list_measurements(self, name: str) -> MeasurementListResponse: - """List measurements within a database.""" + """List measurements within a database. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). Ask for a database it is granted. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = self._http.get(f"/api/v1/databases/{name}/measurements") diff --git a/tests/unit/http/test_databases_permissions.py b/tests/unit/http/test_databases_permissions.py new file mode 100644 index 0000000..e373437 --- /dev/null +++ b/tests/unit/http/test_databases_permissions.py @@ -0,0 +1,280 @@ +"""Arc's answers for the database listing endpoints. + +The three ``GET /api/v1/databases*`` routes require read permission and, on a +server that restricts reads per database, a grant for the database asked for. +A 401, the two different 403s and a 404 are four different answers and must +reach callers as four different exceptions. +""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock + +from arc_client import ArcClient, AsyncArcClient +from arc_client.exceptions import ( + ArcAuthenticationError, + ArcError, + ArcNotFoundError, + ArcPermissionDataUnavailableError, + ArcPermissionError, + ArcScopedAccessError, +) + +# The bodies the server actually sends. Every 403 here shares a status code +# and differs only in message, so the message is the whole discriminator. +NO_TOKEN = {"success": False, "error": "Authentication required"} +BAD_TOKEN = {"success": False, "error": "Invalid or expired token"} +# Coarse refusals: the token carries no read permission at all. +NO_READ_PERMISSION = {"success": False, "error": "token does not have 'read' permission"} +OSS_NO_READ = {"success": False, "error": "Permission denied: read required"} +# The read middleware's RBAC denial, which is what these routes return in +# practice. The name is empty on the list-everything route, which sends none. +SCOPED_ALL = {"success": False, "error": "no permission for read on database ''"} +SCOPED_NAMED = {"success": False, "error": "no permission for read on database 'metrics'"} +# The handler gates' own wording, reachable when they check a different +# database/measurement pair than the middleware did. +GATE_SCOPED_ALL = {"error": "access denied: no read permission for database '*'"} +GATE_SCOPED_NAMED = {"error": "access denied: no read permission for database 'metrics'"} +# Arc could not load the token's grants and refused rather than guess. +PERMISSION_DATA_UNAVAILABLE = {"success": False, "error": "permission data unavailable"} +NOT_FOUND = {"error": "Database 'ghost' not found"} + + +@pytest.fixture +def client() -> ArcClient: + c = ArcClient(host="h", token="t", database="db", max_retries=0) + yield c + c.close() + + +# --- Exception hierarchy ----------------------------------------------------- + + +def test_scoped_access_is_a_permission_error() -> None: + """Callers that only care about "refused" can catch one class.""" + assert issubclass(ArcScopedAccessError, ArcPermissionError) + assert issubclass(ArcPermissionError, ArcAuthenticationError) + assert issubclass(ArcPermissionError, ArcError) + + +def test_scoped_access_carries_the_database() -> None: + error = ArcScopedAccessError("refused", database="metrics") + assert error.database == "metrics" + assert ArcScopedAccessError("refused").database is None + + +# --- list() ------------------------------------------------------------------ + + +def test_list_raises_scoped_for_a_tenant_scoped_token( + httpx_mock: HTTPXMock, client: ArcClient +) -> None: + """Arc refuses the list-everything route rather than filtering it.""" + httpx_mock.add_response(status_code=403, json=SCOPED_ALL) + + with pytest.raises(ArcScopedAccessError) as excinfo: + client.databases.list() + + # Arc sends no database name on this route, so the advice must be + # "name one", not "you have no grant for ''". + assert excinfo.value.database == "" + message = str(excinfo.value) + assert "scoped to specific databases" in message + assert "will not list them all" in message + assert "''" not in message.replace("database ''", "") + + +def test_list_raises_permission_error_without_read( + httpx_mock: HTTPXMock, client: ArcClient +) -> None: + """A token with no read permission is a different problem.""" + httpx_mock.add_response(status_code=403, json=NO_READ_PERMISSION) + + with pytest.raises(ArcPermissionError) as excinfo: + client.databases.list() + + assert not isinstance(excinfo.value, ArcScopedAccessError) + assert "Permission denied" in str(excinfo.value) + + +@pytest.mark.parametrize("body", [NO_TOKEN, BAD_TOKEN]) +def test_list_raises_authentication_error_on_401( + httpx_mock: HTTPXMock, client: ArcClient, body: dict +) -> None: + httpx_mock.add_response(status_code=401, json=body) + + with pytest.raises(ArcAuthenticationError) as excinfo: + client.databases.list() + + # 401 must not surface as a permission problem: re-authenticating fixes it, + # a different token's permissions do not. + assert not isinstance(excinfo.value, ArcPermissionError) + assert "Authentication failed" in str(excinfo.value) + + +def test_list_success_unchanged(httpx_mock: HTTPXMock, client: ArcClient) -> None: + httpx_mock.add_response( + json={"databases": [{"name": "metrics", "measurement_count": 2}], "count": 1} + ) + + result = client.databases.list() + + assert result.count == 1 + assert result.databases[0].name == "metrics" + + +# --- get() and list_measurements() ------------------------------------------ + + +@pytest.mark.parametrize("method", ["get", "list_measurements"]) +def test_per_database_routes_raise_scoped_and_name_the_database( + httpx_mock: HTTPXMock, client: ArcClient, method: str +) -> None: + httpx_mock.add_response(status_code=403, json=SCOPED_NAMED) + + with pytest.raises(ArcScopedAccessError) as excinfo: + getattr(client.databases, method)("metrics") + + assert excinfo.value.database == "metrics" + assert "metrics" in str(excinfo.value) + + +@pytest.mark.parametrize("method", ["get", "list_measurements"]) +def test_per_database_routes_keep_404_separate( + httpx_mock: HTTPXMock, client: ArcClient, method: str +) -> None: + """ "You may not read it" and "it is not there" are different answers.""" + httpx_mock.add_response(status_code=404, json=NOT_FOUND) + + with pytest.raises(ArcNotFoundError) as excinfo: + getattr(client.databases, method)("ghost") + + assert not isinstance(excinfo.value, ArcPermissionError) + assert "not found" in str(excinfo.value) + + +@pytest.mark.parametrize("method", ["get", "list_measurements"]) +def test_per_database_routes_raise_permission_error_without_read( + httpx_mock: HTTPXMock, client: ArcClient, method: str +) -> None: + httpx_mock.add_response(status_code=403, json=NO_READ_PERMISSION) + + with pytest.raises(ArcPermissionError) as excinfo: + getattr(client.databases, method)("metrics") + + assert not isinstance(excinfo.value, ArcScopedAccessError) + + +# --- 403s that are not the per-database denial ------------------------------- + + +def test_unknown_403_body_is_not_reported_as_scoped( + httpx_mock: HTTPXMock, client: ArcClient +) -> None: + """Telling a caller to name a database it already named is worse than + naming the permission, so only the recognised shape classifies as scoped.""" + httpx_mock.add_response(status_code=403, json={"error": "Delete operations are disabled."}) + + with pytest.raises(ArcPermissionError) as excinfo: + client.databases.get("metrics") + + assert not isinstance(excinfo.value, ArcScopedAccessError) + + +def test_license_403_still_maps_to_license_error(httpx_mock: HTTPXMock, client: ArcClient) -> None: + """The pre-existing enterprise-license 403 keeps its own exception.""" + from arc_client.exceptions import ArcLicenseError + + httpx_mock.add_response( + status_code=403, + json={"error": "This feature requires an enterprise license"}, + ) + + with pytest.raises(ArcLicenseError): + client.databases.list() + + +# --- async twin -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_list_raises_scoped(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(status_code=403, json=SCOPED_ALL) + + c = AsyncArcClient(host="h", token="t", database="db", max_retries=0) + try: + with pytest.raises(ArcScopedAccessError) as excinfo: + await c.databases.list() + assert excinfo.value.database == "" + finally: + await c.close() + + +@pytest.mark.asyncio +async def test_async_list_measurements_raises_scoped_and_names_database( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response(status_code=403, json=SCOPED_NAMED) + + c = AsyncArcClient(host="h", token="t", database="db", max_retries=0) + try: + with pytest.raises(ArcScopedAccessError) as excinfo: + await c.databases.list_measurements("metrics") + assert excinfo.value.database == "metrics" + finally: + await c.close() + + +# --- the full 403 message matrix --------------------------------------------- + + +@pytest.mark.parametrize( + ("body", "expected", "expected_database"), + [ + # Arc's read middleware, which is what these routes use. + (SCOPED_NAMED, ArcScopedAccessError, "metrics"), + (SCOPED_ALL, ArcScopedAccessError, ""), + # The handler gates' own wording. + (GATE_SCOPED_NAMED, ArcScopedAccessError, "metrics"), + (GATE_SCOPED_ALL, ArcScopedAccessError, "*"), + # The token carries no read permission at all — a different problem, + # and naming a database will not help. + (NO_READ_PERMISSION, ArcPermissionError, None), + (OSS_NO_READ, ArcPermissionError, None), + # Arc could not read its own grants: a server fault, not the token's. + (PERMISSION_DATA_UNAVAILABLE, ArcPermissionDataUnavailableError, None), + # An unrecognised 403 falls back to the permission message, which is + # safe to say about any refusal. + ({"error": "Delete operations are disabled."}, ArcPermissionError, None), + ], +) +def test_every_forbidden_body_maps_to_its_own_exception( + httpx_mock: HTTPXMock, + client: ArcClient, + body: dict, + expected: type, + expected_database: object, +) -> None: + httpx_mock.add_response(status_code=403, json=body) + + with pytest.raises(expected) as excinfo: + client.databases.get("metrics") + + # The mapping must be exact, not just "a subclass of something". + assert type(excinfo.value) is expected + if expected_database is not None: + assert excinfo.value.database == expected_database + + +def test_unavailable_is_not_reported_as_scoped(httpx_mock: HTTPXMock, client: ArcClient) -> None: + """A broken permission store must not send the caller after a database.""" + httpx_mock.add_response(status_code=403, json=PERMISSION_DATA_UNAVAILABLE) + + with pytest.raises(ArcPermissionDataUnavailableError) as excinfo: + client.databases.list() + + assert not isinstance(excinfo.value, ArcScopedAccessError) + message = str(excinfo.value) + assert "server-side fault" in message + assert "scoped" not in message