firstrun_hold_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"io"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/pingular/pingularity/internal/settings"
"github.com/pingular/pingularity/internal/store"
)
// A LOADED controller with Quick Setup unanswered and NO offer clock is the
// late-load window: a retry/SIGHUP Reload flips Loaded()==true and broadcasts
// the wake BEFORE materializeQuickSetup seeds the offer clock. QuickSetupHold
// reads the bare zero as "never offered -> release", and monitoringLive used
// to LATCH that release permanently - a fresh install started probing without
// first-run consent. The predicate must fail CLOSED there: hold a fresh
// install, latch nothing, and let the real 48h hold take over once the clock
// is seeded.
func TestMonitoringLiveHoldsFreshInstallWithUnseededOfferClock(t *testing.T) {
ctx := context.Background()
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
set, err := settings.New(ctx, st, settings.Values{Monitoring: true})
if err != nil {
t.Fatalf("settings: %v", err)
}
// The window's exact state: loaded, offer open, clock unseeded.
if !set.Loaded() {
t.Fatal("precondition: controller must be loaded")
}
if set.QuickSetupDone() {
t.Fatal("precondition: quick setup must be unanswered")
}
if since, err := set.QuickSetupOfferSinceErr(ctx); err != nil || since != 0 {
t.Fatalf("precondition: offer clock must be unseeded, got %d, %v", since, err)
}
live := newMonitoringLiveFn(ctx, set, nil)
if live() {
t.Fatal("fresh install with a loaded controller and an unseeded offer clock must HOLD, not probe (consent bypass)")
}
// Nothing may have latched in that window: once the clock IS seeded (the
// materialize step catching up), the normal 48h hold must be in force, not
// a permanently released latch.
if err := set.EnsureQuickSetupOffer(ctx, time.Now().Unix()); err != nil {
t.Fatalf("EnsureQuickSetupOffer: %v", err)
}
if live() {
t.Fatal("hold released during the unseeded window and LATCHED - the seeded offer clock no longer holds")
}
// Answering releases for real.
if err := set.SetQuickSetupDone(ctx, true); err != nil {
t.Fatalf("SetQuickSetupDone: %v", err)
}
if !live() {
t.Fatal("monitoring must run once quick setup is answered")
}
}
// The fail-closed branch must not over-hold: the same window state on an
// install whose store already carries operator configuration (it consented
// long ago; only the answered marker hasn't been materialized yet) keeps
// monitoring running.
func TestMonitoringLiveEstablishedInstallRunsWithUnseededOfferClock(t *testing.T) {
ctx := context.Background()
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
// Prior operator configuration = established (see hasPriorConfiguration).
if err := st.SetSetting(ctx, "monitoring", "true"); err != nil {
t.Fatalf("seed config: %v", err)
}
set, err := settings.New(ctx, st, settings.Values{Monitoring: true})
if err != nil {
t.Fatalf("settings: %v", err)
}
if set.QuickSetupDone() {
t.Fatal("precondition: quick setup must be unanswered")
}
if !newMonitoringLiveFn(ctx, set, nil)() {
t.Fatal("established install must keep monitoring through the unseeded-clock window")
}
}
// The recovery paths (settings-retry loop, SIGHUP) must seed the offer clock
// BEFORE calling Reload: Reload itself flips Loaded()==true and broadcasts
// the wake, so a post-Reload seed leaves the latch window open. This drives
// the pre-seed against a REAL unloaded controller on a REAL working store (a
// canceled context makes New's initial read fail exactly like a transient
// store fault, and a live context recovers it).
func TestQuickSetupOfferPreSeedRunsWhileUnloaded(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
dead, cancel := context.WithCancel(context.Background())
cancel()
set, err := settings.New(dead, st, settings.Values{Monitoring: true})
if err == nil {
t.Fatal("settings.New must fail on the canceled context (simulated failed first load)")
}
if set.Loaded() {
t.Fatal("precondition: controller must be unloaded")
}
ctx := context.Background()
p := &program{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
p.seedQuickSetupOfferEarly(ctx, set)
if set.Loaded() {
t.Fatal("pre-seed must not flip Loaded - only Reload may")
}
if since, err := set.QuickSetupOfferSinceErr(ctx); err != nil {
t.Fatalf("offer clock read: %v", err)
} else if since == 0 {
t.Fatal("pre-seed left the offer clock unseeded on a fresh install - the latch window before materializeQuickSetup stays open")
}
// The recovery Reload now finds the clock already there: the hold is in
// force from the first woken round.
if err := set.Reload(ctx); err != nil {
t.Fatalf("Reload: %v", err)
}
if newMonitoringLiveFn(ctx, set, nil)() {
t.Fatal("hold must be in force after a recovered first load")
}
}
// The pre-seed's placement lives in run()'s SIGHUP goroutine and in
// retrySettingsLoad's loop, neither unit-testable end-to-end - the same gap
// TestMainWiresFirstRunAndOptsHooks covers, with the same deliberately literal
// remedy: in both recovery paths the pre-seed must appear, and appear BEFORE
// the set.Reload call it protects.
func TestRecoveryPathsPreSeedBeforeReload(t *testing.T) {
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
for _, tc := range []struct{ name, anchor string }{
{"settings-retry loop", "func (p *program) retrySettingsLoad"},
{"SIGHUP handler", "case <-hup:"},
} {
i := strings.Index(s, tc.anchor)
if i < 0 {
t.Fatalf("%s: anchor %q not found in main.go", tc.name, tc.anchor)
}
rest := s[i:]
j := strings.Index(rest, "set.Reload(")
if j < 0 {
t.Fatalf("%s: no set.Reload call after %q", tc.name, tc.anchor)
}
if !strings.Contains(rest[:j], "seedQuickSetupOfferEarly") {
t.Errorf("%s: seedQuickSetupOfferEarly must run BEFORE set.Reload - Reload flips Loaded and broadcasts, and a post-Reload seed reopens the consent-hold latch window", tc.name)
}
}
}