Files
forgefirm/forgetest/tests/test_setup_suite.py
T
ScottW514 01f59914e8 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.
2026-10-15 08:48:40 -04:00

251 lines
11 KiB
Python

# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the
machine's name, the cookie parsing, and the settle rule for a gated
supervisor."""
import json
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import baseline, catalog # noqa: E402
from forgetest.suite import setup # noqa: E402
IDS = ("setup.account-login", "setup.machine-name")
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
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}
self.assertEqual(sorted(kinds), sorted(IDS))
for tid in IDS:
self.assertEqual(kinds[tid], "auto", tid)
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.recommended(w), {"laser.floor": "the tube was replaced",
"laser.focus": "the tray was replaced",
"airflow": "not run yet"})
def test_the_form_wizards_and_the_required_steps(self):
self.assertEqual(setup.FORM_WIZARDS, ("advisories", "account", "preferences", "machine", "cloud"))
# in the order the gate's reason names them
self.assertEqual(setup.REQUIRED_FORM_STEPS, ("preferences", "machine", "cloud"))
self.assertEqual(setup.REQUIRED_STEPS, ("preferences", "machine", "cloud", "cooling.flow"))
self.assertIn("cooling.flow", setup.REQUIRED_STEPS)
def test_the_intake_tests_start_from_a_count(self):
reg = self.reg
from forgetest.suite import setup_dark
self.assertIs(reg["setup.check-airflow"].precheck, setup_dark.intakes_counted)
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
# print from the Glowforge app, and the focus burn on a fresh
# scrap. Everything else runs from the queue.
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
def test_the_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["setup.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one.
t = self.reg["setup.account-login"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover")
with open(setup.__file__) as f:
src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src)
class RecordTests(unittest.TestCase):
STATUS = {"sheet_id": "ABCDE-FGHIJ",
"documents": [{"id": "safety-and-risk", "hash": "a" * 64, "consent": "typed"},
{"id": "licenses", "hash": "b" * 64, "consent": "check"}],
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
{"id": "machine", "version": 2}]}
def test_complete_record_counts_as_set_up(self):
rec = setup.complete_record(self.STATUS)
self.assertEqual(rec["schema"], 1)
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
self.assertIn("pressed_at", rec["acceptance"])
self.assertEqual(rec["account"]["name"], "bench")
self.assertEqual(rec["wizards"]["machine"]["version"], 2)
self.assertEqual(rec["sheet_id"], "ABCDE-FGHIJ")
self.assertTrue(rec["completed"])
json.dumps(rec)
def test_complete_record_keeps_the_base_account_and_machine(self):
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
"sheet_id": "KKKKK-LLLLL", "created": "then"}
rec = setup.complete_record(self.STATUS, base)
self.assertEqual(rec["account"]["name"], "owner")
self.assertEqual(rec["machine"], {"model": "pro"})
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
self.assertEqual(rec["created"], "then")
def test_without_wizards_keeps_consent_and_account(self):
rec = setup.complete_record(self.STATUS)
rec["flags"] = {"machine": {"reason": "x"}}
out = setup.record_without_wizards(rec)
self.assertEqual(out["wizards"], {})
self.assertEqual(out["flags"], {})
self.assertEqual(out["advisories"], rec["advisories"])
self.assertEqual(out["account"], rec["account"])
self.assertEqual(rec["flags"], {"machine": {"reason": "x"}}) # a copy
def test_with_required_steps_keeps_them_and_nothing_else(self):
rec = setup.complete_record(self.STATUS)
rec["wizards"]["machine"]["result"] = {"model": "basic"}
rec["wizards"]["switches"] = {"version": 1}
rec["flags"] = {"machine": {"reason": "the head was replaced"}}
out = setup.record_with_required_steps(rec)
self.assertEqual(sorted(out["wizards"]), ["cloud", "cooling.flow", "machine", "preferences"])
self.assertEqual(out["wizards"]["machine"]["result"], {"model": "basic"}) # kept from the record
self.assertEqual(out["wizards"]["preferences"]["version"], 1) # written at version 1
self.assertEqual(out["wizards"]["cooling.flow"]["version"], 1)
self.assertEqual(out["flags"], {})
self.assertEqual(out["account"], rec["account"])
self.assertIn("switches", rec["wizards"]) # a copy
def test_without_consent_keeps_the_account_and_the_wizards(self):
rec = setup.complete_record(self.STATUS)
out = setup.record_without_consent(rec)
self.assertEqual(out["advisories"], {})
self.assertNotIn("acceptance", out)
self.assertEqual(out["account"], rec["account"])
self.assertEqual(out["wizards"], rec["wizards"])
self.assertIn("pressed_at", rec["acceptance"]) # a copy
def test_without_account_keeps_the_consent(self):
rec = setup.complete_record(self.STATUS)
out = setup.record_without_account(rec)
self.assertNotIn("account", out)
self.assertEqual(out["advisories"], rec["advisories"])
self.assertEqual(out["acceptance"], rec["acceptance"])
self.assertEqual(rec["account"]["name"], "bench") # a copy
def test_write_file_and_remove(self):
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
try:
p = os.path.join(tmp, "sub", "setup.json")
setup.write_file(p, b"{}\n")
self.assertEqual(setup.read_file(p), b"{}\n")
if os.name == "posix": # a mode means nothing on a Windows host
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
setup.write_file(p, None)
self.assertIsNone(setup.read_file(p))
setup.write_file(p, None) # a second remove is silent
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_paths_follow_the_daemon_environment(self):
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
try:
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
finally:
os.environ.pop("FORGECTRL_DATA_DIR", None)
os.environ.pop("GF_RUN_DIR", None)
class MachineNameTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
def _net(self, **devs):
"""A /sys/class/net tree; setup.mac_suffix reads it through
setup.read_file, which takes an absolute path."""
for dev, mac in devs.items():
d = os.path.join(self.root, dev)
os.makedirs(d)
with open(os.path.join(d, "address"), "w") as f:
f.write(mac + "\n")
real = setup.read_file
def read(path):
head = "/sys/class/net/"
if path.startswith(head):
return real(os.path.join(self.root, path[len(head):]))
return real(path)
setup.read_file = read
self.addCleanup(setattr, setup, "read_file", real)
def test_the_wifi_address_names_the_machine(self):
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(setup.mac_suffix(), "b00a")
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
self._net(eth0="00:11:22:33:44:55")
self.assertEqual(setup.mac_suffix(), "4455")
def test_an_unread_address_is_no_address(self):
self._net(wlan0="00:00:00:00:00:00")
self.assertEqual(setup.mac_suffix(), "")
def test_no_interface_is_no_address(self):
self._net()
self.assertEqual(setup.mac_suffix(), "")
class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self):
sid = "ab" * 32
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
self.assertEqual(setup.session_from_cookie(value), sid)
self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(setup.session_from_cookie(""))
def test_decode(self):
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
class GatedSettleTests(unittest.TestCase):
"""A supervisor that reports the setup gate closed is settled:
it spawns nothing until the gate opens, so a takeover that installs
a gating record must not wait the whole settle timeout for it."""
def setUp(self):
self.fake = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0
def tearDown(self):
self.fake.stop()
def test_gated_returns_at_once(self):
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
"gated": True, "why": "the intake fans are not counted"}
lines = []
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
self.assertEqual(body["controller"], "gated")
self.assertTrue(any("controller=gated" in ln for ln in lines))
if __name__ == "__main__":
unittest.main()