POST /api/v1/canary/tokens grows an optional topology_id field. When present, the server hydrates the topology, validates the named decky is in it, and resolves the docker container via planter.resolve_topology_container — <name>-ssh if the decky exposes ssh, else the topology base container. Absent ⇒ fleet semantics, unchanged. The token row gets a nullable topology_id column (no migration helper per pre-v1 policy). GET /api/v1/canary/tokens accepts ?topology_id= as a filter. DELETE re-resolves the container at revoke time so a redeployed topology is still reachable. 422 when the named decky isn't in the topology; 404 when the topology itself doesn't exist.
589 lines
18 KiB
Python
589 lines
18 KiB
Python
"""End-to-end coverage for /api/v1/canary/* via the live FastAPI app.
|
|
|
|
The planter's docker-exec call is patched so we don't need a real
|
|
docker daemon; everything else (DB, repo, instrumenters, generators,
|
|
storage) runs for real.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
_BASE = "/api/v1/canary"
|
|
|
|
|
|
class _FakeProc:
|
|
def __init__(self, rc: int = 0, stderr: bytes = b"") -> None:
|
|
self.returncode = rc
|
|
self._stderr = stderr
|
|
|
|
async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]:
|
|
return b"", self._stderr
|
|
|
|
def kill(self) -> None: # pragma: no cover
|
|
pass
|
|
|
|
|
|
def _patch_subprocess(rc: int = 0, stderr: bytes = b""):
|
|
async def _fake(*argv, **kw): # noqa: ANN001
|
|
return _FakeProc(rc, stderr)
|
|
return patch.object(asyncio, "create_subprocess_exec", _fake)
|
|
|
|
|
|
def _hdr(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
# ---------------- blob upload ---------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blob_upload_dedupes(
|
|
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
|
files = {"file": ("notes.txt", b"hello canary", "text/plain")}
|
|
res = await client.post(f"{_BASE}/blobs", files=files, headers=_hdr(auth_token))
|
|
assert res.status_code == 201, res.text
|
|
first = res.json()
|
|
# Re-uploading the same bytes returns the same uuid.
|
|
files2 = {"file": ("notes-rename.txt", b"hello canary", "text/plain")}
|
|
res2 = await client.post(f"{_BASE}/blobs", files=files2, headers=_hdr(auth_token))
|
|
assert res2.status_code == 201
|
|
assert res2.json()["uuid"] == first["uuid"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blob_upload_rejects_empty(
|
|
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
|
files = {"file": ("empty.txt", b"", "text/plain")}
|
|
res = await client.post(f"{_BASE}/blobs", files=files, headers=_hdr(auth_token))
|
|
assert res.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blob_list_carries_token_count(
|
|
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
files = {"file": ("x.txt", b"some text", "text/plain")}
|
|
blob = (await client.post(
|
|
f"{_BASE}/blobs", files=files, headers=_hdr(auth_token),
|
|
)).json()
|
|
# Initially zero references.
|
|
res = await client.get(f"{_BASE}/blobs", headers=_hdr(auth_token))
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
assert body["total"] == 1 and body["blobs"][0]["token_count"] == 0
|
|
# Bind a token to bump the count.
|
|
with _patch_subprocess(rc=0):
|
|
tok_res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1", "kind": "http",
|
|
"placement_path": "/etc/x.conf", "blob_uuid": blob["uuid"],
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert tok_res.status_code == 201, tok_res.text
|
|
res = await client.get(f"{_BASE}/blobs", headers=_hdr(auth_token))
|
|
assert res.json()["blobs"][0]["token_count"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blob_delete_refuses_when_referenced(
|
|
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
files = {"file": ("x.txt", b"more text", "text/plain")}
|
|
blob = (await client.post(
|
|
f"{_BASE}/blobs", files=files, headers=_hdr(auth_token),
|
|
)).json()
|
|
with _patch_subprocess(rc=0):
|
|
await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1", "kind": "http",
|
|
"placement_path": "/etc/x.conf", "blob_uuid": blob["uuid"],
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
res = await client.delete(
|
|
f"{_BASE}/blobs/{blob['uuid']}", headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blob_delete_404_for_missing(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.delete(
|
|
f"{_BASE}/blobs/00000000-0000-0000-0000-000000000000",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
# ---------------- token lifecycle ----------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_requires_xor_blob_or_generator(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={"decky_name": "w", "kind": "http", "placement_path": "/x"},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 400
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "w", "kind": "http", "placement_path": "/x",
|
|
"generator": "aws_creds", "blob_uuid": "u",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_rejects_relative_path(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "w", "kind": "http",
|
|
"placement_path": "relative/path", "generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_with_unknown_generator(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "w", "kind": "http",
|
|
"placement_path": "/x", "generator": "bogus",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_with_missing_blob(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "w", "kind": "http",
|
|
"placement_path": "/x",
|
|
"blob_uuid": "00000000-0000-0000-0000-000000000000",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_list_filter_by_decky(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
with _patch_subprocess(rc=0):
|
|
for decky in ("web1", "web2"):
|
|
await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": decky, "kind": "http",
|
|
"placement_path": "/x", "generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
res = await client.get(
|
|
f"{_BASE}/tokens?decky_name=web1", headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
assert body["total"] == 1
|
|
assert body["tokens"][0]["decky_name"] == "web1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_detail_404(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.get(
|
|
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_token_404(
|
|
client: httpx.AsyncClient, auth_token: str
|
|
) -> None:
|
|
res = await client.delete(
|
|
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_token_succeeds(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
with _patch_subprocess(rc=0):
|
|
created = (await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1", "kind": "http",
|
|
"placement_path": "/etc/x.env", "generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)).json()
|
|
res = await client.delete(
|
|
f"{_BASE}/tokens/{created['uuid']}",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 200, res.text
|
|
detail = (await client.get(
|
|
f"{_BASE}/tokens/{created['uuid']}", headers=_hdr(auth_token),
|
|
)).json()
|
|
assert detail["state"] == "revoked"
|
|
|
|
|
|
# ---------------- preview -------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_synthesised_token(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
with _patch_subprocess(rc=0):
|
|
created = (await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1", "kind": "http",
|
|
"placement_path": "/etc/x.env", "generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)).json()
|
|
res = await client.get(
|
|
f"{_BASE}/tokens/{created['uuid']}/preview",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 200
|
|
# Slug round-trips into the previewed bytes (env_file embeds it
|
|
# in API_BASE_URL).
|
|
assert created["callback_token"].encode() in res.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_404(
|
|
client: httpx.AsyncClient, auth_token: str,
|
|
) -> None:
|
|
res = await client.get(
|
|
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000/preview",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
# ---------------- triggers list ------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_triggers_list_for_token(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
with _patch_subprocess(rc=0):
|
|
created = (await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1", "kind": "http",
|
|
"placement_path": "/etc/x.env", "generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)).json()
|
|
# No triggers yet.
|
|
res = await client.get(
|
|
f"{_BASE}/tokens/{created['uuid']}/triggers",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 200
|
|
assert res.json()["total"] == 0
|
|
# 404 for a missing token.
|
|
res = await client.get(
|
|
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000/triggers",
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
# ---------------- topology (MazeNET) deckies ------------------------------
|
|
|
|
|
|
def _patch_subprocess_capture():
|
|
"""Subprocess patcher that records argv for assertion in tests."""
|
|
captured: list[list[str]] = []
|
|
|
|
async def _fake(*argv, **kw): # noqa: ANN001
|
|
captured.append(list(argv))
|
|
return _FakeProc(rc=0)
|
|
|
|
return patch.object(asyncio, "create_subprocess_exec", _fake), captured
|
|
|
|
|
|
def _hydrate_returning(deckies: list[dict]):
|
|
async def _fake_hydrate(_repo, _topology_id):
|
|
return {
|
|
"topology": {"id": _topology_id},
|
|
"lans": [],
|
|
"deckies": deckies,
|
|
"edges": [],
|
|
}
|
|
return _fake_hydrate
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_on_topology_decky_with_ssh_resolves_ssh_container(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
topo_id = "abcdef0123456789"
|
|
monkeypatch.setattr(
|
|
"decnet.topology.persistence.hydrate",
|
|
_hydrate_returning([{
|
|
"uuid": "u1", "name": "web1",
|
|
"decky_config": {"name": "web1"},
|
|
"services": ["ssh", "http"],
|
|
}]),
|
|
)
|
|
patcher, captured = _patch_subprocess_capture()
|
|
with patcher:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1",
|
|
"topology_id": topo_id,
|
|
"kind": "http",
|
|
"placement_path": "/etc/canary.env",
|
|
"generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 201, res.text
|
|
body = res.json()
|
|
assert body["topology_id"] == topo_id
|
|
# docker exec -i <container> sh -c <script>
|
|
assert captured and captured[0][3] == "web1-ssh"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_on_topology_decky_without_ssh_uses_base_container(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
topo_id = "fedcba9876543210"
|
|
monkeypatch.setattr(
|
|
"decnet.topology.persistence.hydrate",
|
|
_hydrate_returning([{
|
|
"uuid": "u1", "name": "router",
|
|
"decky_config": {"name": "router"},
|
|
"services": ["dns"],
|
|
}]),
|
|
)
|
|
patcher, captured = _patch_subprocess_capture()
|
|
with patcher:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "router",
|
|
"topology_id": topo_id,
|
|
"kind": "http",
|
|
"placement_path": "/etc/canary.env",
|
|
"generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 201, res.text
|
|
assert captured[0][3] == "decnet_t_fedcba98_router"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_404_when_topology_unknown(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
async def _no_topology(_repo, _topology_id):
|
|
return None
|
|
monkeypatch.setattr(
|
|
"decnet.topology.persistence.hydrate", _no_topology,
|
|
)
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1",
|
|
"topology_id": "ghost",
|
|
"kind": "http",
|
|
"placement_path": "/x.env",
|
|
"generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_422_when_decky_not_in_topology(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"decnet.topology.persistence.hydrate",
|
|
_hydrate_returning([{
|
|
"uuid": "u1", "name": "other",
|
|
"decky_config": {"name": "other"},
|
|
"services": [],
|
|
}]),
|
|
)
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1",
|
|
"topology_id": "abcdef0123456789",
|
|
"kind": "http",
|
|
"placement_path": "/x.env",
|
|
"generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_token_re_resolves_container_from_topology_id(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
topo_id = "11112222333344445555"
|
|
monkeypatch.setattr(
|
|
"decnet.topology.persistence.hydrate",
|
|
_hydrate_returning([{
|
|
"uuid": "u1", "name": "router",
|
|
"decky_config": {"name": "router"},
|
|
"services": [],
|
|
}]),
|
|
)
|
|
# Create the token on a topology decky.
|
|
create_patcher, _ = _patch_subprocess_capture()
|
|
with create_patcher:
|
|
res = await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "router",
|
|
"topology_id": topo_id,
|
|
"kind": "http",
|
|
"placement_path": "/x.env",
|
|
"generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 201
|
|
token = res.json()
|
|
# Revoke and assert the captured argv targets the topology base
|
|
# container, not <name>-ssh.
|
|
revoke_patcher, captured = _patch_subprocess_capture()
|
|
with revoke_patcher:
|
|
rev = await client.delete(
|
|
f"{_BASE}/tokens/{token['uuid']}", headers=_hdr(auth_token),
|
|
)
|
|
assert rev.status_code == 200, rev.text
|
|
assert captured and captured[0][2] == "decnet_t_11112222_router"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tokens_filters_by_topology_id(
|
|
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
|
topo_id = "topotopotopotopo"
|
|
monkeypatch.setattr(
|
|
"decnet.topology.persistence.hydrate",
|
|
_hydrate_returning([{
|
|
"uuid": "u1", "name": "web1",
|
|
"decky_config": {"name": "web1"},
|
|
"services": ["ssh"],
|
|
}]),
|
|
)
|
|
# Create one fleet token (no topology_id) and one topology token.
|
|
with _patch_subprocess(rc=0):
|
|
await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "fleet1", "kind": "http",
|
|
"placement_path": "/etc/a.env", "generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
await client.post(
|
|
f"{_BASE}/tokens",
|
|
json={
|
|
"decky_name": "web1", "topology_id": topo_id,
|
|
"kind": "http", "placement_path": "/etc/b.env",
|
|
"generator": "env_file",
|
|
},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
# Filter to topology tokens only.
|
|
res = await client.get(
|
|
f"{_BASE}/tokens", params={"topology_id": topo_id},
|
|
headers=_hdr(auth_token),
|
|
)
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
decky_names = {t["decky_name"] for t in body["tokens"]}
|
|
assert decky_names == {"web1"}
|
|
assert all(t["topology_id"] == topo_id for t in body["tokens"])
|
|
|
|
|
|
# ---------------- auth ----------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthenticated_writes_rejected(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
for path, method in [
|
|
(f"{_BASE}/tokens", "POST"),
|
|
(f"{_BASE}/blobs", "POST"),
|
|
]:
|
|
res = await client.request(
|
|
method, path, json={}, files={} if method == "POST" else None,
|
|
)
|
|
# Either 401 from the auth dep or 422 from missing body — the
|
|
# important property is "not anonymous".
|
|
assert res.status_code in (401, 403, 422), f"{path} {method} -> {res.status_code}"
|