Catalog: the report channel, the event stream and what-changed leave

Three catalog tests leave for the host proofs that now carry them; the
catalog goes from 106 to 103.

- cooling.report-channel. forgectrl's tests/host_routes.sh (e69d967)
  runs the channel against a live daemon: a run report with the
  controller's secret writes exhaust 65535; no secret, a wrong secret
  and a short secret are 403 and the duty file is unchanged; an accepted
  idle while the ring still reads running writes 32768; the secret dies
  with the controller. cool_report_test.py and super_test case L own the
  secret, and cooling.fans-quiet-after-motion owns the live M8/M9 return
  to the idle duty. The forged Host: localhost was the same secret
  refusal.
- events.stream. events_test.c owns the hello, the replacement, and
  motors.released; host_routes.sh owns the live cap (three peers held,
  a fourth 503, GET /settings 200 beside them, closing one admitting the
  next). forgectrl.lease and exthost.events still cover src/events.*,
  and _EventStream stays for forgectrl.lease.
- setup.what-changed. setup_test holds tray -> laser.focus and fan ->
  airflow under the image's table beside tube -> laser.floor;
  host_routes.sh holds /wiz/changed (403, 400) and that every step the
  menu names is one GET /wiz lists. src/setup.* and src/wiz.* stay
  covered by the other setup tests, and help.js, panel.js and index.html
  by camera.key-read. preserved() and flag_of() go with it; recommended()
  stays.

tests/test_setup_suite.py drops setup.what-changed from the id list, the
takeover list and the covers lookup, and its flag_of case keeps only
the recommended() assertion.

test_setup_suite.py passes (36 tests). The coverage lint on the tree
manifest finds no uncovered path across the 103 tests; its one finding
is setup.check-lens's empty src/lensfind.* entry, which the forgectrl
pin bump clears. pyflakes on the four files reports only the unused t0
in cooling.py that HEAD already has. The full forgetest suite was not
run.

