diff --git a/internal/handlers/bodylimit_internal_test.go b/internal/handlers/bodylimit_internal_test.go new file mode 100644 index 0000000..59e5fd7 --- /dev/null +++ b/internal/handlers/bodylimit_internal_test.go @@ -0,0 +1,177 @@ +package handlers + +import ( + "log/slog" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "sneak.berlin/go/pixa/internal/config" + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/session" +) + +// Form field names and a throwaway source image URL for the body-limit +// tests. +const ( + sourceURLField = "url" + testSourceURL = "https://example.com/a.jpg" +) + +// newBodyLimitTestRouter mirrors the production wiring for the form POST +// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF +// middleware, which sits in front of the handlers. maxBytes is the body +// cap under test, so a test can trip the limit with a small body. +func newBodyLimitTestRouter( + t *testing.T, maxBytes int64, +) (*Handlers, http.Handler) { + t.Helper() + + cfg := &config.Config{SigningKey: testSigningKey, Debug: true} + + sessMgr, err := session.NewManager(testSigningKey) + if err != nil { + t.Fatalf("session.NewManager() error = %v", err) + } + + encGen, err := encurl.NewGenerator(testSigningKey) + if err != nil { + t.Fatalf("encurl.NewGenerator() error = %v", err) + } + + protect, err := newCSRFProtect(testSigningKey, cfg.Debug) + if err != nil { + t.Fatalf("newCSRFProtect() error = %v", err) + } + + h := &Handlers{ + log: slog.New(slog.DiscardHandler), + config: cfg, + sessMgr: sessMgr, + encGen: encGen, + csrfProtect: protect, + } + + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(h.LimitBody(maxBytes)) + r.Use(h.CSRF()) + r.Get("/", h.HandleRoot()) + r.Post("/", h.HandleRoot()) + r.Post("/generate", h.HandleGenerateURL()) + }) + + return h, r +} + +// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an +// oversized POST / carrying an otherwise valid CSRF cookie and token must +// be rejected with 413. If the body limit ran after CSRF, the truncated +// body would read as a missing token and return 403; if it ran after the +// handler, a valid token would return 303. Getting 413 proves the limit +// fires before CSRF parses the form. +func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) { + t.Parallel() + + _, srv := newBodyLimitTestRouter(t, 16) + + cookies, token := csrfCredentials(t, srv, nil) + + rec := postForm(srv, "/", cookies, url.Values{ + loginKeyField: {testSigningKey}, + csrfTokenField: {token}, + }) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("oversized POST / status = %d, want %d", + rec.Code, http.StatusRequestEntityTooLarge) + } +} + +// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for +// POST /generate, which also parses a form behind CSRF. +func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) { + t.Parallel() + + h, srv := newBodyLimitTestRouter(t, 16) + + sessionCookie := newSessionCookie(t, h) + + cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie}) + cookies = append(cookies, sessionCookie) + + rec := postForm(srv, "/generate", cookies, url.Values{ + sourceURLField: {testSourceURL}, + csrfTokenField: {token}, + }) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("oversized POST /generate status = %d, want %d", + rec.Code, http.StatusRequestEntityTooLarge) + } +} + +// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a +// normal request: under the production cap, a valid login still parses and +// establishes a session (303). This guards against the body limit +// consuming or corrupting the form the CSRF check and handler depend on. +func TestWithinLimitLoginPostSucceeds(t *testing.T) { + t.Parallel() + + _, srv := newBodyLimitTestRouter(t, MaxFormBytes) + + cookies, token := csrfCredentials(t, srv, nil) + + rec := postForm(srv, "/", cookies, url.Values{ + loginKeyField: {testSigningKey}, + csrfTokenField: {token}, + }) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("within-limit POST / status = %d, want %d", + rec.Code, http.StatusSeeOther) + } + + var authed bool + + for _, c := range rec.Result().Cookies() { + if c.Name == session.CookieName && c.Value != "" { + authed = true + } + } + + if !authed { + t.Error("within-limit valid login did not set a session cookie") + } +} + +// TestWithinLimitGeneratePostSucceeds is the same non-regression check for +// POST /generate. +func TestWithinLimitGeneratePostSucceeds(t *testing.T) { + t.Parallel() + + h, srv := newBodyLimitTestRouter(t, MaxFormBytes) + + sessionCookie := newSessionCookie(t, h) + + cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie}) + cookies = append(cookies, sessionCookie) + + rec := postForm(srv, "/generate", cookies, url.Values{ + sourceURLField: {testSourceURL}, + "format": {"jpeg"}, + csrfTokenField: {token}, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("within-limit POST /generate status = %d, want %d", + rec.Code, http.StatusOK) + } + + if !strings.Contains(rec.Body.String(), "/v1/e/") { + t.Error("within-limit generate response did not contain a generated URL") + } +}