Fixes [PLT-720: Refactor CLI to support multiple orgs + projects](https://linear.app/arcadedev/issue/PLT-720/refactor-cli-to-support-multiple-orgs-projects) This PR removes the legacy login flow (login to get an API key) from Arcade CLI. Believe it or not, this flow predates the ability to get an API key from the Dashboard, or even the Dashboard itself! Notable changes: **Legacy handling** - When a user with an existing `credentials.yaml` updates the CLI, they will get instructions on fixing their old credentials: <img width="978" height="146" alt="Screenshot 2025-12-08 at 10 10 37" src="https://github.com/user-attachments/assets/5aeaef2c-bef7-4642-a2f7-f917b257c94b" /> Any commands that require login (non-public commands) will be blocked with the above message until `arcade logout / arcade login` is performed again. **New login flow** ```sh arcade login Opening a browser to log you in... ✅ Logged in as nate@arcade.dev. Active project: Nate Barbettini's organization / Default project Run 'arcade org list' or 'arcade project list' to see available options. ``` **List and set the active organization** ```sh arcade org list ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ ┃ Name ┃ ID ┃ Default ┃ Active ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ │ Nate Barbettini's organization │ 1c64968e-fdc5-4c55-8612-2ce46cd7881b │ ✓ │ ✓ │ │ Sergio 743 │ 1f1f6184-58dc-4bac-bdde-b9184e43fdf3 │ │ │ └────────────────────────────────┴──────────────────────────────────────┴─────────┴────────┘ Use 'arcade org set <org_id>' to switch organizations. ``` ```sh arcade org set 1c64968e-fdc5-4c55-8612-2ce46cd7881b ✓ Switched to organization: Nate Barbettini's organization Active project: Default project ``` **List and set the active project** ```sh arcade project list Active organization: Nate Barbettini's organization Use 'arcade org list' and 'arcade org set <org_id>' to switch organizations. ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ ┃ Name ┃ ID ┃ Default ┃ Active ┃ ┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ │ Default project │ 35166bf3-6e68-481e-bf16-f747fadc6c22 │ ✓ │ ✓ │ │ Second project │ 62963205-31ea-4fda-9fc4-af10db89c06f │ │ │ └─────────────────┴──────────────────────────────────────┴─────────┴────────┘ Use 'arcade project set <project_id>' to switch projects. ``` ```sh arcade project set 35166bf3-6e68-481e-bf16-f747fadc6c22 ✓ Switched to project: Default project ``` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Migrates CLI to OAuth2 (PKCE) with saved org/project context, adds org/project commands, rewrites Engine calls to org-scoped endpoints, and bumps core packages. > > - **Auth & Config** > - Implement OAuth2 Authorization Code + PKCE (`arcade_cli/authn.py`) with local callback server and Jinja templates. > - Persist tokens and active `context` (org/project) in `credentials.yaml` via updated config models (`arcade_core/config_model.py`). > - Add token refresh and CLI config fetch utilities (`arcade_core/auth_tokens.py`). > - Detect legacy API-key credentials and block protected commands until re-login; add `whoami` command. > - **Org/Project Management** > - New subcommands: `arcade org list|set`, `arcade project list|set` (fetch via Coordinator). > - **Engine API usage (org-scoped)** > - Introduce org/project URL rewriting transports (`arcade_core/network/org_transport.py`) and helpers (`get_org_scoped_url`, `get_arcade_client`, `get_auth_headers`). > - Update `deploy`, `server`, and `secret` commands to use Bearer tokens and org-scoped paths; adjust log streaming/status, secrets CRUD, and deployment workflows. > - **CLI UX** > - Replace legacy login URLs/constants; add success/failure HTML templates for browser callback. > - Tweak `dashboard` to health-check without credentials. > - Usage tracking now includes `org_id`/`project_id` properties. > - **Tests** > - Update tests for dashboard, secrets, utils, and usage identity (OAuth `/whoami`). > - **Dependencies & Versions** > - Bump packages: `arcade-core@4.0.0`, `arcade-mcp-server@1.12.0`, `arcade-serve@3.2.0`, `arcade-tdk@3.3.0`; add `authlib`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 49702c2f74b9db15bb286d3ec71179b4e74a9134. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
407 lines
15 KiB
Python
407 lines
15 KiB
Python
import json
|
|
import uuid
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import yaml
|
|
from arcade_core.usage import UsageIdentity
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Setup temporary config directory for testing."""
|
|
config_dir = tmp_path / ".arcade"
|
|
config_dir.mkdir()
|
|
credentials_file = config_dir / "credentials.yaml"
|
|
|
|
monkeypatch.setattr("arcade_core.usage.identity.ARCADE_CONFIG_PATH", str(config_dir))
|
|
monkeypatch.setattr("arcade_core.usage.identity.CREDENTIALS_FILE_PATH", str(credentials_file))
|
|
|
|
return config_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def identity(temp_config_path: Path) -> UsageIdentity:
|
|
"""Create a UsageIdentity instance with temp config path."""
|
|
# NOTE: Although temp_config_path is directly used, it's required to ensure that
|
|
# this fixture depends on the temp_config_path fixture to apply the monkeypatch
|
|
# before creating the UsageIdentity instance
|
|
return UsageIdentity()
|
|
|
|
|
|
class TestLoadOrCreate:
|
|
"""Tests for load_or_create() method."""
|
|
|
|
def test_creates_new_file_when_not_exists(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that load_or_create creates a new usage.json file when it doesn't exist."""
|
|
data = identity.load_or_create()
|
|
|
|
assert "anon_id" in data
|
|
assert data["linked_principal_id"] is None
|
|
assert uuid.UUID(data["anon_id"]) # Validate UUID format
|
|
|
|
# Verify file was created
|
|
usage_file = temp_config_path / "usage.json"
|
|
assert usage_file.exists()
|
|
|
|
def test_loads_existing_file(self, identity: UsageIdentity, temp_config_path: Path) -> None:
|
|
"""Test that load_or_create loads existing usage.json file."""
|
|
existing_data = {"anon_id": str(uuid.uuid4()), "linked_principal_id": "user-123"}
|
|
usage_file = temp_config_path / "usage.json"
|
|
usage_file.write_text(json.dumps(existing_data))
|
|
|
|
data = identity.load_or_create()
|
|
|
|
assert data["anon_id"] == existing_data["anon_id"]
|
|
assert data["linked_principal_id"] == existing_data["linked_principal_id"]
|
|
|
|
def test_caches_data_after_first_load(self, identity: UsageIdentity) -> None:
|
|
"""Test that load_or_create caches data after first load."""
|
|
first_data = identity.load_or_create()
|
|
second_data = identity.load_or_create()
|
|
|
|
# Should return the same object b/c it's cached
|
|
assert first_data is second_data
|
|
|
|
def test_creates_new_data_on_corrupted_json(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that load_or_create creates new data if JSON is corrupted."""
|
|
usage_file = temp_config_path / "usage.json"
|
|
usage_file.write_text("{ invalid json }")
|
|
|
|
data = identity.load_or_create()
|
|
|
|
assert "anon_id" in data
|
|
assert data["linked_principal_id"] is None
|
|
|
|
def test_creates_new_data_on_missing_anon_id(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that load_or_create creates new data if anon_id is missing."""
|
|
usage_file = temp_config_path / "usage.json"
|
|
usage_file.write_text(json.dumps({"some_other_key": "value"}))
|
|
|
|
data = identity.load_or_create()
|
|
|
|
assert "anon_id" in data
|
|
assert data["linked_principal_id"] is None
|
|
|
|
def test_creates_new_data_on_non_dict_json(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that load_or_create creates new data if JSON is not a dict."""
|
|
usage_file = temp_config_path / "usage.json"
|
|
usage_file.write_text(json.dumps(["not", "a", "dict"]))
|
|
|
|
data = identity.load_or_create()
|
|
|
|
assert "anon_id" in data
|
|
assert isinstance(data, dict)
|
|
|
|
|
|
class TestWriteAtomic:
|
|
"""Tests for _write_atomic() method."""
|
|
|
|
def test_writes_data_to_file(self, identity: UsageIdentity, temp_config_path: Path) -> None:
|
|
"""Test that _write_atomic writes data to usage.json."""
|
|
test_data = {"anon_id": str(uuid.uuid4()), "linked_principal_id": "user-456"}
|
|
|
|
identity._write_atomic(test_data)
|
|
|
|
usage_file = temp_config_path / "usage.json"
|
|
assert usage_file.exists()
|
|
|
|
with usage_file.open() as f:
|
|
loaded_data = json.load(f)
|
|
|
|
assert loaded_data == test_data
|
|
|
|
def test_atomic_write_cleans_up_on_failure(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that _write_atomic cleans up temp file on failure."""
|
|
with (
|
|
patch(
|
|
"tempfile.mkstemp", return_value=(999, str(temp_config_path / ".usage_temp.tmp"))
|
|
),
|
|
patch("os.fdopen", side_effect=Exception("Write failed")),
|
|
):
|
|
with pytest.raises(Exception, match="Write failed"):
|
|
identity._write_atomic({"anon_id": "test"})
|
|
|
|
# Verify no temp files are left behind
|
|
temp_files = list(temp_config_path.glob(".usage_*.tmp"))
|
|
assert len(temp_files) == 0
|
|
|
|
|
|
class TestGetDistinctId:
|
|
"""Tests for get_distinct_id() method."""
|
|
|
|
def test_returns_linked_principal_id_when_persisted(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that get_distinct_id returns persisted linked_principal_id."""
|
|
usage_file = temp_config_path / "usage.json"
|
|
usage_file.write_text(
|
|
json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": "persisted-user-123"})
|
|
)
|
|
|
|
distinct_id = identity.get_distinct_id()
|
|
|
|
assert distinct_id == "persisted-user-123"
|
|
|
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
|
def test_returns_principal_id_from_api_when_not_persisted(
|
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
|
) -> None:
|
|
"""Test that get_distinct_id fetches principal_id from API when not persisted."""
|
|
mock_get_principal.return_value = "api-user-456"
|
|
|
|
distinct_id = identity.get_distinct_id()
|
|
|
|
assert distinct_id == "api-user-456"
|
|
mock_get_principal.assert_called_once()
|
|
|
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
|
def test_returns_anon_id_when_not_authenticated(
|
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
|
) -> None:
|
|
"""Test that get_distinct_id returns anon_id when not authenticated."""
|
|
mock_get_principal.return_value = None
|
|
|
|
distinct_id = identity.get_distinct_id()
|
|
data = identity.load_or_create()
|
|
|
|
assert distinct_id == data["anon_id"]
|
|
|
|
|
|
class TestGetPrincipalId:
|
|
"""Tests for get_principal_id() method."""
|
|
|
|
def test_returns_none_when_credentials_file_not_exists(self, identity: UsageIdentity) -> None:
|
|
"""Test that get_principal_id returns None when credentials file doesn't exist."""
|
|
principal_id = identity.get_principal_id()
|
|
|
|
assert principal_id is None
|
|
|
|
@patch("httpx.get")
|
|
def test_returns_principal_id_on_successful_api_call(
|
|
self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that get_principal_id returns principal_id from API."""
|
|
# Create credentials file
|
|
credentials_file = temp_config_path / "credentials.yaml"
|
|
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}))
|
|
|
|
# Mock API response
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"data": {"principal_id": "api-principal-123"}}
|
|
mock_get.return_value = mock_response
|
|
|
|
principal_id = identity.get_principal_id()
|
|
|
|
assert principal_id == "api-principal-123"
|
|
mock_get.assert_called_once_with(
|
|
"https://cloud.arcade.dev/api/v1/auth/validate",
|
|
headers={"accept": "application/json", "Authorization": "Bearer test-api-key"},
|
|
timeout=2.0,
|
|
)
|
|
|
|
@patch("httpx.get")
|
|
def test_returns_none_on_api_failure(
|
|
self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that get_principal_id returns None on API failure."""
|
|
credentials_file = temp_config_path / "credentials.yaml"
|
|
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}))
|
|
|
|
mock_get.side_effect = Exception("Network error")
|
|
|
|
principal_id = identity.get_principal_id()
|
|
|
|
assert principal_id is None
|
|
|
|
def test_returns_none_when_api_key_missing(
|
|
self, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that get_principal_id returns None when API key is missing."""
|
|
credentials_file = temp_config_path / "credentials.yaml"
|
|
credentials_file.write_text(yaml.dump({"cloud": {}}))
|
|
|
|
principal_id = identity.get_principal_id()
|
|
|
|
assert principal_id is None
|
|
|
|
@patch("httpx.get")
|
|
def test_returns_none_on_non_200_status(
|
|
self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that get_principal_id returns None on non-200 status code."""
|
|
credentials_file = temp_config_path / "credentials.yaml"
|
|
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}))
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 401
|
|
mock_get.return_value = mock_response
|
|
|
|
principal_id = identity.get_principal_id()
|
|
|
|
assert principal_id is None
|
|
|
|
@patch("httpx.get")
|
|
def test_returns_account_id_from_oauth_whoami(
|
|
self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that get_principal_id returns account_id using OAuth access token."""
|
|
credentials_file = temp_config_path / "credentials.yaml"
|
|
credentials_file.write_text(
|
|
yaml.dump({"cloud": {"auth": {"access_token": "oauth-token", "refresh_token": "x"}}})
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"data": {"account_id": "acct-123"}}
|
|
mock_get.return_value = mock_response
|
|
|
|
principal_id = identity.get_principal_id()
|
|
|
|
assert principal_id == "acct-123"
|
|
mock_get.assert_called_once_with(
|
|
"https://cloud.arcade.dev/api/v1/auth/whoami",
|
|
headers={"accept": "application/json", "Authorization": "Bearer oauth-token"},
|
|
timeout=2.0,
|
|
)
|
|
|
|
|
|
class TestShouldAlias:
|
|
"""Tests for should_alias() method."""
|
|
|
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
|
def test_returns_true_when_authenticated_but_not_linked(
|
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
|
) -> None:
|
|
"""Test that should_alias returns True when user is authenticated but not yet aliased."""
|
|
mock_get_principal.return_value = "new-principal-id"
|
|
|
|
should_alias = identity.should_alias()
|
|
|
|
assert should_alias is True
|
|
|
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
|
def test_returns_false_when_already_linked(
|
|
self, mock_get_principal: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
|
) -> None:
|
|
"""Test that should_alias returns False when principal_id already linked."""
|
|
principal_id = "already-linked-123"
|
|
mock_get_principal.return_value = principal_id
|
|
|
|
usage_file = temp_config_path / "usage.json"
|
|
usage_file.write_text(
|
|
json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": principal_id})
|
|
)
|
|
|
|
should_alias = identity.should_alias()
|
|
|
|
assert should_alias is False
|
|
|
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
|
def test_returns_false_when_not_authenticated(
|
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
|
) -> None:
|
|
"""Test that should_alias returns False when not authenticated."""
|
|
mock_get_principal.return_value = None
|
|
|
|
should_alias = identity.should_alias()
|
|
|
|
assert should_alias is False
|
|
|
|
|
|
class TestResetToAnonymous:
|
|
"""Tests for reset_to_anonymous() method."""
|
|
|
|
def test_generates_new_anon_id(self, identity: UsageIdentity) -> None:
|
|
"""Test that reset_to_anonymous generates a new anon_id."""
|
|
original_data = identity.load_or_create()
|
|
original_anon_id = original_data["anon_id"]
|
|
|
|
identity.reset_to_anonymous()
|
|
|
|
new_data = identity.load_or_create()
|
|
assert new_data["anon_id"] != original_anon_id
|
|
assert uuid.UUID(new_data["anon_id"]) # Validates UUID format
|
|
|
|
def test_clears_linked_principal_id(self, identity: UsageIdentity) -> None:
|
|
"""Test that reset_to_anonymous clears linked_principal_id."""
|
|
identity.set_linked_principal_id("user-to-clear")
|
|
|
|
identity.reset_to_anonymous()
|
|
|
|
data = identity.load_or_create()
|
|
assert data["linked_principal_id"] is None
|
|
|
|
def test_persists_to_file(self, identity: UsageIdentity, temp_config_path: Path) -> None:
|
|
"""Test that reset_to_anonymous persists changes to file."""
|
|
identity.reset_to_anonymous()
|
|
|
|
usage_file = temp_config_path / "usage.json"
|
|
assert usage_file.exists()
|
|
|
|
with usage_file.open() as f:
|
|
file_data = json.load(f)
|
|
|
|
assert "anon_id" in file_data
|
|
assert file_data["linked_principal_id"] is None
|
|
|
|
|
|
class TestSetLinkedPrincipalId:
|
|
"""Tests for set_linked_principal_id() method."""
|
|
|
|
def test_updates_linked_principal_id(self, identity: UsageIdentity) -> None:
|
|
"""Test that set_linked_principal_id updates the linked_principal_id."""
|
|
identity.load_or_create() # Initialize
|
|
|
|
identity.set_linked_principal_id("new-user-789")
|
|
|
|
data = identity.load_or_create()
|
|
assert data["linked_principal_id"] == "new-user-789"
|
|
|
|
def test_persists_to_file(self, identity: UsageIdentity, temp_config_path: Path) -> None:
|
|
"""Test that set_linked_principal_id persists changes to file."""
|
|
identity.set_linked_principal_id("persisted-user-999")
|
|
|
|
usage_file = temp_config_path / "usage.json"
|
|
with usage_file.open() as f:
|
|
file_data = json.load(f)
|
|
|
|
assert file_data["linked_principal_id"] == "persisted-user-999"
|
|
|
|
def test_updates_cache(self, identity: UsageIdentity) -> None:
|
|
"""Test that set_linked_principal_id updates the cached data."""
|
|
identity.load_or_create()
|
|
|
|
identity.set_linked_principal_id("cached-user-111")
|
|
|
|
# Access _data directly to verify cache updated
|
|
assert identity._data is not None
|
|
assert identity._data["linked_principal_id"] == "cached-user-111"
|
|
|
|
|
|
class TestAnonIdProperty:
|
|
"""Tests for anon_id property."""
|
|
|
|
def test_returns_anon_id(self, identity: UsageIdentity) -> None:
|
|
"""Test that anon_id property returns the anon_id."""
|
|
data = identity.load_or_create()
|
|
|
|
assert identity.anon_id == data["anon_id"]
|
|
|
|
def test_returns_valid_uuid(self, identity: UsageIdentity) -> None:
|
|
"""Test that anon_id property returns a valid UUID."""
|
|
anon_id = identity.anon_id
|
|
|
|
assert uuid.UUID(anon_id) # Validate UUID format
|