| index efc3cad..03b62d6 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -564,6 +564,13 @@ func TestPinHostWritesViaAdminAPI(t *testing.T) { |
| } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| }) |
| + // A current binary reports the stored pin back — pinHost verifies against |
| + // this before claiming success. |
| + mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "auto_update": true, "auto_download": true, "channel": "stable", "pin": gotPin, |
| + }) |
| + }) |
| socket := filepath.Join(dir, "h1.sock") |
| ln, err := net.Listen("unix", socket) |
| if err != nil { |
| @@ -588,6 +595,57 @@ func TestPinHostWritesViaAdminAPI(t *testing.T) { |
| } |
| } |
| |
| +// An instance binary that predates a setting accepts the settings POST and |
| +// silently ignores the unknown field. The read-back turns that into a loud |
| +// error instead of a false success (a pin that didn't stick is how the next |
| +// hourly pass stomps a canary; found live with `update major` vs a pre-0.11 |
| +// build). |
| +func TestPinHostRefusesSilentlyIgnoredWrite(t *testing.T) { |
| + dir, err := os.MkdirTemp("", "lch") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + prevDataDir := instanceDataDir |
| + instanceDataDir = dir |
| + t.Cleanup(func() { instanceDataDir = prevDataDir }) |
| + if err := os.WriteFile(filepath.Join(dir, "h1.example.com.env"), []byte("LCHAT_ADMIN_TOKEN=tok123\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + mux := http.NewServeMux() |
| + // The old binary: 200 on the write, but the field never appears in prefs. |
| + mux.HandleFunc("POST /api/admin/settings", func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| + }) |
| + mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "auto_update": true, "auto_download": true, "channel": "stable", "pin": "", |
| + }) |
| + }) |
| + socket := filepath.Join(dir, "h1.sock") |
| + ln, err := net.Listen("unix", socket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := &http.Server{Handler: mux} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + |
| + rt := &Route{Host: "h1.example.com", Kind: "instance", Socket: socket} |
| + if err := pinHost(rt, "pin7777"); err == nil || !strings.Contains(err.Error(), "predates") { |
| + t.Fatalf("silently ignored pin write reported success (err=%v)", err) |
| + } |
| + // Same helper, the major-pin shape: a prefs response with no "major" field |
| + // decodes as "", so a wanted value must fail and a clear must pass. |
| + if err := confirmHostSetting(rt, "major pin", "1", func(p updatePrefs) string { return p.Major }); err == nil { |
| + t.Fatal("missing major field confirmed as set") |
| + } |
| + if err := confirmHostSetting(rt, "major pin", "", func(p updatePrefs) string { return p.Major }); err != nil { |
| + t.Fatalf("clear should confirm against an old binary: %v", err) |
| + } |
| +} |
| + |
| // entryFor is a syntactically valid manifest entry (validateManifest passes) |
| // whose binary URL nothing in the manifest tests ever downloads. |
| func entryFor(ver string) releaseEntry { |