package web import ( "bytes" "io" "log" "net/http/httptest" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // R-249 — THE PASSPHRASE MUST NOT BE IN THE RESPONSE BODY OF A PAGE THE CUSTOMER MERELY OPENS. // // WHY THESE TESTS ASSERT ON THE BODY AND NOT ON A RENDERED VIEW, which is the whole reason the // defect survived: the old markup put the value inside ``. Every test // that asked "what does the customer SEE" passed, because a browser drew asterisks. The value was // in the bytes the whole time, and a `curl` of the page returned it — which is how it was found, by // landing in a session transcript on 2026-08-07. // // So: render the real page and search the raw HTML. A test that cannot see a display:none span // cannot see this defect at all. const testRetrievalPassphrase = "edeni-oshalom-disztok-harul-Zsolna-TESTONLY" // securityHarness builds a Server complete enough for securityPageData, which also reads the stack // list for the geo per-app override selector. func securityHarness(t *testing.T) *Server { t.Helper() dir := t.TempDir() lg := log.New(io.Discard, "", 0) cfg := config.Default() cfg.Customer.ID = "c1" cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Web.SessionSecret = "test-session-secret-abcdef" sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatalf("settings: %v", err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatalf("stacks: %v", err) } return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} } // renderSecurityPage drives the REAL page-data builder and the REAL template, and returns the bytes // a browser would receive. func renderSecurityPage(t *testing.T, passphrase string) string { t.Helper() s := securityHarness(t) if passphrase != "" { if err := s.settings.SetRetrievalPassword(passphrase); err != nil { t.Fatalf("seed retrieval password: %v", err) } } s.loadTemplates() var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, "settings_security", s.securityPageData()); err != nil { t.Fatalf("render settings_security: %v", err) } return buf.String() } // ── SCENARIO A — the password is not in the page ──────────────────────────────────────────────── // RED-PROOF (run and confirmed failing before the fix was restored): put the value back in the page // data and the template — // // handlers.go: data["RetrievalPassword"] = s.settings.GetRetrievalPassword() // template: {{.RetrievalPassword}} inside the display:none span // // — and this test fails on the first assertion, printing the plaintext's presence in the body. That // is the defect, reproduced. The mutation was applied and observed failing; see the session report. func TestSecurityPage_DoesNotContainTheRetrievalPassphrase(t *testing.T) { html := renderSecurityPage(t, testRetrievalPassphrase) if strings.Contains(html, testRetrievalPassphrase) { t.Error("R-249: the retrieval passphrase is in the response body of the security page — " + "a fetch of this page returns the plaintext, and the reveal toggle only stops a " + "browser DRAWING it (this is the defect, and it is invisible to any test that asserts " + "on what is displayed)") } // The card must still be there — the fix is to remove the VALUE, not the feature (Scenario B). if !strings.Contains(html, "Visszaállítási jelszó") { t.Error("the recovery-info card vanished — the fix must not remove the customer's access, " + "only the value from the markup") } if !strings.Contains(html, `id="retrieval-pw-btn"`) { t.Error("no reveal control rendered, so the customer has no way to obtain the passphrase at all") } } // The card is gated on EXISTENCE, and existence is not the value. A box with no stored passphrase // must not render the card — the old gate was `{{if .RetrievalPassword}}`, which read the secret to // decide whether to show the secret. func TestSecurityPage_NoCardWhenNoPassphraseStored(t *testing.T) { html := renderSecurityPage(t, "") if strings.Contains(html, "Visszaállítási jelszó") { t.Error("the recovery-info card rendered on a box with no stored retrieval passphrase") } } // ── SCENARIO B — the customer can still get it ────────────────────────────────────────────────── // RED-PROOF: delete the `/settings/retrieval-password/reveal` case from server.go (or make the // handler return 404 unconditionally) and this fails — the customer is shown unable to obtain the // passphrase at all, which is the wrong fix for Scenario A. func TestRevealEndpoint_ReturnsThePassphraseToAnAuthenticatedCaller(t *testing.T) { s := securityHarness(t) if err := s.settings.SetRetrievalPassword(testRetrievalPassphrase); err != nil { t.Fatalf("seed: %v", err) } rr := httptest.NewRecorder() s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil)) if rr.Code != 200 { t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own passphrase", rr.Code) } if !strings.Contains(rr.Body.String(), testRetrievalPassphrase) { t.Error("the reveal endpoint did not return the passphrase — Scenario A's fix must not " + "protect the secret by removing the customer's access to it") } // A cached reveal is the same defect one layer down: a back-navigation would re-present the body. if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { t.Errorf("reveal response Cache-Control = %q, want no-store", got) } } // A box with nothing stored answers cleanly rather than leaking the distinction as a 500. func TestRevealEndpoint_404sWhenNothingStored(t *testing.T) { s := securityHarness(t) rr := httptest.NewRecorder() s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil)) if rr.Code != 404 { t.Errorf("reveal on a box with no passphrase returned %d, want 404", rr.Code) } if strings.Contains(rr.Body.String(), testRetrievalPassphrase) { t.Error("the empty-case response carried a passphrase") } }