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

204 lines
7.5 KiB
Go

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")
}
}