package api // Regression tests for #68 input-validation gaps: negative/oversized content // lengths (413), limit=0 → default page size, unchecked query params // (negative offset), and negative burn_after_reads. import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) func createPasteRaw(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // A request whose decoded content exceeds the admin-tunable cap is rejected // with 413 and a clear message (content under the body cap, over the // content cap). func TestCreatePasteContentOverCap413(t *testing.T) { s := testServer(t) h := s.routes() // content over MaxContentBytes (5MiB) but under body cap (+4KiB): send // just over the content cap so the per-field check fires first. content := strings.Repeat("a", 5*1024*1024+10) rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s"}`, content)) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "content exceeds max") { t.Fatalf("unclear error message: %s", rec.Body.String()) } } // A request whose entire body is larger than the server-side body cap is cut // off by http.MaxBytesReader and answered with 413, not decoded into memory // (#68: previously a giant body was fully buffered, then rejected only at // the per-field check — actually the decode happened before any check). func TestCreatePasteBodyOverCap413(t *testing.T) { s := testServer(t) // shrink the content cap so the body cap is small too ss := s.settings.get() ss.MaxContentBytes = 64 * 1024 if err := s.settings.set(ss); err != nil { t.Fatal(err) } h := s.routes() content := strings.Repeat("a", 200*1024) // 200KiB > 64KiB+4KiB body cap rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s","title":"x"}`, content)) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String()) } } // Negative content lengths cannot be expressed via JSON, but a negative // expires-style numeric payload must not crash; more importantly the // burn_after_reads field: negative values are rejected with a clear message. func TestCreatePasteNegativeBurnAfterReads(t *testing.T) { s := testServer(t) h := s.routes() rec := createPasteRaw(t, h, `{"content":"hi","burn_after_reads":-5}`) if rec.Code != http.StatusBadRequest { t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "burn_after_reads") { t.Fatalf("unclear error message: %s", rec.Body.String()) } // 0 and positive values still work (0 = default single read, per #49) rec = createPasteRaw(t, h, `{"content":"hi","burn_after_reads":0,"burn_after_read":true}`) if rec.Code != http.StatusCreated { t.Fatalf("zero burn_after_reads: got %d want 201: %s", rec.Code, rec.Body.String()) } } // limit=0 on list endpoints returns the default page size (existing clamp // treats <=0 as default; #68 asks this be explicit and tested). func TestListLimitZeroUsesDefault(t *testing.T) { s := testServer(t) h := s.routes() // seed 3 public pastes for i := 0; i < 3; i++ { rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i)) if rec.Code != 201 { t.Fatalf("seed: got %d: %s", rec.Code, rec.Body.String()) } } for _, q := range []string{"/api/public?limit=0", "/api/public"} { req := httptest.NewRequest("GET", q, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("%s: got %d", q, rec.Code) } var got struct { Limit int `json:"limit"` Total int `json:"total"` Items []struct{ ID string } `json:"items"` } json.Unmarshal(rec.Body.Bytes(), &got) if got.Limit != 25 || len(got.Items) != 3 { t.Fatalf("%s: limit=%d items=%d, want limit 25 and all 3 items", q, got.Limit, len(got.Items)) } } } // Huge limit values are clamped to the max page size (already the behavior; // regression-tested here per #68 "validate unchecked params"). func TestListLimitHugeClamped(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("GET", "/api/public?limit=999999999", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var got struct { Limit int `json:"limit"` } json.Unmarshal(rec.Body.Bytes(), &got) if got.Limit != 25 { t.Fatalf("limit=%d, want clamped to 25", got.Limit) } } // Negative offset previously passed through unchecked to SQL (harmless in // SQLite, but invalid); it must be clamped to 0 (#68). func TestListNegativeOffsetClamped(t *testing.T) { s := testServer(t) h := s.routes() for i := 0; i < 2; i++ { rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i)) if rec.Code != 201 { t.Fatalf("seed: got %d", rec.Code) } } req := httptest.NewRequest("GET", "/api/public?offset=-999", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("got %d", rec.Code) } var got struct { Offset int `json:"offset"` Total int `json:"total"` } json.Unmarshal(rec.Body.Bytes(), &got) if got.Offset != 0 || got.Total != 2 { t.Fatalf("offset=%d total=%d, want offset 0 and total 2", got.Offset, got.Total) } // /api/mine too (needs the viewer cookie) req = httptest.NewRequest("GET", "/api/mine?offset=-5", nil) req.AddCookie(&http.Cookie{Name: "vwr", Value: "offclamp"}) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) var mine struct { Offset int `json:"offset"` } json.Unmarshal(rec.Body.Bytes(), &mine) if mine.Offset != 0 { t.Fatalf("mine offset=%d, want 0", mine.Offset) } } // Non-numeric limit/offset fall back to defaults instead of 500s. func TestListGarbageParams(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("GET", "/api/public?limit=abc&offset=xyz", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("got %d", rec.Code) } var got struct { Limit int `json:"limit"` Offset int `json:"offset"` } json.Unmarshal(rec.Body.Bytes(), &got) if got.Limit != 25 || got.Offset != 0 { t.Fatalf("limit=%d offset=%d, want 25/0", got.Limit, got.Offset) } }