diff --git a/packages/reflex-hosting-cli/news/6918.breaking.md b/packages/reflex-hosting-cli/news/6918.breaking.md new file mode 100644 index 00000000000..e6aa870a4d4 --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.breaking.md @@ -0,0 +1 @@ +`REFLEX_ACCESS_TOKEN` now takes precedence over the token stored by `reflex login`. Previously the stored token won and the environment variable was consulted only when no token was stored, so exporting it to run a script against a different account had no effect on a machine that had ever logged in — silently, and with no way to tell which credential was in use. Exporting the variable is an explicit choice for that invocation; the config file is ambient state left behind by an earlier login. This changes behavior only when both are present and differ. `reflex cloud whoami` reports which source is in use. diff --git a/packages/reflex-hosting-cli/news/6918.bugfix.md b/packages/reflex-hosting-cli/news/6918.bugfix.md new file mode 100644 index 00000000000..8d47f28591a --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.bugfix.md @@ -0,0 +1 @@ +The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Neither helper reports write failures to the caller (`save_token_to_config` logs a warning, `delete_token_from_config` only a debug message), so this was easy to miss. Both now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. A config that exists but cannot be read is no longer treated as empty either, so `delete_token_from_config` leaves a malformed file alone instead of replacing it; `save_token_to_config` still starts fresh from one, so a corrupt config cannot block re-authenticating. This also covers `reflex login` and `reflex logout`, which share these helpers. diff --git a/packages/reflex-hosting-cli/news/6918.feature.md b/packages/reflex-hosting-cli/news/6918.feature.md new file mode 100644 index 00000000000..bc6302fd6fd --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.feature.md @@ -0,0 +1 @@ +Added `reflex cloud whoami` and `reflex cloud token`. `whoami` reports the account, org, tier and token source that the CLI is authenticating as, resolving the token against the control plane without ever starting a browser login and without printing the token — it shows a non-reversible fingerprint instead, so two machines can be compared without anyone sharing a secret. `reflex cloud token` takes exactly one of `--print`, `--set TOKEN` or `--clear`: `--print` writes the raw token to stdout for capture (`export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`), `--set` validates the token with the control plane before storing it and leaves the previous one in place if it is rejected, and `--clear` removes the stored token, noting when `REFLEX_ACCESS_TOKEN` remains set and will take over. diff --git a/packages/reflex-hosting-cli/news/6918.misc.md b/packages/reflex-hosting-cli/news/6918.misc.md new file mode 100644 index 00000000000..484e9ffde4c --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.misc.md @@ -0,0 +1 @@ +`reflex cloud token --set` accepts the token on stdin — pass `-`, or omit the value entirely — so live credentials need not appear in shell history or the process list. When stdin is a terminal it prompts without echoing. `reflex cloud whoami` writes its output directly rather than through the shared console, which applies rich markup and wraps to the terminal width: identifiers now print in full instead of being truncated to fit, and `--json` stays on one line so it can be piped. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 49ba13f263c..5755fbb6a3b 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -7,10 +7,12 @@ import importlib.metadata import json import logging +import os import platform import re import subprocess import sys +import tempfile import time import uuid import webbrowser @@ -348,33 +350,56 @@ def open(self, url: str, new: int = 0, autoraise: bool = True): webbrowser.BackgroundBrowser = SilentBackgroundBrowser -def get_existing_access_token() -> str: - """Fetch the access token from the existing config if applicable. +class TokenSource(str, Enum): + """Where an access token was loaded from.""" + + CONFIG = "config file" + ENVIRONMENT = "REFLEX_ACCESS_TOKEN environment variable" + OPTION = "--token option" + NONE = "none" + + +def get_existing_access_token_with_source() -> tuple[str, TokenSource]: + """Fetch the access token from the environment or existing config, and say where it came from. + + ``REFLEX_ACCESS_TOKEN`` takes precedence: exporting it is an explicit + choice for this invocation, while the config file is ambient state left + behind by an earlier ``reflex login``. Returns: - The access token. - If not found, return empty string for it instead. + The access token and the source it was loaded from. + If not found, return empty string and ``TokenSource.NONE`` instead. """ - import os + access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") + if access_token: + logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + return access_token, TokenSource.ENVIRONMENT logger.debug("Fetching token from existing config...") - access_token = "" try: - with constants.Hosting.HOSTING_JSON.open() as config_file: - hosting_config = json.load(config_file) - access_token = hosting_config.get("access_token", "") - except Exception as ex: + access_token = stored_access_token() + except (OSError, ValueError) as ex: logger.debug( f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) + return "", TokenSource.NONE - if not access_token: - access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") - if access_token: - logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + if access_token: + return access_token, TokenSource.CONFIG - return access_token + return "", TokenSource.NONE + + +def get_existing_access_token() -> str: + """Fetch the access token from the existing config if applicable. + + Returns: + The access token. + If not found, return empty string for it instead. + + """ + return get_existing_access_token_with_source()[0] def is_reflex_enterprise_installed() -> bool: @@ -466,23 +491,95 @@ def validate_token(token: str) -> dict[str, Any]: raise TokenValidationError("internal errors", request_id=request_id) from ex +def _read_hosting_config() -> dict[str, Any]: + """Read the hosting config file. + + A config that exists but cannot be read is reported rather than treated as + empty, so callers do not overwrite entries they were unable to see. + + Returns: + The stored config, or an empty dict if the file does not exist. + + Raises: + OSError: If the config exists but cannot be read. + ValueError: If the config exists but does not hold a JSON object. + + """ + try: + with constants.Hosting.HOSTING_JSON.open(encoding="utf-8") as config_file: + hosting_config = json.load(config_file) + except FileNotFoundError: + return {} + # Valid JSON is not necessarily the object every caller indexes into. + if not isinstance(hosting_config, dict): + msg = f"{constants.Hosting.HOSTING_JSON} does not hold a JSON object" + raise ValueError(msg) + return hosting_config + + +def stored_access_token() -> str: + """Read the access token held in the config file. + + Unlike ``get_existing_access_token`` this ignores ``REFLEX_ACCESS_TOKEN`` + and reports read failures, so callers can tell "no token stored" apart from + "cannot tell what is stored". + + Returns: + The stored token, or an empty string if the config holds none. + + Raises: + OSError: If the config exists but cannot be read. + ValueError: If the config exists but does not hold valid JSON. + + """ + return _read_hosting_config().get("access_token", "") + + +def _write_hosting_config(hosting_config: dict[str, Any]): + """Write the hosting config file atomically. + + The config is written to a temporary file alongside the target and moved + into place, so a failed or interrupted write leaves the previous + credentials intact rather than truncating them. + + Args: + hosting_config: The config to persist. + + """ + target = constants.Hosting.HOSTING_JSON + target.parent.mkdir(parents=True, exist_ok=True) + # Close the handle before replacing: Windows cannot rename an open file. + temp_fd, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.") + temp_path = Path(temp_name) + try: + with os.fdopen(temp_fd, "w", encoding="utf-8") as config_file: + json.dump(hosting_config, config_file) + config_file.flush() + os.fsync(config_file.fileno()) + temp_path.replace(target) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + + def delete_token_from_config(): """Delete the invalid token from the config file if applicable.""" if constants.Hosting.HOSTING_JSON.exists(): try: - with constants.Hosting.HOSTING_JSON.open("r") as config_file: - hosting_config = json.load(config_file) + hosting_config = _read_hosting_config() hosting_config.pop("access_token", None) - with constants.Hosting.HOSTING_JSON.open("w") as config_file: - json.dump(hosting_config, config_file) + _write_hosting_config(hosting_config) except Exception as ex: # Best efforts removing invalid token is OK logger.debug( f"Unable to delete the invalid token from config file, err: {ex}" ) - # Delete the previous hosting service data if present. - if constants.Hosting.HOSTING_JSON_V0.exists(): - constants.Hosting.HOSTING_JSON_V0.unlink() + # Delete the previous hosting service data if present. Best efforts, like + # the rest of this function: the legacy file holds no token the CLI reads. + try: + constants.Hosting.HOSTING_JSON_V0.unlink(missing_ok=True) + except OSError as ex: + logger.debug(f"Unable to remove {constants.Hosting.HOSTING_JSON_V0}: {ex}") def save_token_to_config(token: str): @@ -493,18 +590,17 @@ def save_token_to_config(token: str): """ try: - if not Path(constants.Reflex.DIR).exists(): - Path(constants.Reflex.DIR).mkdir(parents=True, exist_ok=True) - hosting_config: dict[str, str] = {} - if constants.Hosting.HOSTING_JSON.exists(): - try: - with constants.Hosting.HOSTING_JSON.open("r") as config_file: - hosting_config = json.load(config_file) - except (OSError, ValueError): - hosting_config = {} + try: + hosting_config = _read_hosting_config() + except (OSError, ValueError) as ex: + # An unreadable config must not block re-authenticating; the token + # is what makes the file useful, so start over from an empty one. + logger.debug( + f"Discarding unreadable {constants.Hosting.HOSTING_JSON}: {ex}" + ) + hosting_config = {} hosting_config["access_token"] = token - with constants.Hosting.HOSTING_JSON.open("w") as config_file: - json.dump(hosting_config, config_file) + _write_hosting_config(hosting_config) except Exception as ex: logger.warning( f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py new file mode 100644 index 00000000000..c9a4a7b0f09 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -0,0 +1,262 @@ +"""Authentication inspection commands for the Reflex Cloud CLI.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import sys + +import click +from reflex_base.utils import log + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.utils.exceptions import TokenValidationError + +logger = logging.getLogger(__name__) + +# Identity fields copied from the control plane response, in display order. +_IDENTITY_FIELDS = ("email", "user_id", "org_id", "tier", "is_service_account") + + +def token_fingerprint(token: str) -> str: + """Derive a non-reversible identifier for an access token. + + The same token always produces the same fingerprint, so two machines can be + compared without revealing the token itself. + + Args: + token: The access token to fingerprint. + + Returns: + A short sha256-derived fingerprint, or an empty string if there is no token. + + """ + if not token: + return "" + return f"sha256:{hashlib.sha256(token.encode()).hexdigest()[:16]}" + + +# Sentinel --set value meaning "read the token from stdin". +_STDIN = "-" + + +def _resolve_set_token(value: str) -> str: + """Resolve the `--set` value, reading from stdin when asked to. + + A token passed on the command line lands in shell history and is readable + from the process list, so `-` (or a bare `--set`) takes it from stdin, or + prompts without echo when stdin is a terminal. + + Args: + value: The raw value given to `--set`. + + Returns: + The token to validate and store. + + Raises: + UsageError: If the resolved token is empty. + + """ + if value == _STDIN: + value = ( + # err=True keeps the prompt off stdout. + click.prompt("Access token", hide_input=True, err=True) + if sys.stdin.isatty() + else sys.stdin.readline() + ) + token = value.strip() + if not token: + raise click.UsageError("--set was given an empty token.") + return token + + +_loglevel_option = click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) + + +@click.command() +@click.option("--token", help="The authentication token.") +@_loglevel_option +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def whoami_command(token: str | None, loglevel: str, as_json: bool): + """Show which account the Reflex Cloud CLI is authenticating as. + + Reports the identity the control plane resolves the access token to, along + with where that token was loaded from. Never starts a browser login and + never prints the token itself. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + if token: + access_token, source = token, hosting.TokenSource.OPTION + else: + access_token, source = hosting.get_existing_access_token_with_source() + + if not access_token: + logger.error("Not logged in. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) + + try: + validated_info = hosting.validate_token(access_token) + except TokenValidationError as err: + logger.error( + f"The access token from the {source.value} was rejected: {err} " + f"(auth request id: {err.request_id})" + ) + raise click.exceptions.Exit(1) from err + + identity = { + field: validated_info[field] + for field in _IDENTITY_FIELDS + if field in validated_info + } + identity["token_source"] = source.value + identity["token_fingerprint"] = token_fingerprint(access_token) + + # Both paths bypass the console: it applies rich markup and wraps at the + # terminal width, which corrupts JSON and truncates the identifiers this + # command exists to hand back. + if as_json: + click.echo(json.dumps(identity)) + return + + width = max(map(len, identity)) + for field, value in identity.items(): + click.echo(f"{field:<{width}} {value}") + + +@click.command() +@click.option( + "--print", + "print_token", + is_flag=True, + help="Print the active access token to stdout.", +) +@click.option( + "--set", + "set_token", + metavar="TOKEN", + is_flag=False, + flag_value=_STDIN, + default=None, + help=( + "Validate TOKEN and store it as the access token. Pass `-`, or omit " + "the value, to read the token from stdin instead of the command line." + ), +) +@click.option("--clear", is_flag=True, help="Remove the stored access token.") +@_loglevel_option +def token_command(print_token: bool, set_token: str | None, clear: bool, loglevel: str): + """Inspect or replace the stored Reflex Cloud access token. + + Exactly one of --print, --set or --clear must be given. --print writes the + raw token to stdout so it can be captured, e.g. + `export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`; stdout carries + the token or nothing at all, so use `reflex cloud whoami` to inspect where + the token came from. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + requested = [ + name + for name, chosen in ( + ("--print", print_token), + # `--set ""` is a malformed --set, not an absent one. + ("--set", set_token is not None), + ("--clear", clear), + ) + if chosen + ] + if len(requested) != 1: + raise click.UsageError( + f"Specify exactly one of --print, --set or --clear (got {', '.join(requested) or 'none'})." + ) + + if print_token: + # The shared console writes everything below ERROR to stdout, which + # would land inside `$(reflex cloud token --print)` alongside the + # token. Errors still go to stderr, so stdout stays exact either way. + console.set_log_level(constants.LogLevel.ERROR) + access_token, _ = hosting.get_existing_access_token_with_source() + if not access_token: + logger.error("No access token stored. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) + # Bypass the console so the token is never wrapped or styled. + click.echo(access_token) + return + + if set_token is not None: + set_token = _resolve_set_token(set_token) + try: + validated_info = hosting.validate_token(set_token) + except TokenValidationError as err: + logger.error( + f"Token rejected, nothing was saved: {err} " + f"(auth request id: {err.request_id})" + ) + raise click.exceptions.Exit(1) from err + + hosting.save_token_to_config(set_token) + # Verify against the config alone: the resolution order prefers + # REFLEX_ACCESS_TOKEN, which would mask the write we are confirming. + try: + stored = hosting.stored_access_token() + except (OSError, ValueError) as err: + logger.error( + f"Unable to confirm the token was written to " + f"{constants.Hosting.HOSTING_JSON}: {err}" + ) + raise click.exceptions.Exit(1) from err + if stored != set_token: + logger.error( + f"Unable to persist the token to {constants.Hosting.HOSTING_JSON}." + ) + raise click.exceptions.Exit(1) + + owner = validated_info.get("email") or validated_info.get("user_id") + logger.log( + log.SUCCESS, + f"Saved the access token for {owner} ({token_fingerprint(set_token)}).", + ) + return + + hosting.delete_token_from_config() + # delete_token_from_config swallows filesystem errors, so confirm the token + # is really gone rather than reporting an unverified success. A config that + # cannot be read is not evidence of removal either. + try: + remaining = hosting.stored_access_token() + except (OSError, ValueError) as err: + logger.error( + f"Unable to confirm the token was removed from " + f"{constants.Hosting.HOSTING_JSON}: {err}" + ) + raise click.exceptions.Exit(1) from err + if remaining: + logger.error( + f"Unable to remove the access token from {constants.Hosting.HOSTING_JSON}." + ) + raise click.exceptions.Exit(1) + + logger.log(log.SUCCESS, "Cleared the stored access token.") + if os.environ.get("REFLEX_ACCESS_TOKEN"): + logger.info( + "REFLEX_ACCESS_TOKEN is still set; the CLI will authenticate with it." + ) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index acc11f15462..08d8ba31106 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -13,6 +13,7 @@ from reflex_cli import constants from reflex_cli.v2.apps import apps_cli +from reflex_cli.v2.auth import token_command, whoami_command from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command from reflex_cli.v2.project import project_cli from reflex_cli.v2.providers import providers_cli @@ -94,6 +95,14 @@ def hosting_cli(ctx: click.Context) -> None: scan_command, name="scan", ) +hosting_cli.add_command( + whoami_command, + name="whoami", +) +hosting_cli.add_command( + token_command, + name="token", +) for name, command in vm_types_regions_cli.commands.items(): # Add the command to the hosting CLI hosting_cli.add_command(command, name=name) diff --git a/tests/units/reflex_cli/conftest.py b/tests/units/reflex_cli/conftest.py index f01f3f74a1f..7affb639330 100644 --- a/tests/units/reflex_cli/conftest.py +++ b/tests/units/reflex_cli/conftest.py @@ -2,6 +2,7 @@ import pytest from pytest_mock import MockFixture +from reflex_cli import constants @pytest.fixture(autouse=True) @@ -12,3 +13,26 @@ def mock_check_version(mocker: MockFixture) -> None: causing `check_version` to emit a warning and exit(1). """ mocker.patch("reflex_cli.v2.deployments.check_version") + + +@pytest.fixture(autouse=True) +def isolate_hosting_config( + monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory +) -> None: + """Point the hosting config at a temporary directory. + + Several code paths under test write or delete the token file for real, so + without this a test run destroys the developer's own `reflex login` state. + + Args: + monkeypatch: The pytest monkeypatch fixture. + tmp_path_factory: The pytest temporary directory factory. + """ + reflex_dir = tmp_path_factory.mktemp("reflex_data") + monkeypatch.setattr(constants.Reflex, "DIR", str(reflex_dir)) + monkeypatch.setattr( + constants.Hosting, "HOSTING_JSON", reflex_dir / "hosting_v1.json" + ) + monkeypatch.setattr( + constants.Hosting, "HOSTING_JSON_V0", reflex_dir / "hosting_v0.json" + ) diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 07783941567..fc805ba26d3 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -8,12 +8,14 @@ import httpx import pytest from pytest_mock import MockerFixture, MockFixture +from reflex_cli import constants from reflex_cli.utils.exceptions import NotAuthenticatedError, TokenValidationError from reflex_cli.utils.hosting import ( AuthenticatedClient, ScaleParams, ScaleType, SecurityReviewError, + TokenSource, authenticated_token, create_app, create_deployment, @@ -24,6 +26,7 @@ get_auth_request_id, get_authenticated_client, get_existing_access_token, + get_existing_access_token_with_source, get_gcp_provider_status, get_security_review, get_selected_project, @@ -39,6 +42,7 @@ set_app_full_deploy, set_app_provider, set_instance_bounds, + stored_access_token, submit_security_review, update_deployment_description, validate_token, @@ -67,53 +71,233 @@ def test_get_existing_access_token( assert get_existing_access_token() == "" +def test_get_existing_access_token_prefers_the_environment( + monkeypatch: pytest.MonkeyPatch, +): + """An exported token is an explicit choice; the config file is ambient state. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "env_token", + TokenSource.ENVIRONMENT, + ) + + +def test_get_existing_access_token_falls_back_to_the_config_file( + monkeypatch: pytest.MonkeyPatch, +): + """Without the environment variable the stored token is used. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False) + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "config_token", + TokenSource.CONFIG, + ) + + +def test_get_existing_access_token_ignores_an_empty_environment_variable( + monkeypatch: pytest.MonkeyPatch, +): + """An empty export is not a token and must not shadow the config file. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "") + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "config_token", + TokenSource.CONFIG, + ) + + +def test_get_existing_access_token_with_no_token_anywhere( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False) + mocker.patch("pathlib.Path.open", side_effect=FileNotFoundError("Test exception")) + + assert get_existing_access_token_with_source() == ("", TokenSource.NONE) + + @pytest.mark.parametrize( - "file_exists, config_content", + "config_content, expected", [ - (True, '{"access_token": "valid_token"}'), - (True, '{"another_key": "value"}'), - (False, ""), + ('{"access_token": "valid_token"}', {}), + ('{"access_token": "valid_token", "project": "p1"}', {"project": "p1"}), + ('{"another_key": "value"}', {"another_key": "value"}), ], ) -def test_delete_token_from_config( +def test_delete_token_from_config(config_content: str, expected: dict): + """Only the token is removed; everything else in the config survives. + + Args: + config_content: The starting contents of the config file. + expected: The config expected to remain afterwards. + """ + constants.Hosting.HOSTING_JSON.write_text(config_content) + + delete_token_from_config() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == expected + + +def test_delete_token_from_config_without_a_config_file(): + """Deleting when no config exists is a no-op rather than an error.""" + assert not constants.Hosting.HOSTING_JSON.exists() + + delete_token_from_config() + + assert not constants.Hosting.HOSTING_JSON.exists() + + +def test_delete_token_from_config_keeps_the_config_when_the_write_fails( mocker: MockerFixture, - file_exists: bool, - config_content: str, ): - mocker.patch("pathlib.Path.exists", return_value=file_exists) - mock_os_remove = mocker.patch("pathlib.Path.unlink") + """A failed delete must leave the existing config readable, not truncated. - mocked_open = mock_open(read_data=config_content) - mocker.patch("pathlib.Path.open", mocked_open) - mock_json_load = mocker.patch( - "json.load", return_value=json.loads(config_content or "{}") - ) - mock_json_dump = mocker.patch("json.dump") + Args: + mocker: Pytest mocker fixture. + """ + original = '{"access_token": "good_token", "project": "p1"}' + constants.Hosting.HOSTING_JSON.write_text(original) + mocker.patch("json.dump", side_effect=OSError("disk full")) delete_token_from_config() - if file_exists: - assert mocked_open.call_count == 2 - mock_json_load.assert_called_once() - mock_json_dump.assert_called_once() - assert "access_token" not in mock_json_dump.call_args.args[0] - mock_os_remove.assert_called_once() - else: - mocked_open.assert_not_called() - mock_os_remove.assert_not_called() + assert constants.Hosting.HOSTING_JSON.read_text() == original + assert list(constants.Hosting.HOSTING_JSON.parent.iterdir()) == [ + constants.Hosting.HOSTING_JSON + ] -def test_save_token_to_config(mocker: MockFixture): - mocker.patch("pathlib.Path.exists", return_value=False) - mock_makedirs = mocker.patch("pathlib.Path.mkdir") - save_token_to_config("test_token") - mock_makedirs.assert_called_once() +def test_delete_token_from_config_keeps_an_unreadable_config( + mocker: MockerFixture, +): + """A config that cannot be parsed is left alone rather than replaced. + + Args: + mocker: Pytest mocker fixture. + """ + malformed = '{"access_token": "good_token", "project": "p1"' + constants.Hosting.HOSTING_JSON.write_text(malformed) + + delete_token_from_config() + + assert constants.Hosting.HOSTING_JSON.read_text() == malformed + + +def test_save_token_to_config_recovers_from_an_unreadable_config(): + """Re-authenticating still works when the config is malformed.""" + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "good_token"') + + save_token_to_config("new_token") + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token" + } + + +def test_stored_access_token_distinguishes_absent_from_unreadable(): + """A missing config reads as no token; a malformed one is an error.""" + assert stored_access_token() == "" + + constants.Hosting.HOSTING_JSON.write_text('{"project": "p1"}') + assert stored_access_token() == "" + + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "tok"}') + assert stored_access_token() == "tok" + + constants.Hosting.HOSTING_JSON.write_text("{not json") + with pytest.raises(ValueError): + stored_access_token() + + # Valid JSON that is not an object is still unusable, not empty. + constants.Hosting.HOSTING_JSON.write_text('["not", "an", "object"]') + with pytest.raises(ValueError): + stored_access_token() + + +def test_delete_token_from_config_tolerates_an_unremovable_legacy_file( + mocker: MockerFixture, +): + """The legacy cleanup must not abort the token removal it follows. + + Args: + mocker: Pytest mocker fixture. + """ + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') + constants.Hosting.HOSTING_JSON_V0.write_text("{}") + mocker.patch("pathlib.Path.unlink", side_effect=PermissionError("denied")) + + delete_token_from_config() - mocker.patch("pathlib.Path.exists", return_value=True) - mock_json_dump = mocker.patch("json.dump") - mocker.patch("pathlib.Path.open", mock_open()) + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == {} + + +def test_delete_token_from_config_removes_the_legacy_file(): + """The pre-v1 hosting file is removed alongside the token.""" + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') + constants.Hosting.HOSTING_JSON_V0.write_text("{}") + + delete_token_from_config() + + assert not constants.Hosting.HOSTING_JSON_V0.exists() + + +def test_save_token_to_config_creates_the_config(): + """Saving works when neither the directory nor the file exists yet.""" save_token_to_config("test_token") - mock_json_dump.assert_called_once() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "test_token" + } + + +def test_save_token_to_config_preserves_other_keys(): + """Saving a token leaves unrelated config entries untouched.""" + constants.Hosting.HOSTING_JSON.write_text( + '{"access_token": "old_token", "project": "p1"}' + ) + + save_token_to_config("new_token") + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token", + "project": "p1", + } + + +def test_save_token_to_config_keeps_the_old_token_when_the_write_fails( + mocker: MockerFixture, +): + """A failed write must not truncate the credentials already on disk. + + Args: + mocker: Pytest mocker fixture. + """ + original = '{"access_token": "good_token", "project": "p1"}' + constants.Hosting.HOSTING_JSON.write_text(original) + mocker.patch("json.dump", side_effect=OSError("disk full")) + + save_token_to_config("new_token") + + assert constants.Hosting.HOSTING_JSON.read_text() == original + # The temporary file used for the atomic replace is cleaned up. + assert list(constants.Hosting.HOSTING_JSON.parent.iterdir()) == [ + constants.Hosting.HOSTING_JSON + ] def test_authenticated_token_found_and_valid(mocker: MockFixture): diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py new file mode 100644 index 00000000000..7b83854af27 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -0,0 +1,465 @@ +"""Tests for the `reflex cloud whoami` and `reflex cloud token` commands.""" + +import json +import logging + +import pytest +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_base.utils.log import SUCCESS +from reflex_cli import constants +from reflex_cli.utils import hosting +from reflex_cli.utils.exceptions import TokenAccessDeniedError, TokenValidationError +from reflex_cli.v2.auth import token_fingerprint +from reflex_cli.v2.deployments import hosting_cli +from typer import Typer +from typer.main import get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + +VALIDATED_INFO = { + "email": "user@example.com", + "user_id": "user-uuid", + "org_id": "org-uuid", + "tier": "Pro", + "is_service_account": False, + "_memo": {}, +} + + +def _messages(caplog: pytest.LogCaptureFixture, level: int) -> list[str]: + """Return the captured log messages emitted at the given level. + + Args: + caplog: The pytest log capture fixture. + level: The numeric log level to filter records by. + + Returns: + The formatted messages of the matching records. + """ + return [r.getMessage() for r in caplog.records if r.levelno == level] + + +def test_token_fingerprint_is_stable_and_hides_the_token(): + token = "super-secret-token" + assert token_fingerprint(token) == token_fingerprint(token) + assert token_fingerprint(token) != token_fingerprint(token + "x") + assert token not in token_fingerprint(token) + assert token_fingerprint("") == "" + + +def test_whoami_reports_identity_and_token_source(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["email"] == "user@example.com" + assert payload["org_id"] == "org-uuid" + assert payload["token_source"] == "config file" + assert payload["token_fingerprint"] == token_fingerprint("valid_token") + # Private control-plane fields stay out of the output. + assert "_memo" not in payload + # The token itself is never printed. + assert "valid_token" not in result.output + + +def test_whoami_table_output_is_complete_and_hides_the_token(mocker: MockFixture): + """Identifiers print in full: they are the reason to run the command.""" + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami"], terminal_width=40) + + assert result.exit_code == 0 + fields = dict(line.split(maxsplit=1) for line in result.output.splitlines()) + # A rich table would have truncated these to fit the 40-column terminal. + assert fields["user_id"] == VALIDATED_INFO["user_id"] + assert fields["email"] == "user@example.com" + assert fields["token_source"] == "config file" + assert "valid_token" not in result.output + + +def test_whoami_json_output_is_exact(mocker: MockFixture): + """`--json` must survive piping: one line, no markup, no wrapping.""" + wide = dict(VALIDATED_INFO, email="a-very-long-address@a-long-example-domain.com") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch("reflex_cli.utils.hosting.validate_token", return_value=wide) + + result = runner.invoke(hosting_cli, ["whoami", "--json"], terminal_width=40) + + assert result.exit_code == 0 + assert len(result.output.splitlines()) == 1 + assert json.loads(result.output)["email"] == wide["email"] + + +def test_whoami_prefers_the_token_option(mocker: MockFixture): + from_config = mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source" + ) + validate = mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami", "--json", "--token", "cli_token"]) + + assert result.exit_code == 0 + validate.assert_called_once_with("cli_token") + from_config.assert_not_called() + assert json.loads(result.output)["token_source"] == "--token option" + + +def test_whoami_without_a_token_does_not_open_a_browser( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) + authenticate = mocker.patch("reflex_cli.utils.hosting.authenticate_on_browser") + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 1 + authenticate.assert_not_called() + assert any( + "Not logged in" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_whoami_surfaces_the_auth_request_id( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("stale_token", hosting.TokenSource.ENVIRONMENT), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=TokenAccessDeniedError("access denied", request_id="req-123"), + ) + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 1 + errors = _messages(caplog, logging.ERROR) + assert any("req-123" in message for message in errors) + assert any("REFLEX_ACCESS_TOKEN" in message for message in errors) + + +def test_token_print_writes_the_raw_token_to_stdout(mocker: MockFixture): + long_token = "t" * 300 + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=(long_token, hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", "--print"]) + + assert result.exit_code == 0 + # Captured verbatim on one line, so `$(reflex cloud token --print)` is exact. + assert result.output == long_token + "\n" + + +def test_token_print_keeps_stdout_free_of_diagnostics(): + """Debug logging must not land inside `$(reflex cloud token --print)`. + + The real lookup is exercised rather than mocked: the debug records that + would contaminate stdout come from inside the lookup helper, so a mock + would emit nothing and the test would pass even without the fix. + """ + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "quiet_token"}') + + result = runner.invoke(hosting_cli, ["token", "--print", "--loglevel", "debug"]) + + assert result.exit_code == 0 + assert result.output == "quiet_token\n" + + +def test_token_print_without_a_token_fails( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) + + result = runner.invoke(hosting_cli, ["token", "--print"]) + + assert result.exit_code == 1 + assert any( + "No access token stored" in message + for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_set_validates_before_saving( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", return_value="new_token" + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 0 + save.assert_called_once_with("new_token") + assert any("user@example.com" in message for message in _messages(caplog, SUCCESS)) + + +@pytest.mark.parametrize("args", [["--set", "-"], ["--set"]]) +def test_token_set_reads_the_token_from_stdin(args: list[str], mocker: MockFixture): + """A token on the command line leaks into shell history and the process list. + + Args: + args: The invocation form under test. + mocker: Pytest mocker fixture. + """ + validate = mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", return_value="piped_token" + ) + + result = runner.invoke(hosting_cli, ["token", *args], input="piped_token\n") + + assert result.exit_code == 0 + validate.assert_called_once_with("piped_token") + save.assert_called_once_with("piped_token") + + +@pytest.mark.parametrize("value", ["", " ", "\n"]) +def test_token_set_rejects_an_empty_token(value: str, mocker: MockFixture): + """An empty --set is a malformed --set, not a missing one. + + Args: + value: The empty value under test. + mocker: Pytest mocker fixture. + """ + validate = mocker.patch("reflex_cli.utils.hosting.validate_token") + + result = runner.invoke(hosting_cli, ["token", "--set", value]) + + assert result.exit_code == 2 + assert "empty token" in result.output + # Not reported as "specify exactly one", which reads as though --set was absent. + assert "exactly one" not in result.output + validate.assert_not_called() + + +def test_token_set_succeeds_while_the_environment_variable_is_set( + mocker: MockFixture, monkeypatch: pytest.MonkeyPatch +): + """The write is confirmed against the config, which the environment shadows. + + Args: + mocker: Pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 0 + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token" + } + + +def test_token_set_reports_an_unconfirmable_write( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A config that cannot be read back is not evidence the token was saved. + + Args: + mocker: Pytest mocker fixture. + caplog: The pytest log capture fixture. + """ + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", + side_effect=OSError("permission denied"), + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 1 + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + +def test_token_set_keeps_the_old_token_when_the_new_one_is_rejected( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=TokenValidationError("server error", request_id="req-456"), + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--set", "bad_token"]) + + assert result.exit_code == 1 + # A bad --set must not clobber or delete a working token. + save.assert_not_called() + delete.assert_not_called() + assert any("req-456" in message for message in _messages(caplog, logging.ERROR)) + + +def test_token_set_reports_a_failed_write( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + # save_token_to_config swallows write errors, so the command reads back. + mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch("reflex_cli.utils.hosting.stored_access_token", return_value="") + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 1 + assert any( + "Unable to persist" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_clear_removes_the_token( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch("reflex_cli.utils.hosting.stored_access_token", return_value="") + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 0 + delete.assert_called_once_with() + assert any("Cleared" in message for message in _messages(caplog, SUCCESS)) + + +def test_token_clear_warns_when_the_env_var_still_applies( + mocker: MockFixture, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 0 + assert any( + "REFLEX_ACCESS_TOKEN is still set" in message + for message in _messages(caplog, logging.INFO) + ) + + +def test_token_clear_reports_an_unreadable_config( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A config that cannot be read is not evidence the token was removed.""" + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", + side_effect=OSError("permission denied"), + ) + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + +def test_token_clear_reports_a_non_object_config(caplog: pytest.LogCaptureFixture): + """Valid JSON that is not an object is reported, not raised as a traceback. + + Args: + caplog: The pytest log capture fixture. + """ + constants.Hosting.HOSTING_JSON.write_text('["not", "an", "object"]') + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_clear_reports_a_failed_removal( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A swallowed delete failure must not be reported as success.""" + # delete_token_from_config swallows filesystem errors, so the token can + # still be in the config file when it returns. + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", return_value="still_here" + ) + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert any( + "Unable to remove" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + +@pytest.mark.parametrize( + "args", + [ + [], + ["--print", "--clear"], + ["--print", "--set", "tok"], + ["--set", "tok", "--clear"], + ], +) +def test_token_requires_exactly_one_operation(args: list[str], mocker: MockFixture): + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", *args]) + + assert result.exit_code == 2 + assert "exactly one of --print, --set or --clear" in result.output + save.assert_not_called() + delete.assert_not_called()