favoritter/internal/image/image_test.go

234 lines
6.1 KiB
Go
Raw Permalink Normal View History

// 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)
}
}