arcade-mcp/libs/tests/cli/test_authn_callback.py
jottakka fe8ddfd500
[TOO-326] Windows papercuts (#768)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Touches authentication/login flow, credentials-file permissions, and
subprocess lifecycle behavior across platforms; while mostly defensive,
regressions could impact login or process management on Windows/macOS
runners.
> 
> **Overview**
> Improves Windows/cross-platform reliability across the CLI and MCP
server: OAuth login now binds the callback server to `127.0.0.1`, avoids
slow loopback reverse-DNS, adds a configurable callback timeout
(`--timeout` + env default), and opens URLs via a Windows-friendly
`_open_browser` to avoid flashing console windows.
> 
> Centralizes CLI output via a shared `console` that forces UTF-8 on
Windows, standardizes UTF-8 file reads/writes throughout, tightens
credentials-file permissions on Windows using `icacls`, and adds shared
Windows subprocess helpers for **no-window** process creation and
graceful termination (used by `deploy`, MCP reload, and usage-tracking
worker).
> 
> Updates client configuration UX/robustness (Windows AppData resolution
via `platformdirs`, Cursor config path fallbacks + compatibility writes,
overwrite warnings, absolute `uv` path for GUI clients, safer path
display) and improves `deploy` child-process handling to avoid
pipe-buffer deadlocks while giving better debug-aware error messages.
> 
> Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI
integration workflow, disables usage tracking in toolkits CI, and adds
extensive regression tests for Windows signals, subprocess cleanup,
UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and
`arcade-mcp-server` to `1.17.2` (with updated dependency pin).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-25 13:18:16 -03:00

336 lines
13 KiB
Python

import subprocess
import sys
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError
from urllib.request import urlopen
from arcade_cli.authn import OAuthCallbackServer, _open_browser, oauth_callback_server
def test_oauth_callback_server_success() -> None:
state = "test-state"
with oauth_callback_server(state, port=0) as server:
url = f"{server.get_redirect_uri()}?code=abc123&state={state}"
with urlopen(url) as response:
assert response.status == 200
response.read()
assert server.wait_for_result(timeout=1.0) is True
assert server.result["code"] == "abc123"
def test_oauth_callback_server_timeout() -> None:
state = "test-timeout"
with oauth_callback_server(state, port=0) as server:
assert server.wait_for_result(timeout=0.05) is False
assert "Timed out" in server.result["error"]
def test_oauth_callback_server_binds_to_loopback() -> None:
"""The callback server must bind to 127.0.0.1 (loopback) to avoid
Windows Firewall prompts and keep redirect host aligned with bind host."""
state = "test-bind"
with oauth_callback_server(state, port=0) as server:
assert server.httpd is not None
host, _port = server.httpd.server_address
assert host == "127.0.0.1", f"Expected 127.0.0.1 but got {host}"
# Also confirm the redirect URI host matches the bound loopback host.
redirect = server.get_redirect_uri()
assert redirect.startswith("http://127.0.0.1:")
server.shutdown_server()
def test_oauth_callback_server_state_mismatch() -> None:
"""Requests with a mismatched state parameter should return an error."""
state = "correct-state"
with oauth_callback_server(state, port=0) as server:
url = f"{server.get_redirect_uri()}?code=abc&state=wrong-state"
try:
with urlopen(url) as response:
response.read()
except HTTPError:
pass # Expected — handler returns 400 for state mismatch.
server.wait_for_result(timeout=1.0)
assert "error" in server.result
def test_oauth_callback_server_missing_code() -> None:
"""Requests without a code parameter should produce an error result."""
state = "no-code-state"
with oauth_callback_server(state, port=0) as server:
url = f"{server.get_redirect_uri()}?state={state}"
try:
with urlopen(url) as response:
response.read()
except HTTPError:
pass # Expected — handler returns 400 for missing code.
server.wait_for_result(timeout=1.0)
assert "error" in server.result
def test_oauth_callback_server_wait_until_ready() -> None:
"""wait_until_ready() should return True once the server is listening."""
state = "ready-test"
server = OAuthCallbackServer(state, port=0)
import threading
t = threading.Thread(target=server.run_server, daemon=True)
t.start()
assert server.wait_until_ready(timeout=5.0) is True
assert server.httpd is not None
assert server.port != 0 # Ephemeral port was assigned.
server.shutdown_server()
t.join(timeout=2)
def test_oauth_callback_server_wait_until_ready_timeout() -> None:
"""wait_until_ready() should return False if the server never starts."""
state = "ready-timeout"
server = OAuthCallbackServer(state, port=0)
# Don't start the server — ready_event never gets set.
assert server.wait_until_ready(timeout=0.05) is False
def test_perform_oauth_login_hides_auth_url_when_browser_succeeds() -> None:
"""When browser launch succeeds, status output should not include auth URL."""
status_messages: list[str] = []
def capture_status(msg: str) -> None:
status_messages.append(msg)
# We need to mock the entire OAuth flow since we can't hit a real coordinator.
with (
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
patch("arcade_cli.authn.create_oauth_client"),
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
patch("arcade_cli.authn._open_browser") as mock_browser,
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
):
mock_config.return_value = MagicMock()
mock_gen_url.return_value = ("https://example.com/auth?state=abc", "verifier123")
mock_browser.return_value = True
mock_server = MagicMock()
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
mock_server.result = {"error": "timeout for test"}
mock_server.wait_for_result.return_value = False
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
try:
perform_oauth_login(
"https://fake-coordinator.example.com",
on_status=capture_status,
callback_timeout_seconds=1,
)
except OAuthLoginError:
pass
url_messages = [m for m in status_messages if "https://example.com/auth" in m]
assert len(url_messages) == 0, (
"Auth URL should be hidden when browser launch succeeds. "
f"Got status messages: {status_messages}"
)
def test_perform_oauth_login_shows_url_when_browser_fails() -> None:
"""When _open_browser fails, the URL should still be shown."""
status_messages: list[str] = []
def capture_status(msg: str) -> None:
status_messages.append(msg)
with (
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
patch("arcade_cli.authn.create_oauth_client"),
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
patch("arcade_cli.authn._open_browser") as mock_browser,
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
):
mock_config.return_value = MagicMock()
mock_gen_url.return_value = ("https://example.com/auth?state=xyz", "verifier456")
mock_browser.return_value = False # Browser failed
mock_server = MagicMock()
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
mock_server.result = {"error": "timeout for test"}
mock_server.wait_for_result.return_value = False
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
try:
perform_oauth_login(
"https://fake-coordinator.example.com",
on_status=capture_status,
callback_timeout_seconds=1,
)
except OAuthLoginError:
pass
url_messages = [m for m in status_messages if "https://example.com/auth" in m]
assert len(url_messages) >= 1
assert any("Open this link to log in" in m for m in status_messages)
# When browser fails, the message should say "Could not open a browser"
browser_fail_msgs = [m for m in status_messages if "Could not open a browser" in m]
assert len(browser_fail_msgs) >= 1
def test_perform_oauth_login_timeout_clamps_negative() -> None:
"""Negative --timeout values should be clamped to the default."""
from arcade_cli.authn import DEFAULT_OAUTH_TIMEOUT_SECONDS
status_messages: list[str] = []
def capture_status(msg: str) -> None:
status_messages.append(msg)
with (
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
patch("arcade_cli.authn.create_oauth_client"),
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
patch("arcade_cli.authn._open_browser") as mock_browser,
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
):
mock_config.return_value = MagicMock()
mock_gen_url.return_value = ("https://example.com/auth", "v")
mock_browser.return_value = True
mock_server = MagicMock()
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
mock_server.result = {"error": "timeout"}
mock_server.wait_for_result.return_value = False
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
try:
perform_oauth_login(
"https://fake.example.com",
on_status=capture_status,
callback_timeout_seconds=-5,
)
except OAuthLoginError:
pass
# The timeout message should show the default, not -5.
timeout_msgs = [m for m in status_messages if "timeout:" in m.lower()]
assert any(str(DEFAULT_OAUTH_TIMEOUT_SECONDS) in m for m in timeout_msgs), (
f"Expected default timeout {DEFAULT_OAUTH_TIMEOUT_SECONDS} in messages: {timeout_msgs}"
)
# ---------------------------------------------------------------------------
# _open_browser() — CMD-window suppression on Windows
# ---------------------------------------------------------------------------
class TestOpenBrowser:
"""Tests for the _open_browser helper that suppresses CMD flash on Windows.
On Windows the priority order is:
1. ctypes ShellExecuteW (direct Win32 API, no console)
2. rundll32 url.dll (GUI binary, no console)
3. webbrowser.open (stdlib fallback)
os.startfile is intentionally omitted: it is a thin CPython wrapper around
ShellExecuteExW, making it redundant with step 1.
"""
def test_delegates_to_webbrowser_on_non_windows(self) -> None:
"""On non-Windows, _open_browser should use webbrowser.open."""
with (
patch.object(sys, "platform", "linux"),
patch("arcade_cli.authn.webbrowser") as mock_wb,
):
mock_wb.open.return_value = True
result = _open_browser("https://example.com")
assert result is True
mock_wb.open.assert_called_once_with("https://example.com")
def test_tries_ctypes_shellexecute_first_on_windows(self) -> None:
"""On Windows, _open_browser should try ctypes ShellExecuteW first."""
import ctypes
# On non-Windows, ctypes.windll doesn't exist; provide a mock
mock_shell32 = MagicMock()
mock_shell32.ShellExecuteW = MagicMock(return_value=42)
mock_windll = MagicMock()
mock_windll.shell32 = mock_shell32
with patch.object(sys, "platform", "win32"), patch.object(
ctypes, "windll", mock_windll, create=True
):
result = _open_browser("https://example.com")
assert result is True
def test_falls_back_to_rundll32_on_windows(self) -> None:
"""If ctypes fails, try rundll32 url.dll."""
import ctypes
mock_shell32 = MagicMock()
mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
mock_windll = MagicMock()
mock_windll.shell32 = mock_shell32
with (
patch.object(sys, "platform", "win32"),
patch.object(ctypes, "windll", mock_windll, create=True),
patch("arcade_cli.authn.subprocess.Popen") as mock_popen,
patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True) as mock_si_cls,
patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
patch("arcade_cli.authn.subprocess.DEVNULL", subprocess.DEVNULL),
):
mock_si = MagicMock()
mock_si.dwFlags = 0
mock_si_cls.return_value = mock_si
mock_popen.return_value = MagicMock()
result = _open_browser("https://example.com")
assert result is True
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == "rundll32"
def test_falls_back_to_webbrowser_if_all_else_fails_on_windows(self) -> None:
"""If ctypes and rundll32 both fail, use webbrowser.open (step 3)."""
import ctypes
mock_shell32 = MagicMock()
mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
mock_windll = MagicMock()
mock_windll.shell32 = mock_shell32
with (
patch.object(sys, "platform", "win32"),
patch.object(ctypes, "windll", mock_windll, create=True),
patch("arcade_cli.authn.subprocess.Popen", side_effect=Exception("fail")),
patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True, return_value=MagicMock()),
patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
patch("arcade_cli.authn.subprocess.DEVNULL", -1),
patch("arcade_cli.authn.webbrowser") as mock_wb,
):
mock_wb.open.return_value = True
result = _open_browser("https://example.com")
assert result is True
mock_wb.open.assert_called_once()
def test_returns_false_if_everything_fails(self) -> None:
"""If all methods fail, _open_browser should return False."""
with (
patch.object(sys, "platform", "linux"),
patch("arcade_cli.authn.webbrowser") as mock_wb,
):
mock_wb.open.side_effect = Exception("no browser")
result = _open_browser("https://example.com")
assert result is False