Files
wild-pc/castle-api/tests/test_kind_twins.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

101 lines
3.8 KiB
Python

"""A tool, a service, and a job may share a name — kind-scoped endpoints must
address (and mutate) exactly one twin.
These guard the collision case the per-kind identity refactor enables: a
`backup` service + job + tool coexisting. The risk is a save/delete against one
kind bleeding into a same-named twin of another kind. We assert against the
on-disk config (load_config) so we're testing the persisted invariant, not a
response echo.
"""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from castle_core.config import load_config
# Minimal, valid specs for each kind — all named "backup", all referencing the
# same program. Only the service is HTTP-exposed (none claims a subdomain here),
# so the trio passes subdomain-uniqueness validation.
_SVC = {
"program": "backup",
"run": {"runner": "python", "program": "backup"},
"manage": {"systemd": {}},
}
_JOB = {
"program": "backup",
"run": {"runner": "command", "argv": ["backup"]},
"schedule": "0 3 * * *",
}
_TOOL = {"program": "backup", "run": {"runner": "path"}}
def _put(client: TestClient, section: str, name: str, cfg: dict) -> None:
r = client.put(f"/config/{section}/{name}", json={"config": cfg})
assert r.status_code == 200, r.text
class TestKindScopedTwins:
def test_same_name_across_kinds_coexist(
self, client: TestClient, castle_root: Path
) -> None:
"""Creating a service, job, and tool all named `backup` yields three
distinct deployments — one per kind, none overwriting another."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
_put(client, "tools", "backup", _TOOL)
cfg = load_config(castle_root)
assert "backup" in cfg.services
assert "backup" in cfg.jobs
assert "backup" in cfg.tools
def test_detail_endpoints_resolve_the_right_twin(
self, client: TestClient, castle_root: Path
) -> None:
"""`/services/backup` and `/jobs/backup` each return their own kind, not
whichever twin happens to sort first."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
svc = client.get("/services/backup").json()
job = client.get("/jobs/backup").json()
assert svc["kind"] == "service"
# Each endpoint returns its own twin: the job carries the schedule, the
# service does not — so neither resolved to the other.
assert job["manifest"].get("schedule") == "0 3 * * *"
assert "schedule" not in svc["manifest"]
def test_kind_scoped_save_does_not_touch_the_twin(
self, client: TestClient, castle_root: Path
) -> None:
"""A partial patch to the *service* backup must leave the *job* backup
(and its schedule) untouched — the wrong-twin bleed this refactor closes."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
_put(client, "services", "backup", {"reach": "off"})
cfg = load_config(castle_root)
assert cfg.jobs["backup"].schedule == "0 3 * * *" # job untouched
assert "backup" in cfg.services # service still there
def test_kind_scoped_delete_removes_only_that_twin(
self, client: TestClient, castle_root: Path
) -> None:
"""Deleting `/config/tools/backup` drops only the tool; the service and
job twins survive."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
_put(client, "tools", "backup", _TOOL)
r = client.delete("/config/tools/backup")
assert r.status_code == 200, r.text
cfg = load_config(castle_root)
assert "backup" not in cfg.tools # tool gone
assert "backup" in cfg.services # twins survive
assert "backup" in cfg.jobs