Fingerprints: the cuts change the shared text of suite/cooling.py,
suite/forgectrl.py and suite/setup.py, so 56 of the 103 tests'
implementation hashes move (cooling 12, setup 26, forgectrl 5, and
exthost 13 through its import of suite/forgectrl.py). The next forgectrl
pin bump already owes 54 of them; exthost.platform and
setup.machine-name are the two it adds. releases/ is unchanged.
This commit is contained in:
ScottW514
2026-10-14 07:56:36 -04:00
parent 8e1fe5090d
commit 7172bba92b
4 changed files with 3 additions and 255 deletions
-57
View File
@@ -1500,60 +1500,3 @@ def fail_tier_stop(ctx):
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
@test("cooling.report-channel", title="A forged cooling report inside a run session is refused and changes nothing",
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("forgectrl", "src/super.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/auth.*")],
requires=["forgectrl.auth"],
description="The report channel (POST /cool/state) is what tells the engine a job is running: a "
"forged idle report would stand the fans down under a cut. M8 opens a run session "
"from the suite's Grbl client, and once the engine is in phase run with its run fan "
"profile commanded, a process on this host that is not the controller reports "
"mode=idle, armed=0: with no secret, with a made-up secret, and with a made-up "
"secret and a forged Host header. Each is refused (403). Over the next five seconds "
"the engine stays in phase run and no commanded fan duty drops. M9 "
"ends the session, and the engine leaves phase run on the controller's own report.")
def report_channel(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
forged = {"mode": "idle", "armed": "0"}
with ctx.grbl() as g:
def in_run(c):
return c.get("phase") == "run"
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
g.command("M8")
try:
ok = ctx.wait_for(lambda: in_run(_cool(fc)), VERDICT_WAIT_S, poll=0.5)
ctx.check(ok is not None, "M8 did not open a run session: %s", _cool(fc).get("phase"))
ctx.sleep(2) # the run fan profile is commanded
before = _cool(fc)
duties0 = _duties()
ev["before"] = {"phase": before.get("phase"), "duties": duties0}
refused = {}
for name, headers in (("no secret", {}),
("a made-up secret", {"X-ForgeFIRM-Report": "0123456789abcdef" * 2}),
("a made-up secret and a forged Host",
{"X-ForgeFIRM-Report": "f" * 32, "Host": "localhost"})):
st, body = fc.post("/cool/state", params=forged, headers=headers, auth=False)
refused[name] = [st, str(body)[:100]]
ctx.check(st == 403, "a forged report (%s) -> %s %s", name, st, str(body)[:100])
ev["refused"] = refused
held = []
for _ in range(5):
ctx.sleep(1)
c = _cool(fc)
held.append([c.get("phase"), _duties()])
ev["after"] = held
ctx.log("phase and duties over 5 s after three forged idle reports: %s", held)
ctx.check(all(p == "run" for p, _d in held), "the engine left phase run: %s", [p for p, _d in held])
ctx.check(all(d[k] >= duties0[k] for _p, d in held for k in duties0),
"a commanded fan duty dropped under the forged reports: %s -> %s", duties0, held)
finally:
g.command("M9")
_session_ended(ctx, fc, "report channel")
ctx.check(_cool(fc).get("phase") != "run", "the session did not end on the controller's own report")
ctx.log("PASS: three forged idle reports from this host were refused inside a run session, and the "
"engine held its phase and its fan duties")
-92
View File
@@ -515,98 +515,6 @@ class _EventStream:
pass
@test("events.stream", title="The event stream: edges arrive, and the cap holds",
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
("forgectrl", "src/grblport.*")],
description="GET /events from three loopback source addresses: each gets 200, "
"text/event-stream, and the hello event. A fourth address gets 503 with the "
"reason, and GET /settings still answers while the three are held. A second "
"stream from the first address is served, and the older one gets bye and the "
"end of its response. An edge arrives on an open stream: POST /motion/release "
"and /motion/energize show up as motors.released and motors.energized, with "
"ids that count up (X and Y lose their reference, as after any release). With "
"every stream closed, the places come back within two keep-alive intervals.")
def events_stream(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
streams = []
try:
# A stream an earlier test closed keeps its place until the daemon's
# next write to it (its keep-alive), so the three places may not be
# free yet: that is the daemon as documented, and the last part of
# this test measures it. The three are opened once they can be.
deadline = time.time() + 25
while True:
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
streams.append(_EventStream(fc.base, src))
if all(s.status == 200 for s in streams) or time.time() > deadline:
break
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
[s.status for s in streams])
for s in streams:
s.close()
streams = []
ctx.sleep(3)
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
hello = s.text(1.0)
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
fourth = _EventStream(fc.base, "127.0.0.5")
body = fourth.text(1.0)
fourth.close()
ev["fourth"] = [fourth.status, body.strip()[-120:]]
ctx.log("the fourth stream -> %s %s", fourth.status, ev["fourth"][1])
ctx.check(fourth.status == 503 and "every event stream is taken" in body,
"the fourth stream -> %s %r, expected 503 and the reason", fourth.status, body[:160])
st, _body = fc.get("/settings")
ctx.check(st == 200, "GET /settings -> %s with three event streams held", st)
# One per address, by replacement.
newer = _EventStream(fc.base, "127.0.0.2")
hello = newer.text(1.0)
ctx.check(newer.status == 200 and "event: hello" in hello,
"a second stream from one address -> %s %r", newer.status, hello[:120])
bye = streams[0].text(2.0)
ev["replaced"] = {"bye": "event: bye" in bye, "ended": streams[0].eof}
ctx.check("event: bye" in bye and "replaced" in bye, "the older stream was not told: %r", bye[-160:])
ctx.check(streams[0].eof, "the older stream was not ended")
streams[0].close()
streams[0] = newer
# An edge, on a stream that has been open all along.
st, body = fc.post("/motion/release")
ctx.check(st == 200, "POST /motion/release -> %s %s", st, body)
ctx.sleep(1.0)
st, body = fc.post("/motion/energize")
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, body)
text = streams[1].text(2.0)
ev["edges"] = [l for l in text.splitlines() if l.startswith(("id:", "event:"))]
ctx.log("the stream carried: %s", ev["edges"])
i_rel, i_en = text.find("event: motors.released"), text.find("event: motors.energized")
ctx.check(0 <= i_rel < i_en, "the release and the energize did not arrive in order: %r", text[-300:])
ids = [int(l.split(":")[1]) for l in text.splitlines() if l.startswith("id:")]
ctx.check(ids and ids == sorted(ids) and len(set(ids)) == len(ids), "the ids do not count up: %s", ids)
finally:
for s in streams:
s.close()
if fc.status().get("motors_released"):
fc.post("/motion/energize")
# A closed client is noticed at the daemon's next write to it.
def reopened():
s = _EventStream(fc.base, "127.0.0.5")
try:
return s.status == 200
finally:
s.close()
took = ctx.wait_for(reopened, 25, poll=1.0)
ev["place_back_s"] = took
ctx.check(took is not None, "no stream could be opened 25 s after every stream was closed")
ctx.log("PASS: three streams, the fourth refused in words with the settings route answering, a "
"replacement told and ended, two edges in order, and a place back after %.0f s", took)
def _lease(fc):
return (fc.status().get("lease") or {})
-95
View File
@@ -1711,101 +1711,6 @@ def cert_page(ctx):
# ------------------------------------------------------- the lifecycle
@contextlib.contextmanager
def preserved(ctx, *paths):
"""The files put back as found under a forgectrl restart when the
block ends, on every exit path. For a test whose routes change the
record through the daemon (which keeps it in memory and rewrites
it whole), so the restore must happen under a restart."""
saved = {p: read_file(p) for p in paths}
try:
yield saved
finally:
with ctx.takeover():
for p, data in saved.items():
write_file(p, data)
ctx.log("restored under a restart: %s", ", ".join(os.path.basename(p) for p in saved))
def flag_of(w, wid):
"""The reason a GET /wiz body gives for recommending wid, or None when
it does not recommend it."""
return recommended(w).get(wid)
@test("setup.what-changed", title="A replaced part recommends its steps again",
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
description="GET /wiz carries the what-changed menu: every change with the steps it "
"recommends, all of them in the catalog (the tube the laser floor; the pump and "
"the coolant the coolant flow; a fan the airflow check; the head the machine, "
"the lens travel, the focus, motion, and the cameras; the tray the focus; a "
"service the switches and motion). POST /wiz/changed what=tray recommends the "
"focus burn with the tray as the reason, and what=fan the airflow check with the "
"fan as the reason; neither moves the gate, which only the advisories, the "
"account, the required steps, and the intake fan count close. An unknown change "
"is refused (400). "
"The record is put back as found under a forgectrl restart.")
def what_changed(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
want = {"tube": ["laser.floor"], "pump": ["cooling.flow"], "coolant": ["cooling.flow"],
"fan": ["airflow"], "head": ["machine", "lens.travel", "laser.focus", "motion", "cameras"],
"tray": ["laser.focus"], "service": ["switches", "motion"]}
before = wiz(fc)
ev["before"] = wiz_summary(before)
menu = before.get("changes") or []
catalog_ids = {z["id"] for z in before.get("wizards") or []}
ev["menu"] = {c.get("id"): [x.get("id") for x in c.get("wizards") or []] for c in menu}
ctx.check(ev["menu"] == want, "the menu reads %s, expected %s", ev["menu"], want)
unknown = [x["id"] for c in menu for x in c.get("wizards") or [] if x.get("id") not in catalog_ids]
ctx.check(not unknown, "the menu names wizards outside the catalog: %s", unknown)
ctx.check(all(c.get("title") for c in menu), "a change has no title")
gate_before = before.get("gate")
gated_before = bool(mode(fc).get("gated"))
ev["gated_before"] = gated_before
with preserved(ctx, record_path()):
st, body = fc.post("/wiz/changed", data={"what": "nothing-like-this"})
ctx.log("POST /wiz/changed what=nothing-like-this -> %s", st)
ctx.check(st == 400, "an unknown change -> %s, expected 400", st)
st, body = fc.post("/wiz/changed", data={"what": "tray"})
ctx.log("POST /wiz/changed what=tray -> %s", st)
ctx.check(st == 200 and isinstance(body, dict), "what=tray -> %s", st)
w = body if isinstance(body, dict) else wiz(fc)
reason = flag_of(w, "laser.focus")
ev["tray_reason"] = reason
ctx.check(reason is not None and "tray" in reason,
"the tray does not recommend the focus burn: %s", w.get("recommended"))
ctx.check(w.get("gate") == gate_before and bool(mode(fc).get("gated")) == gated_before,
"a recommendation moved the gate: %s", wiz_summary(w))
st, body = fc.post("/wiz/changed", data={"what": "fan"})
ctx.log("POST /wiz/changed what=fan -> %s", st)
ctx.check(st == 200, "what=fan -> %s", st)
w = wiz(fc)
reason = flag_of(w, "airflow")
ev["fan_reason"] = reason
ctx.check(reason is not None and "fan" in reason,
"a fan does not recommend the airflow check: %s", w.get("recommended"))
m = mode(fc)
ev["gated_after_fan"] = m.get("gated")
ctx.check(w.get("gate") == gate_before and bool(m.get("gated")) == gated_before,
"a recommendation moved the gate: %s, %s", wiz_summary(w), m)
ctx.log("tray -> focus recommended (%s); fan -> airflow recommended (%s); gate %s as found",
ev["tray_reason"], reason, gate_before)
after = wiz(fc)
ev["after"] = wiz_summary(after)
ctx.check(recommended(after) == recommended(before),
"the recommendations do not read as found: %s, was %s", recommended(after), recommended(before))
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
subsystem="setup", kind="auto", est_min=2,
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
+3 -11
View File
@@ -23,7 +23,7 @@ IDS = ("setup.gate-blocks-controllers",
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
"setup.first-run-page", "setup.what-changed", "setup.record-export",
"setup.first-run-page", "setup.record-export",
"setup.mirror")
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
@@ -48,30 +48,22 @@ class RegistrationTests(unittest.TestCase):
def test_the_record_swapping_tests_are_takeovers(self):
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
"setup.first-run-flow", "setup.first-run-page",
"setup.what-changed", "setup.mirror"):
"setup.mirror"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
self.assertEqual(self.reg["setup.record-export"].hardware, "api")
def test_the_lifecycle_tests_cover_their_files(self):
changed = set(self.reg["setup.what-changed"].covers)
export = set(self.reg["setup.record-export"].covers)
mirror = set(self.reg["setup.mirror"].covers)
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
self.assertIn(("forgectrl", "src/logs.*"), export)
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
self.assertIn("setup.check-sensors", self.reg["setup.mirror"].requires)
def test_flag_of_reads_the_status_document(self):
def test_recommended_reads_the_status_document(self):
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
{"id": "laser.focus", "reason": "the tray was replaced"},
{"id": "airflow", "reason": "not run yet"}, "stray"]}
self.assertEqual(setup.flag_of(w, "laser.floor"), "the tube was replaced")
self.assertEqual(setup.flag_of(w, "laser.focus"), "the tray was replaced")
self.assertEqual(setup.flag_of(w, "airflow"), "not run yet")
self.assertIsNone(setup.flag_of(w, "stray")) # only an entry with an id counts
self.assertIsNone(setup.flag_of(w, "cameras"))
self.assertIsNone(setup.flag_of({}, "x"))
self.assertEqual(setup.recommended(w), {"laser.floor": "the tube was replaced",
"laser.focus": "the tray was replaced",
"airflow": "not run yet"})