firstrun_holdnotice_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/pingular/pingularity/internal/config"
"github.com/pingular/pingularity/internal/settings"
"github.com/pingular/pingularity/internal/store"
)
// A fresh install measures NOTHING until Quick Setup is answered or the 48h
// grace runs out, and the boot output used to say nothing about it: the data
// directory notice and the startup line are what a healthy install prints too,
// the health endpoint answers 200, and a headless or package install therefore
// looked fine while collecting zero samples for up to two days. Boot the REAL
// binary on an empty data directory - the only place run()'s wiring is
// observable - and require the boot output to state the hold, what ends it, and
// how long it lasts. The skip case is here too: a notice that fires for an
// install which is NOT held teaches operators to ignore it.
func TestFreshInstallStartupOutputSaysMonitoringIsHeld(t *testing.T) {
if testing.Short() {
t.Skip("skipping build-and-boot first-run notice test in -short mode")
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("go toolchain not on PATH; skipping build-and-boot first-run notice test")
}
dir := t.TempDir()
bin := filepath.Join(dir, "pingularity")
if runtime.GOOS == "windows" {
bin += ".exe"
}
build := exec.Command(goBin, "build", "-o", bin, ".")
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
t.Fatalf("build failed: %v", err)
}
// No monitoring flag and no -quick-setup: silence, so nothing consents on the
// operator's behalf and the first-run hold is in force.
//
// The needle is the WHOLE notice, rendered by the same function run() prints
// (firstRunHoldLine) against the address this boot was actually given.
// Facet substrings were not enough: this test used to accept the dashboard
// URL as its "how do I end the hold" evidence, and the PRE-EXISTING startup
// line prints that URL too - so that sub-assertion passed with the entire
// notice deleted (verified by deleting it: the other three facets failed and
// it did not). What the line has to SAY is pinned by
// TestFirstRunHoldLineIsOneActionableLine; what this pins is that the real
// binary prints it, on a fresh install, in the output an operator sees.
held, heldAddr := bootFreshInstall(t, bin)
notice := firstRunHoldLine(heldAddr)
if !strings.Contains(held, notice) {
t.Errorf("fresh-install boot output never carries the first-run hold notice - the install looks healthy while collecting nothing.\nwant this line:\n%s\nfull output:\n%s", notice, held)
}
// -quick-setup=skip answers the offer at boot, so monitoring runs from the
// first round and there is no hold to announce. The needle is that SAME
// rendered line, not a lowercase phrase quoted out of it: "on hold" was one
// reword of the notice away from matching nothing at all, and a guard that
// cannot fail would let the notice fire on an install that is NOT held,
// which teaches operators to ignore it (verified: reword the notice and
// print it unconditionally, and the old guard stayed green). Sharing one
// needle with the assertion above is what keeps that impossible - reword the
// notice and this guard follows it, or the positive assertion fails.
//
// This also pins the flag SPELLING the notice tells operators to use: an
// unknown flag is rejected at startup, the daemon never serves, and
// bootFreshInstall fails outright.
skipped, skippedAddr := bootFreshInstall(t, bin, "-quick-setup=skip")
if unheld := firstRunHoldLine(skippedAddr); strings.Contains(skipped, unheld) {
t.Errorf("-quick-setup=skip boots with monitoring RUNNING, but the boot output still announces the hold:\n%s\nfull output:\n%s", unheld, skipped)
}
}
// bootFreshInstall builds nothing: it boots the already-built binary against an
// empty data directory on a free high port, waits until the API answers (which
// means boot is past every startup line, so whatever it was going to say it has
// said), then kills it and returns everything it printed on both streams.
//
// It returns the -listen address it used, not the port, so a caller can render
// what the daemon rendered (firstRunHoldLine takes that address). Handing back
// the port instead left each caller to rebuild the address, and an address
// rebuilt slightly differently produces a needle that matches nothing - which a
// "must not contain" assertion would report as a pass.
func bootFreshInstall(t *testing.T, bin string, extraArgs ...string) (output, listenAddr string) {
t.Helper()
dir := t.TempDir()
port, releasePort := reserveHighPort(t)
addr := "127.0.0.1:" + port
args := append([]string{"run", "-listen", addr, "-db", filepath.Join(dir, "fresh.db")}, extraArgs...)
cmd := exec.Command(bin, args...)
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()
releasePort()
if err := cmd.Start(); err != nil {
t.Fatalf("start failed: %v", err)
}
var outBuf, errBuf []byte
drained := make(chan struct{}, 2)
go func() { outBuf, _ = io.ReadAll(stdout); drained <- struct{}{} }()
go func() { errBuf, _ = io.ReadAll(stderr); drained <- struct{}{} }()
killed := false
defer func() {
if !killed {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}()
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(30 * time.Second)
ready := false
for time.Now().Before(deadline) {
resp, err := client.Get("http://" + addr + "/api/status")
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
ready = true
break
}
}
if cmd.ProcessState != nil {
break
}
time.Sleep(100 * time.Millisecond)
}
_ = cmd.Process.Kill()
killed = true
_ = cmd.Wait()
<-drained
<-drained
if !ready {
t.Fatalf("daemon never served /api/status; stdout:\n%s\nstderr:\n%s", outBuf, errBuf)
}
return string(outBuf) + string(errBuf), addr
}
// The notice must be ONE line, in the startup-line voice, and it must name both
// ways out plus the URL of the dashboard the operator is being sent to - the
// point of the line is that nobody should have to go read the docs to find out
// why the install is measuring nothing.
func TestFirstRunHoldLineIsOneActionableLine(t *testing.T) {
line := firstRunHoldLine(":9000")
if strings.Contains(line, "\n") {
t.Errorf("first-run hold notice is not a single line: %q", line)
}
if !strings.HasPrefix(line, "pingularity") {
t.Errorf("first-run hold notice breaks the startup-line style (no \"pingularity\" prefix): %q", line)
}
for _, want := range []string{
"Quick Setup", // what has to be answered
"http://localhost:9000", // where to answer it (dashboardURL of the listen address)
"-quick-setup=skip", // the headless way out, spelled as the flag really is
"48h", // how long the hold lasts
} {
if !strings.Contains(line, want) {
t.Errorf("first-run hold notice missing %q: %q", want, line)
}
}
// A non-default listen address must be reflected, or the operator is sent to
// a dashboard that isn't there.
if other := firstRunHoldLine("127.0.0.1:19123"); !strings.Contains(other, "http://localhost:19123") {
t.Errorf("first-run hold notice ignores the listen address: %q", other)
}
}
// The notice promises a duration the daemon does not enforce - the real grace
// lives in settings.QuickSetupHold, whose constant is unexported. Pin the two
// together: if the grace ever moves, this fails instead of the daemon quietly
// telling every fresh install the wrong deadline.
func TestFirstRunHoldNoticeStatesTheRealGrace(t *testing.T) {
const since = int64(1_700_000_000)
grace := int64(quickSetupHoldGrace / time.Second)
// One second short of the stated grace the offer is still open...
if !settings.QuickSetupHold(false, since, since+grace-1) {
t.Errorf("the notice says the hold lasts %s, but settings.QuickSetupHold had already released by then", quickSetupHoldGrace)
}
// ...and at it, it is over.
if settings.QuickSetupHold(false, since, since+grace) {
t.Errorf("the notice says the hold lasts %s, but settings.QuickSetupHold still holds past that - fresh installs are told the wrong deadline", quickSetupHoldGrace)
}
}
// quickSetupHoldState is what both the notice and the measurement loops read,
// so its answers ARE the feature: a fresh install must read as held, and
// answering Quick Setup must release it (otherwise the install that was told
// "answer it and monitoring starts" still measures nothing).
func TestQuickSetupHoldStateHoldsFreshInstallUntilAnswered(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 boot path seeds the offer clock on a genuinely fresh install; that is
// the state the notice speaks for.
if err := set.EnsureQuickSetupOffer(ctx, time.Now().Unix()); err != nil {
t.Fatalf("EnsureQuickSetupOffer: %v", err)
}
if got := quickSetupHoldState(ctx, set); got != qsHeld {
t.Fatalf("fresh install with the offer open: hold state = %v, want qsHeld (%v) - boot would stay silent while measuring nothing", got, qsHeld)
}
if err := set.SetQuickSetupDone(ctx, true); err != nil {
t.Fatalf("SetQuickSetupDone: %v", err)
}
if got := quickSetupHoldState(ctx, set); got != qsReleased {
t.Fatalf("after Quick Setup was answered: hold state = %v, want qsReleased (%v)", got, qsReleased)
}
}
// quickSetupHoldState's FIRST branch is the one nothing could see. Settings
// have not loaded, so the offer clock is not seeded, and settings.QuickSetupHold
// would read a bare offer_since==0 and fail OPEN. The branch fails CLOSED
// instead - it holds a GENUINELY FRESH install (nothing in the store), keeps an
// ESTABLISHED one running, and latches neither answer, so the real hold takes
// over the moment settings load. Replacing the whole branch with a bare
// `return qsProvisional` left this package green (verified), which means a
// fresh install could start probing before anyone consented and no test would
// have said a word.
func TestQuickSetupHoldStateHoldsFreshInstallWhileSettingsUnloaded(t *testing.T) {
ctx := context.Background()
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
// A failed initial load is the only route to a controller that is not
// Loaded(), and hiding the settings table is the smallest REAL fault that
// causes one: store.AllSettings errors, New surfaces it, and the controller
// runs on defaults. The table comes straight back, so every later read here
// - EstablishedInStore's included - is answered by a working store, which is
// exactly the shape this branch exists for: settings unloaded now, loadable
// a moment later (a locked or slow DB at boot, retried by the settings-retry
// loop). New never reached ensureBornMarker either, so the store is left as
// bare as a first boot's.
renameSettingsTable(t, st, "settings", "settings_hidden")
set, err := settings.New(ctx, st, settings.Values{Monitoring: true})
if err == nil {
t.Fatal("settings.New succeeded with the settings table hidden; the unloaded controller this whole test is about was never built")
}
renameSettingsTable(t, st, "settings_hidden", "settings")
if set.Loaded() {
t.Fatal("controller reports loaded settings after a failed load; the branch under test is unreachable")
}
// Precondition: this is the genuinely-FRESH case, not the store-read-error
// one. Both hold, and a test that cannot tell them apart proves only half
// the branch.
if est, err := set.EstablishedInStore(ctx); err != nil || est {
t.Fatalf("EstablishedInStore = %v, %v; want false, <nil> - this fixture must be a fresh install answered by a WORKING store, or the assertions below cannot say which leg held", est, err)
}
if got := quickSetupHoldState(ctx, set); got != qsHeld {
t.Errorf("fresh install, settings unloaded: hold state = %v, want qsHeld (%v) - nothing has consented, and the offer clock that would judge the hold does not exist yet", got, qsHeld)
}
// The consequence, through the predicate the measurement loops actually
// obey: the defaults say Monitoring is on, so the hold is the ONLY thing
// that can stop this install probing before its settings are even known.
if newMonitoringLiveFn(ctx, set, nil)() {
t.Error("a fresh install whose settings failed to load is already measuring: nobody was asked, and the first-run consent hold it is owed has not been materialized")
}
// The other half, which is what keeps "fail closed" from meaning "hold
// everything": an established install consented long ago, and pausing it
// over a transient settings-load failure would be an outage of the
// monitoring itself. Its pass is PROVISIONAL - see the qsProvisional doc:
// a caller may not latch it, or one unloaded read would release the hold
// permanently on an install that never answered.
if err := st.InsertSpeed(ctx, store.SpeedSample{
TS: 1_700_000_000, DownMbps: 94.2, UpMbps: 11.7, PingMS: 12.5, Server: "Somewhere",
}); err != nil {
t.Fatalf("insert measurement: %v", err)
}
if est, err := st.HasHistory(ctx); err != nil || !est {
t.Fatalf("HasHistory = %v, %v after a recorded measurement; the established half of this test needs a store that reads as established", est, err)
}
if got := quickSetupHoldState(ctx, set); got != qsProvisional {
t.Errorf("established install, settings unloaded: hold state = %v, want qsProvisional (%v) - qsHeld would pause a consenting install's monitoring over a load failure, and qsReleased is latchable, which would end the hold for good off one unloaded read", got, qsProvisional)
}
if !newMonitoringLiveFn(ctx, set, nil)() {
t.Error("an install with measurement history stopped measuring because its settings failed to load; it consented long ago and the first-run hold does not apply to it")
}
}
// renameSettingsTable renames a table on a REAL store, which is how this file
// makes store.AllSettings fail on demand (and only then). The :memory: pool is
// one connection, so the rename is visible to every later call on st; the
// schema carries no view, trigger or foreign key naming the table, so nothing
// else is rewritten by the rename.
func renameSettingsTable(t *testing.T, st *store.Store, from, to string) {
t.Helper()
if _, err := st.DB().ExecContext(context.Background(), "ALTER TABLE "+from+" RENAME TO "+to); err != nil {
t.Fatalf("rename %s -> %s: %v", from, to, err)
}
}
// The hold notice tells operators to "restart with -quick-setup=skip", and
// `pingularity -h` is where they go to look that up. The usage text listed
// every other run flag and not that one (verified: it did not contain the
// string "quick-setup" anywhere), so the boot line advertised a flag the
// program's own documentation denied. Take the flags back out of the NOTICE and
// require an entry for each, so the two cannot drift apart again: rename or
// re-spell the flag in either place and this fails, instead of an operator
// finding out at 3am that the way out of the hold is undocumented.
func TestUsageDocumentsEveryFlagTheHoldNoticeAdvertises(t *testing.T) {
notice := firstRunHoldLine(":9000")
var flags []string
for _, tok := range strings.Fields(notice) {
// Trim sentence punctuation, then drop the value: the notice spells the
// flag as it is USED ("-quick-setup=skip"), usage documents the flag.
name, _, _ := strings.Cut(strings.TrimRight(tok, ".,;:)"), "=")
if len(name) > 1 && strings.HasPrefix(name, "-") {
flags = append(flags, name)
}
}
if len(flags) == 0 {
t.Fatalf("the notice names no flag at all, so this test would prove nothing: the headless way out of the hold has gone missing from %q", notice)
}
usageText := captureUsage(t)
for _, f := range flags {
if _, ok := usageEntry(usageText, f); !ok {
t.Errorf("the first-run notice tells operators to use %s, but the usage text has no entry for it - the one place they look it up says the flag does not exist.\nnotice:\n%s\nusage:\n%s", f, notice, usageText)
}
}
// An entry that names the flag and not its values documents nothing an
// operator can act on. Each value asserted here is proved against the real
// parser first, so this can never end up pinning a value the daemon
// rejects - and the closing check proves the two are the WHOLE set, so a
// third value could not be added without documenting it.
entry, ok := usageEntry(usageText, "-quick-setup")
if !ok {
return // already reported above
}
for _, v := range []string{"skip", "prompt"} {
if _, err := config.ParseFlags([]string{"-quick-setup=" + v}); err != nil {
t.Fatalf("-quick-setup=%s is rejected by the parser, so the usage entry must not offer it: %v", v, err)
}
if !strings.Contains(entry, "'"+v+"'") {
t.Errorf("the -quick-setup usage entry never says what '%s' does:\n%s", v, entry)
}
}
if _, err := config.ParseFlags([]string{"-quick-setup=bogus"}); err == nil {
t.Error("the parser accepts a -quick-setup value the usage entry does not describe; the entry no longer covers what the flag takes")
}
// The window is the operator's whole reason to care which value to pass, and
// it is rendered from quickSetupHoldGrace in both places for that reason
// (see quickSetupHoldGraceText): a hardcoded "48h" here would outlive the
// constant.
if !strings.Contains(entry, quickSetupHoldGraceText()) {
t.Errorf("the -quick-setup usage entry never states the %s the hold really lasts, so nothing tells an operator how long 'prompt' costs them:\n%s", quickSetupHoldGraceText(), entry)
}
}
// captureUsage runs usage() with os.Stdout pointed at a temp file and returns
// what it printed. usage() writes through fmt.Printf, which resolves os.Stdout
// at call time, so the swap is enough - and it is the only way to read the text
// `pingularity -h` gives an operator without shelling out to a build.
func captureUsage(t *testing.T) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "usage")
if err != nil {
t.Fatalf("temp stdout: %v", err)
}
defer f.Close()
orig := os.Stdout
defer func() { os.Stdout = orig }()
os.Stdout = f
usage()
b, err := os.ReadFile(f.Name())
if err != nil {
t.Fatalf("read captured usage: %v", err)
}
return string(b)
}
// usageEntry returns one flag's block from the usage text: its row plus the
// indented continuation lines beneath it. Flag rows are indented two spaces and
// continuations four or more, so an assertion about what an entry SAYS cannot
// be satisfied by a neighbouring flag's description.
func usageEntry(usageText, flag string) (string, bool) {
lines := strings.Split(usageText, "\n")
start := -1
for i, l := range lines {
if strings.HasPrefix(l, " "+flag+" ") || strings.HasPrefix(l, " "+flag+"=") {
start = i
break
}
}
if start < 0 {
return "", false
}
entry := []string{lines[start]}
for _, l := range lines[start+1:] {
if !strings.HasPrefix(l, " ") {
break
}
entry = append(entry, l)
}
return strings.Join(entry, "\n"), true
}