hub v0.70.1: the ghost customer's Delete button must exist (Danger-zone render gate split)
This commit is contained in:
@@ -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…</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user