package stacks import ( "context" "testing" "time" ) // R-608 (v0.261.0) — the two-way lock between the controller's own update and a guarded app update. // // THE GAP THIS CLOSES, measured rather than assumed: the self-updater's only busy gate was // `backupRunning`. The app update's `backing-up` phase DOES take the backup single-flight // (`RunAppBackupNow` → `acquireRunning`), so that one phase was already covered. `checking`, // `safety-dump`, `pinning`, `pulling`, `starting` and `verifying` were not — and the last two are // where the new version may already have touched the customer's data. The controller's swap restarts // this process, and 04:30 (the default `self_update.auto_update_time`) sits inside the 02:30–05:00 // window `09` §3b Q1 proposes for automatic app updates. // TestR608_AnyUpdatingSeesAnUpdateInFlight is the mechanism half. // // COMPANION RED-PROOF (run 2026-09-21): make AnyUpdating always return false. The "while updating" // sub-test then fails — and with it the whole gate, because every caller reads this one answer. func TestR608_AnyUpdatingSeesAnUpdateInFlight(t *testing.T) { m, _, _, _ := newSlice4Manager(t) if m.AnyUpdating() { t.Fatal("no update has started; AnyUpdating must be false") } release := make(chan struct{}) m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { <-release return true, "released" } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatalf("start: %v", err) } deadline := time.Now().Add(3 * time.Second) for !m.AnyUpdating() && time.Now().Before(deadline) { time.Sleep(2 * time.Millisecond) } if !m.AnyUpdating() { t.Fatal("an update is in flight and AnyUpdating says false — the controller could swap under it") } close(release) waitUpdateDone(t, m, "nextcloud") if m.AnyUpdating() { t.Error("the update finished; the lock must not still be held") } } // TestR608_LockReleasesAfterHold is the one that matters most, and it is a CONSEQUENCE test. // // A held app is an app that could not come up. If it kept this lock, the box would never update its // own controller again — including the release that might FIX whatever held the app. That is a worse // failure than the one the lock prevents, and it is the kind that is silent for weeks. // // COMPANION RED-PROOF (run 2026-09-21): in AnyUpdating, return true when `s.updateHeld` is set as // well as when `s.Updating` is — the plausible "a held update is still an update" reading. This test // then fails with the lock still held after the hold. func TestR608_LockReleasesAfterHold(t *testing.T) { m, _, _, _ := newSlice4Manager(t) m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "crash loop" } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatalf("start: %v", err) } st := waitUpdateDone(t, m, "nextcloud") if st.UpdatePhase != UpdatePhaseFailed { t.Fatalf("this fixture must end HELD, or the test proves nothing; phase=%q", st.UpdatePhase) } if m.AnyUpdating() { t.Error("a HELD app must not hold the self-update lock for ever") } } // TestR608_PreflightRefusesWhileTheControllerSwaps is the reverse direction, and a CONSEQUENCE test: // the question is not "is the callback wired" but "does the button refuse". // // COMPANION RED-PROOF (run 2026-09-21): delete the `m.selfUpdatingNow()` block from UpdatePreflight. // The "while swapping" sub-test then fails with `ref = ` — an app update is allowed to start // into a controller restart. func TestR608_PreflightRefusesWhileTheControllerSwaps(t *testing.T) { m, _, _, _ := newSlice4Manager(t) if ref := m.UpdatePreflight("nextcloud"); ref != nil { t.Fatalf("control: with no self-update running the app update must be allowed, got %q", ref.Reason) } swapping := true m.SetSelfUpdatingCheck(func() bool { return swapping }) ref := m.UpdatePreflight("nextcloud") if ref == nil { t.Fatal("while the controller swaps itself the app update must be REFUSED") } if ref.Reason != "self_updating" { t.Errorf("reason = %q, want %q", ref.Reason, "self_updating") } // It must carry its bundle key, or an English household reads a Hungarian refusal — R-589's // failure in a new place, and the reason v0.260.0 routed these through errText. if ref.Cause == nil { t.Error("the refusal must carry its key as a Cause, not only a Hungarian literal") } // And it must be TRANSIENT: once the swap ends the same app update is allowed, with no human // action in between. A gate that latches is an outage. swapping = false if ref := m.UpdatePreflight("nextcloud"); ref != nil { t.Errorf("after the swap the update must be allowed again, got %q", ref.Reason) } } // TestR608_NilChecksAreSafe — both halves default to the pre-v0.261.0 behaviour when unwired. A // Manager built by a test fixture or a future construction path must not panic or fail closed here: // failing closed on an UNWIRED gate would refuse every update on such a box. func TestR608_NilChecksAreSafe(t *testing.T) { m, _, _, _ := newSlice4Manager(t) if m.selfUpdatingNow() { t.Error("an unwired self-update check must read false") } if ref := m.UpdatePreflight("nextcloud"); ref != nil { t.Errorf("an unwired gate must not refuse an update, got %q", ref.Reason) } }