The reporter (#160) saw `USER_PROJECT_DENIED` raw in the CLI because all three CLI error-rendering paths flatten typed BqAccessError / guardrail / RBAC dicts to a truncated single-line string, hiding the structured `hint` field that explains how to fix the misconfig. Fix: shared `cli/error_render.py:render_error(status_code, body)` that recognizes the canonical typed shapes and pretty-prints them. Falls back to truncated-and-flattened form for unrecognized bodies, so the renderer never makes worse-than-status-quo output. Recognized shapes: - {detail: {kind: ..., hint?, billing_project?, data_project?}} — typed BqAccessError responses from /api/v2/scan, /sample, /schema, /api/query (when /api/query escalates a BQ failure) - {detail: {reason: 'remote_scan_too_large', scan_bytes, limit_bytes, tables, suggestion}} — new /api/query cost-guardrail rejection - {detail: {reason: 'bq_path_not_registered'/'bq_path_access_denied', path, hint?, registered_as?}} — new /api/query RBAC patch - {detail: '...'} — string detail (legacy endpoints) Wired through 3 CLI paths: - cli/v2_client.py: V2ClientError.__str__ delegates to render_error; pre-truncation removed from V2ClientError.message (was hiding hints past 200 chars). - cli/commands/query.py:_query_remote: parse JSON body, call renderer on error. - cli/commands/query.py:_query_hybrid: catch RemoteQueryError, build synthetic `{detail: {kind: error_type, **details}}` payload, render. tests/test_cli_query.py:test_remote_query_failure: assertion updated from `"Query failed"` (no longer printed) to `HTTP 400` + `bad SQL` (what the renderer surfaces for string detail). Sample output for cross_project_forbidden: Error: cross_project_forbidden (HTTP 502) billing_project: (empty) data_project: prj-example-data-001 message: USER_PROJECT_DENIED on bigquery.googleapis.com hint: Set data_source.bigquery.billing_project in /admin/server-config to a project where the SA has serviceusage.services.use, or grant the SA that role on the data project. 19 tests pass — 10 from T4a now GREEN + 3 prior cli_query tests still green + 6 ancillary.
60 lines
2.6 KiB
Python
60 lines
2.6 KiB
Python
"""`src/remote_query.py:RemoteQueryError` carries a `details` dict
|
|
(verified to already exist) populated for raise sites that wrap an
|
|
upstream BqAccessError.
|
|
|
|
Currently only the BqAccessError-wrap path (lines 422 + 432) populates
|
|
it. The other 11 raise sites (lines 134, 142, 167, 173, 259, 264, 282,
|
|
289, 313, 322, 375) need an audit — for sites that wrap an external
|
|
exception, populate `details` so the CLI renderer has the structured
|
|
context it needs.
|
|
|
|
Closes the audit half of #160 §4.7.3.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
|
|
def test_blocked_keyword_carries_keyword_in_details():
|
|
"""`raise RemoteQueryError("blocked_keyword", ..., details={"blocked_keyword": kw})`
|
|
already populates the keyword. Lock it in via assertion so a future
|
|
refactor doesn't drop the field."""
|
|
from src.remote_query import RemoteQueryEngine, RemoteQueryError
|
|
import duckdb
|
|
|
|
conn = duckdb.connect(":memory:")
|
|
engine = RemoteQueryEngine(conn)
|
|
try:
|
|
engine.execute("DROP TABLE foo")
|
|
except RemoteQueryError as exc:
|
|
# Blocked-keyword raise lives at src/remote_query.py:134-138 with
|
|
# error_type="query_error" (not "blocked_keyword" — that's the
|
|
# keyword name in details, not the type).
|
|
assert exc.error_type == "query_error", exc.error_type
|
|
assert exc.details, "blocked_keyword raise must populate details"
|
|
assert "blocked_keyword" in exc.details
|
|
assert exc.details["blocked_keyword"] == "drop "
|
|
else:
|
|
raise AssertionError("expected RemoteQueryError")
|
|
|
|
|
|
def test_query_must_be_select_carries_no_unnecessary_details():
|
|
"""A pure local-validation raise (SQL doesn't start with SELECT)
|
|
has nothing structured to surface. details=None is the right shape;
|
|
test locks that in so a future contributor doesn't add noise."""
|
|
from src.remote_query import RemoteQueryEngine, RemoteQueryError
|
|
import duckdb
|
|
|
|
conn = duckdb.connect(":memory:")
|
|
engine = RemoteQueryEngine(conn)
|
|
try:
|
|
engine.execute("WITH x AS (SELECT 1) SELECT * FROM x")
|
|
except RemoteQueryError as exc:
|
|
# `WITH ...` is treated as not-starting-with-SELECT today; this is
|
|
# the shape we want to assert: details may be empty/None for pure
|
|
# local-validation errors.
|
|
assert exc.error_type in ("query_must_be_select", "blocked_keyword"), exc.error_type
|
|
# Either empty dict or dict with explanatory keys is fine.
|
|
assert isinstance(exc.details, dict)
|
|
except Exception:
|
|
# If the engine accepts WITH, that's also fine — this test is
|
|
# primarily about details shape, not what gets blocked.
|
|
pass
|