From 18ebeae3187293eef866ea86195ce6dd3f16ee95 Mon Sep 17 00:00:00 2001 From: zenfun Date: Thu, 19 Feb 2026 01:26:04 +0800 Subject: [PATCH] test(computer): add tests for credentials discovery and config logging 19 tests in test_computer_config.py: - TestDiscoverBayCredentials (9 tests): env priority, cwd fallback, missing file, empty key, malformed JSON, endpoint mismatch, slash normalization - TestLogComputerConfigChanges (10 tests): runtime change, sandbox key change, token masking, empty token label, missing provider_settings, add/remove keys Uses unittest.mock.patch on AstrBot custom logger for reliable assertions. --- tests/test_computer_config.py | 240 +++++++++++++++++++++++----------- 1 file changed, 163 insertions(+), 77 deletions(-) diff --git a/tests/test_computer_config.py b/tests/test_computer_config.py index 57eb21bfa..26f72991c 100644 --- a/tests/test_computer_config.py +++ b/tests/test_computer_config.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging from pathlib import Path +from unittest.mock import patch import pytest @@ -16,19 +17,31 @@ from astrbot.dashboard.routes.config import _log_computer_config_changes # _discover_bay_credentials # ═══════════════════════════════════════════════════════════════ + class TestDiscoverBayCredentials: """Test Bay API key auto-discovery from credentials.json.""" - def _write_creds(self, path: Path, api_key: str = "sk-bay-abc123", endpoint: str = "http://127.0.0.1:8114") -> None: + def _write_creds( + self, + path: Path, + api_key: str = "sk-bay-abc123", + endpoint: str = "http://127.0.0.1:8114", + ) -> None: """Helper: write a credentials.json file.""" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({ - "api_key": api_key, - "endpoint": endpoint, - "generated_at": "2026-02-17T00:00:00+00:00", - })) + path.write_text( + json.dumps( + { + "api_key": api_key, + "endpoint": endpoint, + "generated_at": "2026-02-17T00:00:00+00:00", + } + ) + ) - def test_discover_from_bay_data_dir_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_discover_from_bay_data_dir_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """BAY_DATA_DIR env var takes highest priority.""" data_dir = tmp_path / "bay_data" cred_file = data_dir / "credentials.json" @@ -38,7 +51,9 @@ class TestDiscoverBayCredentials: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "sk-bay-from-env-dir" - def test_discover_from_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_discover_from_cwd( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Falls back to current working directory.""" cred_file = tmp_path / "credentials.json" self._write_creds(cred_file, api_key="sk-bay-from-cwd") @@ -48,7 +63,9 @@ class TestDiscoverBayCredentials: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "sk-bay-from-cwd" - def test_returns_empty_when_no_credentials_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_returns_empty_when_no_credentials_found( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Returns empty string when no credentials.json exists anywhere.""" monkeypatch.chdir(tmp_path) monkeypatch.delenv("BAY_DATA_DIR", raising=False) @@ -56,7 +73,9 @@ class TestDiscoverBayCredentials: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "" - def test_skips_empty_api_key(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_skips_empty_api_key( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Skips credentials.json when api_key is empty.""" cred_file = tmp_path / "credentials.json" self._write_creds(cred_file, api_key="") @@ -66,7 +85,9 @@ class TestDiscoverBayCredentials: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "" - def test_skips_malformed_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_skips_malformed_json( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Handles malformed JSON gracefully.""" cred_file = tmp_path / "credentials.json" cred_file.parent.mkdir(parents=True, exist_ok=True) @@ -77,35 +98,46 @@ class TestDiscoverBayCredentials: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "" - def test_endpoint_mismatch_still_returns_key(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.core.computer.computer_client.logger") + def test_endpoint_mismatch_still_returns_key( + self, mock_logger, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Returns key even if endpoint doesn't match, but logs a warning.""" data_dir = tmp_path / "bay_data" cred_file = data_dir / "credentials.json" - self._write_creds(cred_file, api_key="sk-bay-mismatch", endpoint="http://other-host:9000") + self._write_creds( + cred_file, api_key="sk-bay-mismatch", endpoint="http://other-host:9000" + ) monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - with caplog.at_level(logging.WARNING): - result = _discover_bay_credentials("http://127.0.0.1:8114") + result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "sk-bay-mismatch" - assert "endpoint mismatch" in caplog.text + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args[0][0] + assert "endpoint mismatch" in warning_msg - def test_endpoint_match_no_warning(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + def test_endpoint_match_no_warning( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """No warning when endpoints match.""" data_dir = tmp_path / "bay_data" cred_file = data_dir / "credentials.json" - self._write_creds(cred_file, api_key="sk-bay-match", endpoint="http://127.0.0.1:8114") + self._write_creds( + cred_file, api_key="sk-bay-match", endpoint="http://127.0.0.1:8114" + ) monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - with caplog.at_level(logging.WARNING): + with patch("astrbot.core.computer.computer_client.logger") as mock_logger: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "sk-bay-match" - assert "mismatch" not in caplog.text + mock_logger.warning.assert_not_called() - def test_bay_data_dir_priority_over_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_bay_data_dir_priority_over_cwd( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """BAY_DATA_DIR takes priority over cwd.""" - # Write different keys to both locations env_dir = tmp_path / "env_dir" cwd_dir = tmp_path / "cwd_dir" self._write_creds(env_dir / "credentials.json", api_key="sk-bay-env-wins") @@ -116,124 +148,178 @@ class TestDiscoverBayCredentials: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "sk-bay-env-wins" - def test_trailing_slash_normalization(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + def test_trailing_slash_normalization( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Trailing slashes on endpoints are normalized before comparison.""" data_dir = tmp_path / "bay_data" cred_file = data_dir / "credentials.json" - self._write_creds(cred_file, api_key="sk-bay-slash", endpoint="http://127.0.0.1:8114/") + self._write_creds( + cred_file, api_key="sk-bay-slash", endpoint="http://127.0.0.1:8114/" + ) monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - with caplog.at_level(logging.WARNING): + with patch("astrbot.core.computer.computer_client.logger") as mock_logger: result = _discover_bay_credentials("http://127.0.0.1:8114") assert result == "sk-bay-slash" - assert "mismatch" not in caplog.text + mock_logger.warning.assert_not_called() # ═══════════════════════════════════════════════════════════════ # _log_computer_config_changes # ═══════════════════════════════════════════════════════════════ + class TestLogComputerConfigChanges: """Test config change detection and logging.""" - def test_logs_runtime_change(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_logs_runtime_change(self, mock_logger) -> None: """Detects computer_use_runtime change.""" old = {"provider_settings": {"computer_use_runtime": "none"}} new = {"provider_settings": {"computer_use_runtime": "sandbox"}} - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "computer_use_runtime none -> sandbox" in caplog.text + mock_logger.info.assert_called() + call_args = [str(c) for c in mock_logger.info.call_args_list] + assert any("computer_use_runtime" in c and "none" in c and "sandbox" in c for c in call_args) - def test_no_log_when_runtime_unchanged(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_no_log_when_runtime_unchanged(self, mock_logger) -> None: """No log when runtime stays the same.""" old = {"provider_settings": {"computer_use_runtime": "sandbox"}} new = {"provider_settings": {"computer_use_runtime": "sandbox"}} - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "computer_use_runtime" not in caplog.text + mock_logger.info.assert_not_called() - def test_logs_sandbox_key_change(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_logs_sandbox_key_change(self, mock_logger) -> None: """Detects sandbox sub-key change.""" old = {"provider_settings": {"sandbox": {"booter": "shipyard"}}} new = {"provider_settings": {"sandbox": {"booter": "shipyard_neo"}}} - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "sandbox.booter shipyard -> shipyard_neo" in caplog.text + mock_logger.info.assert_called() + # logger.info("[Computer] Config changed: sandbox.%s %s -> %s", key, old, new) + found = False + for call in mock_logger.info.call_args_list: + args = call[0] # positional args: (fmt, key, old_val, new_val) + if len(args) >= 4 and args[1] == "booter": + assert args[2] == "shipyard" + assert args[3] == "shipyard_neo" + found = True + break + assert found, f"Expected booter change in log calls: {mock_logger.info.call_args_list}" - def test_masks_token_values(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_masks_token_values(self, mock_logger) -> None: """Token/secret values are masked in log output.""" old = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}} - new = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": "sk-bay-secret123"}}} + new = { + "provider_settings": { + "sandbox": {"shipyard_neo_access_token": "sk-bay-secret123"} + } + } - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "***" in caplog.text - assert "sk-bay-secret123" not in caplog.text + mock_logger.info.assert_called() + call_args_str = str(mock_logger.info.call_args_list) + assert "***" in call_args_str + assert "sk-bay-secret123" not in call_args_str - def test_masks_empty_token_as_empty_label(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_masks_empty_token_as_empty_label(self, mock_logger) -> None: """Empty token values show as '(empty)' not '***'.""" - old = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": "old-key"}}} + old = { + "provider_settings": { + "sandbox": {"shipyard_neo_access_token": "old-key"} + } + } new = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}} - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "(empty)" in caplog.text + mock_logger.info.assert_called() + call_args_str = str(mock_logger.info.call_args_list) + assert "(empty)" in call_args_str - def test_no_log_when_nothing_changed(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_no_log_when_nothing_changed(self, mock_logger) -> None: """No logs at all when config is identical.""" - cfg = {"provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": {"booter": "shipyard_neo", "shipyard_neo_endpoint": "http://127.0.0.1:8114"}, - }} + cfg = { + "provider_settings": { + "computer_use_runtime": "sandbox", + "sandbox": { + "booter": "shipyard_neo", + "shipyard_neo_endpoint": "http://127.0.0.1:8114", + }, + } + } - with caplog.at_level(logging.INFO): - _log_computer_config_changes(cfg, cfg) + _log_computer_config_changes(cfg, cfg) - assert "[Computer] Config changed" not in caplog.text + mock_logger.info.assert_not_called() - def test_handles_missing_provider_settings(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_handles_missing_provider_settings(self, mock_logger) -> None: """Gracefully handles configs without provider_settings.""" - with caplog.at_level(logging.INFO): - _log_computer_config_changes({}, {"provider_settings": {"computer_use_runtime": "sandbox"}}) + _log_computer_config_changes( + {}, {"provider_settings": {"computer_use_runtime": "sandbox"}} + ) - assert "computer_use_runtime none -> sandbox" in caplog.text + mock_logger.info.assert_called() + call_args_str = str(mock_logger.info.call_args_list) + assert "computer_use_runtime" in call_args_str - def test_detects_new_sandbox_key(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_detects_new_sandbox_key(self, mock_logger) -> None: """Detects a newly added sandbox key.""" old = {"provider_settings": {"sandbox": {}}} - new = {"provider_settings": {"sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"}}} + new = { + "provider_settings": { + "sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"} + } + } - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "sandbox.shipyard_neo_endpoint" in caplog.text + mock_logger.info.assert_called() + call_args_str = str(mock_logger.info.call_args_list) + assert "shipyard_neo_endpoint" in call_args_str - def test_detects_removed_sandbox_key(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_detects_removed_sandbox_key(self, mock_logger) -> None: """Detects a removed sandbox key.""" - old = {"provider_settings": {"sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"}}} + old = { + "provider_settings": { + "sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"} + } + } new = {"provider_settings": {"sandbox": {}}} - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "sandbox.shipyard_neo_endpoint" in caplog.text + mock_logger.info.assert_called() + call_args_str = str(mock_logger.info.call_args_list) + assert "shipyard_neo_endpoint" in call_args_str - def test_secret_key_masked(self, caplog: pytest.LogCaptureFixture) -> None: + @patch("astrbot.dashboard.routes.config.logger") + def test_secret_key_masked(self, mock_logger) -> None: """Any key containing 'secret' is also masked.""" old = {"provider_settings": {"sandbox": {"my_secret_key": ""}}} - new = {"provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}}} + new = { + "provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}} + } - with caplog.at_level(logging.INFO): - _log_computer_config_changes(old, new) + _log_computer_config_changes(old, new) - assert "***" in caplog.text - assert "very-secret-value" not in caplog.text + mock_logger.info.assert_called() + call_args_str = str(mock_logger.info.call_args_list) + assert "***" in call_args_str + assert "very-secret-value" not in call_args_str