package web import ( "log" "net/http" "net/url" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-544: an acknowledged host delete with an escrow logs the DEMOTION, never "escrow deleted: true" — // the line an operator would read as a household's last key being destroyed. func TestHostDelete_LogSaysEscrowDemotedNotDeleted(t *testing.T) { s, st := newTestServer(t) var logBuf strings.Builder s.logger = log.New(&logBuf, "", 0) 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"}, "delete_escrow": {"1"}}) if rr.Code != http.StatusSeeOther { t.Fatalf("acked delete = %d, want 303: %s", rr.Code, rr.Body.String()) } out := logBuf.String() if strings.Contains(out, "escrow deleted") { t.Errorf("log still says the escrow was deleted:\n%s", out) } if !strings.Contains(out, "escrow custody demoted to retained") { t.Errorf("log must state the demotion:\n%s", out) } // And it is true: the blob survives as retained custody (a delete would be the bug the log implied). if n, err := st.CountSupersededEscrow("esc-host"); err != nil || n == 0 { t.Errorf("retained escrow count = 0 after a host delete — custody was destroyed, not demoted") } } // A host with no escrow says so, not "demoted". func TestHostDelete_LogNoEscrow(t *testing.T) { if got := hostDeleteEscrowEffect(false, nil); got != "no key escrow held" { t.Fatalf("no-escrow effect = %q", got) } } // R-599: an ONLINE refusal names how long ago the last report arrived and when deletion opens, // computed from the CONFIGURED stale threshold (45m here — not the 30m code default). func TestHostDelete_OnlineRefusalSaysWhenItOpens(t *testing.T) { s, st := newTestServer(t) s.staleThreshold = 45 * time.Minute if err := st.UpsertHost(&store.Host{HostID: "gone-vm", CustomerID: "c1", APIKey: "k"}); err != nil { t.Fatal(err) } if err := st.SaveHostReport("gone-vm", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil { t.Fatal(err) } h, err := st.GetHost("gone-vm") if err != nil || h == nil || h.LastReportAt == nil { t.Fatalf("host/last report: %v", err) } opens := h.LastReportAt.Add(45 * time.Minute).UTC().Format("15:04") rr := postHostDelete(t, s, "gone-vm", url.Values{"confirm_host_id": {"gone-vm"}, "delete_escrow": {"1"}}) if rr.Code != http.StatusConflict { t.Fatalf("online delete = %d, want 409", rr.Code) } body := rr.Body.String() for _, want := range []string{"min ago", "deletion opens at " + opens + " UTC", "45m0s"} { if !strings.Contains(body, want) { t.Errorf("host-delete 409 body missing %q:\n%s", want, body) } } // The customer delete cascade's ONLINE refusal says the same. if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", CustomerName: "c1", APIKey: "a", RetrievalPassword: "p"}); err != nil { t.Fatal(err) } rr = postDelete(t, s, "c1", cascadeForm("c1", 1)) if rr.Code != http.StatusConflict || !strings.Contains(rr.Body.String(), "deletion opens at "+opens+" UTC") { t.Errorf("cascade ONLINE refusal = %d %q, want 409 naming the opening time", rr.Code, rr.Body.String()) } } // The arithmetic, with a fixed clock: last report 10 minutes ago at 17:12 UTC, threshold 45m. func TestDeletionOpensAt_Arithmetic(t *testing.T) { s := &Server{staleThreshold: 45 * time.Minute} last := time.Date(2026, 9, 20, 17, 12, 0, 0, time.UTC) got := s.deletionOpensAt(&last, last.Add(10*time.Minute)) for _, want := range []string{"10 min ago (17:12 UTC)", "deletion opens at 17:57 UTC", "(45m0s)"} { if !strings.Contains(got, want) { t.Errorf("deletionOpensAt = %q, missing %q", got, want) } } if s.deletionOpensAt(nil, last) != "" { t.Error("no last report → no timing sentence") } }