package web import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/mailhold" ) // MAIL-HOLD on the operator UI: the banner on every operator page while the marker exists, and the release endpoint // behind the operator's auth + the R-135 CSRF gate. const mailHoldBannerText = "E-mail is on hold" func holdIn(t *testing.T, held bool) (*mailhold.Hold, string) { t.Helper() dir := t.TempDir() marker := filepath.Join(dir, mailhold.FileName) if held { if err := os.WriteFile(marker, nil, 0o644); err != nil { t.Fatal(err) } } return mailhold.New(dir, nil), marker } func getPage(t *testing.T, s *Server, path string) string { t.Helper() r := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() s.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("GET %s: %d", path, w.Code) } return w.Body.String() } // Both branches of the {{if mailHeld}} gate, on the dashboard and on Configuration (which also carries the button). func TestMailHoldBanner_RendersOnlyWhileHeld(t *testing.T) { for _, held := range []bool{true, false} { s, _ := newTestServer(t) h, _ := holdIn(t, held) s.SetMailHold(h) for _, p := range []string{"/", "/configuration", "/hosts", "/configs"} { body := getPage(t, s, p) if got := strings.Contains(body, mailHoldBannerText); got != held { t.Errorf("held=%v GET %s: banner shown=%v", held, p, got) } } cfg := getPage(t, s, "/configuration") if got := strings.Contains(cfg, `action="/configuration/mail-hold/release"`); got != held { t.Errorf("held=%v: release button shown=%v", held, got) } } } // No hold wired at all (nil): no banner, pages render. func TestMailHoldBanner_NilHoldRendersNoBanner(t *testing.T) { s, _ := newTestServer(t) if strings.Contains(getPage(t, s, "/"), mailHoldBannerText) { t.Fatal("a nil hold must render no banner") } } // The operator's release, through the production wiring (RequireAuth around ServeHTTP) with a session and its CSRF // token: the marker is gone, and the banner with it. func TestMailHoldRelease_RemovesMarker(t *testing.T) { s, h := r135Handler(t) hold, marker := holdIn(t, true) s.SetMailHold(hold) s.sessionsMu.Lock() s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} s.sessionsMu.Unlock() w := r135Post(h, "/configuration/mail-hold/release", func(r *http.Request) { r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) r.Header.Set("X-CSRF-Token", "tok1") }) if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=mail_hold_released") { t.Fatalf("release: %d Location=%q", w.Code, w.Header().Get("Location")) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("the marker is still there after the release: %v", err) } if hold.Held() { t.Fatal("still held after the release") } } // Refusals: Basic auth without the operator header (the R-135 cross-site shape), a session without its token, and no // credentials at all. The marker stays in every case. func TestMailHoldRelease_RefusedWithoutOperatorAuthOrCSRF(t *testing.T) { s, h := r135Handler(t) hold, marker := holdIn(t, true) s.SetMailHold(hold) s.sessionsMu.Lock() s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} s.sessionsMu.Unlock() cases := []struct { name string mut func(*http.Request) ok func(int) bool }{ {"basic without operator header", func(r *http.Request) { r.SetBasicAuth("", "op-pass") }, func(c int) bool { return c == http.StatusForbidden }}, {"session without token", func(r *http.Request) { r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) }, func(c int) bool { return c == http.StatusForbidden }}, {"no credentials", func(r *http.Request) { r.Header.Set(OperatorCLIHeader, "cli") }, func(c int) bool { return c == http.StatusFound || c == http.StatusUnauthorized }}, } for _, c := range cases { w := r135Post(h, "/configuration/mail-hold/release", c.mut) if !c.ok(w.Code) { t.Errorf("%s: status %d", c.name, w.Code) } if _, err := os.Stat(marker); err != nil { t.Fatalf("%s: the marker was removed by a refused request: %v", c.name, err) } } // The CLI shape (Basic + the operator header) passes. w := r135Post(h, "/configuration/mail-hold/release", func(r *http.Request) { r.SetBasicAuth("", "op-pass") r.Header.Set(OperatorCLIHeader, "cli") }) if w.Code != http.StatusSeeOther { t.Fatalf("operator CLI release: %d", w.Code) } if hold.Held() { t.Fatal("operator CLI release left the hold in place") } }