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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ data/
5
+ *.db
6
+ .DS_Store
7
+ output.txt
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: verulla-cli
3
+ Version: 0.0.2
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: docker>=7
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: verulla-manifest==0.0.2
@@ -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"})))