package web import ( "bytes" "context" "errors" "image" "image/color" "image/jpeg" "image/png" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "sync" "testing" "plumber/internal/blob" "plumber/internal/pacific" "plumber/internal/store" ) func TestPreparePostImage(t *testing.T) { t.Parallel() wide := solidPNG(t, 2000, 1000) prepared, err := preparePostImageHeader(t, "wide.png", wide) if err != nil { t.Fatal(err) } if prepared.width != 1600 || prepared.height != 800 || prepared.extension != ".png" || prepared.contentType != "image/png" { t.Fatalf("prepared PNG = %+v", prepared) } jpegBody := solidJPEG(t, 40, 20) prepared, err = preparePostImageHeader(t, "photo.jpg", jpegBody) if err != nil { t.Fatal(err) } if prepared.width != 40 || prepared.height != 20 || prepared.extension != ".jpg" || prepared.contentType != "image/jpeg" { t.Fatalf("prepared JPEG = %+v", prepared) } if _, err := preparePostImageHeader(t, "notes.txt", []byte("not an image")); err == nil { t.Fatal("text upload unexpectedly succeeded") } _, err = preparePostImageHeader(t, "too-large.jpg", make([]byte, postImageMaxFileBytes+1)) var requestErr *postImageRequestError if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge { t.Fatalf("oversized file error = %v, want 413 request error", err) } if _, err := preparePostImageHeader(t, "too-wide.png", solidPNG(t, 6001, 1)); err == nil { t.Fatal("oversized dimensions unexpectedly succeeded") } } func TestPreparePostVideo(t *testing.T) { t.Parallel() mp4 := tinyMP4() prepared, err := preparePostVideoHeader(t, "clip.mp4", mp4) if err != nil { t.Fatal(err) } defer prepared.body.Close() got, err := io.ReadAll(prepared.body) if err != nil { t.Fatal(err) } if prepared.extension != ".mp4" || prepared.contentType != "video/mp4" || prepared.size != int64(len(mp4)) || !bytes.Equal(got, mp4) { t.Fatalf("prepared MP4 = %+v len(body)=%d", prepared, len(got)) } webm := tinyWebM() prepared, err = preparePostVideoHeader(t, "clip.webm", webm) if err != nil { t.Fatal(err) } defer prepared.body.Close() if prepared.extension != ".webm" || prepared.contentType != "video/webm" || prepared.size != int64(len(webm)) { t.Fatalf("prepared WebM = %+v", prepared) } if _, err := preparePostVideoHeader(t, "empty.mp4", nil); err == nil { t.Fatal("empty video unexpectedly succeeded") } if _, err := preparePostVideoHeader(t, "notes.txt", []byte("not a video")); err == nil { t.Fatal("text video upload unexpectedly succeeded") } _, err = preparePostVideoHeader(t, "too-large.mp4", make([]byte, postVideoMaxFileBytes+1)) var requestErr *postImageRequestError if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge { t.Fatalf("oversized video error = %v, want 413 request error", err) } } func TestOrientPostImage(t *testing.T) { t.Parallel() source := image.NewNRGBA(image.Rect(0, 0, 2, 1)) source.Set(0, 0, color.NRGBA{R: 255, A: 255}) source.Set(1, 0, color.NRGBA{B: 255, A: 255}) rotated := orientPostImage(source, 6) if rotated.Bounds().Dx() != 1 || rotated.Bounds().Dy() != 2 { t.Fatalf("rotated bounds = %v", rotated.Bounds()) } top := color.NRGBAModel.Convert(rotated.At(0, 0)).(color.NRGBA) bottom := color.NRGBAModel.Convert(rotated.At(0, 1)).(color.NRGBA) if top.R != 255 || bottom.B != 255 { t.Fatalf("rotation colors top=%v bottom=%v", top, bottom) } } func TestPostImageMultipartLifecycle(t *testing.T) { t.Parallel() images := &recordingImageBlob{} srv, mem := newTestServer(t, Config{Blob: images}) handler := srv.Handler() homeowner := seedUser(t, mem, uniq("images"), "hunter22", store.RoleUser) cookies := loginUser(t, handler, homeowner.Username, "hunter22") csrf := csrfForCookies(t, handler, cookies) rec := multipartPost(t, handler, "/submit", map[string][]string{ "_csrf": {csrf}, "title": {"Leaky valve"}, "body": {"Two views of the leak."}, "city": {"Oakland"}, "image_description": {"Front view", "Under the sink"}, }, []multipartTestFile{ {name: "front.png", body: solidPNG(t, 80, 40)}, {name: "under.jpg", body: solidJPEG(t, 40, 80)}, }, cookies) if rec.Code != http.StatusSeeOther { t.Fatalf("root image upload status = %d: %s", rec.Code, rec.Body.String()) } roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID) if err != nil || len(roots) != 1 { t.Fatalf("roots = %+v, %v", roots, err) } root, err := mem.GetPost(context.Background(), roots[0].ID) if err != nil { t.Fatal(err) } if len(root.Images) != 2 || root.Images[0].Description != "Front view" || root.Images[1].Description != "Under the sink" { t.Fatalf("root images = %+v", root.Images) } rec = multipartPost(t, handler, "/posts", map[string][]string{ "_csrf": {csrf}, "parent_id": {root.ID}, "body": {"Here is the model label."}, "image_description": {"Model label"}, }, []multipartTestFile{{name: "label.png", body: solidPNG(t, 60, 30)}}, cookies) if rec.Code != http.StatusSeeOther { t.Fatalf("reply image upload status = %d: %s", rec.Code, rec.Body.String()) } thread, err := mem.GetPostThread(context.Background(), root.ID) if err != nil || len(thread.Replies) != 1 { t.Fatalf("thread = %+v, %v", thread, err) } reply := thread.Replies[0] if len(reply.Images) != 1 || reply.Images[0].Description != "Model label" { t.Fatalf("reply images = %+v", reply.Images) } originalKey := reply.Images[0].ObjectKey rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{ "_csrf": {csrf}, "body": {"Updated label photos."}, "existing_image_id": {reply.Images[0].ID}, "existing_image_description": {"Existing label"}, "image_description": {"Serial number"}, }, []multipartTestFile{{name: "serial.jpg", body: solidJPEG(t, 50, 25)}}, cookies) if rec.Code != http.StatusSeeOther { t.Fatalf("image edit status = %d: %s", rec.Code, rec.Body.String()) } edited, err := mem.GetPost(context.Background(), reply.ID) if err != nil { t.Fatal(err) } if len(edited.Images) != 2 || edited.Images[0].Description != "Existing label" || edited.Images[1].Description != "Serial number" { t.Fatalf("edited images = %+v", edited.Images) } rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{ "_csrf": {csrf}, "body": {"Keep only the serial number."}, "existing_image_id": {edited.Images[1].ID}, "existing_image_description": {"Serial number"}, }, nil, cookies) if rec.Code != http.StatusSeeOther { t.Fatalf("image removal status = %d: %s", rec.Code, rec.Body.String()) } edited, err = mem.GetPost(context.Background(), reply.ID) if err != nil { t.Fatal(err) } if len(edited.Images) != 1 || edited.Images[0].Description != "Serial number" { t.Fatalf("images after removal = %+v", edited.Images) } if !images.wasDeleted(originalKey) { t.Fatalf("removed object %q was not deleted: %+v", originalKey, images.deletedKeys()) } uploadsBefore := images.uploadCount() rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{ "_csrf": {csrf}, "body": {"Invalid retained image."}, "existing_image_id": {"not-owned"}, }, nil, cookies) if rec.Code != http.StatusBadRequest { t.Fatalf("invalid retained image status = %d, want 400", rec.Code) } unchanged, err := mem.GetPost(context.Background(), reply.ID) if err != nil { t.Fatal(err) } if unchanged.Body != "Keep only the serial number." || len(unchanged.Images) != 1 { t.Fatalf("invalid retained image changed post: %+v", unchanged) } files := make([]multipartTestFile, store.MaxPostImages+1) for i := range files { files[i] = multipartTestFile{name: "extra.png", body: solidPNG(t, 10, 10)} } rec = multipartPost(t, handler, "/posts", map[string][]string{ "_csrf": {csrf}, "parent_id": {root.ID}, "body": {"Too many images."}, }, files, cookies) if rec.Code != http.StatusBadRequest { t.Fatalf("five-image status = %d, want 400", rec.Code) } if images.uploadCount() != uploadsBefore { t.Fatal("five-image request uploaded objects before rejecting count") } } func TestPostVideoMultipartLifecycle(t *testing.T) { t.Parallel() blobs := &recordingImageBlob{} srv, mem := newTestServer(t, Config{Blob: blobs}) handler := srv.Handler() homeowner := seedUser(t, mem, uniq("video"), "hunter22", store.RoleUser) cookies := loginUser(t, handler, homeowner.Username, "hunter22") csrf := csrfForCookies(t, handler, cookies) rec := multipartPost(t, handler, "/submit", map[string][]string{ "_csrf": {csrf}, "title": {"Valve clip"}, "body": {"A photo and a video."}, "city": {"Oakland"}, "image_description": {"Still", "Walkthrough"}, }, []multipartTestFile{ {name: "still.png", body: solidPNG(t, 40, 20)}, {name: "walk.mp4", body: tinyMP4()}, }, cookies) if rec.Code != http.StatusSeeOther { t.Fatalf("root video upload status = %d: %s", rec.Code, rec.Body.String()) } roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID) if err != nil || len(roots) != 1 { t.Fatalf("roots = %+v, %v", roots, err) } root, err := mem.GetPost(context.Background(), roots[0].ID) if err != nil { t.Fatal(err) } if len(root.Images) != 2 || root.Images[0].Kind != store.MediaKindImage || root.Images[1].Kind != store.MediaKindVideo || root.Images[1].Description != "Walkthrough" || !strings.HasPrefix(root.Images[1].ObjectKey, "post-videos/") { t.Fatalf("root media = %+v", root.Images) } clip := tinyMP4() var streamed recordedImageUpload for _, upload := range blobs.recordedUploads() { if strings.HasPrefix(upload.key, "post-videos/") { streamed = upload break } } if streamed.size != int64(len(clip)) || !bytes.Equal(streamed.body, clip) { t.Fatalf("streamed video upload = %+v", streamed) } rec = httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil) for _, cookie := range cookies { req.AddCookie(cookie) } handler.ServeHTTP(rec, req) page := rec.Body.String() if rec.Code != http.StatusOK || !strings.Contains(page, `