d55c590c5a
gates / gates (push) Successful in 5m25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
136 lines
4.6 KiB
Go
136 lines
4.6 KiB
Go
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")
|
|
}
|
|
}
|