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.
- apps/cli/README.md +147 -0
- apps/cli/__init__.py +0 -0
- apps/cli/src/__init__.py +3 -0
- apps/cli/src/commands/__init__.py +1 -0
- apps/cli/src/commands/admin.py +160 -0
- apps/cli/src/commands/api.py +48 -0
- apps/cli/src/commands/audit.py +181 -0
- apps/cli/src/commands/auth_product.py +187 -0
- apps/cli/src/commands/billing.py +513 -0
- apps/cli/src/commands/chrono.py +248 -0
- apps/cli/src/commands/context.py +99 -0
- apps/cli/src/commands/data.py +272 -0
- apps/cli/src/commands/db.py +528 -0
- apps/cli/src/commands/deploy.py +1099 -0
- apps/cli/src/commands/governance.py +491 -0
- apps/cli/src/commands/iam.py +436 -0
- apps/cli/src/commands/init_cmd.py +177 -0
- apps/cli/src/commands/keys.py +120 -0
- apps/cli/src/commands/login.py +842 -0
- apps/cli/src/commands/org.py +157 -0
- apps/cli/src/commands/policy.py +317 -0
- apps/cli/src/commands/project.py +130 -0
- apps/cli/src/commands/realtime.py +364 -0
- apps/cli/src/commands/scope.py +42 -0
- apps/cli/src/commands/shell.py +415 -0
- apps/cli/src/commands/sim.py +178 -0
- apps/cli/src/commands/storage.py +405 -0
- apps/cli/src/commands/vector.py +204 -0
- apps/cli/src/main.py +169 -0
- apps/cli/src/utils/__init__.py +1 -0
- apps/cli/src/utils/api_client.py +329 -0
- apps/cli/src/utils/auth_store.py +299 -0
- apps/cli/src/utils/config.py +185 -0
- apps/cli/src/utils/display.py +62 -0
- apps/cli/src/utils/migrations.py +69 -0
- apps/cli/src/utils/project_context.py +124 -0
- apps/cli/tests/test_auth_store_profiles.py +103 -0
- apps/cli/tests/test_cli_security.py +110 -0
- apps/cli/tests/test_cli_smoke_paths.py +279 -0
- apps/cli/tests/test_config_profiles.py +55 -0
- apps/cli/tests/test_main_startup.py +29 -0
- apps/cli/tests/test_project_and_commands.py +196 -0
- sovaria_cli-0.2.0.dist-info/METADATA +170 -0
- sovaria_cli-0.2.0.dist-info/RECORD +46 -0
- sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
- 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": ""}
|