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") } }