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
181 lines
10 KiB
Python
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 "<script>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
|