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