FluxIP/tests/test_fleet_api.py

318 lines
12 KiB
Python

from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from pytest import MonkeyPatch
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_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