package web import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // seedReport writes a latest report carrying a controller version for customerID. func seedReport(t *testing.T, st *store.Store, customerID, ctrlVer string) { t.Helper() if err := st.SaveReport(customerID, []byte(`{"controller_version":"`+ctrlVer+`"}`)); err != nil { t.Fatalf("SaveReport(%s): %v", customerID, err) } } // TestManifestSave_DoesNotTouchFloor is Part C's central guarantee: saving the Day-0 artifact // manifest must NOT write hub_settings.min_controller_version (the publish-train footgun). Companion // red-proof: add a SetGlobalMinControllerVersion call into handleSetArtifacts → the floor changes → // this fails. func TestManifestSave_DoesNotTouchFloor(t *testing.T) { s, st := newTestServer(t) st.SetDefaultMinControllerVersion("0.87.0") if err := st.SetGlobalMinControllerVersion("0.113.0"); err != nil { t.Fatal(err) } before := st.ResolveGlobalFloor() form := url.Values{"agent_version": {"0.82.0"}, "golden_version": {"0.115.0"}} // No Gitea client in the test → resolveArtifactSHA takes the manual path; provide valid shas. form.Set("agent_sha256", strings.Repeat("a", 64)) form.Set("golden_sha256", strings.Repeat("b", 64)) r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleSetArtifacts(w, r) if m := st.GetArtifactManifest(); m.AgentVersion != "0.82.0" || m.GoldenVersion != "0.115.0" { t.Fatalf("manifest not saved: %+v", m) } after := st.ResolveGlobalFloor() if after.Effective != before.Effective || after.Source != before.Source || after.DBValue != before.DBValue { t.Errorf("manifest save MUST NOT change the floor: before=%+v after=%+v", before, after) } } // TestGlobalFloorImpact_Count: the confirm-dialog probe counts boxes below a proposed floor, // honoring per-customer overrides (an overridden box is governed by its own floor, not the proposed // global). Companion red-proof: drop the override exclusion → the overridden box is miscounted. func TestGlobalFloorImpact_Count(t *testing.T) { s, st := newTestServer(t) // Three reporting boxes. seedReport(t, st, "below-a", "0.110.0") // below a 0.113 proposal seedReport(t, st, "below-b", "0.112.0") // below seedReport(t, st, "current", "0.113.0") // at the proposal (not below) seedReport(t, st, "overridden", "0.90.0") // below the global BUT has its own override at 0.90.0 if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "overridden", RetrievalPassword: "x", APIKey: "y"}); err != nil { t.Fatal(err) } if err := st.SetMinControllerVersion("overridden", "0.90.0"); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=0.113.0", nil) w := httptest.NewRecorder() s.handleGlobalFloorImpact(w, req) if w.Code != http.StatusOK { t.Fatalf("impact: %d", w.Code) } var resp struct { Version string `json:"version"` Valid bool `json:"valid"` Below int `json:"below"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if !resp.Valid || resp.Version != "0.113.0" { t.Fatalf("resp = %+v", resp) } // below-a + below-b = 2; current is AT the floor; overridden is governed by its own 0.90.0. if resp.Below != 2 { t.Errorf("below-floor count = %d, want 2 (overridden box excluded, at-floor excluded)", resp.Below) } // Invalid version → valid:false, below:0. req2 := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=notaversion", nil) w2 := httptest.NewRecorder() s.handleGlobalFloorImpact(w2, req2) var resp2 struct { Valid bool `json:"valid"` Below int `json:"below"` } _ = json.Unmarshal(w2.Body.Bytes(), &resp2) if resp2.Valid || resp2.Below != 0 { t.Errorf("invalid version → valid=false below=0, got %+v", resp2) } } // TestConfigurationPage_RendersFloorSource: the effective-floor source line renders both the // DB-wins and env-fallback states through the production template. func TestConfigurationPage_RendersFloorSource(t *testing.T) { render := func(setup func(st *store.Store)) string { t.Helper() s, st := newTestServer(t) setup(st) req := httptest.NewRequest(http.MethodGet, "/configuration", nil) w := httptest.NewRecorder() s.handleConfiguration(w, req) if w.Code != http.StatusOK { t.Fatalf("configuration page: %d", w.Code) } return w.Body.String() } dbWins := render(func(st *store.Store) { st.SetDefaultMinControllerVersion("0.87.0") _ = st.SetGlobalMinControllerVersion("0.113.0") }) if !strings.Contains(dbWins, "DB (hub_settings)") || !strings.Contains(dbWins, "v0.113.0") { t.Errorf("db-wins source line missing:\n%s", excerpt(dbWins)) } if !strings.Contains(dbWins, "env fallback would be") || !strings.Contains(dbWins, "v0.87.0") { t.Errorf("db-wins must also surface the env fallback value:\n%s", excerpt(dbWins)) } envOnly := render(func(st *store.Store) { st.SetDefaultMinControllerVersion("0.87.0") }) if !strings.Contains(envOnly, "env fallback (DEFAULT_MIN_CONTROLLER_VERSION)") { t.Errorf("env-fallback source line missing:\n%s", excerpt(envOnly)) } // The type-to-confirm wiring must be present (no bare submit button). if !strings.Contains(envOnly, "confirmGlobalFloor()") || !strings.Contains(envOnly, "global-floor/impact") { t.Errorf("type-to-confirm dialog wiring missing") } } func excerpt(s string) string { if i := strings.Index(s, "Managed updates"); i >= 0 { end := i + 900 if end > len(s) { end = len(s) } return s[i:end] } if len(s) > 600 { return s[:600] } return s } // ── R-120: the vouch path REFUSES a golden older than the controller the fleet already runs ────── // // These drive handleSetArtifacts — THE production vouch path, the only UI writer of // SetArtifactManifest — over httptest. Deliberately NOT through an injected seam: the whole point of // this gate is that it cannot be inert, and three shipped defects in this project were fully green // with the seam disconnected. The assertions check BOTH the operator-visible outcome and that the // manifest was not written, because a gate that redirects but still saves is worse than none. // RED-PROOF: delete the `goldenVer != ""` gate block in handleSetArtifacts → this fails, because the // stale golden is accepted and the manifest is overwritten. func TestVouchRefusesGoldenBehindFleet(t *testing.T) { s, st := newTestServer(t) // The R-120 situation exactly: a box is running 0.186.0 while the operator vouches a 0.185.1 golden. seedReport(t, st, "demo", "0.186.0") if err := st.SetArtifactManifest(store.ArtifactManifest{ AgentVersion: "0.116.0", GoldenVersion: "0.186.0", AgentSHA256: strings.Repeat("a", 64), GoldenSHA256: strings.Repeat("b", 64), }); err != nil { t.Fatal(err) } form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {"0.185.1"}} form.Set("agent_sha256", strings.Repeat("c", 64)) form.Set("golden_sha256", strings.Repeat("d", 64)) r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleSetArtifacts(w, r) if got := w.Header().Get("Location"); !strings.Contains(got, "golden_behind_fleet") { t.Errorf("expected the refusal flash, got Location=%q — the operator would not know it was refused", got) } // The load-bearing half: the manifest must be UNCHANGED. if m := st.GetArtifactManifest(); m.GoldenVersion != "0.186.0" { t.Fatalf("REFUSED but still wrote the manifest: golden=%q, want 0.186.0 untouched. A gate that "+ "redirects and saves anyway is worse than no gate — it reads as enforcement.", m.GoldenVersion) } } // The gate must not block legitimate vouches, or it gets disabled and becomes another R-29 orphan. func TestVouchAllowsGoldenAtOrAheadOfFleet(t *testing.T) { s, st := newTestServer(t) seedReport(t, st, "demo", "0.186.0") for _, golden := range []string{"0.186.0", "0.187.0"} { form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {golden}} form.Set("agent_sha256", strings.Repeat("a", 64)) form.Set("golden_sha256", strings.Repeat("b", 64)) r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleSetArtifacts(w, r) if m := st.GetArtifactManifest(); m.GoldenVersion != golden { t.Errorf("golden %s (>= fleet 0.186.0) should be vouchable, manifest holds %q", golden, m.GoldenVersion) } } } // Fail-open case 1: a hub whose fleet has reported nothing must be able to vouch its first golden. func TestVouchAllowedWhenFleetVersionUnknown(t *testing.T) { s, st := newTestServer(t) form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {"0.185.1"}} form.Set("agent_sha256", strings.Repeat("a", 64)) form.Set("golden_sha256", strings.Repeat("b", 64)) r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleSetArtifacts(w, r) if m := st.GetArtifactManifest(); m.GoldenVersion != "0.185.1" { t.Errorf("with no reported fleet version the gate must fail OPEN; manifest holds %q", m.GoldenVersion) } } // Fail-open case 2: clearing the golden field is a legitimate operator act, not drift. func TestVouchAllowsClearingGolden(t *testing.T) { s, st := newTestServer(t) seedReport(t, st, "demo", "0.186.0") form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {""}} form.Set("agent_sha256", strings.Repeat("a", 64)) r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleSetArtifacts(w, r) if got := w.Header().Get("Location"); strings.Contains(got, "golden_behind_fleet") { t.Error("clearing the golden must not trip the gate") } } // The comparison must be SEMVER, not lexical — 0.99.0 vs 0.186.0 is the pair that breaks string order, // and it is not hypothetical: the fleet has shipped both 0.9x and 0.18x controllers. func TestNewestReportedControllerIsSemverOrdered(t *testing.T) { _, st := newTestServer(t) seedReport(t, st, "a", "0.99.0") seedReport(t, st, "b", "0.186.0") if got := st.NewestReportedControllerVersion(); got != "0.186.0" { t.Errorf("NewestReportedControllerVersion() = %q, want 0.186.0 — a lexical MAX() would return 0.99.0", got) } }