Files
felhom-controller/controller/scripts/test_debug_route_gate.py
T
admin 3c49dc8ea4
gates / gates (push) Successful in 12s
v0.228.0 — the off-site check reads the data; the debug page stops lying (R-399 + R-400)
R-399: monitoring.integrity.read_data_subset defaults to 100%. A pack damaged
without changing its size made plain `restic check` report "no errors were found"
on demo-hp 2026-08-30; every read-data form caught it. Cost on that 134 MB store:
35.0s structure vs 39.2s at 100%. "off" (any case) is the off token; empty means
not-configured, therefore the default; a malformed value falls back to the DEFAULT,
never to structure. A completed check over 5 minutes logs a WARN naming the
duration, the depth and R-401 — operator log only, no hub event, no depth change.
The depth is now recorded with the verdict (LastIntegrityDepth; empty = NOT
RECORDED, never "structure").

R-400: 24 debug-page references, 17 dispatched, 7 dead — three of which fetched on
page LOAD, so those panels were permanently blank. backup/crossdrive implemented;
backup/infra, hub/infra-push, dr/infra-status, storage/watchdog-status and both
storage/simulate-* deleted with their panels and JavaScript.
scripts/debug_route_gate.py fails in both directions and is registered after the
seven were resolved. 18 referenced, 18 dispatched, none orphaned.

Corrections: the dead-field warning in report/types.go said the controller runs no
integrity check and the notifiers are called from nowhere — both false since
v0.227.0. controller.yaml.example gains its missing integrity: block.
integrityCheckTimeout's "ships OFF" comment rewritten.
2026-08-31 10:24:29 +02:00

110 lines
4.6 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tests for scripts/debug_route_gate.py (R-400).
Run from controller/: python3 scripts/test_debug_route_gate.py
WHY THE RED-PROOFS ARE THE POINT. A gate that has never been seen failing is a gate that has not been
shown to gate anything. This is the second time that sentence has earned its place in this project, so
both directions are proven by MUTATING a real copy of the tree and watching the gate convict.
"""
import io
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(SCRIPTS)
GATE = os.path.join(SCRIPTS, "debug_route_gate.py")
TEMPLATE = os.path.join("internal", "web", "templates", "debug.html")
DISPATCH = os.path.join("internal", "web", "handler_debug.go")
RUNNER = os.path.join(SCRIPTS, "controller_gates.py")
def run_gate(cwd):
p = subprocess.run([sys.executable, GATE], cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
return p.returncode, p.stdout.decode("utf-8", "replace")
def sandbox():
"""A copy of just the two files the gate reads, in a throwaway tree.
Mutating the real tree and reverting is the version of this that leaves a broken repo behind when
an assertion fails mid-test.
"""
tmp = tempfile.mkdtemp(prefix="debugroutegate-")
for rel in (TEMPLATE, DISPATCH):
dst = os.path.join(tmp, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(os.path.join(CTRL, rel), dst)
return tmp
class DebugRouteGateTest(unittest.TestCase):
# C1 — the gate passes on the shipped tree.
def test_passes_on_the_shipped_tree(self):
rc, out = run_gate(CTRL)
self.assertEqual(rc, 0, out)
self.assertIn("debug route gate OK", out)
# C2 — Scenario G, the mandatory red-proof: a reference with no handler convicts, and is NAMED.
def test_fails_on_an_unwired_reference(self):
tmp = sandbox()
try:
path = os.path.join(tmp, TEMPLATE)
src = io.open(path, encoding="utf-8").read()
io.open(path, "w", encoding="utf-8").write(
src + '\n<!-- red-proof --><a href="/api/debug/storage/simulate-disconnect">x</a>\n')
rc, out = run_gate(tmp)
self.assertEqual(rc, 1, "a dead control did not convict the gate:\n" + out)
self.assertIn("storage/simulate-disconnect", out,
"the gate convicted without NAMING the reference:\n" + out)
finally:
shutil.rmtree(tmp)
# C3 — the mirror image: a handler nothing reaches is the same defect.
def test_fails_on_an_unreached_handler(self):
tmp = sandbox()
try:
path = os.path.join(tmp, DISPATCH)
src = io.open(path, encoding="utf-8").read()
marker = 'case subpath == "dump" && r.Method == http.MethodGet:'
self.assertIn(marker, src, "fixture drifted: the dispatch shape changed")
io.open(path, "w", encoding="utf-8").write(src.replace(
marker,
'case subpath == "ghost/handler" && r.Method == http.MethodGet:\n\t\ts.debugDump(w, r)\n\t' + marker,
1))
rc, out = run_gate(tmp)
self.assertEqual(rc, 1, "an unreached handler did not convict the gate:\n" + out)
self.assertIn("ghost/handler", out,
"the gate convicted without NAMING the handler:\n" + out)
finally:
shutil.rmtree(tmp)
# C4 — registration, read from the runner's own GATES table rather than by matching text.
# A commented-out row still contains the string; an entry in the parsed list does not.
def test_gate_is_registered_in_the_runner(self):
import ast
tree = ast.parse(io.open(RUNNER, encoding="utf-8").read())
labels = []
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == "GATES":
for elt in node.value.elts:
first = elt.elts[0]
labels.append(first.value if hasattr(first, "value") else first.s)
self.assertTrue(labels, "the runner's GATES table could not be parsed")
self.assertIn("debug-routes", labels,
"debug_route_gate.py exists but the runner never lists it — a gate nothing runs "
"is the inert seam this repo has shipped four times. Listed: %r" % (labels,))
if __name__ == "__main__":
unittest.main(verbosity=2)