Files
felhom-controller/controller/internal/web/backup_target_render_test.go
T
admin b331f18424 v0.186.0 — R-114 + R-112: tell the truth about the backup target, then show it
Two defects E-2d found on a real box, fixed in this order deliberately: the
message is corrected BEFORE it is put on screen, because switching on a banner
that lies is worse than a silent one.

R-114 — the third state. resolveBackupTargetState had two outcomes: a disk
claims the target (healthy), or nothing does (degraded, "the backup is on the
system disk"). The state "configured, and its drive is gone" had no branch, so
it fell into the second and inherited its message AND its offer. Observed live
with the target detached: degraded:true, target:"felhom-backup" plus the
system-disk copy (false -- the backup was on a drive that had vanished) plus
offer_path naming that same vanished drive as the remedy.

New BackupTargetState.TargetAbsent discriminates. Degraded keeps its meaning
("is there a problem") so the wire contract is unchanged for every consumer;
TargetAbsent answers "which problem", because the two have opposite remedies --
attach any second drive, versus reconnect THAT one. Copy routed through
degradedMessageFor so one place still decides what a customer reads. The offer
is suppressed on the branch itself, NOT left to firstOfferableDrive's
Disconnected skip: that flag is set by the agent-side gate in another repo
(R-113), and this state must be correct independently of it.

R-112 — the state finally has a consumer. The endpoint was byte-correct and
nothing in the product ever asked for it: templates fetch 18 distinct
/api/storage/* endpoints and backup-target[/assign] were the only two with zero
references. Server-rendered on /backups now, following the existing
SingleCopyWarning banner pattern -- not a 19th JS fetch, because a banner that
needs JavaScript to appear is one more thing that can silently not happen.
backupTargetView returns nil for healthy and unknown so those render nothing at
all. The offer control POSTs to the existing assign endpoint behind the standard
inline confirm, never auto-submits, and surfaces restart_required honestly
instead of adding a self-restart.

Scenario E (the seam test) drives backupsHandler over httptest and asserts the
RENDERED HTML -- handler -> view -> resolver -> template. It deliberately does
not call the resolver and assert a string, which would prove the resolver that
was never broken. Deleting the one line that sets data["BackupTarget"]
reproduces the R-112 state and fails every render assertion.

Tests 326 -> 338 (+12) in internal/web; suite green (27 packages); both template
gates pass. Three red-proofs run and reverted, files byte-identical after.

MinAgent unchanged at 0.113.0: R-114 reads BackupTarget/MountPath/GuestPath/Role,
none of which R-113 altered (it changed BoundUnderParent, which this code does
not read). demo-hp on agent 0.113.0 is not held.

The absent copy is verbatim the hub's customerMessages["backup_target_absent"]
so the banner and the email tell one story -- filed as a two-repo drift risk,
not solved.

NOT LIVE-VALIDATED. Scenario C cannot occur on a healthy box; Session C proves it.
2026-07-29 19:21:32 +02:00

146 lines
6.7 KiB
Go

package web
import (
"context"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-112 — SCENARIO E: THE SEAM. This is the test whose absence let E-2 ship.
//
// The endpoint was byte-correct the whole time. `resolveBackupTargetState` produced the right state,
// `degradedMessageFor` produced the right Hungarian, and v0.185.1 even added a test proving the route
// dispatched. None of that was ever reachable by a customer, because NOTHING fetched or rendered it:
// the controller's templates fetch 18 distinct /api/storage/* endpoints and backup-target was one of
// only two with zero references (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.1).
//
// So these tests deliberately do NOT call the resolver and assert its string — that proves the
// resolver, which was never broken. They drive `backupsHandler` over httptest and assert the RENDERED
// HTML, travelling handler → backupTargetView → resolveBackupTargetState → degradedMessageFor →
// the production "backups" template. Delete the one line in backupsHandler that sets
// data["BackupTarget"] and every assertion below fails, which is precisely the class of regression
// that shipped five times in this project.
// renderBackupsPage drives the REAL page handler and returns the HTML a browser would receive.
func renderBackupsPage(t *testing.T, primary string, disks []agentapi.DiskInfo, registerPath string) string {
t.Helper()
s := absentHarness(t, primary, disks, registerPath)
s.loadTemplates()
rr := httptest.NewRecorder()
s.backupsHandler(rr, httptest.NewRequest("GET", "/backups", nil))
if rr.Code != 200 {
t.Fatalf("backups page returned %d, want 200", rr.Code)
}
return rr.Body.String()
}
// C — configured, drive absent: the truthful copy reaches the page, and no offer does.
func TestBackupsPageRendersTheAbsentDriveCopy(t *testing.T) {
html := renderBackupsPage(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
if !strings.Contains(html, backupTargetAbsentText) {
t.Error("the absent-drive copy never reached the page — the customer is told nothing while " +
"their backup drive is missing (R-112: the state had no consumer at all)")
}
if strings.Contains(html, backupTargetDegradedText) {
t.Error("the page shows the SYSTEM-DISK copy for an absent configured drive (R-114)")
}
// Assert the CONTROL's markup, not the bare id: the page script always contains
// getElementById('backup-target-assign'), so a substring match on the id alone matches the
// script and reports a control that is not there. (This test caught exactly that on itself.)
if strings.Contains(html, `id="backup-target-assign"`) {
t.Error("an offer control rendered while the configured target is absent — the remedy is to " +
"reconnect that drive, not to pick another")
}
if strings.Contains(html, `id="backup-target-offer"`) {
t.Error("the offer block rendered while the configured target is absent")
}
}
// A — never configured: the copy AND the offer control both render, and the control carries the
// drive's path so accepting it can only ever assign that one.
func TestBackupsPageRendersTheOfferWhenNeverConfigured(t *testing.T) {
html := renderBackupsPage(t, builtinLocalTarget, []agentapi.DiskInfo{{
Name: "hdd1", MountPath: "/mnt/hdd1", GuestPath: "/mnt/felhom-drives/hdd1", Role: "user-data",
}}, "/mnt/felhom-drives/hdd1")
if !strings.Contains(html, backupTargetDegradedText) {
t.Error("the degraded copy never reached the page")
}
if !strings.Contains(html, backupTargetOfferText) {
t.Error("the offer copy never reached the page")
}
if !strings.Contains(html, `id="backup-target-assign"`) {
t.Error("no offer control rendered — the customer is told to attach a drive but given no way " +
"to assign the one they already have")
}
if !strings.Contains(html, `data-path="/mnt/felhom-drives/hdd1"`) {
t.Error("the offer control does not carry the offered drive's path")
}
// It is an OFFER: it must not submit itself.
if strings.Contains(html, "backup-target-assign.click()") || strings.Contains(html, "autosubmit") {
t.Error("the offer control auto-submits — declining must be possible by doing nothing")
}
}
// B — healthy renders NOTHING. Assert the absence of all three strings, not the absence of an error.
func TestBackupsPageRendersNothingWhenHealthy(t *testing.T) {
html := renderBackupsPage(t, "felhom-backup", []agentapi.DiskInfo{{
Name: "mentes2", MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2",
Role: "user-data", BackupTarget: true,
}}, "/mnt/felhom-drives/mentes2")
assertNoBackupTargetCopy(t, html, "a HEALTHY box grew a banner — a working configuration must "+
"look normal, or every dashboard carries a permanent notice and warnings stop being read")
}
// D — unknown renders NOTHING. Not being able to ask the agent is not evidence of degradation.
func TestBackupsPageRendersNothingWhenAgentUnreachable(t *testing.T) {
s := testServer(t)
if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/hdd1"}); err != nil {
t.Fatalf("register: %v", err)
}
// No tiersFn/disksFn seams and no configured endpoint → agentClient() fails → Known:false.
s.loadTemplates()
rr := httptest.NewRecorder()
s.backupsHandler(rr, httptest.NewRequest("GET", "/backups", nil))
assertNoBackupTargetCopy(t, rr.Body.String(), "an UNREACHABLE agent produced a customer warning — "+
"absence of an answer is not evidence of degradation (R-88 Part 2's mistake)")
}
func assertNoBackupTargetCopy(t *testing.T, html, why string) {
t.Helper()
for _, s := range []struct{ frag, name string }{
{backupTargetDegradedText, "the system-disk copy"},
{backupTargetAbsentText, "the absent-drive copy"},
{backupTargetOfferText, "the offer copy"},
} {
if strings.Contains(html, s.frag) {
t.Errorf("%s rendered: %s", s.name, why)
}
}
if strings.Contains(html, `id="backup-target-alert"`) {
t.Errorf("the banner element rendered: %s", why)
}
}
// The view is nil — not an empty struct — in the two silent states, so a template typo cannot
// accidentally decorate a working box with an empty alert box.
func TestBackupTargetViewIsNilWhenNothingShouldRender(t *testing.T) {
healthy := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{{
MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2", Role: "user-data", BackupTarget: true,
}}, "")
if v := healthy.backupTargetView(context.Background()); v != nil {
t.Errorf("healthy returned a non-nil view %+v — nil is what makes the template render nothing", v)
}
unknown := testServer(t)
if v := unknown.backupTargetView(context.Background()); v != nil {
t.Errorf("unknown returned a non-nil view %+v", v)
}
}