448a68237a
Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
290 lines
12 KiB
Go
290 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"},
|
|
{"delete stays anchor-free (leaves the page)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
|
|
s.handleConfigDelete(rr, postForm("/configs/c1/delete", ""), "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)
|
|
}
|
|
})
|
|
}
|
|
}
|