From 4ab8cc7f9aafd3beb50c3aaca8b642ae4717c0e1 Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 14:55:25 +0100 Subject: [PATCH 01/10] Handle already logged in state --- src/fastapi_cloud_cli/commands/login.py | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index ab0670d..2fe4e59 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -8,7 +8,7 @@ from fastapi_cloud_cli.config import Settings from fastapi_cloud_cli.utils.api import APIClient -from fastapi_cloud_cli.utils.auth import AuthConfig, write_auth_config +from fastapi_cloud_cli.utils.auth import AuthConfig, is_logged_in, write_auth_config from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -72,10 +72,36 @@ def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) - return response_data.access_token +def _verify_token(client: httpx.Client) -> tuple[bool, str | None]: + try: + response = client.get("/users/me") + response.raise_for_status() + data = response.json() + return True, data.get("email") + except httpx.HTTPStatusError as e: + if e.response.status_code in (401, 403): + return False, None + raise + except Exception: + return False, None + + def login() -> Any: """ Login to FastAPI Cloud. 🚀 """ + if is_logged_in(): + with APIClient() as client: + is_valid, email = _verify_token(client) + + if is_valid: + with get_rich_toolkit(minimal=True) as toolkit: + toolkit.print(f"Already logged in as [bold]{email}[/bold]") + toolkit.print( + "Run [bold]fastapi logout[/bold] first if you want to switch accounts." + ) + return + with get_rich_toolkit() as toolkit, APIClient() as client: toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI") From 09b24dae5daae2c9eb8d993dc46a58520c36c9fc Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 15:09:56 +0100 Subject: [PATCH 02/10] Update --- src/fastapi_cloud_cli/commands/login.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 2fe4e59..44269c4 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -1,6 +1,6 @@ import logging import time -from typing import Any +from typing import Any, Tuple, Union import httpx import typer @@ -72,16 +72,14 @@ def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) - return response_data.access_token -def _verify_token(client: httpx.Client) -> tuple[bool, str | None]: +def _verify_token(client: httpx.Client) -> Tuple[bool, Union[str, None]]: try: response = client.get("/users/me") + if response.status_code in {401, 403}: + return False, None response.raise_for_status() data = response.json() return True, data.get("email") - except httpx.HTTPStatusError as e: - if e.response.status_code in (401, 403): - return False, None - raise except Exception: return False, None From 45fc73b17be7871e1047403eb37d98e1e3924f8b Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 15:16:10 +0100 Subject: [PATCH 03/10] Update --- src/fastapi_cloud_cli/commands/login.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 44269c4..dec628e 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -73,15 +73,12 @@ def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) - def _verify_token(client: httpx.Client) -> Tuple[bool, Union[str, None]]: - try: - response = client.get("/users/me") - if response.status_code in {401, 403}: - return False, None - response.raise_for_status() - data = response.json() - return True, data.get("email") - except Exception: + response = client.get("/users/me") + if response.status_code in {401, 403}: return False, None + response.raise_for_status() + data = response.json() + return True, data.get("email") def login() -> Any: From d908e3b18f039e614cbc07c0efab8253bc2501ee Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 15:28:25 +0100 Subject: [PATCH 04/10] Add test --- tests/test_cli_login.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 072d3da..8b74b5f 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -162,3 +162,24 @@ def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> N with APIClient() as client: with pytest.raises(httpx.HTTPStatusError): _fetch_access_token(client, "test_device_code", 5) + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_notify_already_logged_in_user( + respx_mock: respx.MockRouter, logged_in_cli: None +) -> None: + respx_mock.get("/users/me").mock( + return_value=Response(200, json={"email": "userme@example.com"}) + ) + + device_auth_mock = respx_mock.post( + "/login/device/authorization", data={"client_id": settings.client_id} + ).mock(return_value=Response(200, json={})) + + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0 + assert "Already logged in as userme@example.com" in result.output + assert "Run fastapi logout first if you want to switch accounts." in result.output + + assert device_auth_mock.call_count == 0 From 946fc054f362a8f886af1b2ac94bb094b67c0eea Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 15:40:07 +0100 Subject: [PATCH 05/10] Update test --- tests/test_cli_login.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 8b74b5f..be31b76 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -172,14 +172,8 @@ def test_notify_already_logged_in_user( return_value=Response(200, json={"email": "userme@example.com"}) ) - device_auth_mock = respx_mock.post( - "/login/device/authorization", data={"client_id": settings.client_id} - ).mock(return_value=Response(200, json={})) - result = runner.invoke(app, ["login"]) assert result.exit_code == 0 assert "Already logged in as userme@example.com" in result.output assert "Run fastapi logout first if you want to switch accounts." in result.output - - assert device_auth_mock.call_count == 0 From 6f24161f9ae0bdedad198b3229b831bdd3b580e0 Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 16:57:34 +0100 Subject: [PATCH 06/10] Add verify token tests --- tests/test_cli_login.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index be31b76..01d1b5f 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -177,3 +177,31 @@ def test_notify_already_logged_in_user( assert result.exit_code == 0 assert "Already logged in as userme@example.com" in result.output assert "Run fastapi logout first if you want to switch accounts." in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_verify_token_returns_false_on_unauthorized(respx_mock: respx.MockRouter) -> None: + from fastapi_cloud_cli.commands.login import _verify_token + from fastapi_cloud_cli.utils.api import APIClient + + respx_mock.get("/users/me").mock(return_value=Response(401)) + + with APIClient() as client: + is_valid, email = _verify_token(client) + + assert is_valid is False + assert email is None + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_verify_token_returns_false_on_forbidden(respx_mock: respx.MockRouter) -> None: + from fastapi_cloud_cli.commands.login import _verify_token + from fastapi_cloud_cli.utils.api import APIClient + + respx_mock.get("/users/me").mock(return_value=Response(403)) + + with APIClient() as client: + is_valid, email = _verify_token(client) + + assert is_valid is False + assert email is None From 96cfe830c141de75286620bd4e80716b1e57337b Mon Sep 17 00:00:00 2001 From: Alejandra Date: Tue, 28 Oct 2025 16:59:24 +0100 Subject: [PATCH 07/10] Format --- tests/test_cli_login.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 01d1b5f..26e8c95 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -180,7 +180,9 @@ def test_notify_already_logged_in_user( @pytest.mark.respx(base_url=settings.base_api_url) -def test_verify_token_returns_false_on_unauthorized(respx_mock: respx.MockRouter) -> None: +def test_verify_token_returns_false_on_unauthorized( + respx_mock: respx.MockRouter, +) -> None: from fastapi_cloud_cli.commands.login import _verify_token from fastapi_cloud_cli.utils.api import APIClient From 0ac6ee74011637ae064e47a7cd7bc3cca3162782 Mon Sep 17 00:00:00 2001 From: Alejandra Date: Mon, 17 Nov 2025 12:30:17 +0100 Subject: [PATCH 08/10] Update login.py --- src/fastapi_cloud_cli/commands/login.py | 41 ++++++++++++------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index dec628e..d1c42d2 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -1,6 +1,6 @@ import logging import time -from typing import Any, Tuple, Union +from typing import Any import httpx import typer @@ -8,7 +8,13 @@ from fastapi_cloud_cli.config import Settings from fastapi_cloud_cli.utils.api import APIClient -from fastapi_cloud_cli.utils.auth import AuthConfig, is_logged_in, write_auth_config +from fastapi_cloud_cli.utils.auth import ( + AuthConfig, + get_auth_token, + is_logged_in, + is_token_expired, + write_auth_config, +) from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -72,30 +78,23 @@ def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) - return response_data.access_token -def _verify_token(client: httpx.Client) -> Tuple[bool, Union[str, None]]: - response = client.get("/users/me") - if response.status_code in {401, 403}: - return False, None - response.raise_for_status() - data = response.json() - return True, data.get("email") - - def login() -> Any: """ Login to FastAPI Cloud. 🚀 """ + token = get_auth_token() + if token is not None and is_token_expired(token): + with get_rich_toolkit(minimal=True) as toolkit: + toolkit.print("Your session has expired.") + toolkit.print_line() + if is_logged_in(): - with APIClient() as client: - is_valid, email = _verify_token(client) - - if is_valid: - with get_rich_toolkit(minimal=True) as toolkit: - toolkit.print(f"Already logged in as [bold]{email}[/bold]") - toolkit.print( - "Run [bold]fastapi logout[/bold] first if you want to switch accounts." - ) - return + with get_rich_toolkit(minimal=True) as toolkit: + toolkit.print("You are already logged in.") + toolkit.print( + "Run [bold]fastapi logout[/bold] first if you want to switch accounts." + ) + return with get_rich_toolkit() as toolkit, APIClient() as client: toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI") From d05a0dafd28426eacd7423ca47bd60772e423f69 Mon Sep 17 00:00:00 2001 From: Alejandra Date: Mon, 17 Nov 2025 12:33:08 +0100 Subject: [PATCH 09/10] Update test_notify_already_logged_in_user --- tests/test_cli_login.py | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 26e8c95..a01d707 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -168,42 +168,8 @@ def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> N def test_notify_already_logged_in_user( respx_mock: respx.MockRouter, logged_in_cli: None ) -> None: - respx_mock.get("/users/me").mock( - return_value=Response(200, json={"email": "userme@example.com"}) - ) - result = runner.invoke(app, ["login"]) assert result.exit_code == 0 - assert "Already logged in as userme@example.com" in result.output + assert "You are already logged in." in result.output assert "Run fastapi logout first if you want to switch accounts." in result.output - - -@pytest.mark.respx(base_url=settings.base_api_url) -def test_verify_token_returns_false_on_unauthorized( - respx_mock: respx.MockRouter, -) -> None: - from fastapi_cloud_cli.commands.login import _verify_token - from fastapi_cloud_cli.utils.api import APIClient - - respx_mock.get("/users/me").mock(return_value=Response(401)) - - with APIClient() as client: - is_valid, email = _verify_token(client) - - assert is_valid is False - assert email is None - - -@pytest.mark.respx(base_url=settings.base_api_url) -def test_verify_token_returns_false_on_forbidden(respx_mock: respx.MockRouter) -> None: - from fastapi_cloud_cli.commands.login import _verify_token - from fastapi_cloud_cli.utils.api import APIClient - - respx_mock.get("/users/me").mock(return_value=Response(403)) - - with APIClient() as client: - is_valid, email = _verify_token(client) - - assert is_valid is False - assert email is None From 5b8a2e90036875e3af6831c51804084a7d675fdb Mon Sep 17 00:00:00 2001 From: Alejandra Date: Mon, 17 Nov 2025 13:06:53 +0100 Subject: [PATCH 10/10] Update login message for exp session and add test for token expiration --- src/fastapi_cloud_cli/commands/login.py | 2 +- tests/test_cli_login.py | 42 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index d1c42d2..a3378ee 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -85,7 +85,7 @@ def login() -> Any: token = get_auth_token() if token is not None and is_token_expired(token): with get_rich_toolkit(minimal=True) as toolkit: - toolkit.print("Your session has expired.") + toolkit.print("Your session has expired. Logging in again...") toolkit.print_line() if is_logged_in(): diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index a01d707..22d856a 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -1,3 +1,4 @@ +import time from pathlib import Path from unittest.mock import patch @@ -9,6 +10,7 @@ from fastapi_cloud_cli.cli import app from fastapi_cloud_cli.config import Settings +from tests.utils import create_jwt_token runner = CliRunner() settings = Settings.get() @@ -173,3 +175,43 @@ def test_notify_already_logged_in_user( assert result.exit_code == 0 assert "You are already logged in." in result.output assert "Run fastapi logout first if you want to switch accounts." in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_notify_expired_token_user( + respx_mock: respx.MockRouter, temp_auth_config: Path +) -> None: + past_exp = int(time.time()) - 3600 + expired_token = create_jwt_token({"sub": "test_user_12345", "exp": past_exp}) + + temp_auth_config.write_text(f'{{"access_token": "{expired_token}"}}') + + with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open: + respx_mock.post( + "/login/device/authorization", data={"client_id": settings.client_id} + ).mock( + return_value=Response( + 200, + json={ + "verification_uri_complete": "http://test.com", + "verification_uri": "http://test.com", + "user_code": "1234", + "device_code": "5678", + }, + ) + ) + respx_mock.post( + "/login/device/token", + data={ + "device_code": "5678", + "client_id": settings.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ).mock(return_value=Response(200, json={"access_token": "new_token_1234"})) + + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0 + assert "Your session has expired. Logging in again..." in result.output + assert "Now you are logged in!" in result.output + assert mock_open.called