runbook-tutorials/tests/test_ui_projects.py
tegwick eacbf8a21d
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 6s
Work Records / validate (push) Has been cancelled
UI for projects, templates, attributes and maturity (RBT-WP-0008-T08); stop a test from writing runs into the repo
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 13:12:47 +02:00

177 lines
9.3 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}"}
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 "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 "A watch at a lighthouse" in body and "from template lighthouse-watch" in body
assert ">planned<" in body and ">rehearsal<" in body
assert "The night watch is played through" in body # the goal
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, "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, "project_id": "harbour", "environment": "rehearsal"})
assert st == 400 and "already exists" in body
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "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