runbook-tutorials/tests/test_ui_projects.py
tegwick f8ce4717a8
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 52s
Work Records / validate (push) Successful in 12s
Projects have their own name, description and goal; the template is shown only as origin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
2026-09-29 14:12:21 +02:00

198 lines
11 KiB
Python

import http.client
import shutil
import threading
from pathlib import Path
from urllib.parse import urlencode
import pytest
import yaml
from rtut import project as J, run as R, run2, ui
ROOT = Path(__file__).resolve().parent.parent
def write(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(data, sort_keys=False))
@pytest.fixture
def psrv(tmp_path):
repo = tmp_path
shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", repo / "runbooks/show/pack.yaml") if (repo / "runbooks/show").mkdir(parents=True) is None else None
hello = yaml.safe_load((ROOT / "runbooks/example-hello/pack.yaml").read_text())
hello["parameters"] = [{"id": "host", "label": "Host to ping", "type": "string", "pattern": "[a-zA-Z0-9.-]+"},
{"id": "count", "label": "Ping count", "type": "int", "default": 1}]
write(repo / "runbooks/hello/pack.yaml", hello)
v2 = yaml.safe_load((ROOT / "runbooks/showcase-lighthouse/pack.v0.2.yaml").read_text())
v2["id"] = "rtut.showcase-lighthouse-v2"
write(repo / "runbooks/lh2/pack.yaml", v2)
for f in (ROOT / "attribute-sets").glob("*.yaml"):
write(repo / "attribute-sets" / f.name, yaml.safe_load(f.read_text()))
tpl = yaml.safe_load((ROOT / "project-templates/lighthouse-watch.yaml").read_text())
tpl["runbooks"].append({"pack": "rtut.showcase-lighthouse-v2", "after": ["rtut.example-hello"]})
write(repo / "project-templates/lighthouse-watch.yaml", tpl)
s = ui.serve([repo], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
s.repo = repo
yield s
s.shutdown()
def req(s, method, path, form=None, host="127.0.0.1"):
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
body = urlencode(form) if form is not None else None
c.request(method, path, body, {"Host": host, "Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse()
return r.status, r.getheader("Location"), r.read().decode()
def make_project(s, pid="harbour", host=None):
t = s.app.token
form = {"_t": t, "project_id": pid, "environment": "rehearsal", "a_keeper": "Ada", "a_logdir": f"/tmp/rtut-ui-{pid}",
"title": "Harbour watch", "description": "Our own harbour, two lights.", "goal": "Both lights keep watch tonight"}
if host:
form["a_host"] = host
return req(s, "POST", "/template/lighthouse-watch/new", form)
def test_index_has_projects_templates_runbooks_and_maturity(psrv):
_, _, body = req(psrv, "GET", "/")
assert "<h1>Projects</h1>" in body and "Project templates" in body and "<h2>Runbooks</h2>" in body
assert "/template/lighthouse-watch" in body
assert ">draft<" in body # maturity pill on every native runbook
def test_template_page_shows_attributes_with_requirement_and_notes(psrv):
st, _, body = req(psrv, "GET", "/template/lighthouse-watch")
assert st == 200 and "A watch at a lighthouse" in body
assert "name='a_keeper'" in body and "Who keeps this light" in body and ">required<" in body
assert "name='a_weather'" not in body # weather is set per target, so it is not a template question
assert "name=title" in body and "name=description" in body and "name=goal" in body # the project's own identity
assert "Create project" in body and "<option>rehearsal</option>" in body and "<option>production</option>" in body
assert req(psrv, "GET", "/template/nope")[0] == 404
def test_create_project_from_template_and_view_it(psrv):
st, loc, _ = make_project(psrv)
assert st == 303 and loc == "/project/harbour"
made = yaml.safe_load((psrv.repo / "projects/harbour/project.yaml").read_text())
assert made["template"] == "lighthouse-watch" and made["environment"] == "rehearsal" and made["attributes"]["keeper"] == "Ada"
_, _, body = req(psrv, "GET", "/project/harbour")
assert "<h1>Harbour watch</h1>" in body and "Our own harbour, two lights." in body and "Both lights keep watch tonight" in body
assert "from template <a href='/template/lighthouse-watch'>lighthouse-watch</a>" in body # origin is shown, as a link
assert "A watch at a lighthouse" not in body # the template's title...
assert "The night watch is played through" not in body # ...and its description are not
assert ">planned<" in body and ">rehearsal<" in body
assert "target:north-light" in body # provenance of the fog weather
assert "Still needed" in body and "name='a_host'" in body # hello needs a host
assert "cannot be run in this UI yet" in body # the v0.2 member
def test_needed_attributes_can_be_filled_in_and_are_validated(psrv):
make_project(psrv)
t = psrv.app.token
st, _, body = req(psrv, "POST", "/project/harbour/attributes", {"_t": t, "a_host": "bad host;rm"})
assert st == 400 and "host" in body and "Still needed" in body
assert req(psrv, "POST", "/project/harbour/attributes", {"_t": t, "a_host": "hvs.ABCDEFGHIJKLMN"})[0] == 400
assert req(psrv, "POST", "/project/harbour/attributes", {"_t": t, "a_host": "example.org"})[0] == 303
_, _, body = req(psrv, "GET", "/project/harbour")
assert "Still needed" not in body and "example.org" in body
def test_start_a_run_from_a_project_uses_its_attributes_and_links_back(psrv):
make_project(psrv, host="example.org")
t = psrv.app.token
_, _, body = req(psrv, "GET", "/project/harbour")
assert "waiting for rtut.showcase-lighthouse" in body # hello waits for the showcase
assert body.count("Start run") == 1 # only the member that may start
st, loc, _ = req(psrv, "POST", "/project/harbour/run", {"_t": t, "pack": "rtut.showcase-lighthouse", "target": "north-light", "operator": "bernd"})
assert st == 303 and loc.startswith("/run/") and loc.endswith("#current")
_, _, run_page = req(psrv, "GET", loc.split("#")[0])
assert "project <a href='/project/harbour'>harbour</a> @north-light · rehearsal" in run_page
_, _, body = req(psrv, "GET", "/project/harbour")
assert ">applying<" in body and "latest run" in body
st, loc2, _ = req(psrv, "POST", "/project/harbour/run", {"_t": t, "pack": "rtut.showcase-lighthouse-v2", "operator": "bernd"})
assert st == 303 # the engine accepts it; the UI just cannot walk v0.2 runs yet
assert "cannot walk v0.2 runs yet" in req(psrv, "GET", loc2.split("#")[0])[2]
def test_attribute_table_shows_state_source_modified_and_problems(psrv):
make_project(psrv)
reg_file = psrv.repo / "projects/harbour/project.yaml"
d = yaml.safe_load(reg_file.read_text()); d["attributes"]["logdir"] = "/etc"
reg_file.write_text(yaml.safe_dump(d, sort_keys=False))
_, _, body = req(psrv, "GET", "/project/harbour")
assert "<th>Source</th>" in body and "set:harbour-defaults" in body
assert ">err<" in body and "/etc" in body # invalid value is shown as err, not hidden
assert ">nil<" in body # hello's host has no value yet
def test_review_form_records_a_review_and_raises_maturity(psrv):
t = psrv.app.token
i = psrv.app.pack_index("rtut.showcase-lighthouse")
_, _, body = req(psrv, "GET", f"/pack/{i}")
assert "Maturity" in body and "Reviewed: <b>no</b>" in body and "Record my review" in body
st, loc, _ = req(psrv, "POST", f"/pack/{i}/review", {"_t": t, "by": "alice", "note": "read it"})
assert st == 303 and loc.endswith("#maturity")
_, _, body = req(psrv, "GET", f"/pack/{i}")
assert "Reviewed: <b>yes</b>" in body and "alice" in body and "current revision" in body and ">reviewed<" in body
st, _, body = req(psrv, "POST", f"/pack/{i}/review", {"_t": t, "by": "two words"})
assert st == 400 and "one word" in body
def test_idempotency_shown_per_spec_version(psrv):
i = psrv.app.pack_index("rtut.showcase-lighthouse")
_, _, body = req(psrv, "GET", f"/pack/{i}")
assert "Idempotency: <b>not declared (v0.1 packs cannot declare it)</b>" in body
def test_csrf_host_and_escaping(psrv):
assert req(psrv, "POST", "/template/lighthouse-watch/new", {"project_id": "x", "environment": "rehearsal"})[0] == 403
assert req(psrv, "POST", "/project/harbour/run", {"pack": "x"})[0] == 403
assert req(psrv, "GET", "/project/harbour", host="evil.example")[0] == 403
tpl = psrv.repo / "project-templates/lighthouse-watch.yaml"
d = yaml.safe_load(tpl.read_text()); d["title"] = "<script>alert(1)</script> watch"
tpl.write_text(yaml.safe_dump(d, sort_keys=False))
_, _, body = req(psrv, "GET", "/")
assert "<script>alert(1)" not in body and "&lt;script&gt;alert(1)" in body
_, _, body = req(psrv, "GET", "/template/lighthouse-watch")
assert "<script>alert(1)" not in body
def test_bad_project_id_and_duplicate_are_refused(psrv):
t = psrv.app.token
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "Bad Id!", "environment": "rehearsal"})
assert st == 400 and "project id" in body
assert make_project(psrv)[0] == 303
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "harbour", "environment": "rehearsal"})
assert st == 400 and "already exists" in body
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "other", "environment": "prod"})
assert st == 400 and "environment" in body
def test_v02_run_shows_a_guard_instead_of_crashing(psrv):
reg = psrv.app.registry()
pack = reg.packs["rtut.showcase-lighthouse-v2"]
r = run2.create(pack, "bernd", {"logdir": "/tmp/rtut-ui-v2"})
i = psrv.app.pack_index("rtut.showcase-lighthouse-v2")
st, _, body = req(psrv, "GET", f"/run/{i}/{r.data['run_id']}")
assert st == 200 and "cannot walk v0.2 runs yet" in body
def test_front_page_lists_projects_by_their_own_name_and_shows_the_template_as_origin(psrv):
make_project(psrv)
_, _, body = req(psrv, "GET", "/")
projects = body.split("<h2>Project templates</h2>")[0]
assert "Harbour watch" in projects and "Our own harbour, two lights." in projects
assert "from template <a href='/template/lighthouse-watch'>lighthouse-watch</a>" in projects
assert "The night watch is played through" not in projects and "A watch at a lighthouse" not in projects
assert "A watch at a lighthouse" in body.split("<h2>Project templates</h2>")[1] # the template keeps its own listing
def test_creating_a_project_needs_a_name(psrv):
t = psrv.app.token
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "nameless", "environment": "rehearsal", "title": "x"})
assert st == 400 and "name" in body
assert not (psrv.repo / "projects/nameless").exists()