Files
felhom-controller/controller/internal/backup/offbox_verdict_r234_test.go
T
admin c6b69d888e
gates / gates (push) Successful in 21s
v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
THE VERDICT. The R-203 block already said "a warning beside a success is read as a
success" and applied it to ONE of the two shapes it describes: an app missing a
declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY
still reported ok. Both do now. Which skips count, decided by measurement:
selected+deployed with no recovery unit YES; selected but NOT deployed no (named,
with what to do — a box left amber by an app somebody removed is a status nobody
reads); disconnected/decommissioned drive no (own signal); nothing selected no.
LastSuccess and SnapshotCount still record what WAS captured.

THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3
stated that toggling an app on leaves it without a bundle so the first run skips
it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits
for every DEPLOYED stack, through admitApp, before the push — a unit moved aside
was RECREATED and the run reported ok. That state does not survive a run.

What actually produced the 2026-08-06 sequence: the manual run was dropped by the
single-flight while an earlier run was still going. runOffboxBackup returned nil,
the handler had already answered "A tavoli mentes elindult", and the card then
showed the PREVIOUS run's green verdict — read as covering the app just selected.
The decision is now taken synchronously in the handler and a dropped request says
so. The nightly path still returns nil on purpose: nobody asked, and it retries.

§7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose +
manifest, only ENUMERATES dumps rather than creating them, is idempotent and does
NOT stop the app — and already runs inside the off-site run. So there is no wait to
remove for a deployed app and NOTHING was built.

28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture
note: the shared provider's ListDeployedStacks returned nil, so Scenario A first
passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
2026-08-06 21:58:21 +02:00

166 lines
6.8 KiB
Go

