Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/fastapi_cloud_cli/commands/tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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(
no_args_is_help=True,
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"]
102 changes: 102 additions & 0 deletions src/fastapi_cloud_cli/commands/tokens/delete.py
Original file line number Diff line number Diff line change
@@ -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,
)
144 changes: 144 additions & 0 deletions tests/test_cli_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down