Files
felhom-controller/controller/internal/web/r400_debug_routes_test.go
T
admin faef8f8a91 v0.308.0 (R-554, held-back-able): recovery-info.txt points at the dashboard's Restore page and the recovery code; the first-boot setup wizard is deleted
Operator ruling 2026-10-12 (S27, 09 §3 decision 222). The operator approves the Hungarian text before
release. A box whose first ingestion failed now serves one bilingual waiting page and retries every
minute instead of the wizard. Own commit so the lead can hold it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:38:28 +02:00

153 lines
6.8 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"},
{"/api/debug/dr/trigger-setup",
"R-554 (v0.308.0): it wrote .needs-setup and restarted the box into the first-boot setup wizard, which is deleted"},
}
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/hub/push"} {
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",
"btn-dr-trigger", // R-554: the setup-wizard trigger and its panel
"triggerDR",
"section-dr",
} {
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{"section-appexport", "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")
}
}