Files
felhom.eu/hub/internal/web/customer_edit_tab_test.go
T
admin 61dbd870c3 feat(hub): v0.69.0 — customer DELETE is the guided full-teardown cascade (R-25b)
POST /configs/{id}/delete now runs hosts -> RESET -> purge behind three
acknowledgements, a typed customer-id, a stale-preview check and the
ONLINE-host refusal (every gate before any write, so a refusal has zero
side effects). The shallow handleConfigDelete is gone.

Two invariants are asserted, not just commented: ruling 3 is preserved by
construction (leg 2 never sees a host row) and retained escrow custody is
purged exactly once, in leg 3 (leg 2 runs with purgeEscrow=false).

handleCustomerReset's committed half was extracted as commitCustomerReset;
the standalone RESET path is byte-identical to v0.68.1 and its suite is
untouched. Five red-proofs run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J55BQE1gE2V4ffud5jweGS
2026-07-21 19:31:48 +02:00

293 lines
12 KiB
Go

package web
// v0.48.0 edit-a — the standalone customer edit page merged into the customer page's Edit tab
// (config_form_body embedded; Block/Delete relocated to a danger zone) plus the redirect-anchor
// contract of the surrounding POST actions. Groups A + B of the task's test plan.
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// editPanel slices the rendered customer page down to the Edit tab's panel (from its opening div
// to the next panel) so containment assertions are panel-scoped, not page-scoped.
func editPanel(t *testing.T, html string) string {
t.Helper()
start := strings.Index(html, `tab-panel" data-tab="edit"`)
if start < 0 {
t.Fatal("Edit tab panel missing")
}
end := strings.Index(html[start:], `tab-panel" data-tab="backup"`)
if end < 0 {
t.Fatal("Backup panel (the Edit panel's terminator) missing")
}
return html[start : start+end]
}
// Group A — the Edit tab renders the full mutation surface and the header is cleaned.
func TestCustomerEditTab_RendersMutationSurface(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", Email: "a@acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`,
}); err != nil {
t.Fatal(err)
}
if err := st.SaveReport("c1", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "c1")
panel := editPanel(t, html)
// The panel carries the config form + the three sibling cards.
for _, want := range []string{
`action="/configs/c1/edit"`, // the embedded config form
"Controller Update",
"Geo-korlátozás",
"Danger zone",
`action="/customers/c1/block"`,
`action="/configs/c1/delete"`,
`name="_csrf"`, // CSRF field inside the form
} {
if !strings.Contains(panel, want) {
t.Errorf("Edit panel missing %q", want)
}
}
// Stored override values populate the embedded form (values come from Config/ConfigJSON).
if !strings.Contains(panel, `value="stored-tunnel-token"`) {
t.Error("embedded form does not render the stored cf_tunnel_token override")
}
// The named trap: the floor/geo/danger forms must be SIBLINGS after </form>, never nested
// inside the config <form> (invalid HTML; breaks the formaction sub-buttons). Between the
// config form's opening tag and its first </form> no other <form may open.
formStart := strings.Index(panel, `action="/configs/c1/edit"`)
formEnd := strings.Index(panel[formStart:], "</form>")
if formEnd < 0 {
t.Fatal("config form never closes")
}
if inner := panel[formStart : formStart+formEnd]; strings.Contains(inner, "<form") {
t.Error("a form is NESTED inside the config <form> — floor/geo/danger must be siblings")
}
// Header cleaned: the Edit link is gone, and Block/Delete exist EXACTLY once on the whole
// page (in the Edit panel). RED-PROOF: restoring the old header buttons makes the counts 2
// and this FAILS — a naive Contains would not catch the duplicate danger zone.
if strings.Contains(html, `href="/configs/c1/edit"`) {
t.Error("header still links to the retired standalone edit page")
}
if got := strings.Count(html, `action="/customers/c1/block"`); got != 1 {
t.Errorf("Block form count = %d, want exactly 1 (in the danger zone)", got)
}
if got := strings.Count(html, `action="/configs/c1/delete"`); got != 1 {
t.Errorf("Delete form count = %d, want exactly 1 (in the danger zone)", got)
}
}
// A blocked customer's danger zone offers Unblock instead of Block (the old header conditional).
func TestCustomerEditTab_BlockedShowsUnblock(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "cb", CustomerName: "Blocked", Domain: "b.hu",
RetrievalPassword: "pw", APIKey: "k",
}); err != nil {
t.Fatal(err)
}
// SaveCustomerConfig does not persist Status — blocking is its own state transition.
if err := st.SetCustomerConfigStatus("cb", "blocked"); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "cb")
panel := editPanel(t, html)
if !strings.Contains(panel, `action="/customers/cb/unblock"`) {
t.Error("blocked customer's danger zone missing the Unblock form")
}
if strings.Contains(html, `action="/customers/cb/block"`) {
t.Error("blocked customer must not offer Block")
}
}
// Group B1 — the standalone GET edit page 302s to the Edit tab.
func TestConfigEditForm_RedirectsToEditTab(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/c1/edit", nil), "c1")
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/customers/c1#tab=edit" {
t.Errorf("Location = %q, want /customers/c1#tab=edit", loc)
}
// Unknown customer still 404s (no open redirect for garbage ids).
rr = httptest.NewRecorder()
s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/nope/edit", nil), "nope")
if rr.Code != http.StatusNotFound {
t.Errorf("unknown customer edit = %d, want 404", rr.Code)
}
}
// Group B2 — the create flow (IsNew) still renders standalone chrome around the SAME shared
// body sub-template, and the create round-trip is unchanged.
func TestConfigNewForm_StandaloneUnchanged(t *testing.T) {
s, st := newTestServer(t)
rr := httptest.NewRecorder()
s.handleConfigNewForm(rr, httptest.NewRequest("GET", "/configs/new", nil))
if rr.Code != 200 {
t.Fatalf("new form status = %d", rr.Code)
}
html := rr.Body.String()
for _, want := range []string{"Add Customer", `action="/configs/new"`, `id="customer_id"`, "Create Configuration"} {
if !strings.Contains(html, want) {
t.Errorf("new form missing %q", want)
}
}
form := url.Values{"customer_id": {"newcust"}, "customer_name": {"New"}, "domain": {"new.hu"}}
req := httptest.NewRequest("POST", "/configs/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
s.handleConfigCreate(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("create = %d, want 303: %s", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != "/customers/newcust?flash=created" {
t.Errorf("create Location = %q", loc)
}
if cfg, _ := st.GetCustomerConfig("newcust"); cfg == nil {
t.Error("created config not stored")
}
}
// Group B3 — THE load-bearing correctness test: a validation error re-renders the STANDALONE
// form with the SUBMITTED values preserved (overrides pass-through), never the stored ones.
// RED-PROOF (run once during v0.48.0): passing nil overrides in handleConfigUpdate's error path
// makes the builder fall back to the STORED ConfigJSON → "typed-tunnel-token" disappears and
// "stored-tunnel-token" renders → this test FAILS.
func TestConfigUpdate_ValidationErrorPreservesTypedValues(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`,
}); err != nil {
t.Fatal(err)
}
// Invalid: customer_name cleared; the operator also typed a NEW tunnel token.
rr := httptest.NewRecorder()
s.handleConfigUpdate(rr, postForm("/configs/c1/edit",
"customer_name=&domain=acme.hu&cf_tunnel_token=typed-tunnel-token"), "c1")
if rr.Code != 200 {
t.Fatalf("validation error = %d, want 200 (standalone re-render)", rr.Code)
}
html := rr.Body.String()
if !strings.Contains(html, "Display Name and Domain are required.") {
t.Error("error message missing from the re-render")
}
if !strings.Contains(html, `value="typed-tunnel-token"`) {
t.Error("SUBMITTED value lost on the error re-render (values reset to stored)")
}
if strings.Contains(html, "stored-tunnel-token") {
t.Error("stored value rendered instead of the submitted one")
}
// Nothing was saved.
cfg, _ := st.GetCustomerConfig("c1")
if cfg.CustomerName != "Acme" || !strings.Contains(cfg.ConfigJSON, "stored-tunnel-token") {
t.Error("invalid submit mutated the stored config")
}
}
// Group B4/B5 — redirect anchors, table-driven over the handlers whose buttons live on the
// customer page: post-action lands back on the tab the action came from.
func TestCustomerActions_RedirectAnchors(t *testing.T) {
newProvisioned := func(t *testing.T) (*Server, *store.Store) {
s, st := newTestServer(t)
s.SetOffsiteProvisioner(&offsite.Provisioner{
API: hetznerapi.NewFake(), Store: st, Scanner: webTestScanner{},
PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
})
cfg := &store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}",
DRTier: true, // offsite requires the DR tier since v0.51.0 (the F-6 coupling)
}
// Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze
// find exactly one labelled sub-account.
r := postForm("/configs/c1/edit", "dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50")
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
t.Fatalf("provision: %v", err)
}
if err := st.SaveCustomerConfig(cfg); err != nil {
t.Fatal(err)
}
return s, st
}
cases := []struct {
name string
invoke func(t *testing.T, s *Server, rr *httptest.ResponseRecorder)
blocked bool // seed Status=blocked (for unblock)
wantLoc string
}{
{"update", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigUpdate(rr, postForm("/configs/c1/edit", "customer_name=Acme&domain=acme.hu"), "c1")
}, false, "/customers/c1?flash=updated#tab=edit"},
{"block", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleBlockCustomer(rr, postForm("/customers/c1/block", ""), "c1")
}, false, "/customers/c1?flash=blocked#tab=edit"},
{"unblock", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleUnblockCustomer(rr, postForm("/customers/c1/unblock", ""), "c1")
}, true, "/customers/c1?flash=unblocked#tab=edit"},
{"offsite-reissue", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleOffsiteReissue(rr, postForm("/configs/c1/offsite-reissue", ""), "c1")
}, false, "/customers/c1?flash=offsite_reissued#tab=edit"},
{"offsite-freeze", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleOffsiteFreeze(rr, postForm("/configs/c1/offsite-freeze", ""), "c1", true)
}, false, "/customers/c1?flash=offsite_frozen#tab=edit"},
{"regen-password lands on Setup (its card lives there)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigRegenPassword(rr, postForm("/configs/c1/regen-password", ""), "c1")
}, false, "/customers/c1?flash=password_regenerated#tab=setup"},
// v0.69.0 (R-25b): the shallow delete became the guided cascade — same route, same
// anchor-free landing, but the three acks + typed id + host-count are now required.
{"delete stays anchor-free (leaves the page)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleCustomerDelete(rr, postForm("/configs/c1/delete",
"ack_hosts=1&ack_reset=1&ack_purge=1&confirm_id=c1&expect_hosts=0"), "c1")
}, false, "/configs?flash=deleted"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, st := newProvisioned(t)
if tc.blocked {
if err := st.SetCustomerConfigStatus("c1", "blocked"); err != nil {
t.Fatal(err)
}
}
rr := httptest.NewRecorder()
tc.invoke(t, s, rr)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != tc.wantLoc {
t.Errorf("Location = %q, want %q", loc, tc.wantLoc)
}
})
}
}