Files

277 lines
9.2 KiB
Python
Raw Permalink Normal View History

"""Tests for SSH credential polling and add-by-ip pending queue."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from server.application.services.credential_poller import (
format_poll_errors,
parse_batch_ip_lines,
poll_credentials,
CredentialAttemptError,
)
from server.domain.models import Base, PasswordPreset, PendingServer, SshKeyPreset
from server.infrastructure.database.crypto import encrypt_value
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, class_=AsyncSession)
session = session_factory()
try:
yield session
finally:
await session.close()
await engine.dispose()
@pytest.mark.asyncio
async def test_poll_credentials_first_password_success(db_session):
pw_repo = PasswordPresetRepositoryImpl(db_session)
await pw_repo.create(PasswordPreset(
name="prod-root",
username="root",
encrypted_pw=encrypt_value("secret1"),
))
await pw_repo.create(PasswordPreset(
name="prod-admin",
username="admin",
encrypted_pw=encrypt_value("secret2"),
))
call_count = 0
async def fake_try(host, port, username, *, password=None, private_key=None, timeout=8.0):
nonlocal call_count
call_count += 1
if username == "root" and password == "secret1":
return True, ""
return False, "auth failed"
with patch("server.application.services.credential_poller.try_ssh_login", side_effect=fake_try):
result = await poll_credentials(db_session, "10.0.0.1", 22)
assert result.success is True
assert result.match is not None
assert result.match.auth_method == "password"
assert result.match.username == "root"
assert result.match.preset_name == "prod-root"
assert call_count == 1
@pytest.mark.asyncio
async def test_poll_credentials_tries_ssh_key_after_passwords_fail(db_session):
pw_repo = PasswordPresetRepositoryImpl(db_session)
await pw_repo.create(PasswordPreset(
name="bad",
username="root",
encrypted_pw=encrypt_value("wrong"),
))
key_repo = SshKeyPresetRepositoryImpl(db_session)
await key_repo.create(SshKeyPreset(
name="key1",
username="deploy",
encrypted_private_key=encrypt_value("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----"),
public_key="ssh-rsa AAAA",
))
async def fake_try(host, port, username, *, password=None, private_key=None, timeout=8.0):
if private_key and username == "deploy":
return True, ""
return False, "auth failed"
with patch("server.application.services.credential_poller.try_ssh_login", side_effect=fake_try):
result = await poll_credentials(db_session, "10.0.0.2", 22)
assert result.success is True
assert result.match is not None
assert result.match.auth_method == "key"
assert result.match.ssh_key_preset_id is not None
assert result.match.username == "deploy"
@pytest.mark.asyncio
async def test_poll_credentials_all_fail(db_session):
pw_repo = PasswordPresetRepositoryImpl(db_session)
await pw_repo.create(PasswordPreset(
name="p1",
username="root",
encrypted_pw=encrypt_value("x"),
))
with patch(
"server.application.services.credential_poller.try_ssh_login",
new=AsyncMock(return_value=(False, "Permission denied")),
):
result = await poll_credentials(db_session, "10.0.0.3", 22)
assert result.success is False
assert result.match is None
assert len(result.errors) == 1
assert "p1" in format_poll_errors(result.errors)
@pytest.mark.asyncio
async def test_poll_credentials_no_presets(db_session):
result = await poll_credentials(db_session, "10.0.0.4", 22)
assert result.success is False
assert any("凭据" in e.error for e in result.errors)
@pytest.mark.asyncio
async def test_pending_server_repo_record_failure(db_session):
repo = PendingServerRepositoryImpl(db_session)
row = await repo.record_failure(
name="srv",
domain="192.168.1.5",
port=22,
agent_port=8601,
target_path="",
category=None,
platform_id=None,
node_id=None,
last_error="all failed",
)
assert row.id
assert row.attempts == 1
updated = await repo.record_failure(
name="srv",
domain="192.168.1.5",
port=22,
agent_port=8601,
target_path="",
category=None,
platform_id=None,
node_id=None,
last_error="still failed",
existing_id=row.id,
)
assert updated.attempts == 2
assert "still failed" in (updated.last_error or "")
def test_parse_batch_ip_lines_ignores_blank_and_dedupes():
text = " 192.168.1.1 \n\n10.0.0.2\n192.168.1.1\n \n10.0.0.3 "
parsed = parse_batch_ip_lines(text)
assert parsed.domains == ["192.168.1.1", "10.0.0.2", "10.0.0.3"]
assert parsed.input_lines == 4
assert parsed.duplicates_removed == 1
def test_parse_batch_ip_lines_case_insensitive_dedupe():
parsed = parse_batch_ip_lines("Host.EXAMPLE.com\nhost.example.com\n10.0.0.1")
assert parsed.domains == ["Host.EXAMPLE.com", "10.0.0.1"]
assert parsed.duplicates_removed == 1
def test_parse_batch_ip_lines_max_limit():
with pytest.raises(ValueError, match="最多"):
parse_batch_ip_lines("1\n2\n3", max_lines=2)
def test_parse_batch_ip_lines_name_tab_ip():
parsed = parse_batch_ip_lines("武汉测试公司\t192.168.1.1\n10.0.0.2")
assert len(parsed.entries) == 2
assert parsed.entries[0].domain == "192.168.1.1"
assert parsed.entries[0].name == "武汉测试公司"
assert parsed.entries[1].domain == "10.0.0.2"
assert parsed.entries[1].name is None
def test_parse_batch_ip_lines_name_comma_ip():
parsed = parse_batch_ip_lines("广州示例,47.107.1.2")
assert parsed.entries[0].domain == "47.107.1.2"
assert parsed.entries[0].name == "广州示例"
def test_format_poll_errors_truncates():
errors = [
CredentialAttemptError("password", "a", "root", "e1"),
CredentialAttemptError("ssh_key", "b", "admin", "e2"),
]
text = format_poll_errors(errors)
assert "a" in text and "b" in text
@pytest.mark.asyncio
async def test_add_by_ip_endpoint_success(monkeypatch):
"""API layer: mocked _try_add_by_ip → server created response shape."""
from server.api import servers as servers_api
from server.api.schemas import AddByIpRequest, AddByIpsBatchItemResult
async def fake_try_add_by_ip(request, **kwargs):
return AddByIpsBatchItemResult(
domain=kwargs["domain"],
status="created",
server_id=1,
matched_preset="ok-pw",
matched_username="root",
)
monkeypatch.setattr(servers_api, "_try_add_by_ip", fake_try_add_by_ip)
db = MagicMock()
admin = MagicMock(username="admin")
request = MagicMock()
service = MagicMock()
out = await servers_api.add_server_by_ip(
request,
AddByIpRequest(domain="10.1.1.1", port=22),
admin,
service,
db,
)
assert out["success"] is True
assert out["server"]["domain"] == "10.1.1.1"
assert out["matched_preset"] == "ok-pw"
@pytest.mark.asyncio
async def test_add_by_ips_batch_endpoint(monkeypatch):
from server.api import servers as servers_api
from server.api.schemas import AddByIpsBatchRequest, AddByIpsBatchItemResult
calls: list[str] = []
async def fake_try_add_by_ip(request, **kwargs):
domain = kwargs["domain"]
calls.append(domain)
if domain == "10.2.2.2":
return AddByIpsBatchItemResult(domain=domain, status="created", server_id=2)
if domain == "10.2.2.3":
return AddByIpsBatchItemResult(domain=domain, status="pending", pending_id=9, message="fail")
return AddByIpsBatchItemResult(domain=domain, status="skipped", message="exists")
monkeypatch.setattr(servers_api, "_try_add_by_ip", fake_try_add_by_ip)
db = MagicMock()
admin = MagicMock(username="admin")
request = MagicMock()
request.client.host = "127.0.0.1"
service = MagicMock()
audit_repo = MagicMock()
audit_repo.create = AsyncMock()
monkeypatch.setattr(servers_api, "AuditLogRepositoryImpl", lambda _db: audit_repo)
payload = AddByIpsBatchRequest(text="10.2.2.2\n\n10.2.2.3\n10.2.2.2\n10.2.2.4")
out = await servers_api.add_servers_by_ips_batch(request, payload, admin, service, db)
assert calls == ["10.2.2.2", "10.2.2.3", "10.2.2.4"]
assert out.total == 3
assert out.created == 1
assert out.pending == 1
assert out.skipped == 1
assert out.input_lines == 4
assert out.duplicates_removed == 1