package web // DR-tier-by-default (v0.51.0) — the three §10 contracts of the batch spec: // 1. coupling gate: offsite provisioning REQUIRES the DR tier (drill F-6 closed by policy) — // exact refusal message; DR ON proceeds to the existing path. // Red-proof partner: remove the guard in applyOffsite → the refused-case test FAILS. // 2. flag default: the NEW-customer form renders the DR checkbox CHECKED (default ON); // a legacy customer without an applied descriptor stays OFF (backfill covered store-side). // Red-proof partner: flip handleConfigNewForm's DRTier default → the form test FAILS. // 3. capability chips: the host page renders the agent's capability list; "inactive" gets the // NEUTRAL badge (disabled ≠ degraded), degraded stays warn/error, and the pre-v1.15.0 // "binary not found" pbsdr signature surfaces the migration hint. import ( "io" "log" "net/http/httptest" "net/url" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi" "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) const offsiteRequiresDRMsg = "Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)" // Scenario C — the coupling. DR OFF → offsite refused (exact message, Hetzner NEVER touched); // DR ON → proceeds into the existing provisioning path. func TestOffsiteRequiresDRTier(t *testing.T) { newOffsiteServer := func(t *testing.T) (*Server, *store.Store, *hetznerapi.Fake) { s, st := newTestServer(t) fakeAPI := hetznerapi.NewFake() s.SetOffsiteProvisioner(&offsite.Provisioner{ API: fakeAPI, Store: st, Scanner: webTestScanner{}, PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0), }) if err := st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: "cpl", CustomerName: "Coupling", Domain: "cpl.hu", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}", }); err != nil { t.Fatal(err) } return s, st, fakeAPI } post := func(s *Server, form url.Values) *httptest.ResponseRecorder { form.Set("customer_name", "Coupling") form.Set("domain", "cpl.hu") req := httptest.NewRequest("POST", "/configs/cpl", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleConfigUpdate(rr, req, "cpl") return rr } t.Run("DR OFF refuses with the exact message", func(t *testing.T) { s, st, _ := newOffsiteServer(t) rr := post(s, url.Values{"offsite_enabled": {"on"}, "offsite_type": {"shared"}, "offsite_quota_gb": {"50"}}) if rr.Code != 502 { t.Fatalf("offsite with DR OFF = %d (%s), want 502 refusal", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), offsiteRequiresDRMsg) { t.Fatalf("refusal message = %q, want the exact spec wording %q", rr.Body.String(), offsiteRequiresDRMsg) } // The F-6 dead end never starts: no descriptor, no one-time secret, nothing saved. cfg, _ := st.GetCustomerConfig("cpl") if strings.Contains(cfg.ConfigJSON, "offsite") { t.Errorf("offsite descriptor written despite the refusal: %s", cfg.ConfigJSON) } if _, err := st.ConsumeOneTimeSecret("cpl"); err == nil { t.Error("a one-time offsite secret exists despite the refusal") } }) t.Run("DR ON proceeds", func(t *testing.T) { s, st, _ := newOffsiteServer(t) rr := post(s, url.Values{"dr_tier": {"on"}, "offsite_enabled": {"on"}, "offsite_type": {"shared"}, "offsite_quota_gb": {"50"}}) if rr.Code != 303 { t.Fatalf("offsite with DR ON = %d (%s), want 303", rr.Code, rr.Body.String()) } cfg, _ := st.GetCustomerConfig("cpl") if !strings.Contains(cfg.ConfigJSON, `"offsite"`) { t.Fatalf("offsite descriptor missing after a DR-ON provision: %s", cfg.ConfigJSON) } }) } // Flag default: the NEW-customer form starts with the DR-tier checkbox CHECKED (decision 2 — // DR is the default; opting out is the exception). The stored default for a customer saved // WITHOUT the checkbox stays false (legacy shape — asserted store-side with the backfill). func TestDRTier_NewCustomerFormDefaultsOn(t *testing.T) { s, _ := newTestServer(t) req := httptest.NewRequest("GET", "/configs/new", nil) rr := httptest.NewRecorder() s.handleConfigNewForm(rr, req) out := rr.Body.String() if !strings.Contains(out, `name="dr_tier" checked`) { t.Fatal("the new-customer form does not default the DR tier ON") } } // The host page's capability section (v0.51.0): inactive = neutral badge, degraded = warn/error, // and the pre-v1.15.0 pbsdr "binary not found" signature surfaces the migration hint. Also the // honest-reporting edge (§8): degraded is NEVER re-labeled by the hub. func TestHostDetail_CapabilityChips(t *testing.T) { reportFor := func(status, reason string) string { return `{"host":{"cpu_percent":1},"capabilities":[ {"name":"pbsdr-create","feature":"PBS DR storage-entry create","critical":false,"status":"` + status + `","reason":"` + reason + `"}, {"name":"drive-bind","feature":"drive attach","critical":true,"status":"ok"} ]}` } seed := func(t *testing.T, report string) (*Server, *store.Host) { s, st := newTestServer(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: "capc", RetrievalPassword: "pw", APIKey: "k", }); err != nil { t.Fatal(err) } if err := st.UpsertHost(&store.Host{HostID: "capc-01", CustomerID: "capc", APIKey: "h"}); err != nil { t.Fatal(err) } if err := st.SaveHostReport("capc-01", "capc", []byte(report), store.HostReportDenorm{}); err != nil { t.Fatal(err) } host, err := st.GetHost("capc-01") if err != nil || host == nil { t.Fatal("seed host read") } return s, host } render := func(t *testing.T, s *Server, host *store.Host) string { data := s.hostDetailData(host, httptest.NewRequest("GET", "/hosts/capc-01", nil)) var sb strings.Builder if err := s.templates.ExecuteTemplate(&sb, "host_detail_body", data); err != nil { t.Fatalf("render: %v", err) } return sb.String() } t.Run("inactive renders the neutral chip, not red", func(t *testing.T) { s, host := seed(t, reportFor("inactive", "disabled by configuration")) out := render(t, s, host) if !strings.Contains(out, `badge badge-neutral">inactive`) { t.Fatalf("inactive chip not neutral:\n%s", section(out, "Capabilities")) } if strings.Contains(out, `badge-error">inactive`) || strings.Contains(out, `badge-warn">inactive`) { t.Error("inactive rendered as an alarm state — disabled must not look broken") } if strings.Contains(out, "predates the uniform DR plumbing") { t.Error("migration hint shown for a healthy inactive box") } }) t.Run("degraded binary-not-found stays degraded + migration hint", func(t *testing.T) { s, host := seed(t, reportFor("degraded", "binary not found")) out := render(t, s, host) if !strings.Contains(out, `badge badge-warn">degraded`) { t.Fatalf("pbsdr degraded chip missing/re-labeled:\n%s", section(out, "Capabilities")) } if !strings.Contains(out, "predates the uniform DR plumbing") { t.Error("migration hint missing for the pre-v1.15.0 signature (never silently pretend)") } }) t.Run("ok renders ok; critical is marked", func(t *testing.T) { s, host := seed(t, reportFor("ok", "")) out := render(t, s, host) if !strings.Contains(out, `badge badge-ok">ok`) { t.Fatalf("ok chip missing:\n%s", section(out, "Capabilities")) } if !strings.Contains(out, `badge badge-neutral">critical`) { t.Error("critical marker missing on drive-bind") } }) } // section extracts a readable slice of rendered HTML around a heading for failure messages. func section(html, heading string) string { i := strings.Index(html, heading) if i < 0 { return "(heading not found)" } end := i + 1500 if end > len(html) { end = len(html) } return html[i:end] }