// SPDX-License-Identifier: AGPL-3.0-or-later package image import ( "bytes" "image" "image/jpeg" "image/png" "mime/multipart" "net/textproto" "os" "path/filepath" "strings" "testing" ) // testJPEG creates a test JPEG image in memory with given dimensions. func testJPEG(t *testing.T, width, height int) (*bytes.Buffer, *multipart.FileHeader) { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil { t.Fatalf("encode test jpeg: %v", err) } header := &multipart.FileHeader{ Filename: "test.jpg", Size: int64(buf.Len()), Header: textproto.MIMEHeader{"Content-Type": {"image/jpeg"}}, } return &buf, header } // testPNG creates a test PNG image in memory with given dimensions. func testPNG(t *testing.T, width, height int) (*bytes.Buffer, *multipart.FileHeader) { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode test png: %v", err) } header := &multipart.FileHeader{ Filename: "test.png", Size: int64(buf.Len()), Header: textproto.MIMEHeader{"Content-Type": {"image/png"}}, } return &buf, header } // bufferReadSeeker wraps a bytes.Reader to implement multipart.File. type bufferReadSeeker struct { *bytes.Reader } func (b *bufferReadSeeker) Close() error { return nil } func TestProcessJPEG(t *testing.T) { uploadDir := t.TempDir() buf, header := testJPEG(t, 800, 600) result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir) if err != nil { t.Fatalf("Process JPEG: %v", err) } if result.Filename == "" { t.Error("expected non-empty filename") } if !strings.HasSuffix(result.Filename, ".jpg") { t.Errorf("filename %q should end with .jpg", result.Filename) } // Verify file was written. if _, err := os.Stat(result.Path); err != nil { t.Errorf("output file not found: %v", err) } } func TestProcessPNG(t *testing.T) { uploadDir := t.TempDir() buf, header := testPNG(t, 640, 480) result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir) if err != nil { t.Fatalf("Process PNG: %v", err) } if !strings.HasSuffix(result.Filename, ".png") { t.Errorf("filename %q should end with .png", result.Filename) } } func TestProcessResizeWideImage(t *testing.T) { uploadDir := t.TempDir() buf, header := testJPEG(t, 3840, 2160) // 4K width result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir) if err != nil { t.Fatalf("Process wide image: %v", err) } // Read back and check dimensions. f, err := os.Open(result.Path) if err != nil { t.Fatalf("open result: %v", err) } defer f.Close() img, _, err := image.Decode(f) if err != nil { t.Fatalf("decode result: %v", err) } bounds := img.Bounds() if bounds.Dx() != MaxWidth { t.Errorf("resized width = %d, want %d", bounds.Dx(), MaxWidth) } // Aspect ratio should be maintained. expectedHeight := 2160 * MaxWidth / 3840 if bounds.Dy() != expectedHeight { t.Errorf("resized height = %d, want %d", bounds.Dy(), expectedHeight) } } func TestProcessSmallImageNotResized(t *testing.T) { uploadDir := t.TempDir() buf, header := testJPEG(t, 800, 600) result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir) if err != nil { t.Fatalf("Process small image: %v", err) } f, err := os.Open(result.Path) if err != nil { t.Fatalf("open result: %v", err) } defer f.Close() img, _, err := image.Decode(f) if err != nil { t.Fatalf("decode result: %v", err) } if img.Bounds().Dx() != 800 { t.Errorf("small image width = %d, should not be resized", img.Bounds().Dx()) } } func TestProcessInvalidMIME(t *testing.T) { uploadDir := t.TempDir() header := &multipart.FileHeader{ Filename: "test.txt", Header: textproto.MIMEHeader{"Content-Type": {"text/plain"}}, } buf := bytes.NewReader([]byte("not an image")) _, err := Process(&bufferReadSeeker{buf}, header, uploadDir) if err == nil { t.Error("expected error for text/plain MIME type") } if !strings.Contains(err.Error(), "unsupported image type") { t.Errorf("error = %q, should mention unsupported type", err) } } func TestProcessCorruptImage(t *testing.T) { uploadDir := t.TempDir() header := &multipart.FileHeader{ Filename: "corrupt.jpg", Header: textproto.MIMEHeader{"Content-Type": {"image/jpeg"}}, } buf := bytes.NewReader([]byte("this is not valid jpeg data")) _, err := Process(&bufferReadSeeker{buf}, header, uploadDir) if err == nil { t.Error("expected error for corrupt image data") } } func TestProcessUUIDFilename(t *testing.T) { uploadDir := t.TempDir() buf, header := testJPEG(t, 100, 100) // Give a user-supplied filename. header.Filename = "my-vacation-photo.jpg" result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir) if err != nil { t.Fatalf("Process: %v", err) } if strings.Contains(result.Filename, "vacation") { t.Error("filename should be UUID-based, not user-supplied") } // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.ext if len(result.Filename) < 36 { t.Errorf("filename %q too short for UUID", result.Filename) } } func TestAllowedTypes(t *testing.T) { expected := []string{"image/jpeg", "image/png", "image/gif", "image/webp"} for _, mime := range expected { if _, ok := AllowedTypes[mime]; !ok { t.Errorf("AllowedTypes missing %s", mime) } } } func TestDeletePathTraversal(t *testing.T) { uploadDir := t.TempDir() // Create a file outside uploadDir. outsideFile := filepath.Join(t.TempDir(), "sensitive.txt") os.WriteFile(outsideFile, []byte("secret"), 0644) // Attempt to delete it via path traversal. err := Delete(uploadDir, "../../../"+filepath.Base(outsideFile)) if err == nil { t.Error("expected error for path traversal") } // File should still exist. if _, statErr := os.Stat(outsideFile); statErr != nil { t.Error("path traversal should not have deleted the file") } } func TestDeleteEmpty(t *testing.T) { // Empty filename should be a no-op. if err := Delete(t.TempDir(), ""); err != nil { t.Errorf("Delete empty filename: %v", err) } }