4ba45abf38
- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录 - 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除 - 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
234 lines
7.3 KiB
Python
234 lines
7.3 KiB
Python
"""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,
|
|
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_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 poll → server created response shape."""
|
|
from server.api import servers as servers_api
|
|
from server.domain.models import Server
|
|
|
|
mock_server = Server(
|
|
id=1,
|
|
name="10.1.1.1",
|
|
domain="10.1.1.1",
|
|
port=22,
|
|
username="root",
|
|
auth_method="password",
|
|
connectivity="ok",
|
|
)
|
|
|
|
async def fake_poll(*_a, **_k):
|
|
return servers_api.__dict__.get("_PollResult") # won't work
|
|
|
|
match = type("M", (), {
|
|
"auth_method": "password",
|
|
"username": "root",
|
|
"preset_id": 1,
|
|
"ssh_key_preset_id": None,
|
|
"password": "pass",
|
|
"ssh_key_private": None,
|
|
"ssh_key_public": "",
|
|
"preset_name": "ok-pw",
|
|
})()
|
|
poll_result = type("R", (), {"success": True, "match": match, "errors": []})()
|
|
|
|
monkeypatch.setattr(
|
|
"server.application.services.credential_poller.poll_credentials",
|
|
AsyncMock(return_value=poll_result),
|
|
)
|
|
monkeypatch.setattr(
|
|
servers_api,
|
|
"_server_exists_for_domain",
|
|
AsyncMock(return_value=False),
|
|
)
|
|
monkeypatch.setattr(
|
|
servers_api,
|
|
"_create_server_from_poll",
|
|
AsyncMock(return_value=mock_server),
|
|
)
|
|
monkeypatch.setattr(servers_api, "_server_to_dict", lambda s: {"id": s.id, "domain": s.domain})
|
|
|
|
db = MagicMock()
|
|
admin = MagicMock(username="admin")
|
|
request = MagicMock()
|
|
request.client.host = "127.0.0.1"
|
|
service = MagicMock()
|
|
|
|
from server.api.schemas import AddByIpRequest
|
|
|
|
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"
|