package report import ( "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" ) // R-1159 (controller v0.334.0): a live box refreshes its config with its OWN hub key; the retrieval passphrase is sent // only to a hub that has no box-config route, or when the box holds no key yet — and never as a retry after the hub // refused the key. // // COMPANION RED-PROOF (observed 2026-10-19): with PullConfigPreferKey reduced to the old PullConfig (the pre-fix // refresh) the "new hub" case fails with // // r1159_box_config_test.go: new hub: "hub:\n api_key: box-key\n" (key pulls 0), want the config by the box key // // Restored. func TestR1159_RefreshUsesTheBoxKey(t *testing.T) { type seen struct { mu sync.Mutex passphrase, bearer int } hub := func(boxConfig int) (*httptest.Server, *seen) { s := &seen{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() switch { case strings.HasPrefix(r.URL.Path, "/api/v1/box-config/"): if boxConfig == 0 { http.NotFound(w, r) return } s.bearer++ if r.Header.Get("Authorization") != "Bearer box-key" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if boxConfig != 200 { http.Error(w, "x", boxConfig) return } w.Write([]byte("hub:\n api_key: box-key\n")) case strings.HasPrefix(r.URL.Path, "/api/v1/config/"): s.passphrase++ if r.Header.Get("X-Retrieval-Password") != "owner-pass" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } w.Write([]byte("hub:\n api_key: box-key\n")) default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) return srv, s } logf := func(string, ...any) {} srv, s := hub(200) y, err := PullConfigPreferKey(srv.URL, "c1", "box-key", "owner-pass", logf) if err != nil || !strings.Contains(y, "api_key") || s.bearer != 1 { t.Fatalf("new hub: %q %v (key pulls %d), want the config by the box key", y, err, s.bearer) } if s.passphrase != 0 { t.Fatalf("new hub: the passphrase was sent (%d time(s)) — a live box must refresh with its own key", s.passphrase) } srv, s = hub(0) if y, err := PullConfigPreferKey(srv.URL, "c1", "box-key", "owner-pass", logf); err != nil || y == "" || s.passphrase != 1 { t.Fatalf("old hub (no route): %v, passphrase pulls %d — want the fallback", err, s.passphrase) } srv, s = hub(200) if _, err := PullConfigPreferKey(srv.URL, "c1", "", "owner-pass", logf); err != nil || s.passphrase != 1 || s.bearer != 0 { t.Fatalf("no key yet: %v, passphrase %d bearer %d — want the passphrase pull", err, s.passphrase, s.bearer) } for _, code := range []int{http.StatusUnauthorized, http.StatusInternalServerError} { srv, s = hub(code) key := "box-key" if code == http.StatusUnauthorized { key = "wrong-key" } _, err := PullConfigPreferKey(srv.URL, "c1", key, "owner-pass", logf) if err == nil || s.passphrase != 0 { t.Fatalf("key refused (%d): err=%v passphrase pulls %d — the passphrase must not be the retry", code, err, s.passphrase) } if code == http.StatusUnauthorized && !errors.Is(err, ErrAuthFailed) { t.Errorf("a refused key must read as ErrAuthFailed, got %v", err) } } } // The refresh the report ACK drives (cmd/controller main.go, ConfigRefresher.Refresh) must be the key-first pull — a // PullConfigPreferKey nobody calls would leave every live box on the passphrase (the seam-never-wired shape). func TestR1159_MainWiresTheKeyFirstRefresh(t *testing.T) { b, err := os.ReadFile(filepath.Join("..", "..", "cmd", "controller", "main.go")) if err != nil { t.Fatalf("main.go: %v", err) } src := string(b) i := strings.Index(src, "cr := &report.ConfigRefresher{") if i < 0 { t.Fatal("main.go no longer builds a report.ConfigRefresher — find the live refresh and pin it here") } block := src[i:] if j := strings.Index(block, "cr.Reconcile("); j > 0 { block = block[:j] } if !strings.Contains(block, "report.PullConfigPreferKey(") || !strings.Contains(block, "cfg.Hub.APIKey") { t.Fatalf("the live config refresh does not use report.PullConfigPreferKey with the box's hub key:\n%s", block) } }