Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .changes/unreleased/fixed-20251203-155412.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: fixed
body: Avoid re‑authentication when switching from command‑line to interactive mode.
time: 2025-12-03T15:54:12.961104889Z
65 changes: 58 additions & 7 deletions src/fabric_cli/commands/config/fab_config_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,64 @@ def _set_config(args: Namespace, key: str, value: Any, verbose: bool = True) ->

Context().cleanup_context_files(cleanup_all_stale=True, cleanup_current=True)

if (
key == fab_constant.FAB_MODE
and current_mode == fab_constant.FAB_MODE_COMMANDLINE
and previous_mode == fab_constant.FAB_MODE_INTERACTIVE
):
utils_ui.print("Exiting interactive mode. Goodbye!")
os._exit(0)
# Enhanced mode transition handling
if key == fab_constant.FAB_MODE:
Comment thread
aviatco marked this conversation as resolved.
Outdated
if (current_mode == fab_constant.FAB_MODE_INTERACTIVE
and previous_mode == fab_constant.FAB_MODE_COMMANDLINE):
# Handle command_line β†’ interactive transition
if _is_user_authenticated():
Comment thread
aviatco marked this conversation as resolved.
Outdated
utils_ui.print("Switching to interactive mode...")
_start_interactive_mode(args)
else:
utils_ui.print("Please login first to use interactive mode")

elif (current_mode == fab_constant.FAB_MODE_COMMANDLINE
and previous_mode == fab_constant.FAB_MODE_INTERACTIVE):
# Handle interactive β†’ command_line transition
utils_ui.print("Exiting interactive mode. Goodbye!")
os._exit(0)


def _is_user_authenticated() -> bool:
"""Check if user has valid authentication tokens"""
try:
from fabric_cli.core.fab_auth import FabAuth
auth = FabAuth()
# Try to get a token without interactive renewal
token = auth.get_access_token(
fab_constant.SCOPE_FABRIC_DEFAULT,
interactive_renew=False
)
return token is not None
except FabricCLIError as e:
# Handle specific authentication errors
if e.status_code in [
fab_constant.ERROR_UNAUTHORIZED,
fab_constant.ERROR_AUTHENTICATION_FAILED,
]:
return False
raise e
except Exception:
return False


def _start_interactive_mode(args: Namespace) -> None:
Comment thread
aviatco marked this conversation as resolved.
Outdated
"""Launch interactive mode with current parser context"""
try:
# Import parser setup from main module
from fabric_cli.main import _create_parser_and_subparsers

parser, subparsers = _create_parser_and_subparsers()

from fabric_cli.core.fab_interactive import InteractiveCLI
interactive_cli = InteractiveCLI(parser, subparsers)
interactive_cli.start_interactive()

except (KeyboardInterrupt, EOFError):
utils_ui.print("Interactive mode cancelled.")
except Exception as e:
utils_ui.print(f"Failed to start interactive mode: {str(e)}")
utils_ui.print("Please restart the CLI to use interactive mode.")


def _set_capacity(args: Namespace, value: str) -> None:
Expand Down
9 changes: 8 additions & 1 deletion src/fabric_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ def error(self, message):
sys.exit(2)


def main():
def _create_parser_and_subparsers():
Comment thread
aviatco marked this conversation as resolved.
Outdated
"""Create parser and subparsers for reuse in interactive mode transition"""
parser = CustomArgumentParser(description="Fabric CLI")

# -c option for command line execution
Expand Down Expand Up @@ -224,6 +225,12 @@ def main():
)
version_parser.set_defaults(func=fab_ui.print_version)

return parser, subparsers


def main():
parser, subparsers = _create_parser_and_subparsers()

argcomplete.autocomplete(parser, default_completer=None)

args = parser.parse_args()
Expand Down
184 changes: 183 additions & 1 deletion tests/test_commands/test_config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from unittest.mock import patch
from unittest.mock import patch, MagicMock
import pytest

