diff --git a/src/fastapi_cloud_cli/commands/tokens/__init__.py b/src/fastapi_cloud_cli/commands/tokens/__init__.py index f69c6c4..40a0f16 100644 --- a/src/fastapi_cloud_cli/commands/tokens/__init__.py +++ b/src/fastapi_cloud_cli/commands/tokens/__init__.py @@ -1,6 +1,7 @@ import typer from fastapi_cloud_cli.commands.tokens.create import create_token +from fastapi_cloud_cli.commands.tokens.delete import delete_token from fastapi_cloud_cli.commands.tokens.list import list_tokens tokens_app = typer.Typer( @@ -8,6 +9,7 @@ help="Manage deploy tokens for your app.", ) tokens_app.command("create")(create_token) +tokens_app.command("delete")(delete_token) tokens_app.command("list")(list_tokens) __all__ = ["tokens_app"] diff --git a/src/fastapi_cloud_cli/commands/tokens/delete.py b/src/fastapi_cloud_cli/commands/tokens/delete.py new file mode 100644 index 0000000..f20a712 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/tokens/delete.py @@ -0,0 +1,102 @@ +from typing import Annotated, Any + +import typer +from pydantic import BaseModel +from rich_toolkit import RichToolkit + +from fastapi_cloud_cli.utils.api import APIClient +from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail +from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.cli import get_rich_toolkit +from fastapi_cloud_cli.utils.execution import JsonOutputOption + + +class DeployTokenDeleteOutput(BaseModel): + token_id: str + deleted: bool = True + + +def _delete_deploy_token(client: APIClient, *, app_id: str, token_id: str) -> bool: + response = client.delete(f"/apps/{app_id}/tokens/{token_id}") + + if response.status_code == 404: + return False + + response.raise_for_status() + + return True + + +def _render_deploy_token_delete_output( + data: DeployTokenDeleteOutput, toolkit: RichToolkit +) -> None: + toolkit.print( + f"Deleted deploy token [bold]{data.token_id}[/bold]", + bullet=False, + ) + + +def delete_token( + token_id: Annotated[ + str, + typer.Argument( + help="ID of the deploy token to delete.", + ), + ], + app_id: Annotated[ + str | None, + typer.Option( + "--app-id", + help="ID of the app that owns the deploy token.", + ), + ] = None, + json_output: JsonOutputOption = False, +) -> Any: + """ + Delete a deploy token for an app. + """ + identity = Identity() + + with get_rich_toolkit(json_output=json_output) as toolkit: + if not identity.is_logged_in(): + toolkit.fail( + "not_logged_in", + "No credentials found.", + hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + ) + + target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id) + + with APIClient() as client: + with toolkit.progress( + title="Deleting deploy token", + transient=True, + ) as progress: + with client.handle_http_errors( + progress, + default_message="Error deleting deploy token. Please try again later.", + not_found_message="Deploy token not found.", + toolkit=toolkit, + ): + deleted = _delete_deploy_token( + client, + app_id=target_app_id, + token_id=token_id, + ) + + if not deleted: + message = ( + f"Deploy token {token_id} not found." + if toolkit.mode == "json" + else "Deploy token not found." + ) + toolkit.fail( + "not_found", + message, + hint="Run `fastapi cloud tokens list` to see available deploy tokens.", + ) + + toolkit.success( + DeployTokenDeleteOutput(token_id=token_id), + render_output=_render_deploy_token_delete_output, + ) diff --git a/tests/test_cli_tokens.py b/tests/test_cli_tokens.py index 9b68af0..7669f93 100644 --- a/tests/test_cli_tokens.py +++ b/tests/test_cli_tokens.py @@ -43,6 +43,150 @@ def test_creates_token_json_returns_not_logged_in_when_logged_out( assert result.stderr == "" +def test_deletes_token_json_returns_not_logged_in_when_logged_out( + logged_out_cli: None, +) -> None: + result = runner.invoke( + app, + [ + "tokens", + "delete", + "00000000-0000-4000-8000-000000000004", + "--app-id", + "00000000-0000-4000-8000-000000000002", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "not_logged_in", + "message": "No credentials found.", + "hint": "Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + } + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_deletes_token_as_json_with_app_id( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + token_id = "00000000-0000-4000-8000-000000000004" + respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock( + return_value=Response(204) + ) + + result = runner.invoke( + app, + ["tokens", "delete", token_id, "--app-id", app_id, "--json"], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "token_id": token_id, + "deleted": True, + } + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_deletes_token_as_json_uses_linked_app( + logged_in_cli: None, + respx_mock: respx.MockRouter, + configured_app: ConfiguredApp, +) -> None: + token_id = "00000000-0000-4000-8000-000000000004" + respx_mock.delete(f"/apps/{configured_app.app_id}/tokens/{token_id}").mock( + return_value=Response(204) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["tokens", "delete", token_id, "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "token_id": token_id, + "deleted": True, + } + } + assert result.stderr == "" + + +def test_deletes_token_json_returns_missing_required_input_without_app_context( + logged_in_cli: None, +) -> None: + result = runner.invoke( + app, + [ + "tokens", + "delete", + "00000000-0000-4000-8000-000000000004", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "App ID is required.", + "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.", + } + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_delete_token_json_returns_not_found_when_token_is_missing( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + token_id = "00000000-0000-4000-8000-000000000004" + respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock( + return_value=Response(404) + ) + + result = runner.invoke( + app, + ["tokens", "delete", token_id, "--app-id", app_id, "--json"], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "not_found", + "message": f"Deploy token {token_id} not found.", + "hint": "Run `fastapi cloud tokens list` to see available deploy tokens.", + } + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_delete_token_human_output( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + token_id = "00000000-0000-4000-8000-000000000004" + respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock( + return_value=Response(204) + ) + + result = runner.invoke(app, ["tokens", "delete", token_id, "--app-id", app_id]) + + assert result.exit_code == 0 + assert f"Deleted deploy token {token_id}" in result.output + + def test_creates_token_json_returns_missing_required_input_without_name( logged_in_cli: None, ) -> None: