597 lines
21 KiB
Python
597 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
from pytest import MonkeyPatch
|
|
|
|
import app.fleet.service as fleet_service_module
|
|
from app.core.time import to_iso, utc_now
|
|
from tests.conftest import csrf_headers
|
|
|
|
|
|
def create_account(
|
|
client: TestClient,
|
|
payload: dict[str, object],
|
|
) -> tuple[dict[str, Any], str]:
|
|
response = client.post(
|
|
"/api/v1/accounts",
|
|
json=payload,
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()["data"], response.headers["location"]
|
|
|
|
|
|
def aws_access_payload(name: str = "AWS Access") -> dict[str, object]:
|
|
return {
|
|
"provider": "aws",
|
|
"name": name,
|
|
"use_default_aws_credentials": False,
|
|
"aws_access_key_id": "AKIA_API_ACCESS_KEY",
|
|
"aws_secret_access_key": "api-secret-access-key",
|
|
"aws_session_token": "api-session-token",
|
|
}
|
|
|
|
|
|
def cloudflare_payload(name: str = "Cloudflare Main") -> dict[str, object]:
|
|
return {
|
|
"provider": "cloudflare",
|
|
"name": name,
|
|
"cloudflare_api_token": "cloudflare-api-token-value",
|
|
}
|
|
|
|
|
|
def test_account_create_list_get_flags_and_no_plaintext_leak(
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
client = authenticated_client
|
|
access, access_location = create_account(client, aws_access_payload())
|
|
default, _ = create_account(
|
|
client,
|
|
{
|
|
"provider": "aws",
|
|
"name": "AWS Default Chain",
|
|
"use_default_aws_credentials": True,
|
|
},
|
|
)
|
|
cloudflare, _ = create_account(client, cloudflare_payload())
|
|
|
|
assert access["config_version"] == 1
|
|
assert access["use_default_aws_credentials"] is False
|
|
assert access["secrets_configured"] == {
|
|
"aws_access_key_id": True,
|
|
"aws_secret_access_key": True,
|
|
"aws_session_token": True,
|
|
"cloudflare_api_token": False,
|
|
}
|
|
assert default["use_default_aws_credentials"] is True
|
|
assert not any(default["secrets_configured"].values())
|
|
assert cloudflare["use_default_aws_credentials"] is None
|
|
assert cloudflare["secrets_configured"] == {
|
|
"aws_access_key_id": False,
|
|
"aws_secret_access_key": False,
|
|
"aws_session_token": False,
|
|
"cloudflare_api_token": True,
|
|
}
|
|
assert client.get(access_location).json()["data"] == access
|
|
|
|
listed = client.get("/api/v1/accounts")
|
|
assert listed.status_code == 200
|
|
assert [item["id"] for item in listed.json()["data"]] == [
|
|
access["id"],
|
|
default["id"],
|
|
cloudflare["id"],
|
|
]
|
|
aws_only = client.get("/api/v1/accounts", params={"provider": "aws"})
|
|
assert {item["id"] for item in aws_only.json()["data"]} == {
|
|
access["id"],
|
|
default["id"],
|
|
}
|
|
|
|
plaintexts = (
|
|
"AKIA_API_ACCESS_KEY",
|
|
"api-secret-access-key",
|
|
"api-session-token",
|
|
"cloudflare-api-token-value",
|
|
)
|
|
serialized_responses = " ".join((listed.text, client.get(access_location).text))
|
|
assert all(secret not in serialized_responses for secret in plaintexts)
|
|
container = client.app.state.container
|
|
with container.database.connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT account_id, name, nonce, ciphertext FROM account_secrets"
|
|
).fetchall()
|
|
stored = " ".join(str(value) for row in rows for value in tuple(row))
|
|
assert all(secret not in stored for secret in plaintexts)
|
|
|
|
|
|
def test_account_updates_preserve_blank_secrets_replace_pair_and_enforce_cas(
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
client = authenticated_client
|
|
aws, location = create_account(client, aws_access_payload())
|
|
|
|
preserved = client.put(
|
|
location,
|
|
json={
|
|
"config_version": aws["config_version"],
|
|
"name": "AWS Access Renamed",
|
|
"use_default_aws_credentials": False,
|
|
"aws_access_key_id": None,
|
|
"aws_secret_access_key": None,
|
|
"aws_session_token": None,
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert preserved.status_code == 200, preserved.text
|
|
preserved_data = preserved.json()["data"]
|
|
assert preserved_data["config_version"] == 2
|
|
assert all(
|
|
preserved_data["secrets_configured"][name]
|
|
for name in (
|
|
"aws_access_key_id",
|
|
"aws_secret_access_key",
|
|
"aws_session_token",
|
|
)
|
|
)
|
|
assert client.app.state.container.account_repository.resolve_aws(aws["id"])[1] == {
|
|
"aws_access_key_id": "AKIA_API_ACCESS_KEY",
|
|
"aws_secret_access_key": "api-secret-access-key",
|
|
"aws_session_token": "api-session-token",
|
|
}
|
|
|
|
replaced = client.put(
|
|
location,
|
|
json={
|
|
"config_version": preserved_data["config_version"],
|
|
"aws_access_key_id": "AKIA_API_REPLACED_KEY",
|
|
"aws_secret_access_key": "api-replaced-secret",
|
|
"aws_session_token": None,
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert replaced.status_code == 200, replaced.text
|
|
replaced_data = replaced.json()["data"]
|
|
assert replaced_data["config_version"] == 3
|
|
assert replaced_data["secrets_configured"]["aws_session_token"] is False
|
|
assert client.app.state.container.account_repository.resolve_aws(aws["id"])[1] == {
|
|
"aws_access_key_id": "AKIA_API_REPLACED_KEY",
|
|
"aws_secret_access_key": "api-replaced-secret",
|
|
"aws_session_token": None,
|
|
}
|
|
|
|
stale = client.put(
|
|
location,
|
|
json={"config_version": preserved_data["config_version"], "name": "Stale"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert stale.status_code == 409
|
|
assert stale.json()["type"] == "about:blank#account_config_version_conflict"
|
|
|
|
|
|
def test_cloudflare_update_preserves_blank_token(
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
client = authenticated_client
|
|
cloudflare, cloudflare_location = create_account(client, cloudflare_payload())
|
|
cloudflare_preserved = client.put(
|
|
cloudflare_location,
|
|
json={
|
|
"config_version": cloudflare["config_version"],
|
|
"name": "Cloudflare Renamed",
|
|
"cloudflare_api_token": None,
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert cloudflare_preserved.status_code == 200, cloudflare_preserved.text
|
|
assert cloudflare_preserved.json()["data"]["secrets_configured"]["cloudflare_api_token"] is True
|
|
assert (
|
|
client.app.state.container.account_repository.resolve_cloudflare(cloudflare["id"])
|
|
== "cloudflare-api-token-value"
|
|
)
|
|
|
|
|
|
def test_account_soft_delete_and_provider_payload_validation(
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
client = authenticated_client
|
|
account, location = create_account(client, cloudflare_payload("Cloudflare Archived"))
|
|
|
|
assert client.delete(location).status_code == 403
|
|
archived = client.delete(location, headers=csrf_headers(client))
|
|
assert archived.status_code == 200
|
|
assert archived.json()["data"]["archived_at"] is not None
|
|
assert client.get(location).status_code == 404
|
|
assert account["id"] not in {
|
|
item["id"] for item in client.get("/api/v1/accounts").json()["data"]
|
|
}
|
|
stored = client.app.state.container.account_repository.get_account(
|
|
account["id"], include_archived=True
|
|
)
|
|
assert stored is not None and stored.archived_at is not None
|
|
|
|
invalid_payloads = (
|
|
{
|
|
"provider": "aws",
|
|
"name": "AWS With Cloudflare Token",
|
|
"use_default_aws_credentials": True,
|
|
"cloudflare_api_token": "wrong-token",
|
|
},
|
|
{
|
|
"provider": "cloudflare",
|
|
"name": "Cloudflare With AWS Key",
|
|
"cloudflare_api_token": "cf-token",
|
|
"aws_access_key_id": "AKIA_WRONG",
|
|
"aws_secret_access_key": "wrong-secret",
|
|
},
|
|
{
|
|
"provider": "aws",
|
|
"name": "Incomplete AWS",
|
|
"aws_access_key_id": "AKIA_ONLY",
|
|
},
|
|
)
|
|
for payload in invalid_payloads:
|
|
response = client.post(
|
|
"/api/v1/accounts",
|
|
json=payload,
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 422
|
|
assert client.get("/api/v1/accounts", params={"provider": "azure"}).status_code == 422
|
|
|
|
|
|
def test_instance_account_binding_validates_provider_missing_and_in_use(
|
|
authenticated_client: TestClient,
|
|
managed_instance_payload: dict[str, object],
|
|
) -> None:
|
|
client = authenticated_client
|
|
aws, aws_location = create_account(client, aws_access_payload("AWS Instance"))
|
|
cloudflare, cloudflare_location = create_account(
|
|
client, cloudflare_payload("Cloudflare Instance")
|
|
)
|
|
|
|
provider_mismatch = client.post(
|
|
"/api/v1/instances",
|
|
json={
|
|
**managed_instance_payload,
|
|
"id": "provider-mismatch-instance",
|
|
"aws_account_id": cloudflare["id"],
|
|
"cloudflare_account_id": aws["id"],
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert provider_mismatch.status_code == 422
|
|
assert provider_mismatch.json()["type"] == ("about:blank#aws_account_provider_mismatch")
|
|
|
|
missing = client.post(
|
|
"/api/v1/instances",
|
|
json={
|
|
**managed_instance_payload,
|
|
"id": "missing-account-instance",
|
|
"aws_account_id": aws["id"],
|
|
"cloudflare_account_id": "missing-cloudflare-account",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert missing.status_code == 422
|
|
assert missing.json()["type"] == "about:blank#cloudflare_account_not_found"
|
|
|
|
created = client.post(
|
|
"/api/v1/instances",
|
|
json={
|
|
**managed_instance_payload,
|
|
"id": "account-linked-instance",
|
|
"aws_account_id": aws["id"],
|
|
"cloudflare_account_id": cloudflare["id"],
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert created.status_code == 201, created.text
|
|
created_data = created.json()["data"]
|
|
assert created_data["aws_account_id"] == aws["id"]
|
|
assert created_data["cloudflare_account_id"] == cloudflare["id"]
|
|
|
|
for location in (aws_location, cloudflare_location):
|
|
in_use = client.delete(location, headers=csrf_headers(client))
|
|
assert in_use.status_code == 409
|
|
assert in_use.json()["type"] == "about:blank#account_in_use"
|
|
|
|
instance_location = created.headers["location"]
|
|
assert client.delete(instance_location, headers=csrf_headers(client)).status_code == 200
|
|
assert client.delete(aws_location, headers=csrf_headers(client)).status_code == 200
|
|
assert client.delete(cloudflare_location, headers=csrf_headers(client)).status_code == 200
|
|
|
|
|
|
def test_account_writes_are_blocked_by_active_run_and_dns_lock(
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
client = authenticated_client
|
|
account, location = create_account(client, aws_access_payload("AWS Locked"))
|
|
container = client.app.state.container
|
|
now = to_iso()
|
|
with container.database.connect() as connection, connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO fleet_runs(
|
|
id, target_type, target_id, target_name, trigger, status,
|
|
active_slot, total_items, succeeded_items, started_at, updated_at
|
|
) VALUES (
|
|
'api-account-run', 'instance', 'target', 'Target', 'manual',
|
|
'running', 1, 0, 0, ?, ?
|
|
)
|
|
""",
|
|
(now, now),
|
|
)
|
|
|
|
run_blocked_requests = (
|
|
("post", "/api/v1/accounts", cloudflare_payload("Blocked Cloudflare")),
|
|
(
|
|
"put",
|
|
location,
|
|
{"config_version": account["config_version"], "name": "Blocked rename"},
|
|
),
|
|
("delete", location, None),
|
|
)
|
|
for method, path, payload in run_blocked_requests:
|
|
response = client.request(
|
|
method,
|
|
path,
|
|
json=payload,
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 409
|
|
assert response.json()["type"] == "about:blank#fleet_run_active"
|
|
|
|
with container.database.connect() as connection, connection:
|
|
connection.execute("DELETE FROM fleet_runs WHERE id = 'api-account-run'")
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO fleet_operation_locks(
|
|
slot, kind, owner_id, lease_until, created_at
|
|
) VALUES (1, 'dns_sync', 'api-account-dns-lock', ?, ?)
|
|
""",
|
|
(to_iso(utc_now() + timedelta(minutes=5)), now),
|
|
)
|
|
|
|
dns_blocked = client.put(
|
|
location,
|
|
json={"config_version": account["config_version"], "name": "DNS blocked"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert dns_blocked.status_code == 409
|
|
assert dns_blocked.json()["type"] == "about:blank#fleet_run_active"
|
|
|
|
|
|
def test_region_catalog_requires_auth_and_contains_chinese_metadata(
|
|
client: TestClient,
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
# Both fixtures refer to the same test client instance for this test.
|
|
client.cookies.clear()
|
|
assert client.get("/api/v1/regions").status_code == 401
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
|
)
|
|
assert login.status_code == 200
|
|
|
|
response = authenticated_client.get("/api/v1/regions")
|
|
assert response.status_code == 200
|
|
regions = response.json()["data"]
|
|
assert len(regions) == 19
|
|
assert len({region["code"] for region in regions}) == len(regions)
|
|
by_code = {region["code"]: region for region in regions}
|
|
assert by_code["ap-east-1"] == {
|
|
"code": "ap-east-1",
|
|
"name_zh": "亚太地区(香港)",
|
|
"icon": "🇭🇰",
|
|
}
|
|
assert by_code["ap-southeast-1"]["name_zh"] == "亚太地区(新加坡)"
|
|
assert by_code["us-east-1"]["name_zh"] == "美国东部(弗吉尼亚北部)"
|
|
|
|
|
|
def test_instance_rejects_region_outside_catalog(
|
|
authenticated_client: TestClient,
|
|
managed_instance_payload: dict[str, object],
|
|
) -> None:
|
|
response = authenticated_client.post(
|
|
"/api/v1/instances",
|
|
json={**managed_instance_payload, "aws_region": "moon-east-1"},
|
|
headers=csrf_headers(authenticated_client),
|
|
)
|
|
assert response.status_code == 422
|
|
assert any(error["field"] == "aws_region" for error in response.json()["errors"])
|
|
|
|
|
|
def test_fleet_clients_resolve_credentials_from_instance_accounts(
|
|
authenticated_client: TestClient,
|
|
managed_instance_payload: dict[str, object],
|
|
monkeypatch: MonkeyPatch,
|
|
) -> None:
|
|
client = authenticated_client
|
|
aws, _ = create_account(
|
|
client,
|
|
{
|
|
"provider": "aws",
|
|
"name": "AWS Client Resolution",
|
|
"use_default_aws_credentials": False,
|
|
"aws_access_key_id": "AKIA_BOUND_CLIENT",
|
|
"aws_secret_access_key": "bound-client-secret",
|
|
"aws_session_token": "bound-client-session",
|
|
},
|
|
)
|
|
cloudflare, _ = create_account(
|
|
client,
|
|
{
|
|
"provider": "cloudflare",
|
|
"name": "Cloudflare Client Resolution",
|
|
"cloudflare_api_token": "bound-cloudflare-token",
|
|
},
|
|
)
|
|
created = client.post(
|
|
"/api/v1/instances",
|
|
json={
|
|
**managed_instance_payload,
|
|
"id": "client-resolution-instance",
|
|
"aws_account_id": aws["id"],
|
|
"cloudflare_account_id": cloudflare["id"],
|
|
"aws_region": "ap-northeast-1",
|
|
"lightsail_instance_name": "bound-lightsail-node",
|
|
"cloudflare_zone_id": "bound-zone-id",
|
|
"cloudflare_record_name": "bound.example.com",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert created.status_code == 201, created.text
|
|
|
|
captured: dict[str, dict[str, object]] = {}
|
|
aws_client = object()
|
|
cloudflare_client = object()
|
|
|
|
def lightsail_factory(**kwargs: object) -> object:
|
|
captured["aws"] = kwargs
|
|
return aws_client
|
|
|
|
def cloudflare_factory(**kwargs: object) -> object:
|
|
captured["cloudflare"] = kwargs
|
|
return cloudflare_client
|
|
|
|
def reject_legacy_credentials() -> None:
|
|
raise AssertionError("绑定账号完整时不应读取旧全局凭据")
|
|
|
|
container = client.app.state.container
|
|
monkeypatch.setattr(
|
|
fleet_service_module.LightsailClient,
|
|
"from_credentials",
|
|
staticmethod(lightsail_factory),
|
|
)
|
|
monkeypatch.setattr(
|
|
fleet_service_module,
|
|
"CloudflareClient",
|
|
cloudflare_factory,
|
|
)
|
|
monkeypatch.setattr(
|
|
container.integration_service,
|
|
"credentials",
|
|
reject_legacy_credentials,
|
|
)
|
|
instance = container.fleet_repository.get_instance("client-resolution-instance")
|
|
assert instance is not None
|
|
|
|
resolved_aws, resolved_cloudflare = container.fleet_service._clients(instance)
|
|
|
|
assert resolved_aws is aws_client
|
|
assert resolved_cloudflare is cloudflare_client
|
|
assert captured["aws"] == {
|
|
"region": "ap-northeast-1",
|
|
"instance_name": "bound-lightsail-node",
|
|
"use_default": False,
|
|
"secrets": {
|
|
"aws_access_key_id": "AKIA_BOUND_CLIENT",
|
|
"aws_secret_access_key": "bound-client-secret",
|
|
"aws_session_token": "bound-client-session",
|
|
},
|
|
}
|
|
assert captured["cloudflare"] == {
|
|
"token": "bound-cloudflare-token",
|
|
"zone_name": "example.com",
|
|
"zone_id": "bound-zone-id",
|
|
"record_name": "bound.example.com",
|
|
"proxied": False,
|
|
}
|
|
|
|
|
|
def test_rotation_starts_with_bound_accounts_without_global_credentials(
|
|
authenticated_client: TestClient,
|
|
managed_instance_payload: dict[str, object],
|
|
monkeypatch: MonkeyPatch,
|
|
) -> None:
|
|
client = authenticated_client
|
|
legacy = client.get("/api/v1/integrations")
|
|
assert legacy.status_code == 200
|
|
assert not any(legacy.json()["data"]["secrets_configured"].values())
|
|
assert legacy.json()["data"]["use_default_aws_credentials"] is False
|
|
|
|
aws, _ = create_account(
|
|
client,
|
|
{
|
|
"provider": "aws",
|
|
"name": "AWS Rotation Only",
|
|
"use_default_aws_credentials": False,
|
|
"aws_access_key_id": "AKIA_ROTATION_BOUND",
|
|
"aws_secret_access_key": "rotation-bound-secret",
|
|
},
|
|
)
|
|
cloudflare, _ = create_account(
|
|
client,
|
|
{
|
|
"provider": "cloudflare",
|
|
"name": "Cloudflare Rotation Only",
|
|
"cloudflare_api_token": "rotation-bound-cf-token",
|
|
},
|
|
)
|
|
instance = client.post(
|
|
"/api/v1/instances",
|
|
json={
|
|
**managed_instance_payload,
|
|
"id": "account-only-rotation-instance",
|
|
"aws_account_id": aws["id"],
|
|
"cloudflare_account_id": cloudflare["id"],
|
|
"lightsail_instance_name": "account-only-rotation-node",
|
|
"cloudflare_record_name": "rotation-only.example.com",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert instance.status_code == 201, instance.text
|
|
|
|
spawned: list[str] = []
|
|
container = client.app.state.container
|
|
monkeypatch.setattr(container.rotation_service, "_spawn", spawned.append)
|
|
response = client.post(
|
|
"/api/v1/instances/account-only-rotation-instance/rotations",
|
|
headers=csrf_headers(client),
|
|
)
|
|
|
|
assert response.status_code == 202, response.text
|
|
run = response.json()["data"]
|
|
assert response.headers["location"] == f"/api/v1/rotations/{run['id']}"
|
|
assert run["status"] == "queued"
|
|
assert spawned == [run["id"]]
|
|
with container.database.connect() as connection:
|
|
item = connection.execute(
|
|
"""
|
|
SELECT aws_account_id, cloudflare_account_id
|
|
FROM fleet_run_items WHERE run_id = ?
|
|
""",
|
|
(run["id"],),
|
|
).fetchone()
|
|
assert item is not None
|
|
assert item["aws_account_id"] == aws["id"]
|
|
assert item["cloudflare_account_id"] == cloudflare["id"]
|
|
|
|
|
|
def test_account_catalog_coexists_with_legacy_global_integrations(
|
|
authenticated_client: TestClient,
|
|
) -> None:
|
|
client = authenticated_client
|
|
legacy = client.put(
|
|
"/api/v1/integrations",
|
|
json={
|
|
"aws_access_key_id": "AKIA_LEGACY_ACCESS",
|
|
"aws_secret_access_key": "legacy-secret-key",
|
|
"aws_session_token": None,
|
|
"use_default_aws_credentials": False,
|
|
"cloudflare_api_token": "legacy-cloudflare-token",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert legacy.status_code == 200, legacy.text
|
|
account, _ = create_account(client, cloudflare_payload("Independent Cloudflare"))
|
|
|
|
legacy_after = client.get("/api/v1/integrations")
|
|
assert legacy_after.status_code == 200
|
|
assert legacy_after.json()["data"] == legacy.json()["data"]
|
|
assert client.get(f"/api/v1/accounts/{account['id']}").status_code == 200
|