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.
148 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|