## Summary - Removes the `Hint` column from `arcade secret list` table output — secret hints were removed from the API in [monorepo#322](https://github.com/ArcadeAI/monorepo/pull/322), causing a `KeyError: 'hint'` crash - Adds a test for `print_secret_table` with populated secrets (verifying no hint column) - Bumps version from 1.10.0 → 1.11.0 Closes TOO-444 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
import tempfile
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from arcade_cli.secret import (
|
|
_delete_secret,
|
|
_get_secrets,
|
|
_remove_inline_comment,
|
|
_upsert_secret,
|
|
load_env_file,
|
|
print_secret_table,
|
|
)
|
|
|
|
|
|
class TestPrintSecretTable:
|
|
"""Tests for print_secret_table function."""
|
|
|
|
def test_print_secret_table_empty(self, capsys):
|
|
"""Test printing a table with no secrets."""
|
|
secrets = []
|
|
print_secret_table(secrets)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Tool Secrets" in captured.out
|
|
|
|
def test_print_secret_table_with_secrets(self, capsys):
|
|
"""Test printing a table with secrets (no hint field)."""
|
|
secrets = [
|
|
{
|
|
"key": "MY_API_KEY",
|
|
"binding": {"type": "env"},
|
|
"description": "API key for testing",
|
|
"last_accessed_at": "2026-01-15T10:00:00Z",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
"key": "DB_PASSWORD",
|
|
"binding": {"type": "env"},
|
|
"description": "Database password",
|
|
"last_accessed_at": None,
|
|
"created_at": "2026-01-02T00:00:00Z",
|
|
},
|
|
]
|
|
print_secret_table(secrets)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "MY_API_KEY" in captured.out
|
|
assert "DB_PASSWORD" in captured.out
|
|
assert "Never" in captured.out
|
|
assert "Hint" not in captured.out
|
|
|
|
|
|
class TestLoadEnvFile:
|
|
"""Tests for load_env_file function."""
|
|
|
|
def test_load_env_file_basic(self):
|
|
"""Test loading a basic .env file."""
|
|
env_content = """
|
|
KEY1=value1
|
|
KEY2=value2
|
|
# This is a comment
|
|
KEY3=value3
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "value1",
|
|
"KEY2": "value2",
|
|
"KEY3": "value3",
|
|
}
|
|
|
|
def test_load_env_file_with_quotes(self):
|
|
"""Test loading .env file with quoted values."""
|
|
env_content = """
|
|
KEY1="quoted value"
|
|
KEY2='single quoted'
|
|
KEY3="value with = sign"
|
|
KEY4="value with # comment inside"
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "quoted value",
|
|
"KEY2": "single quoted",
|
|
"KEY3": "value with = sign",
|
|
"KEY4": "value with # comment inside",
|
|
}
|
|
|
|
def test_load_env_file_with_inline_comments(self):
|
|
"""Test loading .env file with inline comments."""
|
|
env_content = """
|
|
KEY1=value1 # inline comment
|
|
KEY2="quoted value" # comment after quote
|
|
KEY3=value3# no space before comment
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "value1",
|
|
"KEY2": "quoted value",
|
|
"KEY3": "value3# no space before comment", # No space, so not treated as comment
|
|
}
|
|
|
|
def test_load_env_file_skip_empty_and_invalid(self):
|
|
"""Test that empty lines, comments, and invalid entries are skipped."""
|
|
env_content = """
|
|
# Comment line
|
|
KEY1=value1
|
|
|
|
KEY2=
|
|
=value_without_key
|
|
KEY3=value3
|
|
invalid_line_without_equals
|
|
KEY4=value4
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "value1",
|
|
"KEY3": "value3",
|
|
"KEY4": "value4",
|
|
}
|
|
|
|
|
|
class TestRemoveInlineComment:
|
|
"""Tests for _remove_inline_comment function."""
|
|
|
|
def test_remove_inline_comment_unquoted(self):
|
|
"""Test removing inline comments from unquoted values."""
|
|
assert _remove_inline_comment("value # comment") == "value"
|
|
assert _remove_inline_comment("value# no space") == "value# no space"
|
|
assert _remove_inline_comment("value") == "value"
|
|
assert _remove_inline_comment("value with spaces # comment") == "value with spaces"
|
|
|
|
def test_remove_inline_comment_double_quoted(self):
|
|
"""Test removing inline comments from double-quoted values."""
|
|
assert _remove_inline_comment('"quoted value" # comment') == "quoted value"
|
|
assert _remove_inline_comment('"value with # inside"') == "value with # inside"
|
|
assert _remove_inline_comment('"quoted value"') == "quoted value"
|
|
assert _remove_inline_comment('"unclosed quote') == '"unclosed quote'
|
|
|
|
def test_remove_inline_comment_single_quoted(self):
|
|
"""Test removing inline comments from single-quoted values."""
|
|
assert _remove_inline_comment("'quoted value' # comment") == "quoted value"
|
|
assert _remove_inline_comment("'value with # inside'") == "value with # inside"
|
|
assert _remove_inline_comment("'quoted value'") == "quoted value"
|
|
assert _remove_inline_comment("'unclosed quote") == "'unclosed quote"
|
|
|
|
def test_remove_inline_comment_edge_cases(self):
|
|
"""Test edge cases for inline comment removal."""
|
|
assert _remove_inline_comment("") == ""
|
|
|
|
|
|
class TestUpsertSecretToEngine:
|
|
"""Tests for _upsert_secret function."""
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.put")
|
|
def test_upsert_secret_success(self, mock_put, mock_get_url, mock_get_headers):
|
|
"""Test successful secret upsert."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_put.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
_upsert_secret("SECRET_KEY", "secret-value")
|
|
|
|
mock_put.assert_called_once_with(
|
|
"https://api.example.com/v1/org/test-org/secrets/SECRET_KEY",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
json={"description": "Secret set via CLI", "value": "secret-value"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.put")
|
|
def test_upsert_secret_http_error(self, mock_put, mock_get_url, mock_get_headers):
|
|
"""Test secret upsert with HTTP error."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Bad Request", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_put.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_upsert_secret("SECRET_KEY", "secret-value")
|
|
|
|
|
|
class TestGetSecretsFromEngine:
|
|
"""Tests for _get_secrets function."""
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.get")
|
|
def test_get_secrets_success(self, mock_get, mock_get_url, mock_get_headers):
|
|
"""Test successful secrets retrieval."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_response.json.return_value = {
|
|
"items": [
|
|
{"key": "SECRET1", "id": "id1"},
|
|
{"key": "SECRET2", "id": "id2"},
|
|
]
|
|
}
|
|
mock_get.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
secrets = _get_secrets()
|
|
|
|
assert secrets == [
|
|
{"key": "SECRET1", "id": "id1"},
|
|
{"key": "SECRET2", "id": "id2"},
|
|
]
|
|
mock_get.assert_called_once_with(
|
|
"https://api.example.com/v1/org/test-org/secrets",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.get")
|
|
def test_get_secrets_http_error(self, mock_get, mock_get_url, mock_get_headers):
|
|
"""Test secrets retrieval with HTTP error."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Unauthorized", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_get.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_get_secrets()
|
|
|
|
|
|
class TestDeleteSecretFromEngine:
|
|
"""Tests for _delete_secret function."""
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.delete")
|
|
def test_delete_secret_success(self, mock_delete, mock_get_url, mock_get_headers):
|
|
"""Test successful secret deletion."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_delete.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
_delete_secret("secret-id-123")
|
|
|
|
mock_delete.assert_called_once_with(
|
|
"https://api.example.com/v1/org/test-org/secrets/secret-id-123",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.delete")
|
|
def test_delete_secret_http_error(self, mock_delete, mock_get_url, mock_get_headers):
|
|
"""Test secret deletion with HTTP error."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Not Found", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_delete.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_delete_secret("secret-id-123")
|