Files
felhom-controller/controller/internal/web/backup_target_absent_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

194 lines
9.1 KiB
Go

package web
import (
"context"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-114 — the third state: CONFIGURED, and its drive is GONE.
//
// THE BUG THESE PIN, measured live in E-2d (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3). With
// the assigned target's drive detached, the endpoint returned:
//
// {"degraded":true,"target":"felhom-backup",
// "message":"A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer …",
// "offer_path":"/mnt/felhom-drives/mentes2","offer_label":"Mentés meghajtó"}
//
// Two falsehoods in one payload. The backup was NOT on the system disk — it was on a drive that had
// vanished. And the remedy offered was THE DRIVE THAT JUST DISAPPEARED. resolveBackupTargetState had
// only two outcomes (a disk claims the target, or nothing does) so the third state fell into the
// second and inherited its message and its offer.
//
// The fixtures below are the observed shapes, not invented ones: the drive is still listed with a
// MountPath (the registry keeps the configured path), Role user-data, but BackupTarget is false —
// which is exactly why the healthy branch missed it and the offer branch matched it.
// absentHarness wires a Server whose agent reports `primary` as the tier target and `disks` as the
// drive list, with one registered storage path so firstOfferableDrive has something to find. Both
// halves come through the production seams (tiersFn / disksFn), so the resolver under test is the
// real one.
func absentHarness(t *testing.T, primary string, disks []agentapi.DiskInfo, registerPath string) *Server {
t.Helper()
s := testServer(t)
if registerPath != "" {
if err := s.settings.AddStoragePath(settings.StoragePath{Path: registerPath, Label: "Mentés meghajtó"}); err != nil {
t.Fatalf("register storage path: %v", err)
}
}
s.tiersFn = func(context.Context) (agentapi.TiersResponse, error) {
return agentapi.TiersResponse{Tiers: []agentapi.BackupTierInfo{{Target: primary, Primary: true}}}, nil
}
s.disksFn = func(context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{Disks: disks}, nil
}
return s
}
// theVanishedDrive is the E-2d shape: registered, still carrying its configured MountPath, user-data,
// but no longer claiming the backup target because its device is gone.
var theVanishedDrive = agentapi.DiskInfo{
Name: "mentes2",
MountPath: "/mnt/mentes2",
GuestPath: "/mnt/felhom-drives/mentes2",
Role: "user-data",
BackupTarget: false,
}
// ── Scenario C — configured, drive absent ───────────────────────────────────────────────────────
// RED-PROOF: delete the `if targetIsConfiguredDrive(primary)` branch from resolveBackupTargetState
// and this fails twice — the message becomes the system-disk copy and OfferPath becomes non-empty.
func TestConfiguredButAbsentTargetDoesNotClaimTheSystemDisk(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
st := s.resolveBackupTargetState(context.Background())
if !st.Known {
t.Fatal("state is unknown — the fixture answers both agent calls")
}
if !st.TargetAbsent {
t.Fatalf("TargetAbsent is false for target %q with no disk claiming it — the configured-but-gone "+
"state fell back into never-configured, which is the R-114 bug", st.TargetID)
}
msg := degradedMessageFor(st)
if strings.Contains(msg, "ugyanazon a lemezen") {
t.Errorf("the customer is told the backup is on the SYSTEM DISK, which is false — the target is "+
"%q, a drive that has vanished. Got: %s", st.TargetID, msg)
}
if !strings.Contains(msg, "nem érhető el") {
t.Errorf("the absent-drive copy is missing; got: %s", msg)
}
}
// The remedy must be "reconnect THAT drive", never "pick a different one" — and above all never the
// drive that just disappeared, which is what E-2d actually observed being offered.
func TestConfiguredButAbsentTargetOffersNothing(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
st := s.resolveBackupTargetState(context.Background())
if st.OfferPath != "" {
t.Errorf("offered %q while the configured target is absent — E-2d observed this exact payload "+
"offering the drive that had just vanished", st.OfferPath)
}
if st.OfferLabel != "" {
t.Errorf("offer label %q leaked with no offer path", st.OfferLabel)
}
}
// The suppression must be the branch's own doing, NOT a side effect of the drive being flagged
// Disconnected — that flag is set by the agent-side drive gate in another repo (R-113), and this
// state has to be correct before, during and independently of that landing.
func TestAbsentTargetSuppressesOfferWithoutRelyingOnTheDisconnectedFlag(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
for _, sp := range s.settings.GetStoragePaths() {
if sp.Disconnected {
t.Fatalf("fixture invalid: %s is already marked Disconnected, so this test would pass "+
"for the wrong reason", sp.Path)
}
}
if st := s.resolveBackupTargetState(context.Background()); st.OfferPath != "" {
t.Errorf("offer %q survived with Disconnected UNSET — the suppression is leaning on the "+
"other repo's flag instead of on this state", st.OfferPath)
}
}
// ── Scenario A — never configured (must be unchanged) ───────────────────────────────────────────
func TestNeverConfiguredStillSaysSystemDiskAndStillOffers(t *testing.T) {
s := absentHarness(t, builtinLocalTarget, []agentapi.DiskInfo{{
Name: "hdd1", MountPath: "/mnt/hdd1", GuestPath: "/mnt/felhom-drives/hdd1", Role: "user-data",
}}, "/mnt/felhom-drives/hdd1")
st := s.resolveBackupTargetState(context.Background())
if st.TargetAbsent {
t.Fatal("target `local` marked absent — the builtin root-fs storage is the NEVER-CONFIGURED " +
"state, not a drive that went missing")
}
if !st.Degraded {
t.Fatal("a backup on the system disk must still read degraded")
}
if msg := degradedMessageFor(st); !strings.Contains(msg, "ugyanazon a lemezen") {
t.Errorf("the never-configured copy changed; got: %s", msg)
}
if st.OfferPath == "" {
t.Error("no offer in the never-configured state — this is exactly where an offer belongs")
}
}
// An unset tier is the same customer situation as `local`: nothing chosen yet, so it must not be
// reported as a drive that vanished.
func TestUnsetTierIsNeverConfiguredNotAbsent(t *testing.T) {
if targetIsConfiguredDrive("") {
t.Error("an empty target id was treated as a configured drive")
}
if targetIsConfiguredDrive(builtinLocalTarget) {
t.Error("the builtin `local` was treated as a configured drive")
}
if !targetIsConfiguredDrive("felhom-backup") {
t.Error("a real storage id was not treated as a configured drive")
}
}
// ── Scenario B — healthy still renders nothing ──────────────────────────────────────────────────
func TestHealthyTargetIsNeitherDegradedNorAbsent(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{{
Name: "mentes2", MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2",
Role: "user-data", BackupTarget: true,
}}, "/mnt/felhom-drives/mentes2")
st := s.resolveBackupTargetState(context.Background())
if st.Degraded || st.TargetAbsent {
t.Fatalf("a healthy target reported degraded=%v absent=%v", st.Degraded, st.TargetAbsent)
}
if msg := degradedMessageFor(st); msg != "" {
t.Errorf("healthy produced copy %q — a working box must look normal", msg)
}
}
// ── the copy contract ───────────────────────────────────────────────────────────────────────────
// The absent copy is VERBATIM the hub's customerMessages["backup_target_absent"]. If either side is
// reworded this fails, which is the only thing currently binding the two repos' strings together.
func TestAbsentCopyMatchesTheHubEmailWordForWord(t *testing.T) {
const hubCopy = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
if backupTargetAbsentText != hubCopy {
t.Errorf("the banner and the email now tell the customer different stories about one drive.\n"+
" banner: %s\n email : %s", backupTargetAbsentText, hubCopy)
}
msg := degradedMessageFor(BackupTargetState{Known: true, Degraded: true, TargetAbsent: true})
for _, want := range []struct{ frag, why string }{
{"nem érhető el", "the FACT — the drive cannot be reached"},
{"nem készül el", "the CONSEQUENCE — the full system backup does not happen"},
{"vissza nem csatlakoztatod", "the REMEDY — reconnect it"},
} {
if !strings.Contains(msg, want.frag) {
t.Errorf("absent copy is missing %s (%q); got: %s", want.why, want.frag, msg)
}
}
}