sovaria-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. apps/cli/README.md +147 -0
  2. apps/cli/__init__.py +0 -0
  3. apps/cli/src/__init__.py +3 -0
  4. apps/cli/src/commands/__init__.py +1 -0
  5. apps/cli/src/commands/admin.py +160 -0
  6. apps/cli/src/commands/api.py +48 -0
  7. apps/cli/src/commands/audit.py +181 -0
  8. apps/cli/src/commands/auth_product.py +187 -0
  9. apps/cli/src/commands/billing.py +513 -0
  10. apps/cli/src/commands/chrono.py +248 -0
  11. apps/cli/src/commands/context.py +99 -0
  12. apps/cli/src/commands/data.py +272 -0
  13. apps/cli/src/commands/db.py +528 -0
  14. apps/cli/src/commands/deploy.py +1099 -0
  15. apps/cli/src/commands/governance.py +491 -0
  16. apps/cli/src/commands/iam.py +436 -0
  17. apps/cli/src/commands/init_cmd.py +177 -0
  18. apps/cli/src/commands/keys.py +120 -0
  19. apps/cli/src/commands/login.py +842 -0
  20. apps/cli/src/commands/org.py +157 -0
  21. apps/cli/src/commands/policy.py +317 -0
  22. apps/cli/src/commands/project.py +130 -0
  23. apps/cli/src/commands/realtime.py +364 -0
  24. apps/cli/src/commands/scope.py +42 -0
  25. apps/cli/src/commands/shell.py +415 -0
  26. apps/cli/src/commands/sim.py +178 -0
  27. apps/cli/src/commands/storage.py +405 -0
  28. apps/cli/src/commands/vector.py +204 -0
  29. apps/cli/src/main.py +169 -0
  30. apps/cli/src/utils/__init__.py +1 -0
  31. apps/cli/src/utils/api_client.py +329 -0
  32. apps/cli/src/utils/auth_store.py +299 -0
  33. apps/cli/src/utils/config.py +185 -0
  34. apps/cli/src/utils/display.py +62 -0
  35. apps/cli/src/utils/migrations.py +69 -0
  36. apps/cli/src/utils/project_context.py +124 -0
  37. apps/cli/tests/test_auth_store_profiles.py +103 -0
  38. apps/cli/tests/test_cli_security.py +110 -0
  39. apps/cli/tests/test_cli_smoke_paths.py +279 -0
  40. apps/cli/tests/test_config_profiles.py +55 -0
  41. apps/cli/tests/test_main_startup.py +29 -0
  42. apps/cli/tests/test_project_and_commands.py +196 -0
  43. sovaria_cli-0.2.0.dist-info/METADATA +170 -0
  44. sovaria_cli-0.2.0.dist-info/RECORD +46 -0
  45. sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
  46. sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,124 @@