package backup
import (
"context"
"strings"
"testing"
)
// R-234 — a run that SKIPPED an app the customer selected is not a successful run.
//
// The same paragraph the R-203 verdict block already carries — "a warning beside a success is read
// as a success" — was applied to one of the two shapes it describes. An app missing a declared
// mandatory FOLDER made the run `incomplete`; an app skipped ENTIRELY, with nothing of it in the
// snapshot at all, still reported `ok`. The smaller gap moved the verdict and the bigger one did not.
//
// Run-level on purpose: the classification and the verdict are both inside the run, and the sibling
// test file records what happened when its first version asserted the capture helper alone — its
// red-proof passed while the defect was untouched.
// Scenario A — a selected, DEPLOYED app with no recovery unit makes the run incomplete, names itself,
// and does not suppress what was captured.
//
// RED-PROOF: drop `unprotected` from the verdict condition (leave only mandatoryGaps) → this FAILS
// with the run reporting ok over a skipped app, which is production behaviour up to v0.204.0.
func TestOffboxRun_SkippedSelectedAppIsIncomplete(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
// `kept` has a unit and is pushed; `dropped` is selected and deployed but has NO unit, so the
// per-app loop skips it. backedUp>0 is what made the existing no-silent-success guard stay quiet.
mkUnit(t, drive, "kept")
prov.hdd["kept"] = drive
prov.has["kept"] = true
prov.hdd["dropped"] = drive
prov.has["dropped"] = true
prov.deployed = map[string]bool{"kept": true, "dropped": true}
_ = sett.SetAppOffbox("kept", true)
_ = sett.SetAppOffbox("dropped", true)
var gapNotified map[string][]string
m.SetOffboxGapNotify(func(g map[string][]string) { gapNotified = g })
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("the run itself must SUCCEED — a skipped app is a coverage gap, not a failed run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "incomplete" {
t.Fatalf("LastStatus = %q, want \"incomplete\" — the customer selected an app and the run did not "+
"carry it; on 2026-08-06 this reported „✓ Rendben” and the restore refused minutes later", got.LastStatus)
}
// Scenario A: the counters and the anchor still record what WAS captured.
if got.LastSuccess == "" {
t.Error("LastSuccess must still record what was captured — half a backup is not no backup")
}
if cap.backups != 1 {
t.Errorf("the app that HAD a unit must still be pushed, got %d backup calls", cap.backups)
}
// Scenario E: which app, and why.
if !strings.Contains(got.LastWarning, "dropped") {
t.Errorf("the warning must NAME the skipped app, got %q", got.LastWarning)
}
if !strings.Contains(got.LastWarning, "nincs helyi ment") {
t.Errorf("the warning must say WHY it was skipped, got %q", got.LastWarning)
}
if !strings.Contains(got.LastWarning, "következő ment") {
t.Errorf("the warning must say WHEN it will be protected, got %q", got.LastWarning)
}
// Scenario B: the operator hears about it, in the same vocabulary as a folder gap.
if len(gapNotified["dropped"]) == 0 {
t.Fatalf("the operator signal must carry the skipped app, got %v", gapNotified)
}
}
// Scenario C — a healthy run is untouched. Without this, "always incomplete" would also pass above,
// and a status that is never green is a status that stops being read.
//
// RED-PROOF: count EVERY skip (drop the classification switch and use len(res.missing)) → a healthy
// run goes amber and this FAILS.
func TestOffboxRun_HealthyRunStaysOk(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
mkUnit(t, drive, "kept")
prov.hdd["kept"] = drive
prov.has["kept"] = true
_ = sett.SetAppOffbox("kept", true)
fired := false
m.SetOffboxGapNotify(func(map[string][]string) { fired = true })
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "ok" {
t.Fatalf("LastStatus = %q, want ok — every selected app was carried", got.LastStatus)
}
if fired {
t.Error("the operator signal must NOT fire when nothing was missed")
}
if strings.Contains(got.LastWarning, "NEM kerültek be") {
t.Errorf("a healthy run must carry no skip warning, got %q", got.LastWarning)
}
}
// Scenario D — a box with NOTHING selected keeps today's behaviour: ok, with the existing
// zero-selection notice. An unconfigured box reporting incomplete forever is its own defect.
//
// RED-PROOF: count the empty selection as a gap → this box goes permanently amber and this FAILS.
func TestOffboxRun_NothingSelectedIsNotAGap(t *testing.T) {
drive := t.TempDir()
m, sett, _ := classifiedOffboxManager(t, drive)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "ok" {
t.Fatalf("LastStatus = %q, want ok — nothing was selected, so nothing was skipped", got.LastStatus)
}
if !strings.Contains(got.LastWarning, "nincs mentésre jelölt alkalmazás") {
t.Errorf("the existing zero-selection notice must survive, got %q", got.LastWarning)
}
}
// Scenario F — a selected app that is NOT deployed. Decided deliberately: it is NAMED with what to do
// about it, and it does NOT move the verdict, because a box left amber forever by an app somebody
// removed is a status nobody reads.
func TestOffboxRun_SelectedButUndeployedIsNamedNotCounted(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
mkUnit(t, drive, "kept")
prov.hdd["kept"] = drive
prov.has["kept"] = true
prov.deployed = map[string]bool{"kept": true} // "removed-app" deliberately absent
_ = sett.SetAppOffbox("kept", true)
// selected, no unit, and NOT in the deployed set
_ = sett.SetAppOffbox("removed-app", true)
fired := false
m.SetOffboxGapNotify(func(map[string][]string) { fired = true })
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "ok" {
t.Fatalf("LastStatus = %q, want ok — an app that is not installed cannot be protected, and must "+
"not hold the box amber forever", got.LastStatus)
}
if !strings.Contains(got.LastWarning, "removed-app") {
t.Errorf("the undeployed selection must still be NAMED, got %q", got.LastWarning)
}
if !strings.Contains(got.LastWarning, "vedd ki a kijelöl") {
t.Errorf("it must say what to do about it, got %q", got.LastWarning)
}
if fired {
t.Error("an undeployed app must not raise the operator gap signal")
}
}