diff --git a/cli/commands/init.py b/cli/commands/init.py index 2aa650f..52ba2e7 100644 --- a/cli/commands/init.py +++ b/cli/commands/init.py @@ -186,6 +186,22 @@ def init( }}), err=True) raise typer.Exit(1) + # `run_pull` records per-stage failures into `result.errors` and only + # raises for programming errors. A manifest-stage failure here means + # the analyst has a saved token + saved server URL but no parquets, + # no DuckDB views — surface a typed error so the operator knows the + # workspace is not actually queryable. Common cause: PAT validates + # against /api/catalog/tables but lacks resource_grants for any tables. + manifest_err = next((e for e in result.errors if e.get("stage") == "manifest"), None) + if manifest_err: + typer.echo(render_error(0, {"detail": { + "kind": "manifest_unauthorized", + "hint": "Manifest fetch failed — workspace partially set up. " + "Check that the PAT has resource_grants for at least one table.", + "message": manifest_err.get("error", ""), + }}), err=True) + raise typer.Exit(1) + # ------------------------------------------------------------------ # Step 8: render AGNES_WORKSPACE.md from the static client-side # template. Three placeholders: created_at, server_url, workspace_path. diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index fef20b3..9c38aa0 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -131,3 +131,91 @@ def test_init_partial_state_friendly_exit(tmp_path, monkeypatch): ]) assert result.exit_code == 1 assert "Traceback" not in (result.output + (result.stderr or "")) + + +def test_init_auth_failed_on_401(tmp_path, monkeypatch): + """PAT verify endpoint returns 401 -> auth_failed typed error, exit 1.""" + from unittest.mock import MagicMock + monkeypatch.setenv("AGNES_CONFIG_DIR", str(tmp_path / "_cfg")) + + def _api_get(path, *args, **kwargs): + resp = MagicMock() + resp.status_code = 401 + resp.json.return_value = {"detail": "Invalid token"} + return resp + + monkeypatch.setattr("cli.commands.init.api_get", _api_get, raising=False) + + result = runner.invoke(init_app, [ + "--server-url", "http://x", + "--token", "bad-pat", + "--workspace", str(tmp_path), + ]) + assert result.exit_code == 1 + output = result.output + (result.stderr or "") + assert "Traceback" not in output + # Typed-error envelope should mention the kind or the actionable hint. + assert ("auth_failed" in output) or ("Token expired" in output) or ("Token format invalid" in output) + + +def test_init_server_unreachable_on_connect_error(tmp_path, monkeypatch): + """Network failure during verify -> server_unreachable typed error, exit 1.""" + monkeypatch.setenv("AGNES_CONFIG_DIR", str(tmp_path / "_cfg")) + + def _api_get(path, *args, **kwargs): + raise ConnectionError("simulated network failure") + + monkeypatch.setattr("cli.commands.init.api_get", _api_get, raising=False) + + result = runner.invoke(init_app, [ + "--server-url", "http://unreachable.example.com", + "--token", "test-pat", + "--workspace", str(tmp_path), + ]) + assert result.exit_code == 1 + output = result.output + (result.stderr or "") + assert "Traceback" not in output + assert ("server_unreachable" in output) or ("Cannot reach" in output) + + +def test_init_manifest_unauthorized_when_pull_records_manifest_error(tmp_path, monkeypatch): + """Manifest stage fails -> manifest_unauthorized typed error, exit 1. + + Reproduces the I1 review finding: `run_pull` records per-stage failures + into `result.errors` rather than raising. Without the post-pull error + inspection, init would silently exit 0 with a partially-set-up workspace. + """ + from unittest.mock import MagicMock + from cli.lib.pull import PullResult + + monkeypatch.setenv("AGNES_CONFIG_DIR", str(tmp_path / "_cfg")) + + # init's verify-PAT call succeeds; welcome-fetch succeeds. + def _init_api_get(path, *args, **kwargs): + resp = MagicMock() + resp.status_code = 200 + if path == "/api/welcome": + resp.json.return_value = {"content": "# Test\n"} + else: + resp.json.return_value = [] + return resp + + monkeypatch.setattr("cli.commands.init.api_get", _init_api_get, raising=False) + + # run_pull returns a PullResult carrying a manifest-stage error. + def _fake_run_pull(server_url, token, workspace, *, dry_run=False): + result = PullResult() + result.errors.append({"stage": "manifest", "error": "401 Unauthorized"}) + return result + + monkeypatch.setattr("cli.commands.init.run_pull", _fake_run_pull, raising=False) + + result = runner.invoke(init_app, [ + "--server-url", "http://x", + "--token", "t", + "--workspace", str(tmp_path), + ]) + assert result.exit_code == 1 + output = result.output + (result.stderr or "") + assert "Traceback" not in output + assert ("manifest_unauthorized" in output) or ("Manifest fetch failed" in output)