Coverage for tests/test_cli_tokens.py: 100%
194 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
1import json 1abcdefg
2from pathlib import Path 1abcdefg
3from unittest.mock import patch 1abcdefg
5import pytest 1abcdefg
6import respx 1abcdefg
7from httpx import Response 1abcdefg
8from inline_snapshot import snapshot 1abcdefg
10from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg
11from tests.conftest import ConfiguredApp 1abcdefg
12from tests.utils import Keys, SnapshotCliRunner, changing_dir 1abcdefg
14runner = SnapshotCliRunner() 1abcdefg
17def test_creates_token_json_returns_not_logged_in_when_logged_out( 1abcdefg
18 logged_out_cli: None,
19) -> None:
20 result = runner.invoke( 2xbybzbAbBbCbDb
21 app,
22 [
23 "tokens",
24 "create",
25 "--app-id",
26 "00000000-0000-4000-8000-000000000002",
27 "--name",
28 "GitHub Actions",
29 "--output-file",
30 "deploy-token.txt",
31 "--json",
32 ],
33 )
35 assert result.exit_code == 1 2xbybzbAbBbCbDb
36 assert json.loads(result.stdout) == { 2xbybzbAbBbCbDb
37 "error": {
38 "code": "not_logged_in",
39 "message": "No credentials found.",
40 "hint": "Run `fastapi cloud login`.",
41 }
42 }
43 assert result.stderr == "" 2xbybzbAbBbCbDb
46def test_deletes_token_json_returns_not_logged_in_when_logged_out( 1abcdefg
47 logged_out_cli: None,
48) -> None:
49 result = runner.invoke( 2EbFbGbHbIbJbKb
50 app,
51 [
52 "tokens",
53 "delete",
54 "00000000-0000-4000-8000-000000000004",
55 "--app-id",
56 "00000000-0000-4000-8000-000000000002",
57 "--json",
58 ],
59 )
61 assert result.exit_code == 1 2EbFbGbHbIbJbKb
62 assert json.loads(result.stdout) == { 2EbFbGbHbIbJbKb
63 "error": {
64 "code": "not_logged_in",
65 "message": "No credentials found.",
66 "hint": "Run `fastapi cloud login`.",
67 }
68 }
69 assert result.stderr == "" 2EbFbGbHbIbJbKb
72@pytest.mark.respx 1abcdefg
73def test_deletes_token_as_json_with_app_id( 1abcdefg
74 logged_in_cli: None,
75 respx_mock: respx.MockRouter,
76) -> None:
77 app_id = "00000000-0000-4000-8000-000000000002" 1456789!
78 token_id = "00000000-0000-4000-8000-000000000004" 1456789!
79 respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock( 1456789!
80 return_value=Response(204)
81 )
83 result = runner.invoke( 1456789!
84 app,
85 ["tokens", "delete", token_id, "--app-id", app_id, "--json"],
86 )
88 assert result.exit_code == 0 1456789!
89 assert json.loads(result.stdout) == { 1456789!
90 "data": {
91 "token_id": token_id,
92 "deleted": True,
93 }
94 }
95 assert result.stderr == "" 1456789!
98@pytest.mark.respx 1abcdefg
99def test_deletes_token_as_json_uses_linked_app( 1abcdefg
100 logged_in_cli: None,
101 respx_mock: respx.MockRouter,
102 configured_app: ConfiguredApp,
103) -> None:
104 token_id = "00000000-0000-4000-8000-000000000004" 1#$%'()*
105 respx_mock.delete(f"/apps/{configured_app.app_id}/tokens/{token_id}").mock( 1#$%'()*
106 return_value=Response(204)
107 )
109 with changing_dir(configured_app.path): 1#$%'()*
110 result = runner.invoke(app, ["tokens", "delete", token_id, "--json"]) 1#$%'()*
112 assert result.exit_code == 0 1#$%'()*
113 assert json.loads(result.stdout) == { 1#$%'()*
114 "data": {
115 "token_id": token_id,
116 "deleted": True,
117 }
118 }
119 assert result.stderr == "" 1#$%'()*
122def test_deletes_token_json_returns_missing_required_input_without_app_context( 1abcdefg
123 logged_in_cli: None,
124) -> None:
125 result = runner.invoke( 2LbMbNbObPbQbRb
126 app,
127 [
128 "tokens",
129 "delete",
130 "00000000-0000-4000-8000-000000000004",
131 "--json",
132 ],
133 )
135 assert result.exit_code == 1 2LbMbNbObPbQbRb
136 assert json.loads(result.stdout) == { 2LbMbNbObPbQbRb
137 "error": {
138 "code": "missing_required_input",
139 "message": "App ID is required.",
140 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
141 }
142 }
143 assert result.stderr == "" 2LbMbNbObPbQbRb
146@pytest.mark.respx 1abcdefg
147def test_delete_token_json_returns_not_found_when_token_is_missing( 1abcdefg
148 logged_in_cli: None,
149 respx_mock: respx.MockRouter,
150) -> None:
151 app_id = "00000000-0000-4000-8000-000000000002" 1+,-./:;
152 token_id = "00000000-0000-4000-8000-000000000004" 1+,-./:;
153 respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock( 1+,-./:;
154 return_value=Response(404)
155 )
157 result = runner.invoke( 1+,-./:;
158 app,
159 ["tokens", "delete", token_id, "--app-id", app_id, "--json"],
160 )
162 assert result.exit_code == 1 1+,-./:;
163 assert json.loads(result.stdout) == { 1+,-./:;
164 "error": {
165 "code": "not_found",
166 "message": f"Deploy token {token_id} not found.",
167 "hint": "Run `fastapi cloud tokens list` to see available deploy tokens.",
168 }
169 }
170 assert result.stderr == "" 1+,-./:;
173@pytest.mark.respx 1abcdefg
174def test_delete_token_human_output( 1abcdefg
175 logged_in_cli: None,
176 respx_mock: respx.MockRouter,
177) -> None:
178 app_id = "00000000-0000-4000-8000-000000000002" 1=?@[]^_
179 token_id = "00000000-0000-4000-8000-000000000004" 1=?@[]^_
180 respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock( 1=?@[]^_
181 return_value=Response(204)
182 )
184 result = runner.invoke(app, ["tokens", "delete", token_id, "--app-id", app_id]) 1=?@[]^_
186 assert result.exit_code == 0 1=?@[]^_
187 assert f"Deleted deploy token {token_id}" in result.output 1=?@[]^_
190def test_creates_token_json_returns_missing_required_input_without_name( 1abcdefg
191 logged_in_cli: None,
192) -> None:
193 result = runner.invoke( 2SbTbUbVbWbXbYb
194 app,
195 [
196 "tokens",
197 "create",
198 "--app-id",
199 "00000000-0000-4000-8000-000000000002",
200 "--output-file",
201 "deploy-token.txt",
202 "--json",
203 ],
204 )
206 assert result.exit_code == 1 2SbTbUbVbWbXbYb
207 assert json.loads(result.stdout) == { 2SbTbUbVbWbXbYb
208 "error": {
209 "code": "missing_required_input",
210 "message": "Deploy token name is required.",
211 "hint": "Pass --name to choose a deploy token name.",
212 }
213 }
214 assert result.stderr == "" 2SbTbUbVbWbXbYb
217def test_creates_token_json_returns_missing_required_input_without_output_file( 1abcdefg
218 logged_in_cli: None,
219) -> None:
220 result = runner.invoke( 2Zb0b1b2b3b4b5b
221 app,
222 [
223 "tokens",
224 "create",
225 "--app-id",
226 "00000000-0000-4000-8000-000000000002",
227 "--name",
228 "GitHub Actions",
229 "--json",
230 ],
231 )
233 assert result.exit_code == 1 2Zb0b1b2b3b4b5b
234 assert json.loads(result.stdout) == { 2Zb0b1b2b3b4b5b
235 "error": {
236 "code": "missing_required_input",
237 "message": "Output file is required.",
238 "hint": "Pass --output-file to store the deploy token value.",
239 }
240 }
241 assert result.stderr == "" 2Zb0b1b2b3b4b5b
244def test_create_token_requires_output_file_before_prompting_for_name( 1abcdefg
245 logged_in_cli: None,
246) -> None:
247 result = runner.invoke( 2` { | } ~ abbb
248 app,
249 [
250 "tokens",
251 "create",
252 "--app-id",
253 "00000000-0000-4000-8000-000000000002",
254 ],
255 )
257 assert result.exit_code == 1 2` { | } ~ abbb
258 assert "deploy tokens" in result.output 2` { | } ~ abbb
259 assert "Output file is required." in result.output 2` { | } ~ abbb
260 assert "Pass --output-file to store the deploy token value." in result.output 2` { | } ~ abbb
261 assert "What's the deploy token name?" not in result.output 2` { | } ~ abbb
264@pytest.mark.respx 1abcdefg
265def test_creates_token_as_json_with_app_id_and_writes_value_to_file( 1abcdefg
266 logged_in_cli: None,
267 respx_mock: respx.MockRouter,
268 tmp_path: Path,
269) -> None:
270 app_id = "00000000-0000-4000-8000-000000000002" 1vwxyzAB
271 token_id = "00000000-0000-4000-8000-000000000004" 1vwxyzAB
272 token_value = "fcp_secret_token_value" 1vwxyzAB
273 output_file = tmp_path / "deploy-token" 1vwxyzAB
274 respx_mock.post( 1vwxyzAB
275 f"/apps/{app_id}/tokens",
276 json={"name": "GitHub Actions", "expires_in_days": 365},
277 ).mock(
278 return_value=Response(
279 201,
280 json={
281 "id": token_id,
282 "name": "GitHub Actions",
283 "expired_at": "2027-05-22T10:00:00Z",
284 "value": token_value,
285 },
286 )
287 )
289 result = runner.invoke( 1vwxyzAB
290 app,
291 [
292 "tokens",
293 "create",
294 "--app-id",
295 app_id,
296 "--name",
297 "GitHub Actions",
298 "--output-file",
299 str(output_file),
300 "--json",
301 ],
302 )
304 assert result.exit_code == 0 1vwxyzAB
305 assert json.loads(result.stdout) == { 1vwxyzAB
306 "data": {
307 "app_id": app_id,
308 "token": {
309 "id": token_id,
310 "name": "GitHub Actions",
311 "expired_at": "2027-05-22T10:00:00Z",
312 },
313 "stored_secret": {
314 "provider": "file",
315 "path": str(output_file),
316 },
317 }
318 }
319 assert token_value not in result.stdout 1vwxyzAB
320 assert output_file.read_text(encoding="utf-8") == token_value 1vwxyzAB
321 assert result.stderr == "" 1vwxyzAB
324@pytest.mark.respx 1abcdefg
325def test_creates_token_uses_linked_app_and_custom_expiration( 1abcdefg
326 logged_in_cli: None,
327 respx_mock: respx.MockRouter,
328 configured_app: ConfiguredApp,
329 tmp_path: Path,
330) -> None:
331 output_file = tmp_path / "deploy-token" 1JKLMNOP
332 respx_mock.post( 1JKLMNOP
333 f"/apps/{configured_app.app_id}/tokens",
334 json={"name": "Release Bot", "expires_in_days": 30},
335 ).mock(
336 return_value=Response(
337 201,
338 json={
339 "id": "00000000-0000-4000-8000-000000000004",
340 "name": "Release Bot",
341 "expired_at": "2026-06-21T10:00:00Z",
342 "value": "fcp_secret_token_value",
343 },
344 )
345 )
347 with changing_dir(configured_app.path): 1JKLMNOP
348 result = runner.invoke( 1JKLMNOP
349 app,
350 [
351 "tokens",
352 "create",
353 "--name",
354 "Release Bot",
355 "--expires-in-days",
356 "30",
357 "--output-file",
358 str(output_file),
359 "--json",
360 ],
361 )
363 assert result.exit_code == 0 1JKLMNOP
364 assert json.loads(result.stdout)["data"]["app_id"] == configured_app.app_id 1JKLMNOP
365 assert output_file.read_text(encoding="utf-8") == "fcp_secret_token_value" 1JKLMNOP
366 assert result.stderr == "" 1JKLMNOP
369@pytest.mark.respx 1abcdefg
370def test_create_token_prompts_for_name_under_deploy_tokens_heading( 1abcdefg
371 logged_in_cli: None,
372 respx_mock: respx.MockRouter,
373 tmp_path: Path,
374) -> None:
375 app_id = "00000000-0000-4000-8000-000000000002" 1CDEFGHI
376 output_file = tmp_path / "deploy-token" 1CDEFGHI
377 respx_mock.post( 1CDEFGHI
378 f"/apps/{app_id}/tokens",
379 json={"name": "GitHub Actions", "expires_in_days": 365},
380 ).mock(
381 return_value=Response(
382 201,
383 json={
384 "id": "00000000-0000-4000-8000-000000000004",
385 "name": "GitHub Actions",
386 "expired_at": "2027-05-22T10:00:00Z",
387 "value": "fcp_secret_token_value",
388 },
389 )
390 )
392 with patch( 1CDEFGHI
393 "rich_toolkit.container.getchar",
394 side_effect=[*"GitHub Actions", Keys.ENTER],
395 ):
396 result = runner.invoke( 1CDEFGHI
397 app,
398 [
399 "tokens",
400 "create",
401 "--app-id",
402 app_id,
403 "--output-file",
404 str(output_file),
405 ],
406 )
408 assert result.exit_code == 0 1CDEFGHI
409 assert "deploy tokens" in result.output 1CDEFGHI
410 assert "What's the deploy token name?" in result.output 1CDEFGHI
411 assert "Created deploy token GitHub Actions" in result.output 1CDEFGHI
412 assert output_file.read_text(encoding="utf-8") == "fcp_secret_token_value" 1CDEFGHI
415@pytest.mark.respx 1abcdefg
416def test_create_token_separates_prompt_from_http_error( 1abcdefg
417 logged_in_cli: None,
418 respx_mock: respx.MockRouter,
419 tmp_path: Path,
420) -> None:
421 app_id = "00000000-0000-4000-8000-000000000002" 1hijklmn
422 output_file = tmp_path / "deploy-token" 1hijklmn
423 respx_mock.post( 1hijklmn
424 f"/apps/{app_id}/tokens",
425 json={"name": "GitHub Actions", "expires_in_days": 365},
426 ).mock(
427 return_value=Response(
428 409,
429 json={"detail": "Deploy token name already exists."},
430 )
431 )
433 with patch( 1hijklmn
434 "rich_toolkit.container.getchar",
435 side_effect=[*"GitHub Actions", Keys.ENTER],
436 ):
437 result = runner.invoke( 1hijklmn
438 app,
439 [
440 "tokens",
441 "create",
442 "--app-id",
443 app_id,
444 "--output-file",
445 str(output_file),
446 ],
447 )
449 assert result.exit_code == 1 1hijklmn
450 assert "What's the deploy token name?" in result.output 1hijklmn
451 assert "Deploy token name already exists." in result.output 1hijklmn
452 lines = result.output.splitlines() 1hijklmn
453 error_index = next( 1hijklmn
454 index
455 for index, line in enumerate(lines)
456 if "Deploy token name already exists." in line
457 )
458 assert lines[error_index - 1].replace("\u200b", "").strip() == "" 1hijklmn
459 assert not output_file.exists() 1hijklmn
462@pytest.mark.respx 1abcdefg
463def test_create_token_human_output_does_not_print_token_value( 1abcdefg
464 logged_in_cli: None,
465 respx_mock: respx.MockRouter,
466 tmp_path: Path,
467) -> None:
468 app_id = "00000000-0000-4000-8000-000000000002" 1opqrstu
469 token_value = "fcp_secret_token_value" 1opqrstu
470 output_file = tmp_path / "deploy-token" 1opqrstu
471 respx_mock.post(f"/apps/{app_id}/tokens").mock( 1opqrstu
472 return_value=Response(
473 201,
474 json={
475 "id": "00000000-0000-4000-8000-000000000004",
476 "name": "GitHub Actions",
477 "expired_at": "2027-05-22T10:00:00Z",
478 "value": token_value,
479 },
480 )
481 )
483 result = runner.invoke( 1opqrstu
484 app,
485 [
486 "tokens",
487 "create",
488 "--app-id",
489 app_id,
490 "--name",
491 "GitHub Actions",
492 "--output-file",
493 str(output_file),
494 ],
495 )
497 assert result.exit_code == 0 1opqrstu
498 assert "deploy tokens" in result.output 1opqrstu
499 assert "Created deploy token GitHub Actions" in result.output 1opqrstu
500 assert "Stored deploy token value in" in result.output 1opqrstu
501 assert output_file.name in result.output 1opqrstu
502 assert token_value not in result.output 1opqrstu
503 assert output_file.read_text(encoding="utf-8") == token_value 1opqrstu
506@pytest.mark.respx 1abcdefg
507def test_create_token_json_maps_duplicate_name_to_invalid_input( 1abcdefg
508 logged_in_cli: None,
509 respx_mock: respx.MockRouter,
510 tmp_path: Path,
511) -> None:
512 app_id = "00000000-0000-4000-8000-000000000002" 1QRSTUVW
513 output_file = tmp_path / "deploy-token" 1QRSTUVW
514 respx_mock.post(f"/apps/{app_id}/tokens").mock( 1QRSTUVW
515 return_value=Response(
516 409,
517 json={"detail": "Deploy token name already exists."},
518 )
519 )
521 result = runner.invoke( 1QRSTUVW
522 app,
523 [
524 "tokens",
525 "create",
526 "--app-id",
527 app_id,
528 "--name",
529 "GitHub Actions",
530 "--output-file",
531 str(output_file),
532 "--json",
533 ],
534 )
536 assert result.exit_code == 1 1QRSTUVW
537 assert json.loads(result.stdout) == { 1QRSTUVW
538 "error": {
539 "code": "invalid_input",
540 "message": "Deploy token name already exists.",
541 "hint": None,
542 }
543 }
544 assert not output_file.exists() 1QRSTUVW
545 assert result.stderr == "" 1QRSTUVW
548def test_lists_tokens_json_returns_not_logged_in_when_logged_out( 1abcdefg
549 logged_out_cli: None,
550) -> None:
551 result = runner.invoke( 26b7b8b9b!b#b$b
552 app,
553 [
554 "tokens",
555 "list",
556 "--app-id",
557 "00000000-0000-4000-8000-000000000002",
558 "--json",
559 ],
560 )
562 assert result.exit_code == 1 26b7b8b9b!b#b$b
563 assert json.loads(result.stdout) == { 26b7b8b9b!b#b$b
564 "error": {
565 "code": "not_logged_in",
566 "message": "No credentials found.",
567 "hint": "Run `fastapi cloud login`.",
568 }
569 }
570 assert result.stderr == "" 26b7b8b9b!b#b$b
573def test_lists_tokens_json_returns_missing_required_input_without_app_context( 1abcdefg
574 logged_in_cli: None,
575) -> None:
576 result = runner.invoke(app, ["tokens", "list", "--json"]) 2%b'b(b)b*b+b,b
578 assert result.exit_code == 1 2%b'b(b)b*b+b,b
579 assert json.loads(result.stdout) == { 2%b'b(b)b*b+b,b
580 "error": {
581 "code": "missing_required_input",
582 "message": "App ID is required.",
583 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
584 }
585 }
586 assert result.stderr == "" 2%b'b(b)b*b+b,b
589@pytest.mark.respx 1abcdefg
590def test_lists_tokens_as_json_with_app_id_without_secret_values( 1abcdefg
591 logged_in_cli: None,
592 respx_mock: respx.MockRouter,
593) -> None:
594 app_id = "00000000-0000-4000-8000-000000000002" 1XYZ0123
595 token = { 1XYZ0123
596 "id": "00000000-0000-4000-8000-000000000004",
597 "name": "GitHub Actions",
598 "created_at": "2026-05-22T10:00:00Z",
599 "expired_at": "2027-05-22T10:00:00Z",
600 "value": "fcp_secret_token_value",
601 }
602 respx_mock.get(f"/apps/{app_id}/tokens").mock( 1XYZ0123
603 return_value=Response(200, json={"data": [token]})
604 )
606 result = runner.invoke(app, ["tokens", "list", "--app-id", app_id, "--json"]) 1XYZ0123
608 assert result.exit_code == 0 1XYZ0123
609 assert json.loads(result.stdout) == { 1XYZ0123
610 "data": {
611 "app_id": app_id,
612 "tokens": [
613 {
614 "id": "00000000-0000-4000-8000-000000000004",
615 "name": "GitHub Actions",
616 "created_at": "2026-05-22T10:00:00Z",
617 "expired_at": "2027-05-22T10:00:00Z",
618 }
619 ],
620 }
621 }
622 assert "fcp_secret_token_value" not in result.stdout 1XYZ0123
623 assert result.stderr == "" 1XYZ0123
626@pytest.mark.respx 1abcdefg
627def test_lists_tokens_as_json_uses_linked_app( 1abcdefg
628 logged_in_cli: None,
629 respx_mock: respx.MockRouter,
630 configured_app: ConfiguredApp,
631) -> None:
632 respx_mock.get(f"/apps/{configured_app.app_id}/tokens").mock( 2cbdbebfbgbhbib
633 return_value=Response(200, json={"data": []})
634 )
636 with changing_dir(configured_app.path): 2cbdbebfbgbhbib
637 result = runner.invoke(app, ["tokens", "list", "--json"]) 2cbdbebfbgbhbib
639 assert result.exit_code == 0 2cbdbebfbgbhbib
640 assert json.loads(result.stdout) == { 2cbdbebfbgbhbib
641 "data": {
642 "app_id": configured_app.app_id,
643 "tokens": [],
644 }
645 }
646 assert result.stderr == "" 2cbdbebfbgbhbib
649@pytest.mark.respx 1abcdefg
650def test_lists_tokens_in_human_output_empty( 1abcdefg
651 logged_in_cli: None,
652 respx_mock: respx.MockRouter,
653) -> None:
654 app_id = "00000000-0000-4000-8000-000000000002" 2qbrbsbtbubvbwb
655 respx_mock.get(f"/apps/{app_id}/tokens").mock( 2qbrbsbtbubvbwb
656 return_value=Response(200, json={"data": []})
657 )
659 result = runner.invoke(app, ["tokens", "list", "--app-id", app_id]) 2qbrbsbtbubvbwb
661 assert result.exit_code == 0 2qbrbsbtbubvbwb
662 assert result.output == snapshot("""\ 2qbrbsbtbubvbwb
663deploy tokens
665No deploy tokens found.\
666""")
669@pytest.mark.respx 1abcdefg
670def test_lists_tokens_human_output_without_secret_values( 1abcdefg
671 logged_in_cli: None,
672 respx_mock: respx.MockRouter,
673) -> None:
674 app_id = "00000000-0000-4000-8000-000000000002" 2jbkblbmbnbobpb
675 token_id = "00000000-0000-4000-8000-000000000004" 2jbkblbmbnbobpb
676 respx_mock.get(f"/apps/{app_id}/tokens").mock( 2jbkblbmbnbobpb
677 return_value=Response(
678 200,
679 json={
680 "data": [
681 {
682 "id": token_id,
683 "name": "GitHub Actions",
684 "created_at": "2026-05-22T10:00:00Z",
685 "expired_at": "2027-05-22T10:00:00Z",
686 "value": "fcp_secret_token_value",
687 }
688 ]
689 },
690 )
691 )
693 result = runner.invoke(app, ["tokens", "list", "--app-id", app_id]) 2jbkblbmbnbobpb
695 assert result.exit_code == 0 2jbkblbmbnbobpb
696 assert result.output == snapshot("""\ 2jbkblbmbnbobpb
697deploy tokens
699Name Expiration ID
701GitHub Actions 2027-05-22 00000000-0000-4000-8000-000000000004\
702""")