FluxIP/tests/test_fleet_api.py

495 lines
18 KiB
Python

from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from pytest import MonkeyPatch
import app.fleet.service as fleet_service_module
from tests.conftest import csrf_headers
def post_resource(
client: TestClient,
path: str,
payload: dict[str, object],
) -> tuple[dict[str, Any], str]:
response = client.post(path, json=payload, headers=csrf_headers(client))
assert response.status_code == 201, response.text
return response.json()["data"], response.headers["location"]
def test_instance_crud_cas_csrf_and_soft_delete(
authenticated_client: TestClient,
managed_instance_payload: dict[str, object],
) -> None:
client = authenticated_client
assert client.post("/api/v1/instances", json=managed_instance_payload).status_code == 403
created, location = post_resource(client, "/api/v1/instances", managed_instance_payload)
assert created["config_version"] == 1
assert created["group_id"] is None
assert location == f"/api/v1/instances/{created['id']}"
assert client.get(location).json()["data"] == created
assert client.get("/api/v1/instances").json()["data"] == [created]
updated = client.put(
location,
json={"config_version": 1, "socks_port": 2080},
headers=csrf_headers(client),
)
assert updated.status_code == 200
assert updated.json()["data"]["config_version"] == 2
assert updated.json()["data"]["socks_port"] == 2080
stale = client.put(
location,
json={"config_version": 1, "socks_port": 3080},
headers=csrf_headers(client),
)
assert stale.status_code == 409
assert stale.json()["type"] == "about:blank#instance_config_version_conflict"
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 archived.json()["data"]["enabled"] is False
assert client.get(location).status_code == 404
assert client.get("/api/v1/instances").json()["data"] == []
stored = client.app.state.container.fleet_repository.get_instance(
created["id"], include_archived=True
)
assert stored is not None and stored.archived_at is not None
def test_instance_proxy_url_is_encrypted_and_never_returned(
authenticated_client: TestClient,
managed_instance_payload: dict[str, object],
) -> None:
client = authenticated_client
username = "proxy-test-user"
password = "proxy-test-password"
proxy_url = f"socks5://{username}:{password}@one.example.com:10808"
created, location = post_resource(
client,
"/api/v1/instances",
{
**managed_instance_payload,
"proxy_url": proxy_url,
"cloudflare_proxied": True,
},
)
assert created["proxy_protocol"] == "socks5"
assert created["socks_port"] == 10808
assert created["proxy_auth_configured"] is True
assert created["cloudflare_proxied"] is True
assert username not in str(created)
assert password not in str(created)
assert "proxy_url" not in created
assert username not in client.get(location).text
assert password not in client.get("/api/v1/instances").text
repository = client.app.state.container.fleet_repository
assert repository.resolve_proxy_credentials(created["id"]) == (username, password)
with client.app.state.container.database.connect() as connection:
rows = connection.execute(
"""
SELECT name, nonce, ciphertext
FROM managed_instance_secrets WHERE instance_id = ? ORDER BY name
""",
(created["id"],),
).fetchall()
assert {row["name"] for row in rows} == {"proxy_username", "proxy_password"}
assert all(username not in row["ciphertext"] for row in rows)
assert all(password not in row["ciphertext"] for row in rows)
replacement_user = "next-user"
replacement_password = "next-password"
updated = client.put(
location,
json={
"config_version": created["config_version"],
"proxy_url": (
f"http://one.example.com:18080@{replacement_user}:{replacement_password}"
),
},
headers=csrf_headers(client),
)
assert updated.status_code == 200, updated.text
updated_data = updated.json()["data"]
assert updated_data["proxy_protocol"] == "http"
assert updated_data["socks_port"] == 18080
assert updated_data["proxy_auth_configured"] is True
assert repository.resolve_proxy_credentials(created["id"]) == (
replacement_user,
replacement_password,
)
cleared = client.put(
location,
json={
"config_version": updated_data["config_version"],
"clear_proxy_credentials": True,
},
headers=csrf_headers(client),
)
assert cleared.status_code == 200, cleared.text
assert cleared.json()["data"]["proxy_auth_configured"] is False
assert repository.resolve_proxy_credentials(created["id"]) == (None, None)
mismatched = client.put(
location,
json={
"config_version": cleared.json()["data"]["config_version"],
"proxy_url": "socks5://user:password@other.example.com:1080",
},
headers=csrf_headers(client),
)
assert mismatched.status_code == 422
assert mismatched.json()["type"] == "about:blank#proxy_host_mismatch"
invalid_user = "must-not-leak"
invalid_password = "still-secret"
invalid = client.put(
location,
json={
"config_version": cleared.json()["data"]["config_version"],
"proxy_url": (f"socks5://{invalid_user}:{invalid_password}@one.example.com:not-a-port"),
},
headers=csrf_headers(client),
)
assert invalid.status_code == 422
assert invalid_user not in invalid.text
assert invalid_password not in invalid.text
def test_instance_test_probes_aws_public_ip_with_saved_proxy_credentials(
authenticated_client: TestClient,
managed_instance_payload: dict[str, object],
monkeypatch: MonkeyPatch,
) -> None:
client = authenticated_client
created, _ = post_resource(
client,
"/api/v1/instances",
{
**managed_instance_payload,
"proxy_url": "http://proxy-user:proxy-pass@one.example.com:10808",
"cloudflare_proxied": True,
},
)
class FakeAws:
@staticmethod
def test_connection() -> dict[str, object]:
return {"ok": True, "public_ip": "198.51.100.42"}
class FakeCloudflare:
@staticmethod
def test_connection() -> dict[str, object]:
return {
"ok": True,
"zone_id": "zone-test-id",
"record_ip": "198.51.100.42",
"proxied": True,
"ttl": 1,
}
probe_calls: list[dict[str, object]] = []
async def fake_probe(
host: str,
port: int,
protocol: str,
username: str | None = None,
password: str | None = None,
timeout_seconds: float = 5,
) -> None:
probe_calls.append(
{
"host": host,
"port": port,
"protocol": protocol,
"username": username,
"password": password,
"timeout_seconds": timeout_seconds,
}
)
container = client.app.state.container
monkeypatch.setattr(
container.fleet_service,
"_clients",
lambda _instance: (FakeAws(), FakeCloudflare()),
)
monkeypatch.setattr(fleet_service_module, "probe_proxy", fake_probe)
response = client.post(
f"/api/v1/instances/{created['id']}/tests",
headers=csrf_headers(client),
)
assert response.status_code == 200, response.text
result = response.json()["data"]
assert result["ok"] is True
assert result["proxy"]["ok"] is True
assert probe_calls == [
{
"host": "198.51.100.42",
"port": 10808,
"protocol": "http",
"username": "proxy-user",
"password": "proxy-pass",
"timeout_seconds": 5,
},
{
"host": "one.example.com",
"port": 10808,
"protocol": "http",
"username": "proxy-user",
"password": "proxy-pass",
"timeout_seconds": 5,
},
]
def test_group_crud_members_cas_and_soft_delete_preserves_instances(
authenticated_client: TestClient,
managed_instance_payload: dict[str, object],
group_payload: dict[str, object],
) -> None:
client = authenticated_client
first, _ = post_resource(client, "/api/v1/instances", managed_instance_payload)
second, _ = post_resource(
client,
"/api/v1/instances",
{
**managed_instance_payload,
"id": "instance-two",
"display_name": "Proxy Two",
"lightsail_instance_name": "proxy-node-two",
"cloudflare_record_name": "two.example.com",
},
)
group, location = post_resource(client, "/api/v1/instance-groups", group_payload)
assert location == f"/api/v1/instance-groups/{group['id']}"
assert group["config_version"] == 1
assert group["member_ids"] == [first["id"]]
assert group["members"][0]["id"] == first["id"]
assert client.get("/api/v1/instances").json()["data"][0]["group_id"] == group["id"]
first_after_group_create = client.get(f"/api/v1/instances/{first['id']}").json()["data"]
assert first_after_group_create["config_version"] == first["config_version"] + 1
updated = client.put(
location,
json={
"config_version": 1,
"name": "All proxies",
"member_ids": [first["id"], second["id"]],
},
headers=csrf_headers(client),
)
assert updated.status_code == 200, updated.text
assert updated.json()["data"]["config_version"] == 2
assert updated.json()["data"]["member_ids"] == [first["id"], second["id"]]
first_after_group_update = client.get(f"/api/v1/instances/{first['id']}").json()["data"]
second_after_group_update = client.get(f"/api/v1/instances/{second['id']}").json()["data"]
assert first_after_group_update["config_version"] == first_after_group_create["config_version"]
assert first_after_group_update["group_id"] == first_after_group_create["group_id"]
assert first_after_group_update["group_name"] == "All proxies"
assert second_after_group_update["config_version"] == second["config_version"] + 1
stale_instance = client.put(
f"/api/v1/instances/{second['id']}",
json={"config_version": second["config_version"], "group_id": None},
headers=csrf_headers(client),
)
assert stale_instance.status_code == 409
assert stale_instance.json()["type"] == "about:blank#instance_config_version_conflict"
assert client.get(location).json()["data"]["member_ids"] == [
first["id"],
second["id"],
]
stale = client.put(
location,
json={"config_version": 1, "interval_minutes": 30},
headers=csrf_headers(client),
)
assert stale.status_code == 409
assert stale.json()["type"] == "about:blank#group_config_version_conflict"
archived = client.delete(location, headers=csrf_headers(client))
assert archived.status_code == 200
assert archived.json()["data"]["archived_at"] is not None
assert archived.json()["data"]["member_ids"] == []
assert client.get(location).status_code == 404
assert client.get("/api/v1/instance-groups").json()["data"] == []
instances = client.get("/api/v1/instances").json()["data"]
assert {item["id"] for item in instances} == {first["id"], second["id"]}
assert all(item["group_id"] is None for item in instances)
instances_by_id = {item["id"]: item for item in instances}
assert (
instances_by_id[first["id"]]["config_version"]
== first_after_group_create["config_version"] + 1
)
assert (
instances_by_id[second["id"]]["config_version"]
== second_after_group_update["config_version"] + 1
)
stored = client.app.state.container.fleet_repository.get_group(
group["id"], include_archived=True
)
assert stored is not None and stored.archived_at is not None
def test_instance_group_assignment_through_create_and_update_payloads(
authenticated_client: TestClient,
managed_instance_payload: dict[str, object],
) -> None:
client = authenticated_client
first_group, _ = post_resource(
client,
"/api/v1/instance-groups",
{"id": "payload-first-group", "name": "Payload first", "enabled": False},
)
second_group, _ = post_resource(
client,
"/api/v1/instance-groups",
{"id": "payload-second-group", "name": "Payload second", "enabled": False},
)
created_in_group, _ = post_resource(
client,
"/api/v1/instances",
{**managed_instance_payload, "group_id": f" {first_group['id']} "},
)
assert created_in_group["group_id"] == first_group["id"]
assert created_in_group["group_name"] == first_group["name"]
ungrouped, ungrouped_location = post_resource(
client,
"/api/v1/instances",
{
**managed_instance_payload,
"id": "payload-instance-two",
"display_name": "Payload instance two",
"lightsail_instance_name": "payload-node-two",
"cloudflare_record_name": "payload-two.example.com",
"group_id": " ",
},
)
assert ungrouped["group_id"] is None
assert ungrouped["group_name"] is None
joined = client.put(
ungrouped_location,
json={
"config_version": ungrouped["config_version"],
"group_id": f" {first_group['id']} ",
},
headers=csrf_headers(client),
)
assert joined.status_code == 200, joined.text
joined_data = joined.json()["data"]
assert joined_data["config_version"] == ungrouped["config_version"] + 1
assert joined_data["group_id"] == first_group["id"]
assert joined_data["group_name"] == first_group["name"]
moved = client.put(
ungrouped_location,
json={
"config_version": joined_data["config_version"],
"group_id": second_group["id"],
},
headers=csrf_headers(client),
)
assert moved.status_code == 200, moved.text
moved_data = moved.json()["data"]
assert moved_data["config_version"] == joined_data["config_version"] + 1
assert moved_data["group_id"] == second_group["id"]
assert moved_data["group_name"] == second_group["name"]
stale = client.put(
ungrouped_location,
json={"config_version": joined_data["config_version"], "group_id": None},
headers=csrf_headers(client),
)
assert stale.status_code == 409
assert stale.json()["type"] == "about:blank#instance_config_version_conflict"
assert client.get(ungrouped_location).json()["data"] == moved_data
removed = client.put(
ungrouped_location,
json={"config_version": moved_data["config_version"], "group_id": None},
headers=csrf_headers(client),
)
assert removed.status_code == 200, removed.text
removed_data = removed.json()["data"]
assert removed_data["config_version"] == moved_data["config_version"] + 1
assert removed_data["group_id"] is None
assert removed_data["group_name"] is None
first_group_after = client.get(f"/api/v1/instance-groups/{first_group['id']}").json()["data"]
second_group_after = client.get(f"/api/v1/instance-groups/{second_group['id']}").json()["data"]
assert first_group_after["member_ids"] == [created_in_group["id"]]
assert first_group_after["config_version"] == first_group["config_version"] + 3
assert second_group_after["member_ids"] == []
assert second_group_after["config_version"] == second_group["config_version"] + 2
invalid_target = client.put(
ungrouped_location,
json={
"config_version": removed_data["config_version"],
"group_id": "missing-group",
},
headers=csrf_headers(client),
)
assert invalid_target.status_code == 404
assert invalid_target.json()["type"] == "about:blank#instance_group_not_found"
assert client.get(ungrouped_location).json()["data"] == removed_data
def test_dns_sync_rechecks_config_version_after_acquiring_lock(
authenticated_client: TestClient,
managed_instance_payload: dict[str, object],
monkeypatch: MonkeyPatch,
) -> None:
client = authenticated_client
instance, _ = post_resource(client, "/api/v1/instances", managed_instance_payload)
container = client.app.state.container
rotation_repository = container.rotation_repository
original_acquire = rotation_repository.acquire_dns_sync_lock
def acquire_after_concurrent_update(owner_id: str, ttl_seconds: int = 300) -> bool:
acquired = original_acquire(owner_id, ttl_seconds)
assert acquired is True
container.fleet_repository.update_instance(
instance["id"],
{"socks_port": 2080},
operation_owner_id=owner_id,
)
return True
monkeypatch.setattr(
rotation_repository,
"acquire_dns_sync_lock",
acquire_after_concurrent_update,
)
response = client.post(
f"/api/v1/instances/{instance['id']}/dns-syncs",
headers=csrf_headers(client),
)
assert response.status_code == 409
assert response.json()["type"] == "about:blank#instance_config_changed"
current = container.fleet_repository.get_instance(instance["id"])
assert current is not None
assert current.socks_port == 2080
with container.database.connect() as connection:
lock = connection.execute(
"SELECT 1 FROM fleet_operation_locks WHERE kind = 'dns_sync'"
).fetchone()
assert lock is None