secryn-cli 0.1.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.
@@ -0,0 +1,838 @@
1
+ """Tests for the Secryn CLI — covers all commands and error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from unittest.mock import MagicMock
9
+
10
+ # pyrefly: ignore [missing-import]
11
+ import pytest
12
+ # pyrefly: ignore [missing-import]
13
+ from click.testing import CliRunner
14
+ # pyrefly: ignore [missing-import]
15
+ from pytest_mock import MockerFixture
16
+
17
+ from secryn_cli import __version__
18
+ from secryn_cli.cli import cli, main
19
+ from secryn_cli.client import APIError
20
+ from secryn_cli.config import Config
21
+
22
+ @pytest.fixture()
23
+ def runner() -> CliRunner:
24
+ """Returns a Click CliRunner instance."""
25
+ return CliRunner()
26
+
27
+
28
+ @pytest.fixture()
29
+ def mock_client(mocker: MockerFixture) -> MagicMock:
30
+ """Patches the Client constructor and returns a MagicMock instance."""
31
+ mock_instance = MagicMock()
32
+ mock_instance.config = Config()
33
+ mock_class = mocker.patch("secryn_cli.cli.Client", return_value=mock_instance)
34
+ mock_class.return_value = mock_instance
35
+ return mock_instance
36
+
37
+
38
+ @pytest.fixture()
39
+ def mock_config_funcs(mocker: MockerFixture) -> dict[str, MagicMock]:
40
+ """Mocks config utility functions to prevent real file I/O."""
41
+ mocks: dict[str, MagicMock] = {}
42
+ mocks["load_config"] = mocker.patch(
43
+ "secryn_cli.cli.load_config", return_value=Config()
44
+ )
45
+ mocks["save_config"] = mocker.patch("secryn_cli.cli.save_config")
46
+ mocks["config_path"] = mocker.patch(
47
+ "secryn_cli.cli.config_path", return_value=Path("/tmp/test-config.json")
48
+ )
49
+ mocks["cookie_jar_path"] = mocker.patch(
50
+ "secryn_cli.cli.cookie_jar_path",
51
+ return_value=Path("/tmp/test-cookies.json"),
52
+ )
53
+ return mocks
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Test helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _strip(text: str) -> str:
62
+ """Remove ANSI escape sequences from text."""
63
+ import re
64
+ return re.sub(r"\033\[[0-9;]*[a-zA-Z]", "", text)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Group-level / Meta commands
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class TestRootGroup:
73
+ """Tests for the top-level CLI group and meta commands."""
74
+
75
+ def test_no_args_shows_help(self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]) -> None:
76
+ """``sc`` with no arguments displays the help text."""
77
+ result = runner.invoke(cli, [])
78
+ assert "Secryn CLI" in _strip(result.output)
79
+
80
+ def test_help_flag(self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]) -> None:
81
+ """``sc --help`` displays the help text."""
82
+ result = runner.invoke(cli, ["--help"])
83
+ assert result.exit_code == 0
84
+ assert "Secryn CLI" in _strip(result.stdout)
85
+
86
+ def test_bad_command_shows_error_and_help(
87
+ self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]
88
+ ) -> None:
89
+ """``sc badcommand`` prints an error and the command list."""
90
+ result = runner.invoke(cli, ["badcommand"])
91
+ assert "No such command" in _strip(result.output)
92
+
93
+ def test_bad_option_shows_error(
94
+ self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]
95
+ ) -> None:
96
+ """``sc --badflag`` prints a clear error and exits non-zero."""
97
+ result = runner.invoke(cli, ["--badflag"])
98
+ assert result.exit_code != 0
99
+ assert "No such option" in _strip(result.output)
100
+
101
+ def test_version_command(
102
+ self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]
103
+ ) -> None:
104
+ """``sc version`` prints the version string."""
105
+ result = runner.invoke(cli, ["version"])
106
+ assert result.exit_code == 0
107
+ assert f"Secryn CLI v{__version__}" in result.stdout
108
+
109
+ def test_config_command(
110
+ self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]
111
+ ) -> None:
112
+ """``sc config`` displays the current configuration."""
113
+ result = runner.invoke(cli, ["config"])
114
+ assert result.exit_code == 0
115
+ assert "API URL" in _strip(result.stdout)
116
+ assert "Config file" in _strip(result.stdout)
117
+
118
+ def test_api_url_option_persists(
119
+ self,
120
+ mocker: MockerFixture,
121
+ mock_config_funcs: dict[str, MagicMock],
122
+ ) -> None:
123
+ """``--api-url`` without a command saves the URL via main()."""
124
+ new_url = "http://custom.example.com/api/v1"
125
+ cfg = Config()
126
+ cfg.api_url = "http://localhost:3000/api/v1"
127
+ mock_config_funcs["load_config"].return_value = cfg
128
+ mocker.patch("sys.argv", ["sc", "--api-url", new_url])
129
+ mocker.patch("secryn_cli.cli.Client")
130
+
131
+ with pytest.raises(SystemExit) as exc_info:
132
+ main()
133
+ assert exc_info.value.code == 0
134
+ assert mock_config_funcs["save_config"].call_count >= 1
135
+
136
+ def test_config_show_not_logged_in(
137
+ self, runner: CliRunner, mock_config_funcs: dict[str, MagicMock]
138
+ ) -> None:
139
+ """``sc config`` shows '(not logged in)' when no user is stored."""
140
+ result = runner.invoke(cli, ["config"])
141
+ assert "(not logged in)" in _strip(result.stdout)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Auth commands
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class TestAuthLogin:
150
+ """Tests for ``sc auth login``."""
151
+
152
+ def test_login_success(
153
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
154
+ ) -> None:
155
+ """Successful login saves email and prints success message."""
156
+ mock_client.post.return_value = {"mfaRequired": False}
157
+
158
+ result = runner.invoke(
159
+ cli, ["auth", "login", "--email", "user@example.com", "--password", "secret"]
160
+ )
161
+ assert result.exit_code == 0
162
+ assert "Logged in as user@example.com" in _strip(result.stderr)
163
+ mock_client.post.assert_called_once_with(
164
+ "/auth/login", {"email": "user@example.com", "password": "secret"}
165
+ )
166
+ mock_client.save_config.assert_called_once()
167
+
168
+ def test_login_with_mfa(
169
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
170
+ ) -> None:
171
+ """Login with MFA prompts for token and confirms."""
172
+ mock_client.post.side_effect = [
173
+ {"mfaRequired": True, "mfaToken": "mfa-token-123"},
174
+ {"mfaRequired": False},
175
+ ]
176
+
177
+ result = runner.invoke(
178
+ cli,
179
+ ["auth", "login", "--email", "user@example.com", "--password", "secret"],
180
+ input="123456\n",
181
+ )
182
+ assert result.exit_code == 0
183
+ assert "MFA is required" in _strip(result.stderr)
184
+ assert "Logged in as user@example.com" in _strip(result.stderr)
185
+
186
+ def test_login_prompts_interactively(
187
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
188
+ ) -> None:
189
+ """Login without flags prompts for email and password."""
190
+ mock_client.post.return_value = {"mfaRequired": False}
191
+ result = runner.invoke(
192
+ cli, ["auth", "login"], input="prompt@test.com\npassword123\n"
193
+ )
194
+ assert result.exit_code == 0
195
+ assert "Logged in as prompt@test.com" in _strip(result.stderr)
196
+
197
+ def test_login_api_error(
198
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
199
+ ) -> None:
200
+ """Login with bad credentials raises APIError (caught by main)."""
201
+ mock_client.post.side_effect = APIError(401, "Invalid credentials", "UNAUTHORIZED")
202
+
203
+ result = runner.invoke(
204
+ cli, ["auth", "login", "--email", "bad@test.com", "--password", "wrong"]
205
+ )
206
+ assert result.exit_code == 1
207
+ assert isinstance(result.exception, APIError)
208
+ assert "Invalid credentials" in str(result.exception)
209
+
210
+ def test_login_connection_error(
211
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
212
+ ) -> None:
213
+ """Login with unreachable server shows error (caught by Exception handler)."""
214
+ mock_client.post.side_effect = ConnectionError("Connection refused")
215
+
216
+ result = runner.invoke(
217
+ cli, ["auth", "login", "--email", "user@test.com", "--password", "pass"]
218
+ )
219
+ assert result.exit_code == 1
220
+ assert isinstance(result.exception, ConnectionError)
221
+ assert "Connection refused" in str(result.exception)
222
+
223
+
224
+ class TestAuthLogout:
225
+ """Tests for ``sc auth logout``."""
226
+
227
+ def test_logout_success(
228
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
229
+ ) -> None:
230
+ """Logout clears credentials and prints success."""
231
+ result = runner.invoke(cli, ["auth", "logout"])
232
+ assert result.exit_code == 0
233
+ assert "Logged out successfully" in _strip(result.stderr)
234
+ mock_client.logout.assert_called_once()
235
+
236
+
237
+ class TestAuthWhoami:
238
+ """Tests for ``sc auth whoami``."""
239
+
240
+ def test_whoami_success(
241
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
242
+ ) -> None:
243
+ """Shows the current user identity in a table."""
244
+ mock_client.get.return_value = {
245
+ "id": "user-1",
246
+ "email": "me@example.com",
247
+ "username": "me-user",
248
+ }
249
+ result = runner.invoke(cli, ["auth", "whoami"])
250
+ assert result.exit_code == 0
251
+ assert "me@example.com" in _strip(result.stdout)
252
+
253
+ def test_whoami_json_output(
254
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
255
+ ) -> None:
256
+ """``--json`` outputs raw JSON."""
257
+ mock_client.get.return_value = {
258
+ "id": "user-1",
259
+ "email": "me@example.com",
260
+ "username": "me-user",
261
+ }
262
+ result = runner.invoke(cli, ["auth", "whoami", "--json"])
263
+ assert result.exit_code == 0
264
+ data: dict[str, str] = json.loads(result.stdout)
265
+ assert data["email"] == "me@example.com"
266
+
267
+ def test_whoami_api_error(
268
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
269
+ ) -> None:
270
+ """Whoami with auth failure shows error."""
271
+ mock_client.get.side_effect = APIError(401, "Unauthorized", "UNAUTHORIZED")
272
+ result = runner.invoke(cli, ["auth", "whoami"])
273
+ assert result.exit_code == 1
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Project commands
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ class TestProjectsList:
282
+ """Tests for ``sc projects list``."""
283
+
284
+ def test_list_empty(
285
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
286
+ ) -> None:
287
+ """Empty project list shows info message."""
288
+ mock_client.get.return_value = []
289
+ result = runner.invoke(cli, ["projects", "list"])
290
+ assert result.exit_code == 0
291
+ assert "No projects found" in _strip(result.stderr)
292
+
293
+ def test_list_with_projects(
294
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
295
+ ) -> None:
296
+ """Non-empty list renders a table."""
297
+ mock_client.get.return_value = [
298
+ {
299
+ "id": "p-1",
300
+ "name": "Project Alpha",
301
+ "slug": "alpha",
302
+ "description": "First project",
303
+ "createdAt": "2025-01-01",
304
+ }
305
+ ]
306
+ result = runner.invoke(cli, ["projects", "list"])
307
+ assert result.exit_code == 0
308
+ assert "Project Alpha" in _strip(result.stdout)
309
+
310
+ def test_list_json_output(
311
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
312
+ ) -> None:
313
+ """``--json`` outputs raw JSON."""
314
+ mock_client.get.return_value = [
315
+ {"id": "p-1", "name": "Alpha", "slug": "alpha"}
316
+ ]
317
+ result = runner.invoke(cli, ["projects", "list", "--json"])
318
+ assert result.exit_code == 0
319
+ data: list[dict[str, str]] = json.loads(result.stdout)
320
+ assert data[0]["name"] == "Alpha"
321
+
322
+
323
+ class TestProjectsCreate:
324
+ """Tests for ``sc projects create``."""
325
+
326
+ def test_create_success(
327
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
328
+ ) -> None:
329
+ """Creates a project and prints success."""
330
+ mock_client.post.return_value = {"id": "proj-new", "name": "My Project"}
331
+ result = runner.invoke(
332
+ cli, ["projects", "create", "--name", "My Project", "--description", "Desc"]
333
+ )
334
+ assert result.exit_code == 0
335
+ assert "Project created" in _strip(result.stderr)
336
+ mock_client.post.assert_called_once_with(
337
+ "/projects", {"name": "My Project", "description": "Desc"}
338
+ )
339
+
340
+ def test_create_without_description(
341
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
342
+ ) -> None:
343
+ """Creates a project with name only."""
344
+ mock_client.post.return_value = {"id": "p-1", "name": "Solo"}
345
+ result = runner.invoke(cli, ["projects", "create", "--name", "Solo"])
346
+ assert result.exit_code == 0
347
+ mock_client.post.assert_called_once_with("/projects", {"name": "Solo"})
348
+
349
+
350
+ class TestProjectsDelete:
351
+ """Tests for ``sc projects delete``."""
352
+
353
+ def test_delete_aborted(
354
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
355
+ ) -> None:
356
+ """User declines confirmation — project is not deleted."""
357
+ result = runner.invoke(cli, ["projects", "delete", "--id", "p-1"], input="n\n")
358
+ assert result.exit_code == 0
359
+ assert "Aborted" in _strip(result.stderr)
360
+ mock_client.delete.assert_not_called()
361
+
362
+ def test_delete_force(
363
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
364
+ ) -> None:
365
+ """``--force`` skips confirmation and deletes immediately."""
366
+ result = runner.invoke(cli, ["projects", "delete", "--id", "p-1", "--force"])
367
+ assert result.exit_code == 0
368
+ assert "deleted" in _strip(result.stderr)
369
+ mock_client.delete.assert_called_once_with("/projects/p-1")
370
+
371
+ def test_delete_confirmed(
372
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
373
+ ) -> None:
374
+ """User confirms and project is deleted."""
375
+ result = runner.invoke(cli, ["projects", "delete", "--id", "p-2"], input="y\n")
376
+ assert result.exit_code == 0
377
+ mock_client.delete.assert_called_once_with("/projects/p-2")
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Secret commands
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ class TestSecretsList:
386
+ """Tests for ``sc secrets list``."""
387
+
388
+ def test_list_empty(
389
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
390
+ ) -> None:
391
+ """Empty secret list shows info message."""
392
+ mock_client.get.return_value = []
393
+ result = runner.invoke(cli, ["secrets", "list", "--project-id", "proj-1"])
394
+ assert result.exit_code == 0
395
+ assert "No secrets found" in _strip(result.stderr)
396
+
397
+ def test_list_with_secrets(
398
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
399
+ ) -> None:
400
+ """Non-empty list renders a table with masked values."""
401
+ mock_client.get.return_value = [
402
+ {
403
+ "id": "s-1",
404
+ "name": "DATABASE_URL",
405
+ "value": "postgres://user:pass@host/db",
406
+ "notes": "Main DB",
407
+ "updatedAt": "2025-01-01",
408
+ }
409
+ ]
410
+ result = runner.invoke(cli, ["secrets", "list", "--project-id", "proj-1"])
411
+ assert result.exit_code == 0
412
+ assert "DATABASE_URL" in _strip(result.stdout)
413
+ # Value should be masked (not showing the full URL)
414
+ assert "postgres://" not in _strip(result.stdout)
415
+
416
+ def test_list_json_output(
417
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
418
+ ) -> None:
419
+ """``--json`` outputs all secrets as JSON."""
420
+ mock_client.get.return_value = [
421
+ {"id": "s-1", "name": "KEY", "value": "secret-val"}
422
+ ]
423
+ result = runner.invoke(cli, ["secrets", "list", "--project-id", "p1", "--json"])
424
+ assert result.exit_code == 0
425
+ data: list[dict[str, str]] = json.loads(result.stdout)
426
+ assert data[0]["value"] == "secret-val" # JSON shows full value
427
+
428
+
429
+ class TestSecretsGet:
430
+ """Tests for ``sc secrets get``."""
431
+
432
+ def test_get_masked(
433
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
434
+ ) -> None:
435
+ """Default output masks the value."""
436
+ mock_client.get.return_value = {
437
+ "id": "s-1",
438
+ "name": "API_KEY",
439
+ "value": "sk-1234567890abcdef",
440
+ "notes": "",
441
+ "projectId": "p-1",
442
+ "createdAt": "2025-01-01",
443
+ "updatedAt": "2025-01-01",
444
+ }
445
+ result = runner.invoke(cli, ["secrets", "get", "--id", "s-1"])
446
+ assert result.exit_code == 0
447
+ assert "sk-1234" not in _strip(result.stdout) # masked
448
+ assert "use --show-value" in _strip(result.stdout)
449
+
450
+ def test_get_show_value(
451
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
452
+ ) -> None:
453
+ """``--show-value`` reveals the plain-text value."""
454
+ mock_client.get.return_value = {
455
+ "id": "s-1",
456
+ "name": "API_KEY",
457
+ "value": "sk-1234567890abcdef",
458
+ "notes": "",
459
+ "projectId": "p-1",
460
+ "createdAt": "2025-01-01",
461
+ "updatedAt": "2025-01-01",
462
+ }
463
+ result = runner.invoke(cli, ["secrets", "get", "--id", "s-1", "--show-value"])
464
+ assert result.exit_code == 0
465
+ assert "sk-1234567890abcdef" in _strip(result.stdout)
466
+
467
+ def test_get_json(
468
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
469
+ ) -> None:
470
+ """``--json`` outputs structured data."""
471
+ mock_client.get.return_value = {
472
+ "id": "s-1",
473
+ "name": "KEY",
474
+ "value": "my-secret",
475
+ }
476
+ result = runner.invoke(cli, ["secrets", "get", "--id", "s-1", "--json"])
477
+ assert result.exit_code == 0
478
+ data: dict[str, str] = json.loads(result.stdout)
479
+ assert data["value"] != "my-secret" # masked in JSON
480
+
481
+ def test_get_json_show_value(
482
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
483
+ ) -> None:
484
+ """``--json --show-value`` reveals the value."""
485
+ mock_client.get.return_value = {
486
+ "id": "s-1",
487
+ "name": "KEY",
488
+ "value": "my-secret",
489
+ }
490
+ result = runner.invoke(
491
+ cli, ["secrets", "get", "--id", "s-1", "--json", "--show-value"]
492
+ )
493
+ assert result.exit_code == 0
494
+ data: dict[str, str] = json.loads(result.stdout)
495
+ assert data["value"] == "my-secret"
496
+
497
+ def test_get_not_found(
498
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
499
+ ) -> None:
500
+ """Non-existent secret returns an error."""
501
+ mock_client.get.side_effect = APIError(404, "Secret not found", "NOT_FOUND")
502
+ result = runner.invoke(cli, ["secrets", "get", "--id", "missing"])
503
+ assert result.exit_code == 1
504
+
505
+
506
+ class TestSecretsCreate:
507
+ """Tests for ``sc secrets create``."""
508
+
509
+ def test_create_success(
510
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
511
+ ) -> None:
512
+ """Creates a secret and prints confirmation."""
513
+ mock_client.post.return_value = {"id": "s-new", "name": "MY_KEY"}
514
+ result = runner.invoke(
515
+ cli,
516
+ [
517
+ "secrets",
518
+ "create",
519
+ "--project-id", "p-1",
520
+ "--name", "MY_KEY",
521
+ "--value", "secret123",
522
+ "--notes", "Test secret",
523
+ ],
524
+ )
525
+ assert result.exit_code == 0
526
+ assert "Secret created" in _strip(result.stderr)
527
+ mock_client.post.assert_called_once_with(
528
+ "/projects/p-1/secrets",
529
+ {"name": "MY_KEY", "value": "secret123", "notes": "Test secret"},
530
+ )
531
+
532
+ def test_create_without_notes(
533
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
534
+ ) -> None:
535
+ """Creates a secret without optional notes."""
536
+ mock_client.post.return_value = {"id": "s-1", "name": "KEY"}
537
+ result = runner.invoke(
538
+ cli,
539
+ ["secrets", "create", "--project-id", "p-1", "--name", "KEY", "--value", "val"],
540
+ )
541
+ assert result.exit_code == 0
542
+ assert "notes" not in mock_client.post.call_args[0][1]
543
+
544
+
545
+ class TestSecretsUpdate:
546
+ """Tests for ``sc secrets update``."""
547
+
548
+ def test_update_success(
549
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
550
+ ) -> None:
551
+ """Updates a secret's value."""
552
+ mock_client.put.return_value = {"id": "s-1"}
553
+ result = runner.invoke(
554
+ cli, ["secrets", "update", "--id", "s-1", "--value", "new-value"]
555
+ )
556
+ assert result.exit_code == 0
557
+ assert "updated" in _strip(result.stderr)
558
+ mock_client.put.assert_called_once_with(
559
+ "/projects/secrets/s-1", {"value": "new-value"}
560
+ )
561
+
562
+ def test_update_no_fields(
563
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
564
+ ) -> None:
565
+ """No fields provided — shows info and does nothing."""
566
+ result = runner.invoke(cli, ["secrets", "update", "--id", "s-1"])
567
+ assert result.exit_code == 0
568
+ assert "No fields to update" in _strip(result.stderr)
569
+ mock_client.put.assert_not_called()
570
+
571
+ def test_update_clear_notes(
572
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
573
+ ) -> None:
574
+ """Passing ``--notes \"\"`` clears the notes field."""
575
+ mock_client.put.return_value = {"id": "s-1"}
576
+ result = runner.invoke(
577
+ cli, ["secrets", "update", "--id", "s-1", "--notes", ""]
578
+ )
579
+ assert result.exit_code == 0
580
+ mock_client.put.assert_called_once_with(
581
+ "/projects/secrets/s-1", {"notes": ""}
582
+ )
583
+
584
+
585
+ class TestSecretsDelete:
586
+ """Tests for ``sc secrets delete``."""
587
+
588
+ def test_delete_aborted(
589
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
590
+ ) -> None:
591
+ """User declines confirmation."""
592
+ result = runner.invoke(cli, ["secrets", "delete", "--id", "s-1"], input="n\n")
593
+ assert result.exit_code == 0
594
+ assert "Aborted" in _strip(result.stderr)
595
+
596
+ def test_delete_force(
597
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
598
+ ) -> None:
599
+ """``--force`` deletes immediately."""
600
+ result = runner.invoke(cli, ["secrets", "delete", "--id", "s-1", "--force"])
601
+ assert result.exit_code == 0
602
+ mock_client.delete.assert_called_once_with("/projects/secrets/s-1")
603
+
604
+
605
+ class TestSecretsExport:
606
+ """Tests for ``sc secrets export``."""
607
+
608
+ def test_export_stdout(
609
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
610
+ ) -> None:
611
+ """Exports secrets to stdout."""
612
+ mock_client.get_raw.return_value = "KEY=value\nSECRET=abc"
613
+ result = runner.invoke(cli, ["secrets", "export", "--project-id", "p-1"])
614
+ assert result.exit_code == 0
615
+ assert "KEY=value" in result.stdout
616
+
617
+ def test_export_to_file(
618
+ self,
619
+ runner: CliRunner,
620
+ mock_client: MagicMock,
621
+ mock_config_funcs: dict[str, MagicMock],
622
+ tmp_path: Path,
623
+ ) -> None:
624
+ """Exports secrets to a specified file."""
625
+ output_file = tmp_path / "secrets.env"
626
+ mock_client.get_raw.return_value = "KEY=val"
627
+ result = runner.invoke(
628
+ cli,
629
+ ["secrets", "export", "--project-id", "p-1", "--output", str(output_file)],
630
+ )
631
+ assert result.exit_code == 0
632
+ assert output_file.read_text() == "KEY=val"
633
+
634
+
635
+ # ---------------------------------------------------------------------------
636
+ # API Key commands
637
+ # ---------------------------------------------------------------------------
638
+
639
+
640
+ class TestApiKeysList:
641
+ """Tests for ``sc api-keys list``."""
642
+
643
+ def test_list_empty(
644
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
645
+ ) -> None:
646
+ """Empty list shows info message."""
647
+ mock_client.get.return_value = []
648
+ result = runner.invoke(cli, ["api-keys", "list"])
649
+ assert result.exit_code == 0
650
+ assert "No API keys found" in _strip(result.stderr)
651
+
652
+ def test_list_with_keys(
653
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
654
+ ) -> None:
655
+ """Non-empty list renders a table."""
656
+ mock_client.get.return_value = [
657
+ {
658
+ "id": "ak-1",
659
+ "keyName": "CI Key",
660
+ "isActive": True,
661
+ "permissions": ["read", "write"],
662
+ "createdAt": "2025-01-01",
663
+ "expiresAt": "2026-01-01",
664
+ }
665
+ ]
666
+ result = runner.invoke(cli, ["api-keys", "list"])
667
+ assert result.exit_code == 0
668
+ assert "CI Key" in _strip(result.stdout)
669
+ assert "active" in _strip(result.stdout)
670
+
671
+ def test_list_json_output(
672
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
673
+ ) -> None:
674
+ """``--json`` outputs raw JSON."""
675
+ mock_client.get.return_value = [
676
+ {"id": "ak-1", "keyName": "My Key", "isActive": False, "permissions": ["read"]}
677
+ ]
678
+ result = runner.invoke(cli, ["api-keys", "list", "--json"])
679
+ assert result.exit_code == 0
680
+ data: list[dict[str, Any]] = json.loads(result.stdout)
681
+ assert data[0]["keyName"] == "My Key"
682
+
683
+ def test_list_inactive_key(
684
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
685
+ ) -> None:
686
+ """Inactive key shows 'inactive' status."""
687
+ mock_client.get.return_value = [
688
+ {
689
+ "id": "ak-1",
690
+ "keyName": "Old Key",
691
+ "isActive": False,
692
+ "permissions": [],
693
+ "createdAt": "",
694
+ "expiresAt": "",
695
+ }
696
+ ]
697
+ result = runner.invoke(cli, ["api-keys", "list"])
698
+ assert "inactive" in _strip(result.stdout)
699
+
700
+
701
+ class TestApiKeysCreate:
702
+ """Tests for ``sc api-keys create``."""
703
+
704
+ def test_create_success(
705
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
706
+ ) -> None:
707
+ """Creates an API key and displays the key value."""
708
+ mock_client.post.return_value = {
709
+ "id": "ak-new",
710
+ "key": "sc_abcdef1234567890",
711
+ "permissions": ["read", "write"],
712
+ "keyName": "My Key",
713
+ }
714
+ result = runner.invoke(cli, ["api-keys", "create", "--name", "My Key"])
715
+ assert result.exit_code == 0
716
+ assert "Save this key" in _strip(result.stdout)
717
+ assert "sc_abcdef1234567890" in _strip(result.stdout)
718
+ mock_client.post.assert_called_once_with(
719
+ "/api-keys", {"name": "My Key", "permissions": ["read", "write"]}
720
+ )
721
+
722
+ def test_create_custom_permissions(
723
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
724
+ ) -> None:
725
+ """Custom permission string is parsed correctly."""
726
+ mock_client.post.return_value = {
727
+ "id": "ak-1",
728
+ "key": "sc_xxxx",
729
+ "permissions": ["read"],
730
+ "keyName": "RO Key",
731
+ }
732
+ result = runner.invoke(
733
+ cli, ["api-keys", "create", "--name", "RO Key", "--permissions", "read"]
734
+ )
735
+ assert result.exit_code == 0
736
+ mock_client.post.assert_called_once_with(
737
+ "/api-keys", {"name": "RO Key", "permissions": ["read"]}
738
+ )
739
+
740
+
741
+ class TestApiKeysDelete:
742
+ """Tests for ``sc api-keys delete``."""
743
+
744
+ def test_delete_aborted(
745
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
746
+ ) -> None:
747
+ """User declines confirmation."""
748
+ result = runner.invoke(cli, ["api-keys", "delete", "--id", "ak-1"], input="n\n")
749
+ assert result.exit_code == 0
750
+ assert "Aborted" in _strip(result.stderr)
751
+
752
+ def test_delete_force(
753
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
754
+ ) -> None:
755
+ """``--force`` deletes immediately."""
756
+ result = runner.invoke(cli, ["api-keys", "delete", "--id", "ak-1", "--force"])
757
+ assert result.exit_code == 0
758
+ mock_client.delete.assert_called_once_with("/api-keys/ak-1")
759
+
760
+
761
+ # ---------------------------------------------------------------------------
762
+ # User commands
763
+ # ---------------------------------------------------------------------------
764
+
765
+
766
+ class TestUserInfo:
767
+ """Tests for ``sc user info``."""
768
+
769
+ def test_info_success(
770
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
771
+ ) -> None:
772
+ """Displays user profile in a table."""
773
+ mock_client.get.return_value = {
774
+ "id": "u-1",
775
+ "email": "user@example.com",
776
+ "username": "the-user",
777
+ "role": "admin",
778
+ "createdAt": "2025-01-01",
779
+ }
780
+ result = runner.invoke(cli, ["user", "info"])
781
+ assert result.exit_code == 0
782
+ assert "user@example.com" in _strip(result.stdout)
783
+ assert "admin" in _strip(result.stdout)
784
+
785
+ def test_info_saves_config(
786
+ self, runner: CliRunner, mock_client: MagicMock, mock_config_funcs: dict[str, MagicMock]
787
+ ) -> None:
788
+ """``user info`` persists user identity to config."""
789
+ mock_client.get.return_value = {
790
+ "id": "u-2",
791
+ "email": "u2@example.com",
792
+ "username": "",
793
+ "role": "member",
794
+ "createdAt": "",
795
+ }
796
+ runner.invoke(cli, ["user", "info"])
797
+ mock_client.save_config.assert_called_once()
798
+
799
+
800
+ # ---------------------------------------------------------------------------
801
+ # main() wrapper — system-level edge cases
802
+ # ---------------------------------------------------------------------------
803
+
804
+
805
+ class TestMainEntryPoint:
806
+ """Tests for the ``main()`` entry point that wraps the CLI group."""
807
+
808
+ def test_main_handles_system_exit(self, mocker: MockerFixture) -> None:
809
+ """``main()`` exits cleanly when Click raises ``SystemExit``."""
810
+ mocker.patch("sys.argv", ["sc", "version"])
811
+ mocker.patch("secryn_cli.cli.load_config", return_value=Config())
812
+ mocker.patch("secryn_cli.cli.save_config")
813
+ mocker.patch("secryn_cli.cli.config_path", return_value=Path("/tmp/test-cfg.json"))
814
+ mocker.patch(
815
+ "secryn_cli.cli.cookie_jar_path", return_value=Path("/tmp/test-ck.json")
816
+ )
817
+ with pytest.raises(SystemExit) as exc_info:
818
+ main()
819
+ assert exc_info.value.code == 0
820
+
821
+ def test_main_catches_api_error(self, mocker: MockerFixture) -> None:
822
+ """``main()`` exits with code 1 on APIError."""
823
+ mocker.patch("sys.argv", ["sc", "auth", "whoami"])
824
+ mocker.patch("secryn_cli.cli.load_config", return_value=Config())
825
+ mocker.patch("secryn_cli.cli.save_config")
826
+ mocker.patch("secryn_cli.cli.config_path", return_value=Path("/tmp/test-cfg.json"))
827
+ mocker.patch(
828
+ "secryn_cli.cli.cookie_jar_path", return_value=Path("/tmp/test-ck.json")
829
+ )
830
+
831
+ mock_cls = mocker.patch("secryn_cli.cli.Client")
832
+ mock_inst = MagicMock()
833
+ mock_inst.get.side_effect = APIError(401, "Not authenticated", "UNAUTHORIZED")
834
+ mock_cls.return_value = mock_inst
835
+
836
+ with pytest.raises(SystemExit) as exc_info:
837
+ main()
838
+ assert exc_info.value.code == 1