e247dbc1be
- host_escrow_superseded table + SaveHostEscrow retains a different-sha old blob before overwrite (tx); same-sha idempotent (no supersede row); returns superseded bool. ACK/restore read the current row unchanged. CountSuperseded/ListSuperseded; DeleteHost drops retained rows. - escrow_superseded audit event + operator retained-count on host detail; register offbox_repo_orphaned/reset. Red-proof TestSaveHostEscrow_RetainsSuperseded.
204 lines
7.6 KiB
Go
204 lines
7.6 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")
|
|
}
|
|
}
|