package web // Scenarios C/D (hub v0.47.0 stale host removal) — the web-layer gates. Every refusal test // asserts the NON-effect (the host and its artifacts still exist), not just the status code. // The store-level cascade completeness lives in store/host_delete_test.go. import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) func postHostDelete(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/delete", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleHostDelete(rr, req, hostID) return rr } // D1 — an ONLINE host is never deletable, even with a correct confirmation + escrow ack. // RED-PROOF 1: removing the online gate in handleHostDelete makes this FAIL (host deleted). func TestHostDelete_OnlineRefused(t *testing.T) { s, st := newTestServer(t) if err := st.UpsertHost(&store.Host{HostID: "live-host", CustomerID: "c1", APIKey: "k"}); err != nil { t.Fatal(err) } // A just-saved report → status "ok" (online). if err := st.SaveHostReport("live-host", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil { t.Fatal(err) } before, err := st.CountHostArtifacts("live-host") if err != nil { t.Fatal(err) } rr := postHostDelete(t, s, "live-host", url.Values{ "confirm_host_id": {"live-host"}, "delete_escrow": {"1"}, }) if rr.Code != http.StatusConflict { t.Fatalf("online delete = %d, want 409", rr.Code) } // Non-effect: the host row and every artifact are still there. if h, _ := st.GetHost("live-host"); h == nil { t.Fatal("online host was DELETED despite the 409") } after, _ := st.CountHostArtifacts("live-host") if after != before { t.Errorf("artifacts changed on a refused delete: %+v → %+v", before, after) } } // D2 — escrow present + no acknowledgement → 409 naming the escrow, ZERO deletions. // RED-PROOF 2: dropping the escrow-ack check (passing deleteEscrow=true unconditionally) // makes this FAIL (host + escrow deleted). func TestHostDelete_EscrowAckRequired(t *testing.T) { s, st := newTestServer(t) if err := st.UpsertHost(&store.Host{HostID: "esc-host", CustomerID: "c2", APIKey: "k"}); err != nil { t.Fatal(err) } if _, err := st.SaveHostEscrow("esc-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil { t.Fatal(err) } rr := postHostDelete(t, s, "esc-host", url.Values{"confirm_host_id": {"esc-host"}}) if rr.Code != http.StatusConflict { t.Fatalf("escrow-unacked delete = %d, want 409", rr.Code) } if !strings.Contains(rr.Body.String(), "escrow") { t.Error("409 body must name the escrow so the operator knows what to acknowledge") } if h, _ := st.GetHost("esc-host"); h == nil { t.Fatal("host deleted despite missing escrow ack") } if e, _ := st.GetHostEscrow("esc-host"); e == nil { t.Fatal("escrow deleted despite missing ack") } } // D3 — type-to-confirm mismatch → 400, zero deletions. func TestHostDelete_ConfirmMismatch(t *testing.T) { s, st := newTestServer(t) if err := st.UpsertHost(&store.Host{HostID: "typo-host", CustomerID: "c3", APIKey: "k"}); err != nil { t.Fatal(err) } rr := postHostDelete(t, s, "typo-host", url.Values{"confirm_host_id": {"typo-hots"}}) if rr.Code != http.StatusBadRequest { t.Fatalf("confirm mismatch = %d, want 400", rr.Code) } if h, _ := st.GetHost("typo-host"); h == nil { t.Fatal("host deleted despite confirm mismatch") } // Unknown host → 404. rr = postHostDelete(t, s, "ghost", url.Values{"confirm_host_id": {"ghost"}}) if rr.Code != http.StatusNotFound { t.Errorf("unknown host delete = %d, want 404", rr.Code) } } // D4 — the impact probe returns the documented JSON shape: counts + booleans ONLY. func TestHostDelete_ImpactJSON(t *testing.T) { s, st := newTestServer(t) if err := st.UpsertHost(&store.Host{HostID: "imp-host", CustomerID: "c4", APIKey: "SECRET-KEY"}); err != nil { t.Fatal(err) } if _, err := st.SaveHostEscrow("imp-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil { t.Fatal(err) } if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("imp-host", 100), CustomerID: "c4", HostID: "imp-host", VMID: 100, Status: "stopped"}); err != nil { t.Fatal(err) } rr := httptest.NewRecorder() s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/imp-host/delete-impact", nil), "imp-host") if rr.Code != http.StatusOK { t.Fatalf("impact = %d", rr.Code) } var d struct { Status string `json:"status"` Deletable bool `json:"deletable"` Guests int `json:"guests"` Reports int `json:"reports"` LogBundles int `json:"log_bundles"` EscrowPresent bool `json:"escrow_present"` WGPeerBound bool `json:"wg_peer_bound"` PBSSecretPresent bool `json:"pbs_secret_present"` RecoveryPresent bool `json:"recovery_present"` } if err := json.Unmarshal(rr.Body.Bytes(), &d); err != nil { t.Fatalf("impact JSON: %v", err) } if d.Status != "pending" || !d.Deletable || d.Guests != 1 || !d.EscrowPresent { t.Errorf("impact = %+v, want pending/deletable/guests=1/escrow", d) } // Booleans/counts only — never the api_key or blob bytes. if strings.Contains(rr.Body.String(), "SECRET-KEY") || strings.Contains(rr.Body.String(), "blob") { t.Error("SECRET LEAK: impact JSON carries a secret value") } // Unknown host → 404. rr = httptest.NewRecorder() s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/nope/delete-impact", nil), "nope") if rr.Code != http.StatusNotFound { t.Errorf("unknown impact = %d, want 404", rr.Code) } } // Scenario C (handler level) — a deletable host with escrow + ack: 303 to /hosts, rows gone. func TestHostDelete_HappyPath(t *testing.T) { s, st := newTestServer(t) if err := st.UpsertHost(&store.Host{HostID: "dr-drill", CustomerID: "c5", APIKey: "k"}); err != nil { t.Fatal(err) } if _, err := st.SaveHostEscrow("dr-drill", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil { t.Fatal(err) } rr := postHostDelete(t, s, "dr-drill", url.Values{ "confirm_host_id": {"dr-drill"}, "delete_escrow": {"1"}, }) if rr.Code != http.StatusSeeOther { t.Fatalf("delete = %d (%s), want 303", rr.Code, rr.Body.String()) } if loc := rr.Header().Get("Location"); loc != "/hosts" { t.Errorf("redirect = %q, want /hosts", loc) } if h, _ := st.GetHost("dr-drill"); h != nil { t.Fatal("host row survived the delete") } if e, _ := st.GetHostEscrow("dr-drill"); e != nil { t.Fatal("escrow row survived the acknowledged delete") } } // The danger-zone card renders ONLY for a non-online host. The ONLINE case is pinned by // TestHandleHostDetail's exactly-2-buttons assertion (which now doubles as the // "delete hidden for online hosts" proof). func TestHostDetail_DangerCardForStaleOnly(t *testing.T) { s, st := newTestServer(t) if err := st.UpsertHost(&store.Host{HostID: "junk-host", CustomerID: "c6", APIKey: "k"}); err != nil { t.Fatal(err) } rr := httptest.NewRecorder() s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/junk-host", nil), "junk-host") body := rr.Body.String() if !strings.Contains(body, "Danger zone") { t.Error("non-online host missing the danger-zone card") } if !strings.Contains(body, `action="/hosts/junk-host/delete"`) { t.Error("danger-zone card missing the delete form") } if !strings.Contains(body, "Re-enrollment requires the Day-0 passphrase flow") { t.Error("danger copy must state the consequence") } }