import fabric_cli.core.fab_constant as constant
from fabric_cli.core.fab_exceptions import FabricCLIError
from fabric_cli.errors import ErrorMessages
from tests.test_commands.commands_parser import CLIExecutor
from tests.test_commands.data.static_test_data import StaticTestData
Expand Down Expand Up @@ -171,3 +173,183 @@ def test_config_clear_cache_success(
mock_print_done.assert_called_once()

# endregion

# region config MODE SWITCHING
def test_success_config_set_mode_interactive_authenticated_success(
self, mock_questionary_print, mock_fab_set_state_config, cli_executor: CLIExecutor
):
"""Test successful transition from command_line to interactive mode when authenticated"""
with patch("fabric_cli.commands.config.fab_config_set._is_user_authenticated", return_value=True), \
patch("fabric_cli.commands.config.fab_config_set._start_interactive_mode") as mock_start_interactive:

mock_fab_set_state_config(constant.FAB_MODE, constant.FAB_MODE_COMMANDLINE)

# Execute command
cli_executor.exec_command(f"config set mode {constant.FAB_MODE_INTERACTIVE}")

# Assert
mock_questionary_print.assert_called()
mock_start_interactive.assert_called_once()
assert mock_questionary_print.call_args[0][0] == 'Switching to interactive mode...'


def test_config_set_mode_interactive_user_not_authenticated_failure(
self, mock_fab_set_state_config, mock_questionary_print, cli_executor: CLIExecutor
):
"""Test transition from command_line to interactive mode when not authenticated"""
with patch("fabric_cli.commands.config.fab_config_set._is_user_authenticated", return_value=False), \
patch("fabric_cli.commands.config.fab_config_set._start_interactive_mode") as mock_start_interactive:

mock_fab_set_state_config(constant.FAB_MODE, constant.FAB_MODE_COMMANDLINE)

# Execute command
cli_executor.exec_command(f"config set mode {constant.FAB_MODE_INTERACTIVE}")

# Assert
mock_questionary_print.assert_called()
assert mock_questionary_print.call_args[0][0] == "Please login first to use interactive mode"
mock_start_interactive.assert_not_called()

def test_config_set_mode_command_line_from_interactive_success(
self, mock_fab_set_state_config, mock_questionary_print, cli_executor: CLIExecutor
):
"""Test transition from interactive to command_line mode"""
with patch("os._exit") as mock_exit:

mock_fab_set_state_config(constant.FAB_MODE, constant.FAB_MODE_INTERACTIVE)
# Execute command
cli_executor.exec_command(f"config set mode {constant.FAB_MODE_COMMANDLINE}")

# Assert
mock_questionary_print.assert_called()
assert mock_questionary_print.call_args[0][0] == "Exiting interactive mode. Goodbye!"
mock_exit.assert_called_once_with(0)

def test_is_user_authenticated_with_valid_token_success(self):
"""Test _is_user_authenticated returns True when user has valid token"""
from fabric_cli.commands.config.fab_config_set import _is_user_authenticated

with patch("fabric_cli.core.fab_auth.FabAuth") as mock_fab_auth:
mock_auth_instance = MagicMock()
mock_auth_instance.get_access_token.return_value = "valid_token"
mock_fab_auth.return_value = mock_auth_instance

result = _is_user_authenticated()

assert result is True
mock_auth_instance.get_access_token.assert_called_once_with(
constant.SCOPE_FABRIC_DEFAULT, interactive_renew=False
)

def test_is_user_authenticated_with_no_token_failure(self):
"""Test _is_user_authenticated returns False when user has no token"""
from fabric_cli.commands.config.fab_config_set import _is_user_authenticated

with patch("fabric_cli.core.fab_auth.FabAuth") as mock_fab_auth:
mock_auth_instance = MagicMock()
mock_auth_instance.get_access_token.return_value = None
mock_fab_auth.return_value = mock_auth_instance

result = _is_user_authenticated()

assert result is False

def test_is_user_authenticated_with_authentication_error_failure(self):
"""Test _is_user_authenticated returns False when authentication fails"""
from fabric_cli.commands.config.fab_config_set import _is_user_authenticated

with patch("fabric_cli.core.fab_auth.FabAuth") as mock_fab_auth:
mock_auth_instance = MagicMock()
mock_auth_instance.get_access_token.side_effect = FabricCLIError(
"Authentication failed", constant.ERROR_AUTHENTICATION_FAILED
)
mock_fab_auth.return_value = mock_auth_instance

result = _is_user_authenticated()

assert result is False

def test_is_user_authenticated_with_unexpected_error_failure(self):
"""Test _is_user_authenticated returns False on unexpected error"""
from fabric_cli.commands.config.fab_config_set import _is_user_authenticated

with patch("fabric_cli.core.fab_auth.FabAuth") as mock_fab_auth:
mock_auth_instance = MagicMock()
mock_auth_instance.get_access_token.side_effect = Exception("Unexpected error")
mock_fab_auth.return_value = mock_auth_instance

result = _is_user_authenticated()

assert result is False

def test_start_interactive_mode_success(self):
"""Test _start_interactive_mode successfully launches interactive CLI"""
from fabric_cli.commands.config.fab_config_set import _start_interactive_mode
from argparse import Namespace

args = Namespace()

with patch("fabric_cli.main._create_parser_and_subparsers") as mock_create_parser, \
patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli:

mock_parser = MagicMock()
mock_subparsers = MagicMock()
mock_create_parser.return_value = (mock_parser, mock_subparsers)

mock_cli_instance = MagicMock()
mock_interactive_cli.return_value = mock_cli_instance

_start_interactive_mode(args)

# Assert
mock_create_parser.assert_called_once()
mock_interactive_cli.assert_called_once_with(mock_parser, mock_subparsers)
mock_cli_instance.start_interactive.assert_called_once()

def test_start_interactive_mode_keyboard_interrupt_success(self, mock_questionary_print):
"""Test _start_interactive_mode handles KeyboardInterrupt gracefully"""
from fabric_cli.commands.config.fab_config_set import _start_interactive_mode
from argparse import Namespace

args = Namespace()

with patch("fabric_cli.main._create_parser_and_subparsers") as mock_create_parser, \
patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli:

mock_parser = MagicMock()
mock_subparsers = MagicMock()
mock_create_parser.return_value = (mock_parser, mock_subparsers)

mock_cli_instance = MagicMock()
mock_cli_instance.start_interactive.side_effect = KeyboardInterrupt()
mock_interactive_cli.return_value = mock_cli_instance

_start_interactive_mode(args)

# Assert
mock_questionary_print.call_args[0][0] == "Interactive mode cancelled."

def test_start_interactive_mode_exception_handling_failure(self, mock_questionary_print):
"""Test _start_interactive_mode handles general exceptions"""
from fabric_cli.commands.config.fab_config_set import _start_interactive_mode
from argparse import Namespace

args = Namespace()

with patch("fabric_cli.main._create_parser_and_subparsers") as mock_create_parser, \
patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli:

mock_parser = MagicMock()
mock_subparsers = MagicMock()
mock_create_parser.return_value = (mock_parser, mock_subparsers)

mock_cli_instance = MagicMock()
mock_cli_instance.start_interactive.side_effect = Exception("Test error")
mock_interactive_cli.return_value = mock_cli_instance

_start_interactive_mode(args)

# Assert
mock_questionary_print.call_args[0][0] == "Please restart the CLI to use interactive mode."

# endregion