1
+ """Discover and apply sovaria.config.json scope for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from sovaria.project import DEFAULT_CONFIG_FILENAME, load_project_config
10
+
11
+ from apps.cli.src.utils.config import get_scope, set_scope
12
+
13
+
14
+ def find_project_config(start: Path | None = None) -> Path | None:
15
+ """Walk upward from *start* (or cwd) looking for sovaria.config.json."""
16
+ cwd = (start or Path.cwd()).resolve()
17
+ for directory in (cwd, *cwd.parents):
18
+ candidate = directory / DEFAULT_CONFIG_FILENAME
19
+ if candidate.is_file():
20
+ return candidate
21
+ return None
22
+
23
+
24
+ def load_local_project_config(config_path: Path | None = None) -> dict[str, Any] | None:
25
+ """Return project config dict from disk, or None if not linked locally."""
26
+ path = config_path or find_project_config()
27
+ if path is None:
28
+ return None
29
+ try:
30
+ return load_project_config(path).to_dict()
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def linked_scope(profile: str | None = None) -> dict[str, str]:
36
+ """Return org/project/env from local sovaria.config.json when present."""
37
+ path = find_project_config()
38
+ if path is None:
39
+ return {"org_id": "", "project_id": "", "env_id": ""}
40
+ try:
41
+ cfg = load_project_config(path)
42
+ except Exception:
43
+ return {"org_id": "", "project_id": "", "env_id": ""}
44
+ return {
45
+ "org_id": cfg.scope.org_id,
46
+ "project_id": cfg.scope.project_id,
47
+ "env_id": cfg.scope.env_id,
48
+ }
49
+
50
+
51
+ def resolve_scope(
52
+ *,
53
+ profile: str | None,
54
+ org_id: str | None,
55
+ project_id: str | None,
56
+ env_id: str | None,
57
+ require: bool = False,
58
+ ) -> tuple[str, str, str]:
59
+ """Merge explicit flags, local project config, and profile defaults."""
60
+ local = linked_scope(profile)
61
+ profile_scope = get_scope(profile)
62
+
63
+ resolved_org = (org_id or local.get("org_id") or profile_scope.get("org_id") or "").strip()
64
+ resolved_project = (
65
+ project_id or local.get("project_id") or profile_scope.get("project_id") or ""
66
+ ).strip()
67
+ resolved_env = (env_id or local.get("env_id") or profile_scope.get("env_id") or "").strip()
68
+
69
+ if require and (not resolved_org or not resolved_project):
70
+ raise ValueError(
71
+ "Missing project scope. Run `sov project link`, `sov scope set`, or pass --org/--project."
72
+ )
73
+ return resolved_org, resolved_project, resolved_env
74
+
75
+
76
+ def link_project_to_profile(
77
+ *,
78
+ org_id: str,
79
+ project_id: str,
80
+ env_id: str | None = None,
81
+ profile: str | None = None,
82
+ config_path: Path | None = None,
83
+ ) -> Path:
84
+ """Write scope IDs into sovaria.config.json and ~/.sovaria/config.yaml."""
85
+ path = config_path or find_project_config()
86
+ if path is None:
87
+ raise FileNotFoundError(
88
+ f"No {DEFAULT_CONFIG_FILENAME} found. Run `sov project init-config` or `sov init` first."
89
+ )
90
+
91
+ raw = json.loads(path.read_text())
92
+ if not isinstance(raw, dict):
93
+ raise ValueError(f"Invalid config file: {path}")
94
+ scope = raw.setdefault("scope", {})
95
+ if not isinstance(scope, dict):
96
+ scope = {}
97
+ raw["scope"] = scope
98
+ scope["org_id"] = org_id.strip()
99
+ scope["project_id"] = project_id.strip()
100
+ if env_id:
101
+ scope["env_id"] = env_id.strip()
102
+ path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n")
103
+
104
+ set_scope(org_id=org_id, project_id=project_id, env_id=env_id, profile=profile)
105
+ return path
106
+
107
+
108
+ def project_status() -> dict[str, Any]:
109
+ """Summarize local project linkage for `sov project status`."""
110
+ path = find_project_config()
111
+ if path is None:
112
+ return {"linked": False, "config_path": None}
113
+ try:
114
+ cfg = load_project_config(path)
115
+ except Exception as exc:
116
+ return {"linked": False, "config_path": str(path), "error": str(exc)}
117
+ return {
118
+ "linked": bool(cfg.scope.org_id and cfg.scope.project_id),
119
+ "config_path": str(path),
120
+ "project_name": cfg.name,
121
+ "gateway_url": cfg.gateway_url,
122
+ "scope": cfg.scope.to_dict(),
123
+ "features": cfg.features.to_dict(),
124
+ }
@@ -0,0 +1,103 @@
1
+ """Regression tests for profile-aware CLI token persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import tomllib
7
+ import types
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ try:
13
+ import tomli_w as _tomli_w # noqa: F401
14
+ except ModuleNotFoundError:
15
+ # Minimal shim for test environments without tomli-w installed.
16
+ def _shim_dumps(payload: dict[object, object]) -> str:
17
+ lines: list[str] = []
18
+ if "version" in payload:
19
+ lines.append(f"version = {payload['version']}")
20
+ lines.append("")
21
+ profiles = payload.get("profiles")
22
+ if isinstance(profiles, dict):
23
+ for profile_name, profile_payload in profiles.items():
24
+ lines.append(f"[profiles.{profile_name}]")
25
+ if isinstance(profile_payload, dict):
26
+ for key, value in profile_payload.items():
27
+ lines.append(f'{key} = "{value}"')
28
+ lines.append("")
29
+ return "\n".join(lines).rstrip() + "\n"
30
+
31
+ for key, value in payload.items():
32
+ lines.append(f'{key} = "{value}"')
33
+ return "\n".join(lines).rstrip() + "\n"
34
+
35
+ _shim_module = types.ModuleType("tomli_w")
36
+ _shim_module.dumps = _shim_dumps # type: ignore[attr-defined]
37
+ sys.modules["tomli_w"] = _shim_module
38
+
39
+ from apps.cli.src.utils import auth_store
40
+
41
+
42
+ @pytest.fixture
43
+ def isolated_auth_store(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
44
+ """Force auth_store to use file fallback in a temporary sovaria dir."""
45
+ sovaria_dir = tmp_path / ".sovaria"
46
+ cred_file = sovaria_dir / "credentials.toml"
47
+ monkeypatch.setattr(auth_store, "_KEYRING_AVAILABLE", False)
48
+ monkeypatch.setattr(auth_store, "_SOVARIA_DIR", sovaria_dir)
49
+ monkeypatch.setattr(auth_store, "_CRED_FILE", cred_file)
50
+ return cred_file
51
+
52
+
53
+ def test_save_and_load_tokens_are_profile_scoped(isolated_auth_store: Path) -> None:
54
+ auth_store.save_tokens("dev_access", "dev_refresh", profile="dev")
55
+ auth_store.save_tokens("staging_access", "staging_refresh", profile="staging")
56
+
57
+ dev_tokens = auth_store.load_tokens(profile="dev")
58
+ assert dev_tokens is not None
59
+ assert dev_tokens["access_token"] == "dev_access"
60
+ assert dev_tokens["refresh_token"] == "dev_refresh"
61
+ assert "expires_at" in dev_tokens
62
+
63
+ staging_tokens = auth_store.load_tokens(profile="staging")
64
+ assert staging_tokens is not None
65
+ assert staging_tokens["access_token"] == "staging_access"
66
+ assert staging_tokens["refresh_token"] == "staging_refresh"
67
+ assert "expires_at" in staging_tokens
68
+
69
+ parsed = tomllib.loads(isolated_auth_store.read_text())
70
+ assert parsed["version"] == 1
71
+ assert parsed["profiles"]["dev"]["access_token"] == "dev_access"
72
+ assert parsed["profiles"]["staging"]["access_token"] == "staging_access"
73
+
74
+
75
+ def test_clear_tokens_only_removes_selected_profile(isolated_auth_store: Path) -> None:
76
+ auth_store.save_tokens("dev_access", "dev_refresh", profile="dev")
77
+ auth_store.save_tokens("prod_access", "prod_refresh", profile="prod")
78
+
79
+ auth_store.clear_tokens(profile="dev")
80
+
81
+ assert auth_store.load_tokens(profile="dev") is None
82
+ prod_tokens = auth_store.load_tokens(profile="prod")
83
+ assert prod_tokens is not None
84
+ assert prod_tokens["access_token"] == "prod_access"
85
+ assert prod_tokens["refresh_token"] == "prod_refresh"
86
+ assert "expires_at" in prod_tokens
87
+ assert isolated_auth_store.exists()
88
+
89
+
90
+ def test_load_tokens_accepts_legacy_credential_shape(isolated_auth_store: Path) -> None:
91
+ isolated_auth_store.parent.mkdir(parents=True, exist_ok=True)
92
+ isolated_auth_store.write_text(
93
+ 'access_token = "legacy_access"\nrefresh_token = "legacy_refresh"\n',
94
+ )
95
+
96
+ assert auth_store.load_tokens(profile="prod") == {
97
+ "access_token": "legacy_access",
98
+ "refresh_token": "legacy_refresh",
99
+ }
100
+
101
+ parsed = tomllib.loads(isolated_auth_store.read_text())
102
+ assert parsed["version"] == 1
103
+ assert parsed["profiles"]["prod"]["access_token"] == "legacy_access"
@@ -0,0 +1,110 @@
1
+ """Tests for CLI MFA login handling and token refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+ from unittest.mock import patch
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from apps.cli.src.utils.api_client import CLIMfaRequiredError, _apply_login_response, call_api
13
+ from apps.cli.src.utils.auth_store import clear_mfa_pending, clear_tokens, load_mfa_pending, load_tokens, save_tokens
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def _clean_auth_store(tmp_path, monkeypatch):
18
+ sovaria_dir = tmp_path / ".sovaria"
19
+ sovaria_dir.mkdir()
20
+ monkeypatch.setattr("apps.cli.src.utils.auth_store._SOVARIA_DIR", sovaria_dir)
21
+ monkeypatch.setattr("apps.cli.src.utils.auth_store._CRED_FILE", sovaria_dir / "credentials.toml")
22
+ monkeypatch.setattr("apps.cli.src.utils.auth_store._KEYRING_AVAILABLE", False)
23
+ yield
24
+
25
+
26
+ def test_apply_login_response_rejects_mfa_without_refresh() -> None:
27
+ with pytest.raises(CLIMfaRequiredError):
28
+ _apply_login_response(
29
+ {
30
+ "access_token": "pre-mfa-token",
31
+ "refresh_token": None,
32
+ "expires_in": 300,
33
+ "mfa_required": True,
34
+ },
35
+ profile=None,
36
+ )
37
+
38
+ pending = load_mfa_pending()
39
+ assert pending is not None
40
+ assert pending["access_token"] == "pre-mfa-token"
41
+
42
+
43
+ def test_apply_login_response_persists_full_session() -> None:
44
+ _apply_login_response(
45
+ {
46
+ "access_token": "access-1",
47
+ "refresh_token": "refresh-1",
48
+ "expires_in": 3600,
49
+ "mfa_required": False,
50
+ },
51
+ profile=None,
52
+ )
53
+ tokens = load_tokens()
54
+ assert tokens is not None
55
+ assert tokens["access_token"] == "access-1"
56
+ assert tokens["refresh_token"] == "refresh-1"
57
+
58
+
59
+ def test_call_api_refreshes_expired_token(monkeypatch) -> None:
60
+ save_tokens("old-access", "refresh-1", expires_in=3600)
61
+ monkeypatch.setattr(
62
+ "apps.cli.src.utils.api_client._token_needs_refresh",
63
+ lambda _tokens: True,
64
+ )
65
+
66
+ captured: dict[str, Any] = {}
67
+
68
+ def fake_post(url: str, **kwargs: Any) -> httpx.Response:
69
+ if url.endswith("/api/v1/auth/refresh"):
70
+ captured["refreshed"] = True
71
+ return httpx.Response(
72
+ 200,
73
+ json={
74
+ "access_token": "new-access",
75
+ "refresh_token": "refresh-2",
76
+ "expires_in": 3600,
77
+ },
78
+ request=httpx.Request("POST", url),
79
+ )
80
+ raise AssertionError(f"unexpected POST {url}")
81
+
82
+ def fake_request(method: str, url: str, **kwargs: Any) -> httpx.Response:
83
+ captured["authorized"] = kwargs["headers"].get("Authorization")
84
+ return httpx.Response(200, json={"ok": True}, request=httpx.Request(method, url))
85
+
86
+ monkeypatch.setattr("apps.cli.src.utils.api_client.httpx.request", fake_request)
87
+ monkeypatch.setattr("apps.cli.src.utils.api_client.httpx.post", fake_post)
88
+ monkeypatch.setattr("apps.cli.src.utils.api_client.get_api_url", lambda _p=None: "http://gateway.test")
89
+
90
+ result = call_api(method="GET", path="/api/v1/auth/me")
91
+ assert result == {"ok": True}
92
+ assert captured.get("refreshed") is True
93
+ assert captured.get("authorized") == "Bearer new-access"
94
+
95
+ stored = load_tokens()
96
+ assert stored is not None
97
+ assert stored["access_token"] == "new-access"
98
+
99
+
100
+ def test_call_api_blocks_when_mfa_pending(monkeypatch) -> None:
101
+ from apps.cli.src.utils.auth_store import save_mfa_pending
102
+
103
+ save_mfa_pending("pre-mfa-token")
104
+ clear_tokens()
105
+ monkeypatch.setattr("apps.cli.src.utils.api_client.get_api_url", lambda _p=None: "http://gateway.test")
106
+
107
+ with pytest.raises(CLIMfaRequiredError):
108
+ call_api(method="GET", path="/api/v1/auth/me")
109
+
110
+ clear_mfa_pending()
@@ -0,0 +1,279 @@
1
+ """Smoke tests for core CLI command paths (auth/data/storage/policy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import types
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ if "typer" not in sys.modules:
12
+
13
+ class _TyperExit(Exception):
14
+ def __init__(self, code: int = 0) -> None:
15
+ super().__init__(code)
16
+ self.exit_code = code
17
+
18
+ class _TyperApp:
19
+ def command(self, *args: object, **kwargs: object) -> Any:
20
+ def _decorator(func: Any) -> Any:
21
+ return func
22
+
23
+ return _decorator
24
+
25
+ def add_typer(self, *args: object, **kwargs: object) -> None:
26
+ return None
27
+
28
+ def callback(self, *args: object, **kwargs: object) -> Any:
29
+ def _decorator(func: Any) -> Any:
30
+ return func
31
+
32
+ return _decorator
33
+
34
+ _typer_module = types.ModuleType("typer")
35
+ _typer_module.Typer = lambda *args, **kwargs: _TyperApp() # type: ignore[attr-defined]
36
+ _typer_module.Option = lambda default=None, *args, **kwargs: default # type: ignore[attr-defined]
37
+ _typer_module.Argument = lambda default=None, *args, **kwargs: default # type: ignore[attr-defined]
38
+ _typer_module.echo = lambda *args, **kwargs: None # type: ignore[attr-defined]
39
+ _typer_module.style = lambda text, *args, **kwargs: text # type: ignore[attr-defined]
40
+ _typer_module.Exit = _TyperExit # type: ignore[attr-defined]
41
+ _typer_module.Context = object # type: ignore[attr-defined]
42
+ _typer_module.colors = types.SimpleNamespace(RED="red", GREEN="green") # type: ignore[attr-defined]
43
+ sys.modules["typer"] = _typer_module
44
+
45
+ if "tomli_w" not in sys.modules:
46
+
47
+ def _shim_tomli_w_dumps(payload: dict[object, object]) -> str:
48
+ lines: list[str] = []
49
+ if "version" in payload:
50
+ lines.append(f"version = {payload['version']}")
51
+ lines.append("")
52
+ profiles = payload.get("profiles")
53
+ if isinstance(profiles, dict):
54
+ for profile_name, profile_payload in profiles.items():
55
+ lines.append(f"[profiles.{profile_name}]")
56
+ if isinstance(profile_payload, dict):
57
+ for key, value in profile_payload.items():
58
+ lines.append(f'{key} = "{value}"')
59
+ lines.append("")
60
+ return "\n".join(lines).rstrip() + "\n"
61
+ for key, value in payload.items():
62
+ lines.append(f'{key} = "{value}"')
63
+ return "\n".join(lines).rstrip() + "\n"
64
+
65
+ _tomli_w_module = types.ModuleType("tomli_w")
66
+ _tomli_w_module.dumps = _shim_tomli_w_dumps # type: ignore[attr-defined]
67
+ sys.modules["tomli_w"] = _tomli_w_module
68
+
69
+
70
+ from apps.cli.src.utils.api_client import CLIRequestError
71
+ from apps.cli.src.commands import auth_product, data, policy, realtime, storage
72
+
73
+
74
+ def test_auth_users_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
75
+ captured: dict[str, Any] = {}
76
+
77
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
78
+ captured.update(kwargs)
79
+ return {"ok": True}
80
+
81
+ monkeypatch.setattr(auth_product, "call_api", _fake_call_api)
82
+ auth_product.list_users(profile="staging", org_id="org-1", project_id="proj-1", env_id=None)
83
+
84
+ assert captured["method"] == "GET"
85
+ assert captured["path"] == "/api/v1/projects/proj-1/auth/users"
86
+ assert captured["profile"] == "staging"
87
+ assert captured["require_scope"] is True
88
+
89
+
90
+ def test_auth_user_roles_set_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
91
+ captured: dict[str, Any] = {}
92
+
93
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
94
+ captured.update(kwargs)
95
+ return {"ok": True}
96
+
97
+ monkeypatch.setattr(auth_product, "call_api", _fake_call_api)
98
+ auth_product.set_roles(
99
+ user_id="user-1",
100
+ roles=["SUPPORT"],
101
+ profile="staging",
102
+ org_id="org-1",
103
+ project_id="proj-1",
104
+ env_id=None,
105
+ )
106
+
107
+ assert captured["method"] == "PUT"
108
+ assert captured["path"] == "/api/v1/projects/proj-1/auth/users/user-1/roles"
109
+ assert captured["body"] == {"roles": ["SUPPORT"]}
110
+ assert captured["require_scope"] is True
111
+
112
+
113
+ def test_data_sql_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
114
+ captured: dict[str, Any] = {}
115
+
116
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
117
+ captured.update(kwargs)
118
+ return {"columns": ["x"], "rows": [[1]]}
119
+
120
+ monkeypatch.setattr(data, "call_api", _fake_call_api)
121
+ data.sql_query(
122
+ sql="select 1",
123
+ txid=0,
124
+ profile="prod",
125
+ org_id="org-1",
126
+ project_id="proj-1",
127
+ env_id=None,
128
+ )
129
+
130
+ assert captured["method"] == "POST"
131
+ assert captured["path"] == "/api/v1/sql/query"
132
+ assert captured["profile"] == "prod"
133
+ assert captured["require_scope"] is True
134
+
135
+
136
+ def test_policy_list_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
137
+ captured: dict[str, Any] = {}
138
+
139
+ def _fake_call_api(**kwargs: Any) -> list[dict[str, Any]]:
140
+ captured.update(kwargs)
141
+ return [{"policy_id": "p1"}]
142
+
143
+ monkeypatch.setattr(policy, "call_api", _fake_call_api)
144
+ policy.list_policies(profile="dev", org_id="org-1", project_id="proj-1", env_id=None)
145
+
146
+ assert captured["method"] == "GET"
147
+ assert captured["path"] == "/api/v1/policies/"
148
+ assert captured["profile"] == "dev"
149
+
150
+
151
+ def test_policy_check_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
152
+ captured: dict[str, Any] = {}
153
+
154
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
155
+ captured.update(kwargs)
156
+ return {"allowed": True}
157
+
158
+ monkeypatch.setattr(policy, "call_api", _fake_call_api)
159
+ policy.check_policy(
160
+ action="sql:select",
161
+ resource="users",
162
+ context='{"actor":{"roles":["SUPPORT"]}}',
163
+ profile="dev",
164
+ org_id="org-1",
165
+ project_id="proj-1",
166
+ env_id=None,
167
+ )
168
+
169
+ assert captured["method"] == "POST"
170
+ assert captured["path"] == "/api/v1/policies/check"
171
+ assert captured["body"] == {
172
+ "org_id": "org-1",
173
+ "project_id": "proj-1",
174
+ "action": "sql:select",
175
+ "resource": "users",
176
+ "context": {"actor": {"roles": ["SUPPORT"]}},
177
+ }
178
+
179
+
180
+ def test_policy_upsert_surfaces_non_conflict_create_errors(monkeypatch: pytest.MonkeyPatch) -> None:
181
+ captured: list[tuple[str, bool]] = []
182
+
183
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
184
+ raise CLIRequestError("create failed", status_code=400)
185
+
186
+ monkeypatch.setattr(policy, "call_api", _fake_call_api)
187
+ monkeypatch.setattr(
188
+ policy.typer,
189
+ "echo",
190
+ lambda message, err=False: captured.append((str(message), err)),
191
+ )
192
+
193
+ with pytest.raises(policy.typer.Exit) as exc_info:
194
+ policy.upsert_policy(
195
+ policy_id="p1",
196
+ name="policy",
197
+ spl_source="allow",
198
+ org_id="org-1",
199
+ profile="dev",
200
+ project_id="proj-1",
201
+ env_id=None,
202
+ )
203
+
204
+ assert exc_info.value.exit_code == 1
205
+ assert any("create failed" in message for message, _ in captured)
206
+
207
+
208
+ def test_policy_upsert_updates_on_create_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
209
+ calls: list[dict[str, Any]] = []
210
+
211
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
212
+ calls.append(kwargs)
213
+ if kwargs["method"] == "POST":
214
+ raise CLIRequestError("already exists", status_code=409)
215
+ return {"updated": True}
216
+
217
+ monkeypatch.setattr(policy, "call_api", _fake_call_api)
218
+
219
+ policy.upsert_policy(
220
+ policy_id="p1",
221
+ name="policy",
222
+ spl_source="allow",
223
+ org_id="org-1",
224
+ profile="dev",
225
+ project_id="proj-1",
226
+ env_id=None,
227
+ )
228
+
229
+ assert [call["method"] for call in calls] == ["POST", "PUT"]
230
+ def test_realtime_ws_url_issues_ticket_not_token_query(monkeypatch: pytest.MonkeyPatch) -> None:
231
+ captured: dict[str, Any] = {}
232
+ output: list[str] = []
233
+
234
+ def _fake_call_api(**kwargs: Any) -> dict[str, Any]:
235
+ captured.update(kwargs)
236
+ return {"ticket": "ticket-1"}
237
+
238
+ monkeypatch.setattr(realtime, "call_api", _fake_call_api)
239
+ monkeypatch.setattr(realtime, "get_api_url", lambda profile=None: "http://gateway:8000")
240
+ monkeypatch.setattr(
241
+ realtime,
242
+ "get_scope",
243
+ lambda profile=None: {"org_id": "org-1", "project_id": "proj-1", "env_id": ""},
244
+ )
245
+ monkeypatch.setattr(realtime.typer, "echo", lambda message, err=False: output.append(str(message)))
246
+
247
+ realtime.ws_url(channel="db.table.users", profile="dev", ticket=None, org_id=None, project_id=None, env_id=None)
248
+
249
+ assert captured["method"] == "POST"
250
+ assert captured["path"] == "/api/v1/auth/ws-ticket"
251
+ assert captured["body"] == {
252
+ "channel": "db.table.users",
253
+ "org_id": "org-1",
254
+ "project_id": "proj-1",
255
+ }
256
+ assert output == [
257
+ "ws://gateway:8000/api/v1/rt/ws/db.table.users?ticket=ticket-1&org_id=org-1&project_id=proj-1"
258
+ ]
259
+ assert "token=" not in output[0]
260
+
261
+
262
+ def test_storage_buckets_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
263
+ captured: dict[str, Any] = {}
264
+ output: list[str] = []
265
+
266
+ def _fake_call_api(**kwargs: Any) -> list[dict[str, Any]]:
267
+ captured.update(kwargs)
268
+ return [{"name": "bucket-a", "public": False, "object_count": 1}]
269
+
270
+ monkeypatch.setattr(storage, "call_api", _fake_call_api)
271
+ monkeypatch.setattr(
272
+ storage.typer, "echo", lambda message, err=False: output.append(str(message))
273
+ )
274
+
275
+ storage.list_buckets(project_id="proj-1", org_id=None, env_id=None, profile=None)
276
+
277
+ assert captured["path"] == "/api/v1/store/buckets"
278
+ assert captured["require_scope"] is True
279
+ assert any("bucket-a" in line for line in output)
@@ -0,0 +1,55 @@
1
+ """Regression tests for CLI config profile normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ import yaml
9
+
10
+ from apps.cli.src.utils import config
11
+
12
+
13
+ def test_get_api_url_upgrades_unversioned_string_profile(
14
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
15
+ ) -> None:
16
+ config_file = tmp_path / ".sovaria" / "config.yaml"
17
+ config_file.parent.mkdir(parents=True, exist_ok=True)
18
+ config_file.write_text(
19
+ yaml.safe_dump(
20
+ {
21
+ "default_profile": "dev",
22
+ "profiles": {
23
+ "dev": "http://localhost:8000",
24
+ },
25
+ }
26
+ )
27
+ )
28
+ monkeypatch.setattr(config, "_CONFIG_FILE", config_file)
29
+
30
+ assert config.get_api_url("dev") == "http://localhost:8000"
31
+
32
+ parsed = yaml.safe_load(config_file.read_text())
33
+ assert parsed["version"] == 1
34
+ assert parsed["profiles"]["dev"] == {"api_url": "http://localhost:8000"}
35
+
36
+
37
+ def test_get_scope_coerces_string_profile(
38
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
39
+ ) -> None:
40
+ config_file = tmp_path / ".sovaria" / "config.yaml"
41
+ config_file.parent.mkdir(parents=True, exist_ok=True)
42
+ config_file.write_text(
43
+ yaml.safe_dump(
44
+ {
45
+ "version": 1,
46
+ "default_profile": "dev",
47
+ "profiles": {
48
+ "dev": "http://localhost:8000",
49
+ },
50
+ }
51
+ )
52
+ )
53
+ monkeypatch.setattr(config, "_CONFIG_FILE", config_file)
54
+
55
+ assert config.get_scope("dev") == {"org_id": "", "project_id": "", "env_id": ""}