Add 125 new test functions across 10 new test files, covering: - CSRF middleware (8 tests): double-submit cookie validation - Auth middleware (12 tests): SessionLoader, RequireAdmin, context helpers - API handlers (28 tests): auth, faves CRUD, tags, users, export/import - Web handlers (41 tests): signup, login, password reset, fave CRUD, admin panel, feeds, import/export, profiles, settings - Config (8 tests): env parsing, defaults, trusted proxies, normalization - Database (6 tests): migrations, PRAGMAs, idempotency, seeding - Image processing (10 tests): JPEG/PNG, resize, EXIF strip, path traversal - Render (6 tests): page/error/partial rendering, template functions - Settings store (3 tests): CRUD operations - Regression tests for display name fallback and CSP-safe autocomplete Also adds CSRF middleware to testServer chain for end-to-end CSRF verification, TESTPLAN.md documenting coverage, and PLANS-v1.1.md with implementation plans for notes+OG, PWA, editing UX, and admin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
234 lines
6.1 KiB
Go
234 lines
6.1 KiB
Go
// 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)
|
|
}
|
|
}
|