Files
wild-pc/castle-api/tests/test_exposure_derivation.py
Paul Payne 20bf78caf1 api+ui: kind-scoped save/delete endpoints and links
API: /config/{services,jobs,tools,static}/{name} now pin the twin they
target — _save_deployment/_delete_deployment take an explicit kind so a
patch to a 'backup' service can't bleed into a 'backup' job/tool. Add
/tools and /static endpoints; keep /deployments/{name} kind-agnostic.
New test_kind_twins proves per-kind save/delete isolation on disk.

UI: ConfigPanel and CreateDeploymentForm write to the kind-scoped
resource; GatewayPanel/NodeDetail/DeploymentsSection link via a shared
detailPath(name, kind) helper instead of the ambiguous /deployment/:name.

Includes incidental ruff-format reflow of untouched api files.
2026-07-06 02:51:08 -07:00

226 lines
8.0 KiB
Python

"""Exposure-derivation coverage for the gateway route table.
Guards the calculator bug: a public STATIC (caddy) deployment got public_url=None
because `public_names` scanned only config.services (systemd), not statics. Also
guards that a raw-TCP service is excluded from the HTTP route table (the
http_exposed derivation that decides subdomain/route).
"""
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
from castle_api.main import app
from castle_core.registry import Deployment, NodeConfig, NodeRegistry, save_registry
@pytest.fixture
def public_client(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Generator[TestClient, None, None]:
root = tmp_path
(root / "castle.yaml").write_text(
yaml.dump(
{
"gateway": {
"port": 9000,
"tls": "acme",
"domain": "civil.test",
"public_domain": "pub.test",
}
}
)
)
(root / "programs").mkdir()
(root / "programs" / "calc.yaml").write_text(
yaml.dump({"source": str(root / "calc")})
)
(root / "calc" / "public").mkdir(
parents=True
) # static build dir must exist to route
deps = {
# public STATIC (the calculator case)
"calc": {
"program": "calc",
"manager": "caddy",
"root": "public",
"reach": "public",
},
# public systemd service
"web": {
"manager": "systemd",
"run": {"launcher": "python", "program": "web"},
"expose": {"http": {"internal": {"port": 9001}}},
"reach": "public",
},
# internal systemd service
"intern": {
"manager": "systemd",
"run": {"launcher": "python", "program": "intern"},
"expose": {"http": {"internal": {"port": 9002}}},
"reach": "internal",
},
# raw-TCP service (postgres-like) — must NOT become an HTTP route
"pg": {
"manager": "systemd",
"run": {"launcher": "container", "image": "postgres:17"},
"expose": {"tcp": {"port": 5432}},
"reach": "internal",
},
}
(root / "deployments").mkdir()
for name, spec in deps.items():
(root / "deployments" / f"{name}.yaml").write_text(yaml.dump(spec))
reg = NodeRegistry(
node=NodeConfig(
hostname="n",
castle_root=str(root),
gateway_port=9000,
gateway_tls="acme",
gateway_domain="civil.test",
public_domain="pub.test",
),
deployed={
"calc": Deployment(
manager="caddy",
run_cmd=[],
kind="static",
subdomain="calc",
public=True,
static_root=str(root / "calc" / "public"),
),
"web": Deployment(
manager="systemd",
launcher="python",
run_cmd=["x"],
kind="service",
port=9001,
subdomain="web",
public=True,
managed=True,
),
"intern": Deployment(
manager="systemd",
launcher="python",
run_cmd=["x"],
kind="service",
port=9002,
subdomain="intern",
public=False,
managed=True,
),
"pg": Deployment(
manager="systemd",
launcher="container",
run_cmd=["x"],
kind="service",
port=None,
subdomain=None,
tcp_port=5432,
managed=True,
),
},
)
reg_path = tmp_path / "registry.yaml"
save_registry(reg, reg_path)
import castle_core.registry as reg_mod
import castle_api.routes as routes_mod
monkeypatch.setattr(reg_mod, "REGISTRY_PATH", reg_path)
monkeypatch.setattr(
routes_mod, "get_registry", lambda: reg_mod.load_registry(reg_path)
)
monkeypatch.setattr(routes_mod, "get_castle_root", lambda: root)
with TestClient(app) as client:
yield client
def _routes(client: TestClient) -> dict:
return {r["name"]: r for r in client.get("/gateway").json()["routes"]}
class TestGatewayPublicUrl:
def test_public_static_has_public_url(self, public_client: TestClient) -> None:
"""The calculator bug: a public STATIC must get public_url, not None."""
assert _routes(public_client)["calc"]["public_url"] == "https://calc.pub.test"
def test_public_service_has_public_url(self, public_client: TestClient) -> None:
assert _routes(public_client)["web"]["public_url"] == "https://web.pub.test"
def test_internal_service_has_no_public_url(
self, public_client: TestClient
) -> None:
assert _routes(public_client)["intern"]["public_url"] is None
class TestServiceDetailServesSpec:
def test_services_endpoint_serves_static_editable_spec(
self, public_client: TestClient
) -> None:
"""/services/{name} for a STATIC must serve the editable spec (reach/root/
program), not the runtime view. This is the endpoint the dashboard's
/services/ detail page reads — serving the runtime shape made the reach
dropdown default to 'internal' for a public static (calculator)."""
m = public_client.get("/services/calc").json()["manifest"]
assert m.get("reach") == "public" # <-- was None (runtime view) before the fix
assert m.get("root") == "public"
assert m.get("program") == "calc"
class TestDetailEndpointInvariant:
"""The invariant, swept across the matrix — not one example. Every deployment
in castle.yaml must serve its EDITABLE SPEC (reach/program, no runtime-only
keys) on EVERY detail endpoint the dashboard calls, for EVERY kind. Testing a
single instance is what let the static x /services cell (calculator) rot."""
@pytest.mark.parametrize(
"endpoint,expected_reach",
[
("/deployments/calc", "public"), # static, unified endpoint
("/services/calc", "public"), # static, /services endpoint (broke here)
("/deployments/web", "public"), # service, unified endpoint
("/services/web", "public"), # service, /services endpoint
("/deployments/intern", "internal"),
("/services/intern", "internal"),
],
)
def test_detail_serves_editable_spec(
self, public_client: TestClient, endpoint: str, expected_reach: str
) -> None:
m = public_client.get(endpoint).json()["manifest"]
assert m.get("reach") == expected_reach # spec field present
assert "run_cmd" not in m # not the runtime view
@pytest.mark.parametrize("name", ["calc", "web", "intern"])
def test_deployments_and_services_endpoints_agree(
self, public_client: TestClient, name: str
) -> None:
"""The two detail endpoints must return the SAME editable manifest for the
same deployment. Fixing one and forgetting its twin (the calculator bug)
fails this immediately, whatever the kind."""
dep = public_client.get(f"/deployments/{name}").json()["manifest"]
svc = public_client.get(f"/services/{name}").json()["manifest"]
assert dep.get("reach") == svc.get("reach")
assert dep.get("program") == svc.get("program")
assert dep.get("root") == svc.get("root")
class TestTcpNotHttpRouted:
def test_tcp_service_absent_from_http_route_table(
self, public_client: TestClient
) -> None:
"""A raw-TCP service (expose.tcp, no http) is reachable by name+port via
DNS, never an HTTP gateway route — so it must not appear in the table."""
assert "pg" not in _routes(public_client)
# sanity: the HTTP services are present
assert {"calc", "web", "intern"} <= set(_routes(public_client))