package web import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "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" ) // postForm builds a POST request whose urlencoded body is parsed by r.FormValue/r.ParseForm, // exactly like the real edit submission. func postForm(target, body string) *http.Request { r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body)) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") return r } // The debug-mode toggle is a FORM field (not raw-JSON injection) so it survives the on-save rebuild. // This asserts the form→JSON leg: checked writes logging.level=debug; unchecked omits the key entirely // (so the generated controller.yaml default stands — no needless "info"). func TestBuildConfigJSON_DebugMode(t *testing.T) { // Checked → logging.level=debug present. got := buildConfigJSON(postForm("/configs/x/edit", "debug_mode=on")) var on map[string]interface{} if err := json.Unmarshal([]byte(got), &on); err != nil { t.Fatalf("checked: bad JSON %q: %v", got, err) } logging, ok := on["logging"].(map[string]interface{}) if !ok { t.Fatalf("checked: expected a logging object, got %q", got) } if logging["level"] != "debug" { t.Fatalf("checked: expected logging.level=debug, got %v (%q)", logging["level"], got) } // Unchecked → NO logging key at all (not level=info). got = buildConfigJSON(postForm("/configs/x/edit", "customer_name=Kov%C3%A1cs")) var off map[string]interface{} if err := json.Unmarshal([]byte(got), &off); err != nil { t.Fatalf("unchecked: bad JSON %q: %v", got, err) } if _, present := off["logging"]; present { t.Fatalf("unchecked: logging key must be OMITTED, got %q", got) } } // The whole point of the form-level toggle: it survives the on-save ConfigJSON REBUILD, and coexists // with the offsite descriptor (which survives via the separate provision-merge). Companion RED-PROOF: // a foreign key injected straight into the stored ConfigJSON is GONE after one save — which is exactly // why the switch had to be a form field, not raw-JSON injection. func TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched(t *testing.T) { 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), }) const id = "cust-dbg" if err := st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: id, CustomerName: "Kovács", Domain: "kovacs.felhom.eu", ConfigJSON: "{}", }); err != nil { t.Fatalf("seed customer: %v", err) } // 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet. const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50" w := httptest.NewRecorder() s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id) if w.Code != http.StatusSeeOther { t.Fatalf("first save: expected 303, got %d: %s", w.Code, w.Body.String()) } cfg, _ := st.GetCustomerConfig(id) before := offsiteOf(t, cfg.ConfigJSON) if before["host"] == nil || before["host"] == "" { t.Fatalf("first save should have merged an offsite descriptor, got %q", cfg.ConfigJSON) } // 2) Inject a foreign key directly into the stored ConfigJSON — the raw-JSON path the task ruled out. var obj map[string]json.RawMessage json.Unmarshal([]byte(cfg.ConfigJSON), &obj) obj["foo"] = json.RawMessage(`"bar"`) inj, _ := json.Marshal(obj) cfg.ConfigJSON = string(inj) if err := st.SaveCustomerConfig(cfg); err != nil { t.Fatalf("inject foreign key: %v", err) } // 3) Second save WITH offsite still enabled AND debug checked. w = httptest.NewRecorder() s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm+"&debug_mode=on"), id) if w.Code != http.StatusSeeOther { t.Fatalf("second save: expected 303, got %d: %s", w.Code, w.Body.String()) } cfg, _ = st.GetCustomerConfig(id) var final map[string]interface{} if err := json.Unmarshal([]byte(cfg.ConfigJSON), &final); err != nil { t.Fatalf("final ConfigJSON invalid: %v", err) } // (a) debug logging key made it in. logging, _ := final["logging"].(map[string]interface{}) if logging == nil || logging["level"] != "debug" { t.Fatalf("debug toggle did not survive the save: logging=%v (%q)", final["logging"], cfg.ConfigJSON) } // (b) offsite descriptor UNCHANGED across the save+re-provision (idempotent label lookup). after := offsiteOf(t, cfg.ConfigJSON) for _, k := range []string{"host", "user", "repo_path", "host_fingerprint", "quota_gb"} { if before[k] != after[k] { t.Fatalf("offsite.%s changed across save: %v -> %v", k, before[k], after[k]) } } // RED-PROOF: the injected foreign key is GONE — buildConfigJSON rebuilt ConfigJSON from the form, // so anything not represented as a form field is dropped. This is why the switch is a form field. if _, present := final["foo"]; present { t.Fatalf("foreign key survived the rebuild — the on-save rebuild premise is wrong: %q", cfg.ConfigJSON) } } // offsiteOf extracts the offsite sub-object as a generic map for field-by-field comparison. func offsiteOf(t *testing.T, configJSON string) map[string]interface{} { t.Helper() var m map[string]interface{} if err := json.Unmarshal([]byte(configJSON), &m); err != nil { t.Fatalf("parse ConfigJSON: %v", err) } o, _ := m["offsite"].(map[string]interface{}) if o == nil { t.Fatalf("no offsite object in %q", configJSON) } return o } // Render leg: a debug-on ConfigJSON draws the checkbox checked; a plain one draws it unchecked. func TestConfigForm_DebugRenderState(t *testing.T) { s, _ := newTestServer(t) render := func(configJSON string) string { var overrides map[string]interface{} json.Unmarshal([]byte(configJSON), &overrides) data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string CSRFField string PBSDR pbsDRView }{ Config: &store.CustomerConfig{CustomerID: "c1"}, Overrides: overrides, ActiveNav: "configs", } var b strings.Builder if err := s.templates.ExecuteTemplate(&b, "config_form.html", data); err != nil { t.Fatalf("render: %v", err) } return b.String() } // debug on → the debug_mode checkbox carries `checked`. out := render(`{"logging":{"level":"debug"}}`) if !debugChecked(out) { t.Fatalf("debug ConfigJSON should render the checkbox checked:\n%s", isolate(out)) } // plain → not checked. out = render(`{}`) if debugChecked(out) { t.Fatalf("plain ConfigJSON must render the checkbox UNchecked:\n%s", isolate(out)) } } // debugChecked reports whether the debug_mode checkbox input renders with the `checked` attribute. func debugChecked(html string) bool { i := strings.Index(html, `name="debug_mode"`) if i < 0 { return false } // The input tag ends at the next '>'; `checked` (if present) sits before it. end := strings.Index(html[i:], ">") if end < 0 { return false } return strings.Contains(html[i:i+end], "checked") } // isolate trims the rendered page down to the debug_mode input's neighborhood for readable failures. func isolate(html string) string { i := strings.Index(html, `name="debug_mode"`) if i < 0 { return "(debug_mode input not found)" } start := i - 60 if start < 0 { start = 0 } end := i + 60 if end > len(html) { end = len(html) } return html[start:end] }