Files
felhom-controller/controller/internal/web/r400_debug_routes_test.go
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

148 lines
6.5 KiB
Go

package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── R-400 — the debug page stops lying ───────────────────────────────────────────────────────────
//
// Verified 2026-08-31 against the shipped tree: debug.html referenced 24 `/api/debug/...` addresses
// and handleDebugAPI answered 17. Seven controls did nothing. Three of the seven were not buttons —
// `dr/infra-status` and both `storage/watchdog-status` calls fetch on page LOAD, so those panels had
// been permanently empty and nobody had to click anything to be misled. This is the page an operator
// opens when something is already wrong.
//
// The gate in scripts/debug_route_gate.py makes the CLASS impossible. These tests name the individual
// DECISIONS, so that re-adding one of them is deliberate rather than accidental.
// debugTemplateSource reads the shipped template from disk. The served page is rendered from this
// file, so a reference that is gone here is gone from the page — and reading the source is what lets
// a deletion be asserted without a browser (none is available on this host).
func debugTemplateSource(t *testing.T) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("templates", "debug.html"))
if err != nil {
t.Fatalf("read debug.html: %v", err)
}
return string(b)
}
// D2 — every control DELETED in Part 2.1, by name, with its reason.
func TestR400_DeletedControlsAreGoneFromTheTemplate(t *testing.T) {
src := debugTemplateSource(t)
deleted := []struct{ ref, why string }{
{"/api/debug/backup/infra",
"the disk-tier infra backup moved to the host agent (slice 8C); no function in this repo backs it"},
{"/api/debug/hub/infra-push",
"Pusher.PushInfraBackup was removed 2026-06-16 — retired hub-side, and it had pushed plaintext secrets"},
{"/api/debug/dr/infra-status",
"it rendered local infra backups and the hub infra push, both of which are the two retired mechanisms above"},
{"/api/debug/storage/watchdog-status",
"the slice-8C storage watchdog is retired; the drive-gate reconcile replaced it and publishes no such status"},
{"/api/debug/storage/simulate-disconnect",
"no backing capability, and it WRITES storage state — a button that fakes a drive disconnect on a customer's machine is a foot-gun"},
{"/api/debug/storage/simulate-reconnect", "same as simulate-disconnect"},
}
for _, d := range deleted {
if strings.Contains(src, d.ref) {
t.Errorf("%s is still referenced by debug.html — it was deleted because %s", d.ref, d.why)
}
}
// POSITIVE CONTROL. Without it this test passes against a template it failed to read, or one that
// was emptied — the exact shape of an assertion that proves nothing.
for _, kept := range []string{"/api/debug/backup/integrity", "/api/debug/dr/trigger-setup"} {
if !strings.Contains(src, kept) {
t.Fatalf("positive control failed: %s is missing too, so the assertions above prove nothing "+
"about what was deliberately deleted", kept)
}
}
}
// D2b — the panels and the JavaScript went with the controls. A panel left behind renders nothing
// forever, which is how this whole class hides.
func TestR400_DeletedControlsLeftNoPanelOrScript(t *testing.T) {
src := debugTemplateSource(t)
// ASCII-only fragments (R-364): accented Hungarian through a template read is not the hazard here,
// but the rule is uniform and these identifiers are ASCII anyway.
for _, orphan := range []string{
"watchdog-status", // the panel div and its two loaders
"renderWatchdogStatus",
"loadWatchdogStatus",
"simulateDisconnect",
"simulateReconnect",
"loadDRStatus",
"dr-status",
"btn-infra-backup",
"btn-hub-infra",
"section-storage",
} {
if strings.Contains(src, orphan) {
t.Errorf("%q survived the deletion — an orphaned panel or handler renders nothing forever "+
"and reads as a working page", orphan)
}
}
// POSITIVE CONTROL: the neighbours that must stay.
for _, kept := range []string{"btn-dr-trigger", "triggerDR", "section-dr", "btn-crossdrive"} {
if !strings.Contains(src, kept) {
t.Fatalf("positive control failed: %q is gone too — the deletion took a neighbour with it", kept)
}
}
}
// D1 — the one control IMPLEMENTED in Part 2.1 dispatches and answers with its JSON shape.
func TestR400_CrossDriveRouteDispatches(t *testing.T) {
// backupMgr nil is the fixture on purpose: it reaches the handler's own guard, which proves the
// route was DISPATCHED. A 404 here is the defect — that is precisely what the button got before.
s := newDebugServer(t, nil)
req := httptest.NewRequest(http.MethodPost, "/api/debug/backup/crossdrive", nil)
w := httptest.NewRecorder()
s.handleDebugAPI(w, req)
if w.Code == http.StatusNotFound {
t.Fatal("POST /api/debug/backup/crossdrive returned 404 — the button still posts to nothing")
}
var env map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("the route answered with something that is not the debug JSON envelope: %q", w.Body.String())
}
if _, has := env["ok"]; !has {
t.Errorf("no `ok` in the envelope: %v", env)
}
}
// D1b — and it answers with the app list when a manager IS present. The empty sweep and the non-empty
// sweep are different facts, and „elindítva" alone cannot tell them apart.
func TestR400_CrossDriveReportsWhichAppsItStarted(t *testing.T) {
// The selection itself, both answers, through the same function the handler calls.
all := []stacks.Stack{
{Name: "immich", Deployed: true},
{Name: "vaultwarden", Deployed: true},
{Name: "not-deployed", Deployed: false},
}
hdd := func(name string) bool { return name == "immich" }
got := crossDriveTargets(all, hdd)
if len(got) != 1 || got[0] != "immich" {
t.Fatalf("the sweep picked %v — it must take deployed HDD-backed apps only: an app with no "+
"second drive has nowhere to copy to, and an undeployed app has nothing to copy", got)
}
if none := crossDriveTargets(all, func(string) bool { return false }); len(none) != 0 {
t.Errorf("with no HDD app the sweep must be EMPTY, not a silent all-apps run: %v", none)
}
// NON-nil empty slice: it marshals as [] rather than null, so the JSON says "zero apps" instead of
// "no answer".
if none := crossDriveTargets(nil, hdd); none == nil {
t.Error("the empty answer is nil — it would marshal as `null`, which reads as 'unknown' rather " +
"than 'none', the same conflation R-331 cost a whole operator card")
}
}