3c49dc8ea4
gates / gates (push) Successful in 12s
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.
110 lines
4.6 KiB
Python
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)
|