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
This commit is contained in:
parent
3470552789
commit
eacbf8a21d
9 changed files with 560 additions and 12 deletions
|
|
@ -212,3 +212,26 @@ def test_instantiate_template_records_origin_merges_values_and_lists_needs(repo)
|
|||
J.instantiate(reg, "lighthouse-watch", "harbour-x", "prod")
|
||||
with pytest.raises(R.RunError, match="template is not run"):
|
||||
J.start_run(reg, reg.templates["lighthouse-watch"], "rtut.showcase-lighthouse", "bob")
|
||||
|
||||
|
||||
def test_set_attributes_validates_against_the_consuming_runbooks(repo):
|
||||
reg = project(repo, attributes={})
|
||||
doc = reg.projects["north-watch"]
|
||||
J.set_attributes(reg, doc, {"keeper": "Bea", "logdir": "/tmp/rtut-set-here", "unrelated": "x"})
|
||||
made = yaml.safe_load(doc.path.read_text())["attributes"]
|
||||
assert made["keeper"] == "Bea" and made["logdir"] == "/tmp/rtut-set-here" and made["unrelated"] == "x"
|
||||
with pytest.raises(ValueError, match="rtut-|violates"):
|
||||
J.set_attributes(reg, doc, {"logdir": "/etc"})
|
||||
with pytest.raises(ValueError, match="secret"):
|
||||
J.set_attributes(reg, doc, {"keeper": "hvs.ABCDEFGHIJKLMN"})
|
||||
with pytest.raises(ValueError, match="bad attribute name"):
|
||||
J.set_attributes(reg, doc, {"Bad-Name": "x"})
|
||||
assert yaml.safe_load(doc.path.read_text())["attributes"]["logdir"] == "/tmp/rtut-set-here" # refused values change nothing
|
||||
reg = Registry([repo])
|
||||
with pytest.raises(ValueError, match="template"):
|
||||
J.set_attributes(reg, reg.templates["lighthouse-watch"], {"keeper": "x"})
|
||||
|
||||
|
||||
def test_attribute_notes_survive_resolution(repo):
|
||||
reg = Registry([repo])
|
||||
assert "Who keeps this light" in fold(set_layers(reg, "harbour-defaults"))["keeper"]["note"]
|
||||
|
|
|
|||
177
tests/test_ui_projects.py
Normal file
177
tests/test_ui_projects.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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 "<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, "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
|
||||
|
|
@ -35,9 +35,12 @@ def test_showcase_v02_is_valid_and_loads_through_pack_load():
|
|||
assert pk.spec == "runbook-pack/v0.2"
|
||||
|
||||
|
||||
def test_v02_packs_run_through_run_create():
|
||||
def test_v02_packs_run_through_run_create(tmp_path):
|
||||
import shutil
|
||||
import uuid
|
||||
r = R.create(P.load(FILE), "tester", {"logdir": f"/tmp/rtut-{uuid.uuid4().hex[:8]}"})
|
||||
d = tmp_path / "runbooks" / "lh"; d.mkdir(parents=True) # a copy: never write run records into the real repo
|
||||
shutil.copy(FILE, d / "pack.yaml")
|
||||
r = R.create(P.load(d / "pack.yaml"), "tester", {"logdir": f"/tmp/rtut-{uuid.uuid4().hex[:8]}"})
|
||||
assert r.data["spec"] == "runbook-run/v0.2" and r.data["mode"] == "manual"
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue