package api import ( "bytes" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "palette/internal/store" ) // multipartCreate posts a multipart create to the routes handler; extra // fields are appended as text parts. Returns recorder and parsed response. func multipartCreate(t *testing.T, h http.Handler, filename string, content []byte, fields map[string]string) (*httptest.ResponseRecorder, map[string]any) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) if filename != "" { fw, _ := mw.CreateFormFile("file", filename) fw.Write(content) } for k, v := range fields { mw.WriteField(k, v) } mw.Close() req := httptest.NewRequest("POST", "/api/pastes", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var resp map[string]any json.Unmarshal(rec.Body.Bytes(), &resp) return rec, resp } func TestMultipartAttachmentCreateAndServe(t *testing.T) { s := testServer(t) h := s.routes() png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0, 1, 2, 3}, 32)...) rec, resp := multipartCreate(t, h, "shot.png", png, map[string]string{"title": "with file"}) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } att, _ := resp["attachment"].(map[string]any) if att == nil { t.Fatalf("no attachment in response: %v", resp) } id, _ := att["id"].(string) url, _ := att["url"].(string) if url != "/f/"+id+"/shot.png" { t.Fatalf("attachment url = %q", url) } if att["mime"] != "image/png" { t.Fatalf("sniffed mime = %v want image/png", att["mime"]) } // serve: image mime -> inline, nosniff, stored bytes req := httptest.NewRequest("GET", url, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != 200 { t.Fatalf("serve: %d %s", rec2.Code, rec2.Body.String()) } if got := rec2.Header().Get("Content-Type"); got != "image/png" { t.Fatalf("Content-Type = %q", got) } if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("nosniff = %q", got) } if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { t.Fatalf("Content-Disposition = %q", got) } if !bytes.Equal(rec2.Body.Bytes(), png) { t.Fatal("served bytes differ from upload") } } func TestMultipartFileReplacesText(t *testing.T) { s := testServer(t) h := s.routes() rec, resp := multipartCreate(t, h, "notes.txt", []byte("file body"), map[string]string{"content": "some text"}) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } id, _ := resp["id"].(string) req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) var got map[string]any json.Unmarshal(rec2.Body.Bytes(), &got) if got["content"] != "" { t.Fatalf("content should be empty when file provided, got %v", got["content"]) } } func TestMultipartSecondFileRejected(t *testing.T) { s := testServer(t) h := s.routes() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for _, name := range []string{"a.txt", "b.txt"} { fw, _ := mw.CreateFormFile("file", name) fw.Write([]byte("x")) } mw.Close() req := httptest.NewRequest("POST", "/api/pastes", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 400 { t.Fatalf("two files: got %d want 400", rec.Code) } if !strings.Contains(rec.Body.String(), "one_file_only") { t.Fatalf("error code missing: %s", rec.Body.String()) } } func TestMultipartHtmlUploadServesAsPlainText(t *testing.T) { s := testServer(t) h := s.routes() html := []byte("") rec, resp := multipartCreate(t, h, "page.html", html, nil) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } url, _ := resp["attachment"].(map[string]any)["url"].(string) req := httptest.NewRequest("GET", url, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { t.Fatalf("html served as %q, want text/plain", got) } if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") { t.Fatalf("html Content-Disposition = %q, want attachment", got) } } func TestMultipartSvgUploadServesAsPlainText(t *testing.T) { s := testServer(t) h := s.routes() svg := []byte(``) rec, resp := multipartCreate(t, h, "evil.svg", svg, nil) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } url, _ := resp["attachment"].(map[string]any)["url"].(string) req := httptest.NewRequest("GET", url, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) ct := rec2.Header().Get("Content-Type") if strings.Contains(ct, "svg") || strings.Contains(ct, "html") { t.Fatalf("svg served as %q", ct) } if ct != "text/plain; charset=utf-8" { t.Fatalf("svg Content-Type = %q", ct) } } func TestMultipartClientMimeIgnored(t *testing.T) { // client claims image/png; server must sniff the real type (text) s := testServer(t) h := s.routes() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("file", "fake.png") fw.Write([]byte("just plain text, definitely not a png")) // note: CreateFormFile sets Content-Type: application/octet-stream; the // sniffed type for text content is text/plain either way. mw.Close() req := httptest.NewRequest("POST", "/api/pastes", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } var resp map[string]any json.Unmarshal(rec.Body.Bytes(), &resp) att := resp["attachment"].(map[string]any) if att["mime"] != "text/plain; charset=utf-8" && att["mime"] != "text/plain" { t.Fatalf("mime = %v, want sniffed text/plain", att["mime"]) } } func TestMultipartOversizeRejected(t *testing.T) { s := testServer(t) h := s.routes() big := bytes.Repeat([]byte("A"), MaxAttachmentBytes+1024) rec, _ := multipartCreate(t, h, "big.bin", big, nil) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("oversize: got %d want 413", rec.Code) } } func TestMultipartExactlyAtLimitAccepted(t *testing.T) { s := testServer(t) h := s.routes() exact := bytes.Repeat([]byte("A"), MaxAttachmentBytes) rec, resp := multipartCreate(t, h, "exact.bin", exact, nil) if rec.Code != 201 { t.Fatalf("at-limit: got %d %s", rec.Code, rec.Body.String()) } att := resp["attachment"].(map[string]any) if att["size"].(float64) != float64(MaxAttachmentBytes) { t.Fatalf("size = %v", att["size"]) } } func TestMultipartEmptyFileRejected(t *testing.T) { s := testServer(t) h := s.routes() rec, _ := multipartCreate(t, h, "empty.txt", nil, nil) if rec.Code != 400 { t.Fatalf("empty file: got %d want 400", rec.Code) } } func TestServeAttachment404Missing(t *testing.T) { s := testServer(t) h := s.routes() req := httptest.NewRequest("GET", "/f/zzzzzzzz/nonexistent.txt", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Fatalf("missing attachment: got %d want 404", rec.Code) } } func TestServeAttachmentUnknownPaste404(t *testing.T) { // attachment row referencing a paste that doesn't exist must 404, not leak s := testServer(t) h := s.routes() s.store.CreatePaste(&store.Paste{Content: "x"}) att := store.Attachment{PasteID: "ghost00", Filename: "f.txt", Mime: "text/plain"} blobs := s.store.Blobs() if err := s.store.CreateAttachment(&att, strings.NewReader("hello"), blobs); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/f/"+att.ID+"/f.txt", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 404 { t.Fatalf("orphan attachment: got %d want 404", rec.Code) } } func TestServeAttachmentPdfInline(t *testing.T) { s := testServer(t) h := s.routes() pdf := []byte("%PDF-1.4\n%fake pdf body\n") rec, resp := multipartCreate(t, h, "doc.pdf", pdf, nil) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } url, _ := resp["attachment"].(map[string]any)["url"].(string) req := httptest.NewRequest("GET", url, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if got := rec2.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/pdf") { t.Fatalf("pdf Content-Type = %q", got) } if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { t.Fatalf("pdf Content-Disposition = %q", got) } } func TestServeAttachmentBurnedPaste404(t *testing.T) { s := testServer(t) h := s.routes() rec, resp := multipartCreate(t, h, "burn.txt", []byte("burn me"), nil) if rec.Code != 201 { t.Fatalf("create: %d", rec.Code) } att := resp["attachment"].(map[string]any) url, _ := att["url"].(string) pid, _ := resp["id"].(string) // burn the paste via API read (burn_after_read default off here, so force) s.store.SoftDelete(pid) req := httptest.NewRequest("GET", url, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != 404 { t.Fatalf("deleted paste attachment: got %d want 404", rec2.Code) } } func TestMultipartPasswordFieldAccepted(t *testing.T) { s := testServer(t) h := s.routes() rec, resp := multipartCreate(t, h, "secret.txt", []byte("top secret"), map[string]string{"password": "hunter2", "expires_in": "1h"}) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } if resp["attachment"] == nil { t.Fatal("attachment missing") } id, _ := resp["id"].(string) req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != 401 { t.Fatalf("paste should require password, got %d", rec2.Code) } } // #281: /raw/{id} must stream the attachment blob for ALL attachment mimes, // not just raster images (the old isImageMime gate left non-image // attachments serving an empty body from row.Content). func TestRawStreamsNonImageAttachment(t *testing.T) { s := testServer(t) h := s.routes() body := []byte("hello, this is a plain text attachment body") rec, resp := multipartCreate(t, h, "notes.txt", body, nil) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } if resp["attachment"] == nil { t.Fatalf("no attachment in response: %v", resp) } id, _ := resp["id"].(string) req := httptest.NewRequest("GET", "/raw/"+id, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != 200 { t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String()) } if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { t.Fatalf("Content-Type = %q", got) } if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("nosniff = %q", got) } if !bytes.Equal(rec2.Body.Bytes(), body) { t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body)) } } // #281: active-content attachment types still get forced to text/plain on // /raw, same rule as the /f/ serving path (#34). func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) { s := testServer(t) h := s.routes() html := []byte("") rec, resp := multipartCreate(t, h, "page.html", html, nil) if rec.Code != 201 { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } id, _ := resp["id"].(string) req := httptest.NewRequest("GET", "/raw/"+id, nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != 200 { t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String()) } if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { t.Fatalf("Content-Type = %q", got) } if !bytes.Equal(rec2.Body.Bytes(), html) { t.Fatal("raw bytes differ from upload") } }