diff --git a/internal/api/issue86_field_bounds_test.go b/internal/api/issue86_field_bounds_test.go new file mode 100644 index 0000000..8e97d16 --- /dev/null +++ b/internal/api/issue86_field_bounds_test.go @@ -0,0 +1,152 @@ +package api + +// Regression tests for #86: title and language are bounded at create time. +// Titles over 200 chars are truncated; language must match +// ^[a-zA-Z0-9+#-]{1,40}$ or the create is rejected with a clear 400. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// pasteMeta fetches a created paste's stored metadata via the API. +func pasteMeta(t *testing.T, h http.Handler, id string) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/pastes/"+id, nil)) + if rec.Code != 200 { + t.Fatalf("get paste %s: got %d: %s", id, rec.Code, rec.Body.String()) + } + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + return m +} + +// A 5000-char title is truncated to 200 characters at create time (#86). +func TestCreatePasteTitleTruncated(t *testing.T) { + s := testServer(t) + h := s.routes() + + title := strings.Repeat("t", 5000) + body, _ := json.Marshal(map[string]any{"content": "hi", "title": title}) + rec := createPasteRaw(t, h, string(body)) + if rec.Code != http.StatusCreated { + t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String()) + } + var resp struct { + ID string `json:"id"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + meta := pasteMeta(t, h, resp.ID) + got, _ := meta["title"].(string) + if got != strings.Repeat("t", 200) { + t.Fatalf("title not truncated to 200 chars: len=%d", len(got)) + } +} + +// A title within the 200-char bound is stored verbatim (minus surrounding +// whitespace, which is trimmed). +func TestCreatePasteTitleWithinBoundKept(t *testing.T) { + s := testServer(t) + h := s.routes() + + title := " " + strings.Repeat("x", 200) + " " + body, _ := json.Marshal(map[string]any{"content": "hi", "title": title}) + rec := createPasteRaw(t, h, string(body)) + if rec.Code != http.StatusCreated { + t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String()) + } + var resp struct { + ID string `json:"id"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + meta := pasteMeta(t, h, resp.ID) + if got, _ := meta["title"].(string); got != strings.Repeat("x", 200) { + t.Fatalf("title changed unexpectedly: len=%d", len(got)) + } +} + +// A language longer than 40 chars is rejected with a clear 400 (#86). +func TestCreatePasteLanguageTooLong400(t *testing.T) { + s := testServer(t) + h := s.routes() + + body, _ := json.Marshal(map[string]any{"content": "hi", "language": strings.Repeat("a", 41)}) + rec := createPasteRaw(t, h, string(body)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "language") { + t.Fatalf("unclear error message: %s", rec.Body.String()) + } +} + +// Language strings outside ^[a-zA-Z0-9+#-]{1,40}$ are rejected with 400. +func TestCreatePasteLanguageBadFormat400(t *testing.T) { + s := testServer(t) + h := s.routes() + + for _, bad := range []string{ + "", + "java script", + "c++ extra!", + "py_thon", + "go.lang", + } { + body, _ := json.Marshal(map[string]any{"content": "hi", "language": bad}) + rec := createPasteRaw(t, h, string(body)) + if rec.Code != http.StatusBadRequest { + t.Errorf("language %q: got %d want 400: %s", bad, rec.Code, rec.Body.String()) + continue + } + if !strings.Contains(rec.Body.String(), "language must match") { + t.Errorf("language %q: unclear error: %s", bad, rec.Body.String()) + } + } +} + +// Valid languages (letters, digits, #, +, -) within 40 chars are accepted. +func TestCreatePasteLanguageValidAccepted(t *testing.T) { + for _, ok := range []string{"go", "c#", "f#", "c++", "objective-c", "ECMAScript-2023", strings.Repeat("a", 40)} { + s2 := testServer(t) // fresh rate limiter per case + h2 := s2.routes() + body, _ := json.Marshal(map[string]any{"content": "hi", "language": ok}) + rec := createPasteRaw(t, h2, string(body)) + if rec.Code != http.StatusCreated { + t.Errorf("language %q: got %d want 201: %s", ok, rec.Code, rec.Body.String()) + } + } +} + +// An absent or blank language still creates fine and stores NULL, and a +// blank title is stored NULL rather than an empty string. +func TestCreatePasteBlankMetadataOK(t *testing.T) { + s := testServer(t) + h := s.routes() + + for _, body := range []string{ + `{"content":"hi"}`, + `{"content":"hi","language":"","title":" "}`, + } { + rec := createPasteRaw(t, h, body) + if rec.Code != http.StatusCreated { + t.Fatalf("body %s: got %d want 201: %s", body, rec.Code, rec.Body.String()) + } + var resp struct { + ID string `json:"id"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + meta := pasteMeta(t, h, resp.ID) + if lang, ok := meta["language"]; ok && lang != nil && lang != "" { + t.Fatalf("body %s: language not null: %v", body, lang) + } + if title, ok := meta["title"]; ok && title != nil && title != "" { + t.Fatalf("body %s: title not null: %v", body, title) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index abde902..fa2543a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -165,6 +165,27 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { writeErr(w, status, msg) return } + // #86: bound free-form metadata at create time + if p.Title != nil { + t, err := checkTitle(*p.Title) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + p.Title = &t + } + if p.Language != nil { + l, err := checkLanguage(*p.Language) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + if l == "" { + p.Language = nil + } else { + p.Language = &l + } + } if p.BurnAfterReads != nil { // #68: reject negative read budgets if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil { writeErr(w, 400, err.Error()) diff --git a/internal/api/validate.go b/internal/api/validate.go index fe2e22d..40c7cdb 100644 --- a/internal/api/validate.go +++ b/internal/api/validate.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "strconv" "strings" ) @@ -90,3 +91,46 @@ func parseBurnAfterReads(n int) error { } return nil } + +// #86: bounds for free-form metadata fields on create. +const ( + maxTitleLen = 200 + maxLanguageLen = 40 +) + +// languageRe restricts language to identifiers like go, c#, f#, c++, objc. +var languageRe = regexp.MustCompile(`^[a-zA-Z0-9+#-]{1,40}$`) + +// checkTitle validates the paste title (#86): over-max titles are truncated +// to 200 characters so a bloated listing entry can't be stored; whitespace +// is trimmed first. +func checkTitle(title string) (string, error) { + title = strings.TrimSpace(title) + if len(title) > maxTitleLen { + return truncateRunes(title, maxTitleLen), nil + } + return title, nil +} + +// checkLanguage validates the language field (#86): optional, max 40 chars, +// and must match ^[a-zA-Z0-9+#-]{1,40}$. Returns "" for absent/blank values. +// Anything else malformed is a 400. +func checkLanguage(lang string) (string, error) { + lang = strings.TrimSpace(lang) + if lang == "" { + return "", nil + } + if len(lang) > maxLanguageLen || !languageRe.MatchString(lang) { + return "", fmt.Errorf("language must match ^[a-zA-Z0-9+#-]{1,40}$ (max %d chars)", maxLanguageLen) + } + return lang, nil +} + +// truncateRunes cuts s to at most max runes, keeping the prefix intact. +func truncateRunes(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) +}