Catalog: forgectrl.panel-serves, setup.ssh-until-reboot, setup.first-run-flow and logs.routing leave

Each leaves for a host proof that forgectrl's build.yml runs at the
pinned 22a2cfc, and that run passed (38728407038):

- forgectrl.panel-serves: tests/host_routes.sh serves the panel page
  bundled, with the theme and the save bar and the policy frame-src
  'none', and answers /status, /cam/status and the dose-curve reads.
  Rows: src/ui.h.
- setup.ssh-until-reboot: tests/host_routes.sh, GET and POST
  /system/ssh with the flag in the run directory and sshd's init a
  stand-in told start and stop; enable=2 refused. Rows: src/paths.h.
- setup.first-run-flow: tests/host_setup.sh, the first run in the
  page's order: the four documents accepted at their hashes, the press
  step breathing teal on a scratch LED tree, the press taken as the
  acceptance, the account and its cookie, the gate's reason after each
  form step, the run completed. Rows: src/advisories.*, src/button.*,
  src/led.*, src/hooks.h and the four first-run documents, one row
  each (docs/advisories/extensions.md stays with exthost.service);
  src/sheetid.* to tests/sheetid_test.c, the golden ids, because
  host_setup.sh does not read the sheet id.
- logs.routing: tests/fflog_e2e.sh, the image's rsyslog.conf and the
  rendered rules, each logger in its own file at its level, a stray
  tag in system/, the kern facility in kernel/. Rows: src/fflog.*.

logs.py held logs.routing alone and goes with its import. The five
camera tests no longer require forgectrl.panel-serves.
test_setup_suite.py keeps setup.account-login and setup.machine-name in
its ids; the cases that described setup.first-run-flow (takeover,
fixture, covers) go, with the cases of the helpers that left with the
two tests (breathes_teal, first_run_record, sshd_policy).
test_manifest's behavioral-path case runs the report with no
host-tested rows: the real table now names the first-run documents,
and the case is about the allowlist.

exthost.motion-job stays. It is the only catalog test that grants
motion.job and posts /v0/motion/job, the path that reads
machine_host_token() on the default token file, and it stays the bench
proof of that path until an image carries forgeext's token-file change.
src/jobpost.* stays in its covers, with no row.

