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