verulla-cli 0.0.2__tar.gz
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.
- verulla_cli-0.0.2/.gitignore +7 -0
- verulla_cli-0.0.2/PKG-INFO +8 -0
- verulla_cli-0.0.2/pyproject.toml +21 -0
- verulla_cli-0.0.2/tests/test_admin_cmd.py +49 -0
- verulla_cli-0.0.2/tests/test_apps_client.py +225 -0
- verulla_cli-0.0.2/tests/test_auth.py +442 -0
- verulla_cli-0.0.2/tests/test_buildkit.py +85 -0
- verulla_cli-0.0.2/tests/test_buildkit_real.py +95 -0
- verulla_cli-0.0.2/tests/test_cli_import.py +4 -0
- verulla_cli-0.0.2/tests/test_deploy_client.py +101 -0
- verulla_cli-0.0.2/tests/test_dev_check.py +139 -0
- verulla_cli-0.0.2/tests/test_emulate.py +242 -0
- verulla_cli-0.0.2/tests/test_main.py +518 -0
- verulla_cli-0.0.2/tests/test_owner_commands.py +63 -0
- verulla_cli-0.0.2/tests/test_packaging.py +118 -0
- verulla_cli-0.0.2/tests/test_secrets_client.py +111 -0
- verulla_cli-0.0.2/tests/test_sketch_status.py +58 -0
- verulla_cli-0.0.2/verulla_cli/__init__.py +7 -0
- verulla_cli-0.0.2/verulla_cli/apps_client.py +182 -0
- verulla_cli-0.0.2/verulla_cli/auth.py +235 -0
- verulla_cli-0.0.2/verulla_cli/buildkit.py +107 -0
- verulla_cli-0.0.2/verulla_cli/deploy_client.py +47 -0
- verulla_cli-0.0.2/verulla_cli/emulate.py +124 -0
- verulla_cli-0.0.2/verulla_cli/main.py +1000 -0
- verulla_cli-0.0.2/verulla_cli/packaging.py +106 -0
- verulla_cli-0.0.2/verulla_cli/secrets_client.py +79 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "verulla-cli"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"verulla-manifest==0.0.2",
|
|
7
|
+
"typer>=0.12",
|
|
8
|
+
"httpx>=0.27",
|
|
9
|
+
"docker>=7",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
verulla = "verulla_cli.main:app"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.deptry]
|
|
20
|
+
known_first_party = ["verulla_cli"]
|
|
21
|
+
extend_exclude = ["tests"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from typer.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from verulla_cli import main as cli_main
|
|
7
|
+
from verulla_cli.main import app
|
|
8
|
+
|
|
9
|
+
runner = CliRunner()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _patch(monkeypatch, handler):
|
|
13
|
+
monkeypatch.setattr(cli_main, "get_token", lambda: "tok")
|
|
14
|
+
real = httpx.get
|
|
15
|
+
|
|
16
|
+
def fake_get(url, **kw):
|
|
17
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as c:
|
|
18
|
+
return c.get(url, headers=kw.get("headers"))
|
|
19
|
+
|
|
20
|
+
monkeypatch.setattr(httpx, "get", fake_get)
|
|
21
|
+
return real
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_whoami_prints_identity(monkeypatch) -> None:
|
|
25
|
+
seen = {}
|
|
26
|
+
|
|
27
|
+
def handler(req):
|
|
28
|
+
seen["url"] = str(req.url)
|
|
29
|
+
seen["auth"] = req.headers["authorization"]
|
|
30
|
+
return httpx.Response(200, json={"sub": "u1", "roles": ["operator"], "scopes": ["platform:operator"], "client": "c1"})
|
|
31
|
+
|
|
32
|
+
_patch(monkeypatch, handler)
|
|
33
|
+
r = runner.invoke(app, ["admin", "whoami", "--admin-url", "https://x.ts.net/"])
|
|
34
|
+
assert r.exit_code == 0, r.output
|
|
35
|
+
assert seen["url"] == "https://x.ts.net/v1/admin/whoami"
|
|
36
|
+
assert seen["auth"] == "Bearer tok"
|
|
37
|
+
assert "operator" in r.output and "platform:operator" in r.output
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_whoami_shows_api_error(monkeypatch) -> None:
|
|
41
|
+
_patch(monkeypatch, lambda req: httpx.Response(403, text="missing admin scope"))
|
|
42
|
+
r = runner.invoke(app, ["admin", "whoami", "--admin-url", "https://x.ts.net"])
|
|
43
|
+
assert r.exit_code == 1
|
|
44
|
+
assert "403" in r.output and "missing admin scope" in r.output
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_whoami_requires_url(monkeypatch) -> None:
|
|
48
|
+
monkeypatch.delenv("VERULLA_ADMIN_URL", raising=False)
|
|
49
|
+
assert runner.invoke(app, ["admin", "whoami"]).exit_code == 2
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from verulla_cli.apps_client import (
|
|
7
|
+
AppsError, activate_app, delete_app, delete_customer, export_customers, get_app_logs, get_auth_policy,
|
|
8
|
+
get_spend_summary, invite_customer, list_apps, list_customers, lock_customer,
|
|
9
|
+
get_sketch_events, rollback_app, set_auth_policy, unlock_customer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeClient:
|
|
14
|
+
def __init__(self, response):
|
|
15
|
+
self.response = response
|
|
16
|
+
self.calls = []
|
|
17
|
+
|
|
18
|
+
def request(self, method, url, headers, **kwargs):
|
|
19
|
+
self.calls.append((method, url, headers, kwargs))
|
|
20
|
+
return self.response
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def response(status, body):
|
|
24
|
+
req = httpx.Request("GET", "http://api.test")
|
|
25
|
+
return httpx.Response(status, json=body, request=req)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_list_apps_uses_authenticated_apps_endpoint():
|
|
29
|
+
client = FakeClient(response(200, {"apps": [{"app": "demo"}]}))
|
|
30
|
+
assert list_apps("http://api.test", "token", client) == [{"app": "demo"}]
|
|
31
|
+
assert client.calls[0][:3] == ("GET", "http://api.test/v1/apps", {"Authorization": "Bearer token"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_sketch_events_passes_cursor_and_limit_without_changing_contract():
|
|
36
|
+
body = {
|
|
37
|
+
"events": [{
|
|
38
|
+
"id": "12345678-90ab-cdef-0123-4567890abcde",
|
|
39
|
+
"type": "sketch.reminder_48h",
|
|
40
|
+
"app": "demo",
|
|
41
|
+
"occurred_at": "2026-10-29T12:00:00Z",
|
|
42
|
+
"payload": {"due_at": "2026-10-29T12:00:00Z"},
|
|
43
|
+
"cursor": "opaque",
|
|
44
|
+
}],
|
|
45
|
+
"next_cursor": "opaque",
|
|
46
|
+
"has_more": False,
|
|
47
|
+
}
|
|
48
|
+
client = FakeClient(response(200, body))
|
|
49
|
+
result = get_sketch_events("http://api.test", "token", "my app", cursor="opaque", limit=25, client=client)
|
|
50
|
+
from importlib.util import module_from_spec, spec_from_file_location
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
contract_path = Path(__file__).resolve().parents[2] / "mcp" / "tests" / "api_contract.py"
|
|
54
|
+
spec = spec_from_file_location("mcp_api_contract", contract_path)
|
|
55
|
+
assert spec is not None and spec.loader is not None
|
|
56
|
+
contract = module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(contract)
|
|
58
|
+
contract.validate_sketch_event_page(result)
|
|
59
|
+
assert result == body
|
|
60
|
+
method, url, headers, kwargs = client.calls[0]
|
|
61
|
+
assert method == "GET"
|
|
62
|
+
assert url == "http://api.test/v1/apps/my%20app/events"
|
|
63
|
+
assert headers["Authorization"] == "Bearer token"
|
|
64
|
+
assert kwargs["params"] == {"limit": 25, "cursor": "opaque"}
|
|
65
|
+
|
|
66
|
+
def test_sketch_events_rejects_invalid_response_payload():
|
|
67
|
+
client = FakeClient(response(200, {"events": [{"type": "sketch.retention_ended"}], "next_cursor": None, "has_more": False}))
|
|
68
|
+
with pytest.raises(AppsError, match="invalid Sketch event response"):
|
|
69
|
+
get_sketch_events("http://api.test", "token", "demo", client=client)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_currency_client_calls_preserve_legacy_cent_fields_and_send_topup_currency():
|
|
74
|
+
client = FakeClient(response(201, {"topup_id": "top-1", "amount_cents": 1500, "currency": "CAD"}))
|
|
75
|
+
from verulla_cli.apps_client import create_topup, get_usage_summary, get_wallet_summary
|
|
76
|
+
|
|
77
|
+
assert create_topup("http://api.test", "token", 1500, "CAD", client)["currency"] == "CAD"
|
|
78
|
+
assert client.calls[0][0:2] == ("POST", "http://api.test/v1/wallet/topups")
|
|
79
|
+
assert client.calls[0][3]["json"] == {"amount_cents": 1500, "currency": "CAD"}
|
|
80
|
+
get_wallet_summary("http://api.test", "token", client)
|
|
81
|
+
get_usage_summary("http://api.test", "token", client)
|
|
82
|
+
assert [call[1] for call in client.calls[1:]] == [
|
|
83
|
+
"http://api.test/v1/wallet", "http://api.test/v1/usage",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_term_usage_client_requests_optional_app_filter():
|
|
88
|
+
from verulla_cli.apps_client import get_term_usage
|
|
89
|
+
|
|
90
|
+
client = FakeClient(response(200, {"currency": "CAD", "apps": []}))
|
|
91
|
+
assert get_term_usage("http://api.test", "token", client, app="store") == {
|
|
92
|
+
"currency": "CAD", "apps": []
|
|
93
|
+
}
|
|
94
|
+
assert client.calls[0][0:2] == ("GET", "http://api.test/v1/usage/term")
|
|
95
|
+
assert client.calls[0][3]["params"] == {"app": "store"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_wallet_currency_conflict_keeps_machine_readable_code():
|
|
100
|
+
client = FakeClient(response(409, {"detail": {
|
|
101
|
+
"code": "wallet_currency_mismatch",
|
|
102
|
+
"message": "wallet currency is immutable after funding",
|
|
103
|
+
}}))
|
|
104
|
+
from verulla_cli.apps_client import create_topup
|
|
105
|
+
|
|
106
|
+
with pytest.raises(AppsError, match="wallet_currency_mismatch"):
|
|
107
|
+
create_topup("http://api.test", "token", 1500, "CAD", client)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_delete_and_rollback_use_app_routes():
|
|
112
|
+
client = FakeClient(response(202, {"status": "deleted"}))
|
|
113
|
+
delete_app("http://api.test", "token", "demo", client)
|
|
114
|
+
rollback_app("http://api.test", "token", "demo", client)
|
|
115
|
+
assert [call[:2] for call in client.calls] == [
|
|
116
|
+
("DELETE", "http://api.test/v1/apps/demo"),
|
|
117
|
+
("POST", "http://api.test/v1/apps/demo/rollback"),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_get_app_logs_uses_authenticated_logs_endpoint():
|
|
122
|
+
client = FakeClient(response(200, {"app": "demo", "status": "failed", "build_log_tail": "output"}))
|
|
123
|
+
result = get_app_logs("http://api.test", "token", "demo", client)
|
|
124
|
+
assert result["build_log_tail"] == "output"
|
|
125
|
+
assert client.calls[0][:3] == ("GET", "http://api.test/v1/apps/demo/logs", {"Authorization": "Bearer token"})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_owner_claim_client_posts_email_with_idempotency_and_safe_status_get():
|
|
129
|
+
from verulla_cli.apps_client import get_owner_claim_status, issue_owner_claim
|
|
130
|
+
|
|
131
|
+
client = FakeClient(response(201, {
|
|
132
|
+
"status": "pending", "expires_at": "2026-10-31T12:00:00Z",
|
|
133
|
+
"claim_url": "https://account.verulla.com/claim#one-time",
|
|
134
|
+
}))
|
|
135
|
+
issued = issue_owner_claim("http://api.test", "token", "owner@example.test", "request-uuid", client)
|
|
136
|
+
assert issued["claim_url"].endswith("#one-time")
|
|
137
|
+
method, url, headers, kwargs = client.calls[0]
|
|
138
|
+
assert (method, url) == ("POST", "http://api.test/v1/owner-claims")
|
|
139
|
+
assert headers["Idempotency-Key"] == "request-uuid"
|
|
140
|
+
assert kwargs["json"] == {"owner_email": "owner@example.test"}
|
|
141
|
+
|
|
142
|
+
client.response = response(200, {"status": "pending", "expires_at": "2026-10-31T12:00:00Z"})
|
|
143
|
+
assert get_owner_claim_status("http://api.test", "token", client) == {
|
|
144
|
+
"status": "pending", "expires_at": "2026-10-31T12:00:00Z",
|
|
145
|
+
}
|
|
146
|
+
assert client.calls[1][:2] == ("GET", "http://api.test/v1/owner-claims/current")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_owner_claim_api_errors_do_not_echo_email():
|
|
150
|
+
from verulla_cli.apps_client import issue_owner_claim
|
|
151
|
+
|
|
152
|
+
client = FakeClient(response(400, {"detail": "invalid owner@example.test"}))
|
|
153
|
+
with pytest.raises(AppsError) as exc_info:
|
|
154
|
+
issue_owner_claim("http://api.test", "token", "owner@example.test", "request-uuid", client)
|
|
155
|
+
assert "owner@example.test" not in str(exc_info.value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_auth_policy_get_and_set_use_versioned_policy_contract():
|
|
159
|
+
client = FakeClient(response(200, {"app": "demo", "version": 0, "policy": {"signup_mode": "open"}}))
|
|
160
|
+
assert get_auth_policy("http://api.test", "token", "demo", client)["version"] == 0
|
|
161
|
+
policy = {"signup_mode": "invite", "password_min_length": 10}
|
|
162
|
+
set_auth_policy("http://api.test", "token", "demo", 0, policy, client)
|
|
163
|
+
assert client.calls[0][0:2] == ("GET", "http://api.test/v1/apps/demo/auth")
|
|
164
|
+
assert client.calls[1][0:2] == ("PUT", "http://api.test/v1/apps/demo/auth")
|
|
165
|
+
assert client.calls[1][3] == {"params": {"version": 0}, "json": {"policy": policy}}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_customer_list_search_uses_server_query_parameter():
|
|
169
|
+
client = FakeClient(response(200, {"customers": [], "total": 0, "limit": 50, "offset": 0}))
|
|
170
|
+
list_customers("http://api.test", "token", "demo", client, search="ada")
|
|
171
|
+
assert client.calls[0][3]["params"]["q"] == "ada"
|
|
172
|
+
assert "search" not in client.calls[0][3]["params"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.parametrize("status", [200, 204])
|
|
176
|
+
def test_delete_customer_accepts_empty_response(status):
|
|
177
|
+
client = FakeClient(httpx.Response(status, request=httpx.Request("DELETE", "http://api.test")))
|
|
178
|
+
assert delete_customer("http://api.test", "token", "demo", "uuid", client) is None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_customer_actions_use_scoped_routes_and_export_csv():
|
|
182
|
+
csv_response = httpx.Response(200, text="id,email\\n", request=httpx.Request("GET", "http://api.test"))
|
|
183
|
+
client = FakeClient(response(200, {"customers": [], "total": 0, "limit": 50, "offset": 0}))
|
|
184
|
+
list_customers("http://api.test", "token", "demo", client)
|
|
185
|
+
lock_customer("http://api.test", "token", "demo", "uuid", client)
|
|
186
|
+
unlock_customer("http://api.test", "token", "demo", "uuid", client)
|
|
187
|
+
delete_customer("http://api.test", "token", "demo", "uuid", client)
|
|
188
|
+
invite_customer("http://api.test", "token", "demo", "user@example.com", client)
|
|
189
|
+
client.response = csv_response
|
|
190
|
+
assert export_customers("http://api.test", "token", "demo", client) == "id,email\\n"
|
|
191
|
+
assert [call[0:2] for call in client.calls] == [
|
|
192
|
+
("GET", "http://api.test/v1/apps/demo/customers"),
|
|
193
|
+
("POST", "http://api.test/v1/apps/demo/customers/uuid/lock"),
|
|
194
|
+
("POST", "http://api.test/v1/apps/demo/customers/uuid/unlock"),
|
|
195
|
+
("DELETE", "http://api.test/v1/apps/demo/customers/uuid"),
|
|
196
|
+
("POST", "http://api.test/v1/apps/demo/customers"),
|
|
197
|
+
("GET", "http://api.test/v1/apps/demo/customers/export"),
|
|
198
|
+
]
|
|
199
|
+
assert client.calls[4][3]["json"] == {"email": "user@example.com"}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_activate_app_posts_idempotency_key():
|
|
203
|
+
client = FakeClient(response(200, {"app": "demo", "tier_id": "live"}))
|
|
204
|
+
result = activate_app("http://api.test", "token", "demo", "request-1", client)
|
|
205
|
+
assert result["tier_id"] == "live"
|
|
206
|
+
assert client.calls == [("POST", "http://api.test/v1/apps/demo/activate", {
|
|
207
|
+
"Authorization": "Bearer token", "Idempotency-Key": "request-1",
|
|
208
|
+
}, {})]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_activation_402_has_actionable_spending_limit_guidance():
|
|
212
|
+
client = FakeClient(response(402, {"detail": {"code": "spend_cap_exceeded"}}))
|
|
213
|
+
with pytest.raises(AppsError, match="Ask the account owner to raise the limit"):
|
|
214
|
+
activate_app("http://api.test", "token", "demo", "request-2", client)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_activation_402_explains_required_spending_limit():
|
|
218
|
+
client = FakeClient(response(402, {"detail": {"code": "spend_cap_required"}}))
|
|
219
|
+
with pytest.raises(AppsError, match="Set a monthly spending limit before activating Live"):
|
|
220
|
+
activate_app("http://api.test", "token", "demo", "request-3", client)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_http_errors_are_reported():
|
|
224
|
+
with pytest.raises(AppsError, match="404"):
|
|
225
|
+
list_apps("http://api.test", "token", FakeClient(response(404, {"detail": "missing"})))
|