package http import ( "bytes" "context" "errors" "io" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-playground/validator/v10" ) // multipartBody builds a multipart/form-data body with one file part of // size bytes. func multipartBody(t *testing.T, size int) (*bytes.Buffer, string) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) part, err := mw.CreateFormFile("file", "big.tif") if err != nil { t.Fatal(err) } if _, err := part.Write(bytes.Repeat([]byte{'x'}, size)); err != nil { t.Fatal(err) } if err := mw.Close(); err != nil { t.Fatal(err) } return &buf, mw.FormDataContentType() } func TestLimitRequestBodyRejectsOversizedUpload(t *testing.T) { body, ct := multipartBody(t, 4096) req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() h := limitRequestBody(1024)(http.HandlerFunc(newLandscapeTestHandler().uploadSlot)) h.ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "1024 byte limit") { t.Fatalf("body %s does not state the limit", rec.Body.String()) } } func TestLimitRequestBodyPassesSmallBody(t *testing.T) { var got []byte h := limitRequestBody(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error got, err = io.ReadAll(r.Body) if err != nil { t.Errorf("read: %v", err) } })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader("hello"))) if string(got) != "hello" { t.Fatalf("body = %q, want hello", got) } } func TestLimitRequestBodyDisabled(t *testing.T) { h := limitRequestBody(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, err := io.ReadAll(r.Body); err != nil { t.Errorf("read with cap disabled: %v", err) } })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(make([]byte, 1<<20)))) } func newTestRouter() http.Handler { v := validator.New(validator.WithRequiredStructEnabled()) return NewRouter(RouterDeps{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Health: NewHealthHandler(nil, nil), Categories: NewCategoryHandler(nil, v), Datasets: NewDatasetHandler(nil, v), DatasetGroups: NewDatasetGroupHandler(nil, v), Landscape: NewLandscapeHandler(nil, v), MaxBodyBytes: 1024, }) } func TestRouterSetsNosniff(t *testing.T) { rec := httptest.NewRecorder() newTestRouter().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) } } func TestRouterCapsUploadBody(t *testing.T) { body, ct := multipartBody(t, 4096) req := httptest.NewRequest(http.MethodPost, "/landscape/slots/lp-z-lcr", body) req.Header.Set("Content-Type", ct) rec := httptest.NewRecorder() newTestRouter().ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) } } func TestReadyDoesNotLeakCheckErrors(t *testing.T) { h := NewHealthHandler(map[string]ReadinessCheck{ "postgres": func(context.Context) error { return errors.New("dial tcp 10.1.2.3:5432: connect: connection refused (user=gis)") }, "s3": func(context.Context) error { return nil }, }, slog.New(slog.NewTextHandler(io.Discard, nil))) rec := httptest.NewRecorder() h.Ready(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) } body := rec.Body.String() if strings.Contains(body, "10.1.2.3") || strings.Contains(body, "dial tcp") { t.Fatalf("readiness body leaks internal error details: %s", body) } if !strings.Contains(body, `"postgres":"unavailable"`) || !strings.Contains(body, `"s3":"ok"`) { t.Fatalf("unexpected readiness body: %s", body) } }