Fingerprints: every test in forgectrl.py, setup.py and camera.py moves
with the module text. The next image cannot inherit a campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (49 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes.
This commit is contained in:
ScottW514
2026-10-15 08:48:40 -04:00
parent 87155be433
commit 01f59914e8
8 changed files with 39 additions and 809 deletions
+26
View File
@@ -173,6 +173,32 @@ HOST_TESTED = [
# routes and a package's jog through the host's credential are # routes and a package's jog through the host's credential are
# tests/host_routes.sh). # tests/host_routes.sh).
("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"), ("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"),
# The panel page as the daemon serves it: bundled, the theme and the
# save bar in it, and its policy frame-src 'none'.
("forgectrl", "src/ui.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
# The data and run directories from the environment: the SSH flag in the
# run directory, written and removed by POST /system/ssh.
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
# The first run in the page's order: the four documents at their hashes,
# the press step breathing teal, the press taken as the acceptance, the
# form steps through main.c's hooks (extensions.md stays with
# exthost.service, which covers it).
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
# The setup sheet id from the fuse serial, against golden ids.
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
# The logging path: the image's rsyslog.conf and the rendered rules, each
# logger in its own file at its level, a stray tag in system/, the kern
# facility in kernel/.
("forgectrl", "src/fflog.*", "forgectrl:tests/fflog_e2e.sh", "forgectrl:.github/workflows/build.yml"),
] ]
# This repository's root, when the package runs from a checkout of it; a # This repository's root, when the package runs from a checkout of it; a
-1
View File
@@ -12,7 +12,6 @@ from . import forgectrl # noqa: F401,E402
from . import setup # noqa: F401,E402 from . import setup # noqa: F401,E402
from . import setup_dark # noqa: F401,E402 from . import setup_dark # noqa: F401,E402
from . import setup_burns # noqa: F401,E402 from . import setup_burns # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402 from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402 from . import cooling # noqa: F401,E402
from . import laser # noqa: F401,E402 from . import laser # noqa: F401,E402
+5 -5
View File
@@ -55,7 +55,7 @@ def _jpeg_size(data):
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera", @test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="auto", est_min=2, kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
steps=["Setup: lid closed."], steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses " description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), a second snapshot with the lid lamp switched off differs from it " "far smaller), a second snapshot with the lid lamp switched off differs from it "
@@ -169,7 +169,7 @@ def snapshot(ctx):
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera", @test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an " description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it " "8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
"actually returns are that size. A machine that reports 'unknown', or whose frames " "actually returns are that size. A machine that reports 'unknown', or whose frames "
@@ -212,7 +212,7 @@ def sensor_profile(ctx):
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera", @test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
kind="auto", est_min=2, kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
steps=["Setup: lid closed."], steps=["Setup: lid closed."],
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an " description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few " "init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
@@ -274,7 +274,7 @@ def h264_stream(ctx):
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera", @test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
description="The capture queue flags a frame errored when it is short, torn, or arrived " description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than " "after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, restarts the stream if they persist, and withholds the " "demosaicing them, restarts the stream if they persist, and withholds the "
@@ -362,7 +362,7 @@ def frame_health(ctx):
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera", @test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
kind="operator", est_min=3, kind="operator", est_min=3,
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"], covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
actions=["lid"], actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.", steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."], "Nothing moves and the laser is not involved."],
-140
View File
@@ -5,7 +5,6 @@
"""forgectrl.* - the machine-services daemon's API, access control, and panel.""" """forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json import json
import os
import socket import socket
import time import time
@@ -312,142 +311,3 @@ def settings_bounds(ctx):
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied) ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back) ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
ctx.log("lid lamp follows the setting live and returns to %s", want) ctx.log("lid lamp follows the setting live and returns to %s", want)
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")],
description="The panel page is served, with a policy that lets no frame navigate anywhere "
"(frame-src 'none': a package's page renders in a frame of it, and a frame that "
"could go to another address could carry what it was shown in the URL; what the "
"header does in each browser is the frame-isolation harness's to prove, and this "
"holds the machine's own page to carrying it), /status carries the machine "
"telemetry the panel and "
"the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent; and homed_axes, "
"the axes that carry a reference, Z alone once the lens has taken its "
"own at the controller's start), and /cam/status "
"answers. In GRBL mode with a live controller, /status also echoes the "
"controller's published state file as the grbl block (fresh age, machine "
"state, sender session, laser window and dose model, modal report), "
"GET /grbl/settings serves the published $$ view, and the controller's "
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
"it at the top of /data.")
def panel_serves(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/", raw=True)
ev["panel_status"] = st
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
ctx.check(st == 200, "GET / -> %s", st)
text = body.decode("utf-8", "replace")
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
ctx.check("<link " not in text and "<script src=" not in text,
"the panel references an external asset (the build did not bundle src/ui/)")
# The daemon stores the page gzipped and inflates it once at first
# request; what it serves is the plain page with the theme attribute
# the head script sets and the one save bar every settings tab shares.
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
from .setup import request
st_h, _page, hdrs = request(fc.base, "GET", "/", headers={"Host": fc.host_header()})
ev["page_policy"] = hdrs.get("content-security-policy")
ctx.log("GET / Content-Security-Policy: %s", ev["page_policy"])
ctx.check(st_h == 200 and "frame-src 'none'" in (ev["page_policy"] or ""),
"the panel page lets a frame navigate: %s %r", st_h, ev["page_policy"])
s = fc.status()
for key in ("state", "switches", "coolant", "fans"):
ctx.check(key in s, "/status lacks %r", key)
ev["state"] = s.get("state")
ev["switches"] = s.get("switches")
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
# SoC utilization rides /status next to the temperatures. The CPU
# number is a delta over the interval since the previous read, so
# the read above primes it; after a beat both percents must be
# numbers in range.
ctx.sleep(1)
sys_ = ctx.forgectrl.status().get("sys") or {}
ev["sys"] = sys_
ctx.log("/status sys=%s", sys_)
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
"/status sys.cpu_pct is not a percent: %s", sys_)
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
"/status sys.mem_pct is not a percent: %s", sys_)
# The lens takes its own reference on the hall edge at every
# controller start, so with a controller running Z is referenced on
# its own while X and Y wait for a home: homed_axes names the axes
# that carry one, and Z reads inside the lens reach rather than the
# zero an unreferenced axis would show.
axes = s.get("homed_axes")
ev["homed_axes"] = axes
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
"/status homed_axes is not an axis mask: %s", axes)
ctx.check(bool(s.get("homed")) == (axes == 7),
"/status homed (%s) disagrees with homed_axes (%s)",
s.get("homed"), axes)
st, mode0 = fc.get("/mode")
if isinstance(mode0, dict) and mode0.get("controller") == "running":
lens = s.get("lens") or {}
pos = s.get("pos") or {}
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
ctx.check(axes is not None and axes & 4,
"Z is not referenced with a controller running: %s", axes)
ctx.check(isinstance(pos.get("z"), (int, float))
and lens.get("reach_min") is not None
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
"Z %s is outside the lens reach %s..%s", pos.get("z"),
lens.get("reach_min"), lens.get("reach_max"))
st, cam = fc.get("/cam/status")
ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam)
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
# The controller's published state, echoed only while a live GRBL
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
st, mode = fc.get("/mode")
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
g = ctx.forgectrl.status().get("grbl") or {}
ev["grbl"] = g
ctx.log("/status grbl=%s", g)
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
"/status grbl block missing or stale: %s", g)
rep = g.get("report") or {}
for key in ("state", "sender", "laser", "modals"):
ctx.check(key in rep, "/status grbl.report lacks %r", key)
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
"grbl.report.laser carries no model: %s", rep.get("laser"))
ctx.check((rep.get("laser") or {}).get("curve"),
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
st, text = fc.get("/grbl/settings", raw=True)
ev["grbl_settings_status"] = st
ctx.check(st == 200 and b"$35=" in (text or b""),
"GET /grbl/settings -> %s without the $$ view", st)
# The $-settings persist in the data directory, never loose in /data.
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
ev["grbl_nvs"] = os.path.isfile(nvs)
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
"a settings store remains at the top of /data")
else:
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
# The dose-curve recorder's read surface answers in any mode.
st, cs = fc.get("/curve/status")
ev["curve_status"] = cs
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
("idle", "waiting", "recording", "done", "failed"),
"GET /curve/status -> %s %s", st, cs)
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
-187
View File
@@ -1,187 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""logs.* - unified logging: the routing path (emitters, relays, rendered
rules); the level settings."""
import os
import re
import time
from ..catalog import test
from .. import hw
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
LOGS_ROOT = "/data/log/forgefirm"
# The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
# logger's own file in the ff_line format. It stands for the emitters in
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
# Python apps), the supervisor's per-controller relay, the daemon's fifo
# relay in its init script, and the render step.
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
("forgectrl", "init/**"),
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "forgeext", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
def _tail_lines(path, n=400):
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 256 * 1024))
data = f.read()
except OSError:
return []
return data.decode("utf-8", "replace").splitlines()[-n:]
def _wait_line(path, needle, timeout=6.0):
"""Poll a log file for a line containing needle; the matched line or None."""
deadline = time.time() + timeout
while time.time() < deadline:
for line in _tail_lines(path, 200):
if needle in line:
return line
time.sleep(0.25)
return None
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
kind="auto", est_min=1,
covers=_ROUTING_COVERS,
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
"the settings and the effective-levels record exist and agree with /logs, every "
"logger has its directory, the daemon's own emitter and the per-controller "
"`logger` relay both land in the right file in the ff_line format, a stray "
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
"and nothing of ours is written outside the tree.")
def routing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
# 1. one logger daemon
rs = hw.pidof("rsyslogd")
ev["rsyslogd_pids"] = rs
ctx.check(rs, "rsyslogd is not running")
for legacy in ("syslogd", "klogd"):
pids = hw.pidof(legacy)
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
# 2. the render step: rules + effective levels, consistent with what
# the API reports as effective
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
for name in LOGGERS:
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
st, body = fc.get("/logs")
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
eff = {}
for l in body.get("loggers") or []:
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
ev["effective"] = eff
ctx.log("effective levels: %s", eff)
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
levels = _read("/var/run/forgefirm-loglevels") or ""
for name, (disk, _remote) in eff.items():
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
"effective record and /logs disagree on %s (%s)", name, disk)
# 3. the tree
for name in LOGGERS:
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
# 4. the daemon's own emitter (fflog): a line from the running pid, if
# its level admits info
pids = hw.pidof("forgectrl")
ctx.check(pids, "forgectrl is not running")
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
if disk_rank >= 6:
own = [l for l in _tail_lines(fpath, 2000)
if any((" forgectrl[%d] " % p) in l for p in pids)]
ev["own_lines"] = len(own)
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
m = _LINE_RE.match(own[-1])
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
else:
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
# 5. the relay path: a `logger` probe under a controller's name lands
# in that controller's file with the ff_line format
nonce = "forgetest-routing-%d" % int(time.time())
for name in ("grblhal", "gfhome"):
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
if rank < 3:
ctx.log("%s disk level off: relay probe skipped", name)
continue
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
path = os.path.join(LOGS_ROOT, name, name + ".log")
line = _wait_line(path, nonce)
ev["probe_" + name] = line
ctx.check(line, "%s probe did not reach %s", name, path)
m = _LINE_RE.match(line)
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
"%s probe line not in the ff_line format: %r", name, line)
ctx.log("%s: %s", name, line)
# a probe under another name must NOT leak into these files
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl", "forgeext"):
path = os.path.join(LOGS_ROOT, name, name + ".log")
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
"a stray program's line landed in %s", path)
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
if sys_rank >= 3:
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
ctx.check(line, "the stray program's line did not reach system/")
# 6. relay processes: the supervisor spawns one `logger` per controller
# (its stdin is the controller's output pipe); the daemon's init
# script keeps one on the fifo
st, mode = fc.get("/mode")
relays = hw.pidof("logger")
ev["logger_relays"] = relays
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
if isinstance(mode, dict) and mode.get("controller") == "running":
ctx.check(relays, "controller running but no `logger` relay process")
if os.path.exists("/var/run/forgectrl.stderr"):
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
"fifo relay present but only %s logger process(es)", relays)
# 7. the kernel reaches kernel/ (unless filtered off)
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
ev["kernel_lines"] = len(klines)
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
# 8. nothing outside the tree
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
-379
View File
@@ -537,121 +537,6 @@ def login_checks(ctx, name, pw):
ctx.log("session set by the login, seen by GET /wiz, ended by the logout") ctx.log("session set by the login, seen by GET /wiz, ended by the logout")
# ------------------------------------------------------------------ ssh
def sshd_policy():
"""The ForgeFIRM keys of the effective sshd policy (sshd -T,
lowercase keys), or {"error": ...} when sshd cannot report it."""
for exe in ("/usr/sbin/sshd", "sshd"):
rc, out = hw.run([exe, "-T"], timeout=15)
if rc == 127 and "No such file" in out:
continue
if rc != 0:
return {"error": out.strip()[:200]}
policy = {}
for line in out.splitlines():
parts = line.split(None, 1)
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
"passwordauthentication", "banner"):
policy[parts[0]] = parts[1].strip()
return policy
return {"error": "no sshd binary"}
def restore_ssh(fc, before):
"""Put the SSH state back as found: the flag file, then sshd."""
flag = ssh_flag_path()
if before.get("running") and not tcp_open("127.0.0.1", 22):
fc.post("/system/ssh", params={"enable": "1"})
elif not before.get("running") and tcp_open("127.0.0.1", 22):
fc.post("/system/ssh", params={"enable": "0"})
write_file(flag, b"" if before.get("enabled") else None)
@test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
requires=["forgectrl.auth"],
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
"flag file and port 22. POST enable=1 writes /run/forgefirm/ssh-enabled and "
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
"dev image turns root over SSH back on for the bench; the release gate checks "
"the release image's sshd_config as built), and with no pre-authentication "
"banner, so the machine says nothing to a client that has not logged in. "
"An enable outside 0 and 1 is refused (400). On a release "
"image POST enable=0 removes the flag and stops sshd; on the dev image the boot "
"rule keeps sshd up and the stop is never gated, so the test removes the flag "
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
"state is restored.")
def ssh_until_reboot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, before = fc.get("/system/ssh")
ctx.log("GET /system/ssh -> %s %s", st, before)
ctx.check(st == 200 and isinstance(before, dict), "GET /system/ssh -> %s", st)
for k in ("enabled", "running", "dev_image"):
ctx.check(isinstance(before.get(k), bool), "/system/ssh lacks a boolean %s", k)
ev["before"] = before
flag = ssh_flag_path()
ctx.check(os.path.exists(flag) == before["enabled"], "the flag file %s disagrees with enabled=%s",
flag, before["enabled"])
ctx.check(tcp_open("127.0.0.1", 22) == before["running"], "port 22 disagrees with running=%s",
before["running"])
try:
st, on = fc.post("/system/ssh", params={"enable": "1"})
ev["enable"] = on
ctx.log("POST /system/ssh enable=1 -> %s %s", st, on)
ctx.check(st == 200 and isinstance(on, dict) and on.get("enabled") is True,
"enable=1 -> %s %s", st, on)
ctx.check(os.path.exists(flag), "enable=1 did not write %s", flag)
took = ctx.wait_for(lambda: tcp_open("127.0.0.1", 22), 15)
ctx.check(took is not None, "sshd does not listen on 22 within 15 s of enable=1")
ctx.log("sshd listens on 22 (%.1f s)", took)
policy = sshd_policy()
ev["sshd_policy"] = policy
ctx.log("sshd -T: %s", policy)
ctx.check(policy.get("passwordauthentication") == "yes",
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
# No pre-authentication banner: the machine says nothing to a
# client that has not logged in. The mark and the version go in
# the motd, which a login prints (image.health).
ctx.check(policy.get("banner") in (None, "none"),
"sshd sends a pre-authentication banner (%r)", policy.get("banner"))
if not before["dev_image"]:
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
policy.get("permitrootlogin"), policy.get("permitemptypasswords"))
st, body = fc.post("/system/ssh", params={"enable": "2"})
ctx.check(st == 400, "enable=2 -> %s, expected 400", st)
if before["dev_image"]:
os.remove(flag)
st, off = fc.get("/system/ssh")
ctx.log("dev image: flag removed by hand; GET /system/ssh -> %s %s", st, off)
ctx.check(isinstance(off, dict) and off.get("enabled") is False and off.get("running") is True,
"with the flag gone the dev image reads %s", off)
else:
st, off = fc.post("/system/ssh", params={"enable": "0"})
ctx.log("POST /system/ssh enable=0 -> %s %s", st, off)
ctx.check(st == 200 and isinstance(off, dict) and off.get("enabled") is False,
"enable=0 -> %s %s", st, off)
ctx.check(not os.path.exists(flag), "enable=0 left %s", flag)
took = ctx.wait_for(lambda: not tcp_open("127.0.0.1", 22), 15)
ctx.check(took is not None, "sshd still listens on 22 within 15 s of enable=0")
ev["after"] = off
finally:
restore_ssh(fc, before)
st, final = fc.get("/system/ssh")
ev["final"] = final
ctx.check(isinstance(final, dict) and final.get("enabled") == before["enabled"]
and final.get("running") == before["running"],
"the SSH state is not as found: %s (was %s)", final, before)
# --------------------------------------------------- the machine's name # --------------------------------------------------- the machine's name
def mac_suffix(): def mac_suffix():
@@ -735,267 +620,3 @@ def machine_name(ctx):
ev["avahi_pids"] = hw.pidof("avahi-daemon") ev["avahi_pids"] = hw.pidof("avahi-daemon")
ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"]) ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"])
ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image") ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image")
# --------------------------------------------------------- the first run
def led_pattern():
"""The pattern the button LEDs hold, from the ledtrig_smooth
attributes: {"target": [r, g, b], "pulse_on": [...], "pulse_off": [...]}
(None where unreadable). The hardware breathes on its own once
pulse_on is set, so the pattern is the evidence, not a sample."""
out = {"target": [], "pulse_on": [], "pulse_off": []}
for name in hw.BUTTON_LEDS:
for attr in out:
try:
with open(hw.leds_root() + name + "/" + attr) as f:
out[attr].append(int(f.read().strip()))
except (OSError, ValueError):
out[attr].append(None)
return out
def breathes_teal(pattern):
"""True for the acceptance cue: red off, green and blue on, and the
green and blue channels pulsing."""
t = pattern.get("target") or []
on = pattern.get("pulse_on") or []
if len(t) < 3 or len(on) < 3:
return False
return (t[0] or 0) == 0 and (t[1] or 0) > 0 and (t[2] or 0) > 0 and (on[1] or 0) > 0 and (on[2] or 0) > 0
FIRST_RUN_SETTINGS = ["cloud_enabled", "homing_mode", "controller_mode", "gfcloud_home_timeout_s",
"cool_tec_present", "ui_units", "wifi_country"]
def first_run_record(status, base):
"""A record that reads as a first run with the checks and the burns
behind it: no consent, no press, no account, no form wizard, not
complete; every check and burn of the catalog kept from `base` (the
real record) or written at its catalog version. It is the machine
after the button-hold reset of the consent and the account, so the
run walks the form steps and not the hour of checks and burns a
fresh machine is offered."""
base = base or {}
ts = now_iso()
rec = {"schema": 1, "created": base.get("created") or ts, "advisories": {}, "wizards": {},
"flags": dict(base.get("flags") or {})}
have = base.get("wizards") or {}
for w in status.get("wizards") or []:
if w.get("class") == "form":
continue
rec["wizards"][w["id"]] = have.get(w["id"]) or {
"version": int(w.get("version") or 1), "completed": ts, "result": {}, "applied": {}}
return rec
@contextlib.contextmanager
def first_run(ctx):
"""The machine as a first run for the block: the record seeded by
first_run_record and the account record removed, under a forgectrl
restart; the settings the form steps write put back as found; the
real records restored under another restart, the system accounts
replayed from them, and the temporary home directory removed. Yields
a dict the block fills with `temp_account`."""
fc = ctx.forgectrl
ev = ctx.evidence
before = wiz(fc)
ev["before"] = wiz_summary(before)
ev["account_before"] = (before.get("users") or {}).get("name")
try:
base = json.loads((read_file(record_path()) or b"{}").decode("utf-8"))
except ValueError:
base = {}
seed = json.dumps(first_run_record(before, base), indent=1).encode("utf-8")
homes = "/data/forgefirm/home"
try:
homes_before = set(os.listdir(homes))
except OSError:
homes_before = set()
state = {"temp_account": None}
try:
with Restore(ctx, FIRST_RUN_SETTINGS):
with installed(ctx, {record_path(): seed, users_path(): None}):
w = wiz(fc)
ev["fresh"] = wiz_summary(w)
ctx.check(w.get("first_run") is True and not (w.get("users") or {}).get("exists")
and w.get("advisories_complete") is False and w.get("acceptance_done") is False,
"the machine does not read as a first run: %s", wiz_summary(w))
m = mode(fc)
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run",
m.get("controller"))
yield state
finally:
# the real records are back, whether or not the block passed; the
# system accounts follow the record, and a temporary account the
# block created goes with its home
rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
temp = state.get("temp_account")
if temp and temp not in homes_before and temp != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, temp), ignore_errors=True)
ctx.log("removed the temporary home directory of %r", temp)
after = wiz(fc)
ev["after"] = wiz_summary(after)
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
ev["account_before"])
ctx.check(after.get("completed") == before.get("completed") and after.get("gate") == before.get("gate"),
"the machine does not read as before: %s", wiz_summary(after))
def finished_first_run(ctx):
"""The end of a first run: the record complete, the gate open, the
controller running."""
fc = ctx.forgectrl
ev = ctx.evidence
w = wiz(fc)
ev["completed"] = wiz_summary(w)
ctx.check(w.get("completed") is True and w.get("gate") == "open",
"the run did not complete with the gate open: %s", wiz_summary(w))
m = wait_settled(ctx)
ev["mode_after_setup"] = m
ctx.check(isinstance(m, dict) and m.get("controller") == "running",
"the controller did not come back after the setup: %s", m)
@test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
kind="operator", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
("forgectrl", "src/main.c"), ("forgectrl", "docs/advisories/safety-and-risk.md"),
("forgectrl", "docs/advisories/licenses.md"), ("forgectrl", "docs/advisories/privacy.md"),
("forgectrl", "docs/advisories/cloud-service.md")],
actions=["button"],
description="With the record seeded as a first run behind the checks and no account, the "
"test makes the page's own calls in the page's order: POST /wiz/advisories/accept "
"for each document at the hash GET /wiz lists (the phrase for the typed one), "
"advisories_complete; POST /wiz/advisories/press, the button state waiting and "
"the button LEDs breathing teal, the press (the bench actuator's), "
"acceptance_done; POST /wiz/account with a temporary name and password, the "
"account exists and the response sets the session cookie, and the gate holds "
"with the reason 'setup required: preferences, machine, cloud' and no "
"controller (the seed keeps the coolant flow calibration done). Then the "
"required form steps, the gate's reason naming what is left "
"after each: POST /wiz/preferences with the units as found; POST /wiz/machine "
"with the model as found (basic when unknown) and no TEC; POST /wiz/cloud as "
"found (enabled with the phrase and the homing as found, or off). With the "
"intake fans counted (the machine's setting, which the seed leaves alone) the "
"gate opens and the supervisor starts the controller. POST /wiz/complete, which "
"Start using the machine and Finish later both call: the record complete, the "
"gate open, the controller running. The settings "
"the steps write, the real record and the account are restored, the system "
"accounts replayed, the temporary home removed. Nothing moves and the laser is "
"not involved.")
def first_run_flow(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
settings = fc.settings() or {}
with first_run(ctx) as state:
w = wiz(fc)
for d in w.get("documents") or []:
data = {"doc": d.get("id"), "hash": d.get("hash")}
if d.get("consent") == "typed":
data["phrase"] = d.get("phrase") or SAFETY_PHRASE
st, body = fc.post("/wiz/advisories/accept", data=data)
ctx.check(st == 200, "accept %s -> %s %s", d.get("id"), st, body)
w = wiz(fc)
ctx.check(w.get("advisories_complete") is True, "the documents do not read accepted: %s", wiz_summary(w))
ctx.log("the four documents are accepted")
st, body = fc.post("/wiz/advisories/press")
ctx.check(st == 200, "POST /wiz/advisories/press -> %s %s", st, body)
took = ctx.wait_for(lambda: wiz(fc).get("button") == "waiting", 30)
ctx.check(took is not None, "the press step did not start")
took = ctx.wait_for(lambda: breathes_teal(led_pattern()), 10)
pattern = led_pattern()
ev["led_pattern"] = pattern
ctx.check(took is not None, "the button LEDs do not breathe teal at the press step: %s", pattern)
# The daemon records the acceptance when the press status is polled
# with the button read as pressed (button_take_pressed), which the
# page does from its press step; the test polls the same way.
ctx.act("button", "press", until=lambda: press_status(fc).get("accepted") is True, timeout=600,
text="It breathes teal: this press is the acceptance.")
ctx.check(wiz(fc).get("acceptance_done") is True, "the press was not accepted")
ctx.log("the press step: teal (%s), the press accepted", pattern)
name = "bench-%04x" % random.randrange(1 << 16)
pw = "flow-%08x" % random.randrange(1 << 32)
st, body, hdrs = request(fc.base, "POST", "/wiz/account",
data={"name": name, "password": pw},
headers={"X-ForgeFIRM-Token": fc.token})
ev["account"] = {"status": st, "cookie": bool(hdrs.get("set-cookie"))}
ctx.check(st == 200, "POST /wiz/account -> %s %s", st, body[:200])
state["temp_account"] = name
ctx.check(session_from_cookie(hdrs.get("set-cookie")) is not None,
"the account step did not log this client in: %r", hdrs.get("set-cookie"))
w = wiz(fc)
ctx.check((w.get("users") or {}).get("exists") is True and (w.get("users") or {}).get("name") == name,
"the account does not read created: %s", w.get("users"))
ctx.log("account %r created, the session cookie set", name)
# After the account the gate waits for the required steps: no
# controller yet, and the reason names what is left.
def still_gated(left):
w = wiz(fc)
m = mode(fc)
why = "setup required: " + ", ".join(left)
ev["gated " + why] = {"wiz": wiz_summary(w), "mode": m}
ctx.check(w.get("gate") == "closed" and w.get("why") == why,
"GET /wiz does not hold the gate on %s: %s", ", ".join(left), wiz_summary(w))
ctx.check(m.get("controller") == "gated", "the controller is %s with %s left", m.get("controller"),
", ".join(left))
still_gated(REQUIRED_FORM_STEPS)
ctx.log("the gate holds after the account: %s", wiz(fc).get("why"))
st, body = fc.post("/wiz/preferences", data={"ui_units": settings.get("ui_units") or "metric"})
ctx.check(st == 200, "POST /wiz/preferences -> %s %s", st, body if isinstance(body, dict) else "")
still_gated(REQUIRED_FORM_STEPS[1:])
machine = (w.get("machine") or {})
model = machine.get("model") if machine.get("model") in ("basic", "plus", "pro") else "basic"
st, body = fc.post("/wiz/machine", data={"model": model, "tec": "0"})
ctx.check(st == 200, "POST /wiz/machine -> %s %s", st, body if isinstance(body, dict) else "")
# The machine facts the step records see the head: its hardware id
# is the module's head/info "id" line.
head = (wiz(fc).get("machine") or {}).get("head") or {}
ev["head"] = head
ctx.check(head.get("present") is True and bool(head.get("hw_id")),
"the machine facts do not see the head: %s", head)
still_gated(REQUIRED_FORM_STEPS[2:])
if settings.get("cloud_enabled") == "1":
data = {"enabled": "1", "phrase": SAFETY_PHRASE,
"homing_mode": settings.get("homing_mode") or "gfcloud"}
else:
data = {"enabled": "0"}
st, body = fc.post("/wiz/cloud", data=data)
ev["cloud_step"] = {"data": data, "status": st}
ctx.check(st == 200, "POST /wiz/cloud %s -> %s %s", data, st, body if isinstance(body, dict) else "")
w = wiz(fc)
done = w.get("versions") or {}
for wid in FORM_WIZARDS:
ctx.check(done.get(wid) == 1, "the record does not carry %s at version 1: %s", wid, done)
ctx.log("preferences, machine (%s), cloud (%s) written", model, data.get("enabled"))
# The required steps done and the intake fans counted (the
# machine's setting, which the seed leaves alone, so the page shows
# the Intake fans step as done): the gate opens and the controller
# runs, whatever the setup does next.
ev["after_required"] = wiz_summary(w)
ctx.check(w.get("gate") == "open" and not w.get("why") and w.get("fans_counted") is True,
"the gate does not open with the required steps: %s", wiz_summary(w))
took = ctx.wait_for(lambda: mode(fc).get("controller") == "running", 60)
m = wait_settled(ctx)
ev["mode_after_required"] = m
ctx.check(took is not None and isinstance(m, dict) and m.get("controller") == "running",
"the controller did not start with the required steps: %s", m)
ctx.log("the gate is open; the controller runs (%.1f s)", took)
st, body = fc.post("/wiz/complete")
ctx.check(st == 200, "POST /wiz/complete -> %s %s", st, body if isinstance(body, dict) else "")
finished_first_run(ctx)
ctx.log("the run completed: gate open, the controller running")
+4 -3
View File
@@ -123,13 +123,14 @@ class CoverageReportTests(unittest.TestCase):
def test_behavioral_paths_are_never_allowed_away(self): def test_behavioral_paths_are_never_allowed_away(self):
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and # An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint. # **/*.md do not take it out of the report, as they do not take it out of a fingerprint. No
# host-tested rows here: the real table's rows name the first-run documents.
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text") man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text")
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"), rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
("kernel-module-glowforge", "**"), ("linux-fslc", "**")]) ("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
self.assertEqual(m.coverage_report(man, [rest]), {"forgectrl": ["docs/advisories/privacy.md"]}) self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")]) doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
self.assertEqual(m.coverage_report(man, [rest, doc]), {}) self.assertEqual(m.coverage_report(man, [rest, doc], host=()), {})
self.assertNotEqual(m.fingerprint(man, doc.covers), self.assertNotEqual(m.fingerprint(man, doc.covers),
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"), m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
doc.covers)) doc.covers))
+4 -94
View File
@@ -5,8 +5,8 @@
"""The setup.* suite on the host: the registration (ids, kinds, the """The setup.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the takeover tests, the operator tests' hands), the record builders, the
machine's name, the cookie parsing, the LED cue, and the settle rule machine's name, the cookie parsing, and the settle rule for a gated
for a gated supervisor.""" supervisor."""
import json import json
import os import os
import shutil import shutil
@@ -19,9 +19,7 @@ import helpers # noqa: E402
from forgetest import baseline, catalog # noqa: E402 from forgetest import baseline, catalog # noqa: E402
from forgetest.suite import setup # noqa: E402 from forgetest.suite import setup # noqa: E402
IDS = ("setup.account-login", IDS = ("setup.account-login", "setup.machine-name")
"setup.ssh-until-reboot", "setup.machine-name", "setup.first-run-flow")
OPERATOR = ("setup.first-run-flow",)
class RegistrationTests(unittest.TestCase): class RegistrationTests(unittest.TestCase):
@@ -31,14 +29,8 @@ class RegistrationTests(unittest.TestCase):
def test_every_test_is_registered_with_its_kind(self): def test_every_test_is_registered_with_its_kind(self):
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg} kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
self.assertEqual(sorted(kinds), sorted(IDS)) self.assertEqual(sorted(kinds), sorted(IDS))
for tid in OPERATOR:
self.assertEqual(kinds[tid], "operator", tid)
for tid in IDS: for tid in IDS:
if tid not in OPERATOR: self.assertEqual(kinds[tid], "auto", tid)
self.assertEqual(kinds[tid], "auto", tid)
def test_the_record_swapping_tests_are_takeovers(self):
self.assertEqual(self.reg["setup.first-run-flow"].hardware, "takeover")
def test_recommended_reads_the_status_document(self): def test_recommended_reads_the_status_document(self):
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"}, w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
@@ -60,18 +52,6 @@ class RegistrationTests(unittest.TestCase):
from forgetest.suite import setup_dark from forgetest.suite import setup_dark
self.assertIs(reg["setup.check-airflow"].precheck, setup_dark.intakes_counted) self.assertIs(reg["setup.check-airflow"].precheck, setup_dark.intakes_counted)
def test_the_first_run_flow_runs_unattended_with_the_fixture(self):
flow = self.reg["setup.first-run-flow"]
self.assertEqual(list(flow.actions), ["button"])
self.assertFalse(flow.hands)
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
def test_the_first_run_flow_covers_the_backend(self):
flow = set(self.reg["setup.first-run-flow"].covers)
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
self.assertIn(want, flow, want)
def test_the_only_attended_tests_need_a_workstation_or_a_scrap(self): def test_the_only_attended_tests_need_a_workstation_or_a_scrap(self):
# With the bench actuator up, two setup tests keep a person: the # With the bench actuator up, two setup tests keep a person: the
# print from the Glowforge app, and the focus burn on a fresh # print from the Glowforge app, and the focus burn on a fresh
@@ -240,16 +220,6 @@ class SmallHelpersTests(unittest.TestCase):
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0")) self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(setup.session_from_cookie("")) self.assertIsNone(setup.session_from_cookie(""))
def test_the_teal_breathe_cue(self):
self.assertTrue(setup.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
"pulse_off": [0, 1400, 1400]}))
self.assertFalse(setup.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
"pulse_off": [0, 0, 0]})) # solid green
self.assertFalse(setup.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
"pulse_off": [1800] * 3})) # white
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(setup.breathes_teal({}))
def test_decode(self): def test_decode(self):
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1}) self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled") self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
@@ -276,65 +246,5 @@ class GatedSettleTests(unittest.TestCase):
self.assertTrue(any("controller=gated" in ln for ln in lines)) self.assertTrue(any("controller=gated" in ln for ln in lines))
class FirstRunSeedTests(unittest.TestCase):
"""The record the first-run tests install: a first run behind the
checks and the burns."""
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
{"id": "account", "version": 1, "class": "form"},
{"id": "cloud", "version": 1, "class": "form"},
{"id": "switches", "version": 1, "class": "dark"},
{"id": "motion", "version": 2, "class": "dark"},
{"id": "laser.focus", "version": 1, "class": "live"}]}
def test_no_consent_no_account_not_complete_and_every_check_done(self):
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
rec = setup.first_run_record(self.STATUS, base)
self.assertNotIn("completed", rec)
self.assertNotIn("acceptance", rec)
self.assertNotIn("account", rec)
self.assertEqual(rec["advisories"], {})
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
self.assertEqual(rec["flags"], {"flow_thin": True})
self.assertEqual(sorted(rec["wizards"]), ["laser.focus", "motion", "switches"])
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
def test_without_a_base_record(self):
rec = setup.first_run_record(self.STATUS, None)
self.assertEqual(sorted(rec["wizards"]), ["laser.focus", "motion", "switches"])
self.assertEqual(rec["wizards"]["laser.focus"]["version"], 1)
class SshdPolicyTests(unittest.TestCase):
def test_the_three_keys_out_of_sshd_t(self):
from forgetest import hw
calls = []
def fake_run(cmd, timeout=60):
calls.append(cmd)
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
real = hw.run
hw.run = fake_run
try:
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
"permitemptypasswords": "no"})
finally:
hw.run = real
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
def test_a_failing_sshd_reports_the_error(self):
from forgetest import hw
real = hw.run
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
try:
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
finally:
hw.run = real
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()