FluxIP/tests/test_accounts_api.py

596 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",
}
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