hub v0.70.1: the ghost customer's Delete button must exist (Danger-zone render gate split)

This commit is contained in:
2026-07-22 09:23:37 +02:00
parent 2cf37f263b
commit f4c2c4151b
7 changed files with 199 additions and 4 deletions
+20
View File
@@ -283,6 +283,12 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Overrides map[string]interface{}
IsBlocked bool
// Deletable (v0.70.1) gates the Danger-zone card. It is the exact negation of the delete
// preview's 404 predicate (customer_delete.go: cfg == nil && no hosts && residue empty) —
// one truth, not a lookalike. Before v0.70.1 the card sat inside {{if .HasConfig}}, so the
// entire v0.70.0 ghost-delete path was implemented but unreachable (dead UI).
Deletable bool
HasReports bool
Customer *store.CustomerSummary
Report map[string]interface{}
@@ -397,6 +403,19 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt)
}
// v0.70.1: the Danger-zone render gate. Hosts are already fetched above for the Host tab —
// only the residue count is an extra read, and it runs ONLY on the ghost shape (config-less,
// hostless), never on the hot normal path. A lookup error logs and leaves Deletable=false:
// fail toward HIDING a destructive control, never toward showing one on unknown state.
deletable := cfg != nil || len(hostViews) > 0
if !deletable {
if residue, err := s.store.CustomerResidue(customerID); err != nil {
s.logger.Printf("[ERROR] CustomerResidue %s: %v", customerID, err)
} else {
deletable = residue.Total() > 0
}
}
// R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate
// the offsite re-issue handler already uses to refuse ("No provisioned offsite tier").
var offsiteView struct {
@@ -420,6 +439,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Config: cfg,
Overrides: overrides,
IsBlocked: cfg != nil && cfg.Status == "blocked",
Deletable: deletable,
HasReports: customer != nil,
Customer: customer,
@@ -0,0 +1,120 @@
package web
// v0.70.1 — the ghost customer's Delete button must EXIST. v0.70.0 made ghost customers
// deletable server-side, but the Danger-zone card sat inside {{if .HasConfig}}, so the whole
// ghost path was implemented and unreachable (dead UI — the fourth inert-seam defect, found
// live on demo-vm-felhom). Handler tests that POST directly prove nothing about reachability:
// these tests assert the RENDERED page, per branch of the new gate.
//
// Assertions anchor on the delete form's action attribute and the customerDeleteOpen( call
// site — not free-text strings a copy edit would break.
import (
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
const (
deleteFormMarker = `action="/configs/ghost/delete"`
deleteOpenMarker = `customerDeleteOpen('ghost')`
deleteFuncMarker = `function customerDeleteOpen(`
resetFormMarker = `action="/configs/ghost/reset"`
blockFormMarker = `action="/customers/ghost/block"`
unblockFormMkr = `action="/customers/ghost/unblock"`
)
// Scenario A — ghost with residue (the demo-vm-felhom shape): no config row, 0 hosts,
// residue.Total() > 0 (report rows). The Danger zone must render — delete form, opener button,
// and its script — while RESET and Block (config-coupled affordances) must NOT.
func TestCustomerPage_GhostWithResidue_RendersDeleteOnly(t *testing.T) {
s, st := newTestServer(t)
// Reports only — NO SaveCustomerConfig. This is the ghost: config gone, residue keeps the
// customer on the list and raising staleness alerts.
if err := st.SaveReport("ghost", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
if residue, err := st.CustomerResidue("ghost"); err != nil || residue.Total() == 0 {
t.Fatalf("fixture must have residue (got total=%v err=%v)", residue, err)
}
html := renderCustomerPage(t, s, "ghost")
if !strings.Contains(html, deleteFormMarker) {
t.Errorf("ghost page must render the delete form (%s) — without it the entire v0.70.0 ghost-delete path is dead UI", deleteFormMarker)
}
if !strings.Contains(html, deleteOpenMarker) {
t.Errorf("ghost page must render the Delete button's %s opener", deleteOpenMarker)
}
if !strings.Contains(html, deleteFuncMarker) {
t.Errorf("the customerDeleteOpen script must exist whenever the button does (was inside the old HasConfig gate)")
}
// The WRONG outcome this split must not introduce: RESET rendering for a ghost. RESET is
// identity-preserving re-onboarding; a ghost has no identity to preserve.
if strings.Contains(html, resetFormMarker) {
t.Errorf("RESET card must NOT render for a ghost (found %s)", resetFormMarker)
}
// Blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost.
if strings.Contains(html, blockFormMarker) || strings.Contains(html, unblockFormMkr) {
t.Error("Block/Unblock forms must NOT render for a ghost")
}
}
// Scenario B — normal configured customer: byte-for-byte the affordances of today — RESET card
// + full Danger zone incl. Block, and the delete machinery.
func TestCustomerPage_ConfiguredCustomer_KeepsAllAffordances(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "ghost", CustomerName: "Ghost", Domain: "ghost.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
if err := st.SaveReport("ghost", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "ghost")
for _, marker := range []string{resetFormMarker, blockFormMarker, deleteFormMarker, deleteOpenMarker, deleteFuncMarker} {
if !strings.Contains(html, marker) {
t.Errorf("configured customer lost an affordance: %s absent — the gate split must not change the normal shape", marker)
}
}
}
// Scenario B variant — a BLOCKED configured customer renders Unblock (the IsBlocked branch
// survived the extra HasConfig wrapper around the block forms).
func TestCustomerPage_BlockedCustomer_RendersUnblock(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "ghost", CustomerName: "Ghost", Domain: "ghost.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
// SaveCustomerConfig never writes status — blocking goes through SetCustomerConfigStatus
// (the same call the /block handler makes).
if err := st.SetCustomerConfigStatus("ghost", "blocked"); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "ghost")
if !strings.Contains(html, unblockFormMkr) {
t.Errorf("blocked configured customer must render the Unblock form (%s)", unblockFormMkr)
}
}
// Scenario C — nothing left: no config, 0 hosts, residue.Total() == 0. There is no renderable
// Deletable=false state with real store reads (customer != nil implies a report row implies
// residue > 0), so "no Danger zone" manifests as the page-level 404 — the same semantics as the
// delete preview's 404 ("there is nothing here").
func TestCustomerPage_NothingLeft_404(t *testing.T) {
s, _ := newTestServer(t)
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/ghost", nil), "ghost")
if rr.Code != 404 {
t.Fatalf("a customer with no config, no hosts and no residue must 404 (mirrors the preview) — got %d", rr.Code)
}
}
@@ -811,13 +811,20 @@
document.getElementById('cust-reset-form-' + cid).submit();
}
</script>
{{end}}
<!-- Danger zone (v0.48.0 edit-a): the Block/Delete forms relocated verbatim from the
Customer Info header — endpoints and confirm() handlers unchanged. -->
Customer Info header — endpoints and confirm() handlers unchanged.
v0.70.1: gated on .Deletable, NOT .HasConfig — a GHOST customer (config gone, hosts or
report/telemetry residue remain) must render the Delete button, or the whole v0.70.0
ghost-delete path is dead UI (found live on demo-vm-felhom). RESET stays HasConfig-gated
above (identity-preserving re-onboarding — a ghost has no identity to preserve). -->
{{if .Deletable}}
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}}
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
{{.CSRFField}}
@@ -829,6 +836,7 @@
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Block this customer? They will be hidden from the Dashboard.">Block</button>
</form>
{{end}}
{{end}}
<button type="button" class="btn btn-danger btn-sm" onclick="customerDeleteOpen('{{.CustomerID}}')">Delete customer&hellip;</button>
</div>