mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
756d91acad
CI on main has been failing since the PLAN-866 attachment work
landed. Two independent issues:
1. gofmt failures (golangci-lint) — seven files in the attachments
path had trailing-comment alignment that gofmt wanted nudged a
column. Pure whitespace; ran `gofmt -w` across the affected
files. golangci-lint's gofmt linter caught it on every PR /
push since TASK-870 but we hadn't been watching those signals.
Files cleaned: internal/attachments/{fs_store_test,mime,
mime_test,processor_test}.go, internal/server/{
handlers_attachments_download_test,handlers_attachments_transform,
render/attachments_test}.go.
Local guard: `gofmt -l ./...` now exits clean.
2. Race-detector tests timed out at 20m on the GitHub-hosted runner.
Two contributors:
- PostgreSQL adds latency on every CREATE/DROP plus on the
bcrypt hash inside auth/bootstrap (~3s per call under -race
on the runner). Tests that bootstrap a fresh user (e.g.
TestSessionIPChange_*) pay the full cost each time.
- The PLAN-866 image-processing tests (thumbnail derivation,
rotate / crop transform) added ~2-3 minutes of decode/encode
work on top of the existing suite.
The previous "20m gives margin without papering over a hang"
comment was right at the time it was written; we now genuinely
need more headroom. Bumped to 30m on both the SQLite and
PostgreSQL race steps. Genuine deadlocks would still trip this
and produce the goroutine-dump panic — we just stop confusing
"slow but progressing" with "permanently hung".
Reference points before / after:
- TASK-875 main run #294: Go (PostgreSQL) finished in 17m48s ✓
- TASK-880 main run #298: Go (PostgreSQL) hit 20m timeout ✗
- Local: my new tests under -race add ~63s on a developer laptop
(TestThumbnails + TestTransform + TestProcessor combined).
Verification:
go test ./... — pass
go vet ./... — clean
gofmt -l (recursively) — clean
334 lines
9.6 KiB
Go
334 lines
9.6 KiB
Go
//go:build !libvips
|
|
|
|
package attachments
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"hash/crc32"
|
|
"image"
|
|
"image/color"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// makeTestImage returns a freshly-allocated NRGBA image filled with a
|
|
// distinguishable pattern so transformation tests can assert on
|
|
// specific pixels. Width/height are exported so callers can dial them
|
|
// up for memory-pressure tests without having to know the impl.
|
|
func makeTestImage(w, h int) *image.NRGBA {
|
|
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
img.Set(x, y, color.NRGBA{
|
|
R: uint8(x % 256),
|
|
G: uint8(y % 256),
|
|
B: uint8((x + y) % 256),
|
|
A: 255,
|
|
})
|
|
}
|
|
}
|
|
return img
|
|
}
|
|
|
|
func encodePNG(t *testing.T, img image.Image) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatalf("encode test PNG: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func encodeJPEG(t *testing.T, img image.Image) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
|
t.Fatalf("encode test JPEG: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func encodeGIF(t *testing.T, img image.Image) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := gif.Encode(&buf, img, nil); err != nil {
|
|
t.Fatalf("encode test GIF: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestProcessor_Capabilities_PureGo(t *testing.T) {
|
|
p := NewProcessor()
|
|
caps := p.Capabilities()
|
|
|
|
wantFormats := []string{"png", "jpeg", "gif", "bmp", "tiff"}
|
|
if len(caps.ImageFormats) != len(wantFormats) {
|
|
t.Errorf("ImageFormats = %v, want %v", caps.ImageFormats, wantFormats)
|
|
}
|
|
got := strings.Join(caps.ImageFormats, ",")
|
|
want := strings.Join(wantFormats, ",")
|
|
if got != want {
|
|
t.Errorf("ImageFormats = %q, want %q", got, want)
|
|
}
|
|
if !caps.CanTranscode {
|
|
t.Error("CanTranscode = false, want true (pure-Go can encode PNG + JPEG)")
|
|
}
|
|
if caps.MaxPixels != MaxPixelsDefault {
|
|
t.Errorf("MaxPixels = %d, want %d", caps.MaxPixels, MaxPixelsDefault)
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Decode_RoundTrip(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
format string
|
|
bytes func(t *testing.T) []byte
|
|
}{
|
|
{"png", "png", func(t *testing.T) []byte { return encodePNG(t, makeTestImage(64, 32)) }},
|
|
{"jpeg", "jpeg", func(t *testing.T) []byte { return encodeJPEG(t, makeTestImage(64, 32)) }},
|
|
{"gif", "gif", func(t *testing.T) []byte { return encodeGIF(t, makeTestImage(64, 32)) }},
|
|
}
|
|
p := NewProcessor()
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
img, format, err := p.Decode(bytes.NewReader(c.bytes(t)))
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if format != c.format {
|
|
t.Errorf("format = %q, want %q", format, c.format)
|
|
}
|
|
if img.Bounds().Dx() != 64 || img.Bounds().Dy() != 32 {
|
|
t.Errorf("decoded bounds = %v, want 64x32", img.Bounds())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Decode_RejectsUnsupportedFormat(t *testing.T) {
|
|
// Synthetic "WebP-ish" bytes — RIFF header but not actually a valid
|
|
// WebP. image.DecodeConfig will fail because no WebP decoder is
|
|
// registered in the pure-Go build.
|
|
bogus := []byte("RIFF\x10\x00\x00\x00WEBPVP8L\x00\x00\x00\x00")
|
|
_, _, err := NewProcessor().Decode(bytes.NewReader(bogus))
|
|
if err == nil {
|
|
t.Fatal("Decode succeeded on bogus WebP-like bytes; want ErrUnsupportedFormat")
|
|
}
|
|
if !errors.Is(err, ErrUnsupportedFormat) {
|
|
t.Errorf("err = %v, want errors.Is ErrUnsupportedFormat", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Decode_RejectsTooLarge(t *testing.T) {
|
|
// Build a real 1x1 PNG, patch its IHDR to claim absurd dimensions,
|
|
// and recompute the chunk's CRC32 so image.DecodeConfig can read
|
|
// the patched header and our MaxPixels gate fires before any real
|
|
// pixel buffer gets allocated. We can't construct an actual
|
|
// 100k x 100k PNG — that's the entire point of the header-peek
|
|
// check, which exists so an attacker can't OOM the server with a
|
|
// forged dimensions claim.
|
|
//
|
|
// PNG layout: 8-byte signature, then chunks. Each chunk:
|
|
// length(4) type(4) data(N) crc(4)
|
|
// IHDR is the first chunk and is always 13 bytes of data.
|
|
// CRC covers type + data — i.e. bytes [12 : 12+4+13] = [12 : 29].
|
|
template := encodePNG(t, makeTestImage(1, 1))
|
|
if len(template) < 33 {
|
|
t.Fatalf("PNG template too short: %d bytes", len(template))
|
|
}
|
|
patched := append([]byte(nil), template...)
|
|
const huge = uint32(100000) // 100k x 100k = 1e10 px > MaxPixelsDefault (64M)
|
|
binary.BigEndian.PutUint32(patched[16:20], huge) // width
|
|
binary.BigEndian.PutUint32(patched[20:24], huge) // height
|
|
crc := crc32.ChecksumIEEE(patched[12:29]) // type + 13-byte data
|
|
binary.BigEndian.PutUint32(patched[29:33], crc)
|
|
|
|
_, _, err := NewProcessor().Decode(bytes.NewReader(patched))
|
|
if err == nil {
|
|
t.Fatal("Decode succeeded on 100kx100k claim; want ErrImageTooLarge")
|
|
}
|
|
if !errors.Is(err, ErrImageTooLarge) {
|
|
t.Errorf("err = %v, want errors.Is ErrImageTooLarge", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Resize_PreservesAspectAndShorterEdgePassThrough(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(800, 400)
|
|
|
|
// Resize where source > target → scaled
|
|
out, err := p.Resize(src, 256)
|
|
if err != nil {
|
|
t.Fatalf("Resize: %v", err)
|
|
}
|
|
if out.Bounds().Dx() != 256 {
|
|
t.Errorf("scaled width = %d, want 256", out.Bounds().Dx())
|
|
}
|
|
// Aspect 2:1 → height 128
|
|
if out.Bounds().Dy() != 128 {
|
|
t.Errorf("scaled height = %d, want 128 (preserve 2:1 aspect)", out.Bounds().Dy())
|
|
}
|
|
|
|
// Resize where source already smaller → pass-through (same image)
|
|
small := makeTestImage(100, 50)
|
|
out2, err := p.Resize(small, 1024)
|
|
if err != nil {
|
|
t.Fatalf("Resize pass-through: %v", err)
|
|
}
|
|
if out2.Bounds().Dx() != 100 || out2.Bounds().Dy() != 50 {
|
|
t.Errorf("pass-through bounds = %v, want 100x50", out2.Bounds())
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Resize_TallerThanWide(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(400, 800) // 1:2
|
|
|
|
out, err := p.Resize(src, 256)
|
|
if err != nil {
|
|
t.Fatalf("Resize: %v", err)
|
|
}
|
|
if out.Bounds().Dy() != 256 {
|
|
t.Errorf("scaled height = %d, want 256", out.Bounds().Dy())
|
|
}
|
|
if out.Bounds().Dx() != 128 {
|
|
t.Errorf("scaled width = %d, want 128 (preserve 1:2 aspect)", out.Bounds().Dx())
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Rotate(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(64, 32)
|
|
|
|
cases := []struct {
|
|
deg int
|
|
wantW, wantH int
|
|
}{
|
|
{0, 64, 32},
|
|
{90, 32, 64},
|
|
{180, 64, 32},
|
|
{270, 32, 64},
|
|
{-90, 32, 64}, // negative normalizes to 270
|
|
{360, 64, 32}, // 360 normalizes to 0
|
|
{450, 32, 64}, // 450 normalizes to 90
|
|
}
|
|
for _, c := range cases {
|
|
out, err := p.Rotate(src, c.deg)
|
|
if err != nil {
|
|
t.Errorf("Rotate(%d): %v", c.deg, err)
|
|
continue
|
|
}
|
|
if out.Bounds().Dx() != c.wantW || out.Bounds().Dy() != c.wantH {
|
|
t.Errorf("Rotate(%d) bounds = %v, want %dx%d", c.deg, out.Bounds(), c.wantW, c.wantH)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Rotate_RejectsNon90Multiple(t *testing.T) {
|
|
if _, err := NewProcessor().Rotate(makeTestImage(8, 8), 45); err == nil {
|
|
t.Error("Rotate(45) succeeded; want error (only multiples of 90 supported)")
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Crop(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(100, 100)
|
|
|
|
out, err := p.Crop(src, image.Rect(10, 20, 60, 80))
|
|
if err != nil {
|
|
t.Fatalf("Crop: %v", err)
|
|
}
|
|
if out.Bounds().Dx() != 50 || out.Bounds().Dy() != 60 {
|
|
t.Errorf("Crop bounds = %v, want 50x60", out.Bounds())
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Crop_ClipsToImageBounds(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(100, 100)
|
|
|
|
// Rect extends past image bounds — should clip, not error.
|
|
out, err := p.Crop(src, image.Rect(50, 50, 200, 200))
|
|
if err != nil {
|
|
t.Fatalf("Crop with overflow rect: %v", err)
|
|
}
|
|
if out.Bounds().Dx() != 50 || out.Bounds().Dy() != 50 {
|
|
t.Errorf("clipped bounds = %v, want 50x50", out.Bounds())
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Crop_RejectsEmptyIntersection(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(100, 100)
|
|
if _, err := p.Crop(src, image.Rect(200, 200, 300, 300)); err == nil {
|
|
t.Error("Crop with rect outside bounds succeeded; want error")
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Encode_PNGAndJPEG(t *testing.T) {
|
|
p := NewProcessor()
|
|
src := makeTestImage(64, 32)
|
|
|
|
for _, format := range []string{"png", "jpeg"} {
|
|
t.Run(format, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if err := p.Encode(src, format, &buf); err != nil {
|
|
t.Fatalf("Encode(%s): %v", format, err)
|
|
}
|
|
// Round-trip: decode the encoded bytes; format must match.
|
|
_, gotFormat, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
|
if err != nil {
|
|
t.Fatalf("decode encoded %s: %v", format, err)
|
|
}
|
|
if gotFormat != format {
|
|
t.Errorf("decoded format = %q, want %q", gotFormat, format)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessor_Encode_RejectsUnknownFormat(t *testing.T) {
|
|
p := NewProcessor()
|
|
var buf bytes.Buffer
|
|
err := p.Encode(makeTestImage(8, 8), "webp", &buf)
|
|
if !errors.Is(err, ErrUnsupportedFormat) {
|
|
t.Errorf("Encode(webp) err = %v, want errors.Is ErrUnsupportedFormat", err)
|
|
}
|
|
}
|
|
|
|
func TestThumbnailFormat(t *testing.T) {
|
|
cases := map[string]string{
|
|
"png": "png",
|
|
"jpeg": "jpeg",
|
|
"gif": "jpeg",
|
|
"bmp": "jpeg",
|
|
"tiff": "jpeg",
|
|
"": "jpeg",
|
|
}
|
|
for in, want := range cases {
|
|
if got := ThumbnailFormat(in); got != want {
|
|
t.Errorf("ThumbnailFormat(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThumbnailMimeAndExt(t *testing.T) {
|
|
if m := ThumbnailMime("png"); m != "image/png" {
|
|
t.Errorf("ThumbnailMime(png) = %q", m)
|
|
}
|
|
if m := ThumbnailMime("jpeg"); m != "image/jpeg" {
|
|
t.Errorf("ThumbnailMime(jpeg) = %q", m)
|
|
}
|
|
if e := ThumbnailExt("png"); e != ".png" {
|
|
t.Errorf("ThumbnailExt(png) = %q", e)
|
|
}
|
|
if e := ThumbnailExt("jpeg"); e != ".jpg" {
|
|
t.Errorf("ThumbnailExt(jpeg) = %q", e)
|
|
}
|
|
}
|