495 lines
18 KiB
Python
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
|