248 lines
8.5 KiB
Python
248 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from botocore.exceptions import ClientError
|
|
|
|
import app.integrations.aws_client as aws_module
|
|
from app.core.errors import ExternalServiceError, ValidationAppError
|
|
from app.integrations.aws_client import LightsailClient
|
|
|
|
|
|
class FakeNotFoundException(Exception):
|
|
pass
|
|
|
|
|
|
class FakeLightsail:
|
|
exceptions = SimpleNamespace(NotFoundException=FakeNotFoundException)
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
|
self.pages: dict[str | None, dict[str, Any]] = {}
|
|
self.static_ip_response: dict[str, Any] | Exception = {}
|
|
self.operation: dict[str, Any] = {
|
|
"id": "operation-id",
|
|
"isTerminal": True,
|
|
"status": "Succeeded",
|
|
}
|
|
|
|
def get_instance(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.calls.append(("get_instance", kwargs))
|
|
return {
|
|
"instance": {
|
|
"name": kwargs["instanceName"],
|
|
"state": {"name": "RUNNING"},
|
|
"publicIpAddress": "203.0.113.20",
|
|
"isStaticIp": True,
|
|
}
|
|
}
|
|
|
|
def get_static_ips(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.calls.append(("get_static_ips", kwargs))
|
|
return self.pages[kwargs.get("pageToken")]
|
|
|
|
def get_static_ip(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.calls.append(("get_static_ip", kwargs))
|
|
if isinstance(self.static_ip_response, Exception):
|
|
raise self.static_ip_response
|
|
return self.static_ip_response
|
|
|
|
def allocate_static_ip(self, **kwargs: Any) -> dict[str, Any]:
|
|
return self._mutation("allocate_static_ip", kwargs)
|
|
|
|
def detach_static_ip(self, **kwargs: Any) -> dict[str, Any]:
|
|
return self._mutation("detach_static_ip", kwargs)
|
|
|
|
def attach_static_ip(self, **kwargs: Any) -> dict[str, Any]:
|
|
return self._mutation("attach_static_ip", kwargs)
|
|
|
|
def release_static_ip(self, **kwargs: Any) -> dict[str, Any]:
|
|
return self._mutation("release_static_ip", kwargs)
|
|
|
|
def get_operation(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.calls.append(("get_operation", kwargs))
|
|
return {"operation": dict(self.operation)}
|
|
|
|
def _mutation(self, method: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
self.calls.append((method, kwargs))
|
|
return {"operations": [{"id": "operation-id"}]}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_client(monkeypatch: pytest.MonkeyPatch) -> tuple[FakeLightsail, LightsailClient]:
|
|
fake = FakeLightsail()
|
|
monkeypatch.setattr(aws_module.boto3, "client", lambda *_args, **_kwargs: fake)
|
|
return fake, LightsailClient(region="us-east-1", instance_name="proxy-node")
|
|
|
|
|
|
def static_ip(
|
|
name: str,
|
|
ip_address: str,
|
|
*,
|
|
attached_to: str | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"name": name,
|
|
"arn": f"arn:aws:lightsail:us-east-1:123456789012:StaticIp/{name}",
|
|
"ipAddress": ip_address,
|
|
"isAttached": attached_to is not None,
|
|
"attachedTo": attached_to,
|
|
"location": {"regionName": "us-east-1"},
|
|
}
|
|
|
|
|
|
def test_lists_all_pages_and_reports_attached_static_ips(
|
|
fake_client: tuple[FakeLightsail, LightsailClient],
|
|
) -> None:
|
|
fake, client = fake_client
|
|
fake.pages = {
|
|
None: {
|
|
"staticIps": [static_ip("proxy-old", "203.0.113.20", attached_to="proxy-node")],
|
|
"nextPageToken": "page-2",
|
|
},
|
|
"page-2": {"staticIps": [static_ip("spare-ip", "203.0.113.21")]},
|
|
}
|
|
|
|
snapshots = client.list_static_ips()
|
|
assert [snapshot.name for snapshot in snapshots] == ["proxy-old", "spare-ip"]
|
|
assert snapshots[0].attached_to == "proxy-node"
|
|
assert snapshots[0].region == "us-east-1"
|
|
assert ("get_static_ips", {}) in fake.calls
|
|
assert ("get_static_ips", {"pageToken": "page-2"}) in fake.calls
|
|
|
|
status = client.test_connection()
|
|
assert status["attached_static_ips"] == [
|
|
{
|
|
"name": "proxy-old",
|
|
"ip_address": "203.0.113.20",
|
|
"attached_to": "proxy-node",
|
|
}
|
|
]
|
|
|
|
|
|
def test_get_static_ip_handles_success_not_found_and_other_errors(
|
|
fake_client: tuple[FakeLightsail, LightsailClient],
|
|
) -> None:
|
|
fake, client = fake_client
|
|
fake.static_ip_response = {"staticIp": static_ip("proxy-old", "203.0.113.20")}
|
|
snapshot = client.get_static_ip("proxy-old")
|
|
assert snapshot is not None
|
|
assert snapshot.ip_address == "203.0.113.20"
|
|
assert fake.calls[-1] == ("get_static_ip", {"staticIpName": "proxy-old"})
|
|
|
|
fake.static_ip_response = FakeNotFoundException()
|
|
assert client.get_static_ip("proxy-old") is None
|
|
|
|
fake.static_ip_response = ClientError(
|
|
{"Error": {"Code": "ThrottlingException", "Message": "slow down"}},
|
|
"GetStaticIp",
|
|
)
|
|
with pytest.raises(ExternalServiceError, match="slow down") as error:
|
|
client.get_static_ip("proxy-old")
|
|
assert error.value.code == "ThrottlingException"
|
|
|
|
|
|
def test_mutations_send_exact_parameters_and_wait_for_terminal_operation(
|
|
fake_client: tuple[FakeLightsail, LightsailClient],
|
|
) -> None:
|
|
fake, client = fake_client
|
|
|
|
client.allocate_static_ip("proxy-new")
|
|
client.detach_static_ip("proxy-old")
|
|
client.attach_static_ip("proxy-new")
|
|
client.attach_static_ip("proxy-new", "another-node")
|
|
client.release_static_ip("proxy-old")
|
|
|
|
assert ("allocate_static_ip", {"staticIpName": "proxy-new"}) in fake.calls
|
|
assert ("detach_static_ip", {"staticIpName": "proxy-old"}) in fake.calls
|
|
assert (
|
|
"attach_static_ip",
|
|
{"staticIpName": "proxy-new", "instanceName": "proxy-node"},
|
|
) in fake.calls
|
|
assert (
|
|
"attach_static_ip",
|
|
{"staticIpName": "proxy-new", "instanceName": "another-node"},
|
|
) in fake.calls
|
|
assert ("release_static_ip", {"staticIpName": "proxy-old"}) in fake.calls
|
|
assert sum(method == "get_operation" for method, _kwargs in fake.calls) == 5
|
|
|
|
|
|
def test_mutation_maps_failed_and_timed_out_operations(
|
|
fake_client: tuple[FakeLightsail, LightsailClient],
|
|
) -> None:
|
|
fake, client = fake_client
|
|
fake.operation = {
|
|
"id": "operation-id",
|
|
"isTerminal": True,
|
|
"status": "Failed",
|
|
"errorCode": "StaticIpLimitExceeded",
|
|
"errorDetails": "quota exhausted",
|
|
}
|
|
with pytest.raises(ExternalServiceError, match="quota exhausted") as failed:
|
|
client.allocate_static_ip("proxy-new")
|
|
assert failed.value.code == "StaticIpLimitExceeded"
|
|
|
|
fake.operation = {"id": "operation-id", "isTerminal": False, "status": "Started"}
|
|
client.operation_timeout_seconds = 0
|
|
with pytest.raises(ExternalServiceError, match="结果仍需重新查询确认") as timed_out:
|
|
client.detach_static_ip("proxy-old")
|
|
assert timed_out.value.code == "AWS_OPERATION_TIMEOUT"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_name",
|
|
["a", "-proxy", "proxy-", "proxy_ip", "代理-ip", "a" * 256],
|
|
)
|
|
def test_rejects_non_conservative_static_ip_names(
|
|
fake_client: tuple[FakeLightsail, LightsailClient],
|
|
invalid_name: str,
|
|
) -> None:
|
|
_fake, client = fake_client
|
|
with pytest.raises(ValidationAppError) as error:
|
|
client.allocate_static_ip(invalid_name)
|
|
assert error.value.code == "INVALID_STATIC_IP_NAME"
|
|
|
|
|
|
def test_from_credentials_supports_explicit_and_default_credentials(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
constructor_calls: list[dict[str, Any]] = []
|
|
|
|
def fake_boto_client(_service: str, **kwargs: Any) -> FakeLightsail:
|
|
constructor_calls.append(kwargs)
|
|
return FakeLightsail()
|
|
|
|
monkeypatch.setattr(aws_module.boto3, "client", fake_boto_client)
|
|
secrets = {
|
|
"aws_access_key_id": "access-key",
|
|
"aws_secret_access_key": "secret-key",
|
|
"aws_session_token": "session-token",
|
|
}
|
|
LightsailClient.from_credentials(
|
|
region="us-east-1",
|
|
instance_name="proxy-node",
|
|
use_default=False,
|
|
secrets=secrets,
|
|
)
|
|
assert constructor_calls[-1]["aws_access_key_id"] == "access-key"
|
|
assert constructor_calls[-1]["aws_session_token"] == "session-token"
|
|
|
|
LightsailClient.from_credentials(
|
|
region="us-east-1",
|
|
instance_name="proxy-node",
|
|
use_default=True,
|
|
secrets=secrets,
|
|
)
|
|
assert "aws_access_key_id" not in constructor_calls[-1]
|
|
|
|
with pytest.raises(ValidationAppError) as error:
|
|
LightsailClient.from_credentials(
|
|
region="us-east-1",
|
|
instance_name="proxy-node",
|
|
use_default=False,
|
|
secrets={},
|
|
)
|
|
assert error.value.code == "AWS_CREDENTIALS_MISSING"
|