From 7172bba92b8ffb4cb26fe82f8fdd67f3af95febf Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Wed, 14 Oct 2026 07:56:36 -0400 Subject: [PATCH] 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. --- forgetest/forgetest/suite/cooling.py | 57 ---------------- forgetest/forgetest/suite/forgectrl.py | 92 ------------------------- forgetest/forgetest/suite/setup.py | 95 -------------------------- forgetest/tests/test_setup_suite.py | 14 +--- 4 files changed, 3 insertions(+), 255 deletions(-) diff --git a/forgetest/forgetest/suite/cooling.py b/forgetest/forgetest/suite/cooling.py index acff552..4b948bf 100644 --- a/forgetest/forgetest/suite/cooling.py +++ b/forgetest/forgetest/suite/cooling.py @@ -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") diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index ff89879..54f8aa2 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -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 {}) diff --git a/forgetest/forgetest/suite/setup.py b/forgetest/forgetest/suite/setup.py index ebe66a9..b52b78e 100644 --- a/forgetest/forgetest/suite/setup.py +++ b/forgetest/forgetest/suite/setup.py @@ -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.*"), diff --git a/forgetest/tests/test_setup_suite.py b/forgetest/tests/test_setup_suite.py index 1bbed23..171f7b7 100644 --- a/forgetest/tests/test_setup_suite.py +++ b/forgetest/tests/test_setup_suite.py @@ -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"})