From f41464cc588656778c709c4a275e462e6dba7c09 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 12 Sep 2026 12:41:56 -0400 Subject: [PATCH] Pin forgectrl 0.1.22 (the releases-API check and the release dialog) and cover its routes forgectrl 0.1.22 = 0235a88: the release check reads the GitHub releases API and never requests the firmware file's URL, the daemon checks daily, and the panel raises a per-release dismissable alert and runs the install from one dialog. Acceptance: update.release-check (auto) exercises GET /update/release, POST /update/check (a machine with no route to the API answers 502, which the drill records and steps over), the v shape and `new` of a published release, and the dismissal round trip, and puts the dismissal back. forgectrl.auth's unauthenticated-write list gains /update/check and /update/dismiss. Coverage lint: 84 tests, 0 uncovered paths; the forgetest unit tests pass (373). On the bench reference (dev image 20260911203113 with forgectrl 0.1.22 hot-deployed) every check of both tests passed; the runs were marked FAIL only by the hand-back baseline, because the controller is gated until the changed privacy advisory is accepted again. --- forgetest/forgetest/suite/forgectrl.py | 1 + forgetest/forgetest/suite/update.py | 74 +++++++++++++++++++ .../forgectrl/forgectrl-pin.inc | 4 +- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index 1c1b9bb..b065651 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -56,6 +56,7 @@ def auth(ctx): ("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}), ("/diag/flow-verify", None), ("/diag/abort", None), ("/update/apply", None), ("/boot", {"target": "a"}), + ("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}), ("/system/reboot", None), ("/restore/factory", None), ("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}), ("/wiz/advisories/accept", None), ("/wiz/account", None), diff --git a/forgetest/forgetest/suite/update.py b/forgetest/forgetest/suite/update.py index 05cb8c6..e67e3fd 100644 --- a/forgetest/forgetest/suite/update.py +++ b/forgetest/forgetest/suite/update.py @@ -1,5 +1,6 @@ """update.* - the A/B slot inventory and the firmware verification path.""" import os +import re import tempfile import time import shutil @@ -118,3 +119,76 @@ def slots_and_signature(ctx): except OSError: pass shutil.rmtree(work, ignore_errors=True) + + +_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+") + + +@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release", + subsystem="update", kind="auto", est_min=1, + covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")], + requires=["forgectrl.auth"], + description="GET /update/release answers the kept answer of the daily check (available, version, " + "current, new, checked, dismissed). POST /update/check asks the releases API now and " + "answers the same shape; a machine with no route to the API answers 502, which the drill " + "records and steps over. A published release is a v tag with the firmware " + "file's size and its notes, and `new` follows the version order: a development build " + "sees every release as newer. POST /update/dismiss marks that version dismissed, an " + "empty version undoes it, and a version that is not a tag is refused. The dismissal " + "the machine had before the drill is put back.") +def release_check(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes") + + st, before = fc.get("/update/release") + ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before) + ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before, + "GET /update/release -> %s", st) + ev["before"] = {k: before.get(k) for k in keys} + prior = before.get("version", "") if before.get("dismissed") else "" + + st, now = fc.post("/update/check") + ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now) + if st == 502: + ctx.log("the machine has no route to the releases API; the kept answer stands") + now = before + else: + ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st) + ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0), + "the check did not move `checked`: %s", now.get("checked")) + ev["after_check"] = {k: now.get(k) for k in keys} + + if now.get("available"): + ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None, + "the release version is not a v tag: %r", now.get("version")) + ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0, + "the firmware file's size is missing: %r", now.get("bytes")) + ctx.check(isinstance(now.get("notes"), str), "the notes are not a string") + ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool") + if not _VERSION_RX.match(str(now.get("current", ""))): + ctx.check(now.get("new") is True, + "a development build (%r) must see release %s as newer", now.get("current"), now["version"]) + else: + ctx.log("no release available: %s", now.get("detail")) + ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason") + + try: + st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"}) + ctx.log("POST /update/dismiss version= -> %s %s", st, body) + ctx.check(st == 400, "a version that is not a tag was not refused: %s", st) + + tag = now["version"] if now.get("available") else "v0.0.0" + st, body = fc.post("/update/dismiss", params={"version": tag}) + ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st, + {k: body.get(k) for k in keys} if isinstance(body, dict) else body) + ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st) + if now.get("available"): + ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed")) + st, body = fc.post("/update/dismiss", params={"version": ""}) + ctx.log("POST /update/dismiss version= -> %s", st) + ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False, + "the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body) + finally: + st, body = fc.post("/update/dismiss", params={"version": prior}) + ctx.log("dismissal put back to %r -> %s", prior, st) diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index f5b0e5c..ac3794a 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc @@ -2,5 +2,5 @@ # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # layer content hash because the component entry already identifies the # pinned source (forgefirm-image-manifest.bbclass). -SRCREV = "92cead6d501cd660c62ed93844b11d7f0f13d956" -PV = "0.1.21" +SRCREV = "0235a88ed452107387c62dd57a39d57ffb69a8e4" +PV = "0.1.22"