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 , never nested // inside the config
(invalid HTML; breaks the formaction sub-buttons). Between the // config form's opening tag and its first
no other
") if formEnd < 0 { t.Fatal("config form never closes") } if inner := panel[formStart : formStart+formEnd]; strings.Contains(inner, " — 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) } }) } }