Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
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

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:
tegwick 2026-09-29 18:03:12 +02:00
parent b7edb8f023
commit 711c4e1149
13 changed files with 381 additions and 42 deletions

View file

@ -42,16 +42,17 @@ def test_finalize_rules(pk):
R.finalize(r, "abandoned") # already finished
def test_finished_run_is_read_only_but_takes_feedback(pk):
def test_finished_run_is_read_only_and_feedback_is_locked_too(pk):
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
for call in (lambda: R.start(r, "read-the-sign"), lambda: R.add_capture(r, "read-the-sign", "x")):
with pytest.raises(R.RunError, match="finished"):
call()
R.add_feedback(r, "poor", "gave up: unclear") # feedback stays possible
with pytest.raises(R.RunError, match="closed and locked"):
R.add_feedback(r, "poor", "gave up: unclear") # feedback goes in before closing, or with the closing form
def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded"); R.add_feedback(ok, "good")
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded", rating="good")
bad = R.create(pk, "b"); R.start(bad, "read-the-sign"); R.fail(bad, "read-the-sign", "x"); R.finalize(bad, "failed")
gone = R.create(pk, "c"); R.finalize(gone, "abandoned")
stalled = R.create(pk, "d")
@ -151,3 +152,72 @@ def test_unfinished_run_elapsed_runs_to_now_and_no_average_without_succeeded_run
t = R.run_times(r, now=datetime.now(timezone.utc) + timedelta(seconds=90))
assert not t["finished_run"] and 89 <= t["elapsed"] <= 91
assert R.scorecard(pk)["avg_seconds"] is None and R.scorecard(pk)["timed_runs"] == 0
# ---- a closed run is locked and sealed --------------------------------------------------------------------------
def test_closing_records_feedback_with_the_outcome_and_validates_before_changing_anything(pk):
r = R.create(pk, "bernd")
with pytest.raises(R.RunError, match="rating must be"):
R.finalize(r, "abandoned", rating="great")
assert not r.data.get("outcome") and "seal" not in r.data # a refused close changed nothing
R.finalize(r, "abandoned", "gave up", rating="poor", comment="step 2 was unclear")
assert R.latest_feedback(r)["comment"] == "step 2 was unclear" and r.data["outcome"] == "abandoned"
assert R.load(pk, r.data["run_id"]).data["feedback"][0]["rating"] == "poor"
def test_a_closed_record_cannot_be_saved_with_changes_but_the_exercise_marker_is_allowed(pk):
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
r2 = R.load(pk, r.data["run_id"])
r2.data["steps"]["read-the-sign"]["state"] = "done"
with pytest.raises(R.RunError, match="closed and locked"):
r2.save()
r3 = R.load(pk, r.data["run_id"])
r3.data["operator"] = "someone-else"
with pytest.raises(R.RunError, match="operator"):
r3.save()
assert R.load(pk, r.data["run_id"]).data["operator"] == "bernd" # nothing reached the disk
r4 = R.load(pk, r.data["run_id"])
r4.data["exercise_recorded"] = "x"
r4.save(allow=("exercise_recorded",)) # the one permitted later update
assert R.load(pk, r.data["run_id"]).data["exercise_recorded"] == "x" and R.seal_state(R.load(pk, r.data["run_id"])) == "sealed"
def test_closing_seals_the_file_read_only_and_tampering_is_detected(pk):
import json, stat
r = R.create(pk, "bernd")
assert R.seal_state(r) == "open" and r.path.stat().st_mode & stat.S_IWUSR
R.finalize(r, "abandoned")
assert R.seal_state(r) == "sealed" and not (r.path.stat().st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
assert len(r.data["seal"]) == 64
r.path.chmod(0o644) # someone edits the file by hand
data = json.loads(r.path.read_text()); data["operator"] = "forged"
r.path.write_text(json.dumps(data))
assert R.seal_state(R.load(pk, r.data["run_id"])) == "broken"
def test_a_run_closed_before_sealing_existed_is_reported_as_unsealed_not_broken(pk):
import json
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
r.path.chmod(0o644)
data = json.loads(r.path.read_text()); data.pop("seal"); r.path.write_text(json.dumps(data))
assert R.seal_state(R.load(pk, r.data["run_id"])) == "unsealed"
def test_a_tampered_run_no_longer_counts_as_maturity_evidence(pk):
import json
from rtut import maturity as M
M.add_review(pk, "alice")
r = R.create(pk, "bob", {"logdir": "/tmp/rtut-lock-mat"}, {"project": "lab", "environment": "rehearsal"})
for s in pk.data["steps"]:
sid = s["id"]; R.start(r, sid)
cmd = R.command_for(r, sid)
if cmd:
subprocess.run(cmd, shell=True, check=True, capture_output=True)
R.verify(r, sid, confirm=True, manual_ok=True if s["verify"]["expect"] == "manual" else None)
R.complete(r, sid, {n: "1" for n in s.get("evidence", [])})
R.finalize(r, "succeeded")
assert M.maturity(pk)["evidence"]["rehearsal_runs"] == 1
r.path.chmod(0o644)
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
assert M.maturity(pk)["evidence"]["clean_runs"] == 0 # changed after closing: not evidence

View file

@ -205,18 +205,18 @@ def test_feedback_form_after_all_steps_done_and_recording(srv):
t, loc, _ = _showcase_run(srv)
base = loc.split("#")[0]
_, _, body = req(srv, "GET", base)
assert "How was this runbook?" not in body # not before the run is complete
assert "<h2>How was this runbook?</h2>" not in body # not before the run is complete
_finish_showcase(srv, t, base)
_, _, body = req(srv, "GET", base)
assert "All steps done." in body and "How was this runbook?" in body
assert body.index("All steps done.") < body.index("How was this runbook?")
assert "All steps done." in body and "<h2>How was this runbook?</h2>" in body
assert body.index("All steps done.") < body.index("<h2>How was this runbook?</h2>")
assert "🙂" in body and "😐" in body and "🙁" in body and "name=comment" in body
st, loc2, _ = req(srv, "POST", base + "/feedback", {"_t": t, "rating": "neutral", "comment": "step 3 was unclear"})
assert st == 303 and loc2.endswith("#feedback")
_, _, body = req(srv, "GET", base)
assert "Thank you." in body and "step 3 was unclear" in body
assert "How was this runbook?" not in body # the section closed once feedback was collected
assert "<a href='/'>← Back to the runbook list</a>" in body # the probable next step
assert "<h2>How was this runbook?</h2>" not in body # the section closed once feedback was collected
assert "Back to the runbook list" not in body # the sidebar's Home replaces that link
assert "Change my feedback" in body # the form is still reachable, folded away
assert req(srv, "GET", "/")[0] == 200

152
tests/test_ui_locked.py Normal file
View file

@ -0,0 +1,152 @@
import http.client
import json
import shutil
import subprocess
import threading
import uuid
from pathlib import Path
from urllib.parse import urlencode
import pytest
from rtut import run as R1, run2 as R, ui
ROOT = Path(__file__).resolve().parent.parent
_scratch = []
@pytest.fixture
def srv(tmp_path):
for name, src in (("v1", "showcase-lighthouse"), ("v2", "showcase-lighthouse-v2")):
d = tmp_path / "runbooks" / name; d.mkdir(parents=True)
shutil.copy(ROOT / f"runbooks/{src}/pack.yaml", d / "pack.yaml")
s = ui.serve([tmp_path], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
s.repo = tmp_path
yield s
s.shutdown()
while _scratch:
shutil.rmtree(_scratch.pop(), ignore_errors=True)
def req(s, method, path, form=None):
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": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse()
return r.status, r.getheader("Location"), r.read().decode()
def post(s, path, **form):
return req(s, "POST", path, {"_t": s.app.token, **form})
def start(s, pack_id):
i = s.app.pack_index(pack_id)
logdir = f"/tmp/rtut-lock-{uuid.uuid4().hex[:8]}"
_scratch.append(logdir)
st, loc, _ = post(s, f"/run/new/{i}", operator="bernd", p_logdir=logdir)
assert st == 303
return loc.split("#")[0], i
def run_of(s, base):
return R1.load(s.app.packs[int(base.split("/")[2])], base.rsplit("/", 1)[1])
# ---- Home headlines are shortcuts ---------------------------------------------------------------------------
def test_home_headlines_link_to_the_sidebar_sections_and_the_section_pages_do_not(srv):
_, _, home = req(srv, "GET", "/")
assert "<h1 class=hl><a href='/projects'>Projects</a></h1>" in home
assert "<h2 class=hl><a href='/projects'>Project templates</a></h2>" in home
assert "<a href='/activity'>Latest activities</a>" in home and "<h2 class=hl><a href='/runbooks'>Runbooks</a></h2>" in home
_, _, projects = req(srv, "GET", "/projects")
assert "<h1>Projects</h1>" in projects and "class=hl" not in projects # on its own page the headline is just a headline
assert "<h1>Runbooks</h1>" in req(srv, "GET", "/runbooks")[2]
assert "<h1>Latest activities" in req(srv, "GET", "/activity")[2]
# ---- no "back to the runbook list" -------------------------------------------------------------------------------
def test_there_is_no_back_to_the_runbook_list_link_in_either_run_view(srv):
for pack in ("rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"):
base, i = start(srv, pack)
assert post(srv, f"{base}/feedback", rating="good", comment="fine")[0] == 303
assert "Back to the runbook list" not in req(srv, "GET", base)[2]
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
assert "Back to the runbook list" not in req(srv, "GET", base)[2]
# ---- closed runs are locked ------------------------------------------------------------------------------------------
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
def test_a_closed_run_shows_its_feedback_read_only_without_a_change_option(srv, pack):
base, _ = start(srv, pack)
assert post(srv, f"{base}/feedback", rating="neutral", comment="a bit long")[0] == 303
assert "Change my feedback" in req(srv, "GET", base)[2] # while open it can still be changed
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
body = req(srv, "GET", base)[2]
assert "Change my feedback" not in body and f"action='{base}/feedback'" not in body
assert "<h2>Feedback</h2>" in body and "a bit long" in body and "closed and locked, so its feedback can no longer be changed" in body
st, _, err = post(srv, f"{base}/feedback", rating="good", comment="changed my mind")
assert st == 400 and "closed and locked" in err
assert run_of(srv, base).data["feedback"][0]["comment"] == "a bit long" # nothing reached the record
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
def test_a_closed_run_without_feedback_shows_no_feedback_section(srv, pack):
base, _ = start(srv, pack)
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
body = req(srv, "GET", base)[2]
assert "<h2>Feedback</h2>" not in body and "<h2>How was this runbook?</h2>" not in body and "Change my feedback" not in body
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
def test_the_closing_form_takes_optional_feedback_so_abandoned_runs_are_not_silent(srv, pack):
base, _ = start(srv, pack)
body = req(srv, "GET", base)[2]
assert "How was this runbook? <span class=note>(optional; it cannot be added after closing)</span>" in body and "name=fb_comment" in body
assert post(srv, f"{base}/finalize", outcome="abandoned", rating="poor", fb_comment="step 1 was unclear")[0] == 303
body = req(srv, "GET", base)[2]
assert "<h2>Feedback</h2>" in body and "step 1 was unclear" in body
assert run_of(srv, base).data["feedback"][0]["rating"] == "poor"
def test_the_closing_form_does_not_ask_again_when_feedback_was_already_given(srv):
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
post(srv, f"{base}/feedback", rating="good", comment="ok")
assert "name=fb_comment" not in req(srv, "GET", base)[2]
def test_a_bad_rating_on_closing_refuses_the_whole_close(srv):
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
st, _, err = post(srv, f"{base}/finalize", outcome="abandoned", rating="great")
assert st == 400 and "rating must be" in err
assert not run_of(srv, base).data.get("outcome") # still open
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
def test_a_closed_run_says_it_is_locked_and_refuses_every_change(srv, pack):
base, _ = start(srv, pack)
post(srv, f"{base}/finalize", outcome="abandoned")
body = req(srv, "GET", base)[2]
assert "closed and locked" in body and "its record is sealed and cannot be changed" in body
first = "read-the-sign"
for path, form in ((f"{base}/{first}/start", {}), (f"{base}/mode", {"to": "auto-confirm"}), (f"{base}/data", {"d_weather": "clear"}),
(f"{base}/{first}/capture", {"output": "x"}), (f"{base}/finalize", {"outcome": "succeeded"})):
st, _, err = post(srv, path, **form)
assert st in (400,) and ("closed" in err or "finished" in err or "already" in err), path
assert run_of(srv, base).data["outcome"] == "abandoned"
def test_the_record_file_is_read_only_after_closing_and_tampering_is_called_out(srv):
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
post(srv, f"{base}/finalize", outcome="abandoned")
r = run_of(srv, base)
assert r.path.stat().st_mode & 0o222 == 0 and R1.seal_state(r) == "sealed"
assert "changed outside the app" not in req(srv, "GET", base)[2]
r.path.chmod(0o644)
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
body = req(srv, "GET", base)[2]
assert "changed outside the app after it was sealed" in body and "no longer counts towards maturity" in body

View file

@ -59,7 +59,7 @@ def make_project(s, pid="harbour", host=None):
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 "<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

View file

@ -91,7 +91,7 @@ def test_sections_are_separate_pages(srv):
_, _, body = req(srv, "GET", "/activity")
assert "<h1>Latest activities" in body and "(times in UTC)" in body and "started a run" in body
_, _, body = req(srv, "GET", "/")
assert "<h1>Projects</h1>" in body and "Latest activities" in body and "<h2>Runbooks</h2>" in body # Home shows all three
assert "<h1 class=hl><a href='/projects'>Projects</a></h1>" in body and "Latest activities" in body and "<h2 class=hl><a href='/runbooks'>Runbooks</a></h2>" in body # Home shows all three, as shortcuts
# ---- attribute sets ----------------------------------------------------------------------------------------------------

View file

@ -314,9 +314,9 @@ def test_finishing_a_completed_run_feedback_and_the_read_only_result(s2):
do_step(s2, base, "close-log", phrase=PHRASE)
pass_gate(s2, base, "night-on-record")
body = page(s2, base)
assert "Everything is done." in body and "Finish: succeeded" in body and "How was this runbook?" in body
assert "Everything is done." in body and "Finish: succeeded" in body and "<h2>How was this runbook?</h2>" in body
assert post(s2, f"{base}/feedback", rating="good", comment="clear")[0] == 303
assert "Back to the runbook list" in page(s2, base)
assert "Back to the runbook list" not in page(s2, base) and "Change my feedback" in page(s2, base) # still open: changeable
assert post(s2, f"{base}/finalize", outcome="succeeded", note="smooth")[0] == 303
body = page(s2, base)
assert "Run finished: succeeded" in body and "Start this step" not in body