Files
felhom.eu/hub/internal/web/drtier_test.go
T

190 lines
7.7 KiB
Go

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]
}