runbook-tutorials/tests/test_ui_projects.py
tegwick 711c4e1149
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Work Records / validate (push) Has been cancelled
Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
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 18:03:12 +02:00

181 lines
10 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-v2/pack.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 class=hl><a href='/projects'>Projects</a></h1>" in body and "Project templates" in body and "<a href='/runbooks'>Runbooks</a>" 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 "waiting for rtut.example-hello" in body # the v0.2 member waits for its predecessor
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 # a v0.2 member can be started too, in either mode
assert "Guided flow" 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_is_walkable_in_the_ui(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 "Guided flow" in body and "Keep in mind" in body