更新
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func avatarPNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{uint8(x*31 + y*7), uint8(x + y*19), uint8(x*5 + y), 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestAvatarVariantsDimensionsAndSource(t *testing.T) {
|
||||
source := avatarPNG(t, 1800, 1200)
|
||||
reader := bytes.NewReader(source)
|
||||
result, err := createAvatarImages(context.Background(), reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
data []byte
|
||||
max int
|
||||
}{{result.Display, 640}, {result.Thumbnail, 256}} {
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(item.data))
|
||||
if err != nil || format != "jpeg" || cfg.Width != item.max || cfg.Height != item.max*2/3 {
|
||||
t.Fatalf("unexpected avatar dimensions: %+v %s %v", cfg, format, err)
|
||||
}
|
||||
}
|
||||
if result.Width != 640 || result.Height != 426 {
|
||||
t.Fatalf("unexpected display metadata: %+v", result)
|
||||
}
|
||||
preserved, _ := io.ReadAll(reader)
|
||||
if !bytes.Equal(source, preserved) {
|
||||
t.Fatal("source bytes or file offset changed")
|
||||
}
|
||||
t.Logf("PNG %d bytes -> display %d bytes, thumbnail %d bytes", len(source), len(result.Display), len(result.Thumbnail))
|
||||
}
|
||||
|
||||
func TestAvatarSmallTransparentImageIsNotEnlarged(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewNRGBA(image.Rect(0, 0, 32, 16))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := createAvatarImages(context.Background(), bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, data := range [][]byte{result.Display, result.Thumbnail} {
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil || img.Bounds().Dx() != 32 || img.Bounds().Dy() != 16 {
|
||||
t.Fatal("small avatar was enlarged", err)
|
||||
}
|
||||
r, g, b, _ := img.At(5, 5).RGBA()
|
||||
if r < 64000 || g < 64000 || b < 64000 {
|
||||
t.Fatal("transparency must be composited onto white")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarPhoneEXIFOrientation(t *testing.T) {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 400, 200))
|
||||
for y := 0; y < 200; y++ {
|
||||
for x := 0; x < 400; x++ {
|
||||
c := color.NRGBA{R: 255, A: 255}
|
||||
if x >= 200 {
|
||||
c = color.NRGBA{B: 255, A: 255}
|
||||
}
|
||||
img.SetNRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// JPEG APP1 containing a little-endian TIFF orientation=6 (90 degrees CW).
|
||||
exif := []byte{'E', 'x', 'i', 'f', 0, 0, 'I', 'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0}
|
||||
source := append([]byte{0xff, 0xd8, 0xff, 0xe1, 0, byte(len(exif) + 2)}, exif...)
|
||||
source = append(source, buf.Bytes()[2:]...)
|
||||
result, err := createAvatarImages(context.Background(), bytes.NewReader(source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := jpeg.Decode(bytes.NewReader(result.Display))
|
||||
if err != nil || output.Bounds().Dx() != 200 || output.Bounds().Dy() != 400 {
|
||||
t.Fatal("orientation was not applied", err)
|
||||
}
|
||||
r, _, b, _ := output.At(100, 50).RGBA()
|
||||
if r <= b {
|
||||
t.Fatal("top half should be red")
|
||||
}
|
||||
r, _, b, _ = output.At(100, 350).RGBA()
|
||||
if b <= r {
|
||||
t.Fatal("bottom half should be blue")
|
||||
}
|
||||
if bytes.Contains(result.Display, []byte("Exif")) {
|
||||
t.Fatal("display variant retained EXIF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarRejectsInvalidAndExcessivePixels(t *testing.T) {
|
||||
valid := avatarPNG(t, 2, 2)
|
||||
huge := append([]byte(nil), valid...)
|
||||
binary.BigEndian.PutUint32(huge[16:20], 7000)
|
||||
binary.BigEndian.PutUint32(huge[20:24], 7000)
|
||||
binary.BigEndian.PutUint32(huge[29:33], crc32.ChecksumIEEE(huge[12:29]))
|
||||
for name, source := range map[string][]byte{"not image": []byte("not an image"), "truncated": valid[:33], "decompression bomb": huge} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := createAvatarImages(context.Background(), bytes.NewReader(source)); err == nil {
|
||||
t.Fatal("invalid image accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
avatarProcessing <- struct{}{}
|
||||
_, err := createAvatarImages(context.Background(), bytes.NewReader(valid))
|
||||
<-avatarProcessing
|
||||
if !errors.Is(err, errAvatarBusy) {
|
||||
t.Fatalf("busy decoder should reject without queuing: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err = createAvatarImages(ctx, bytes.NewReader(valid)); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarGIFAndWebP(t *testing.T) {
|
||||
var gifBuf bytes.Buffer
|
||||
if err := gif.Encode(&gifBuf, image.NewNRGBA(image.Rect(0, 0, 20, 10)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webp, err := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, source := range [][]byte{gifBuf.Bytes(), webp} {
|
||||
if _, err := createAvatarImages(context.Background(), bytes.NewReader(source)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarPhotoFixtureSize(t *testing.T) {
|
||||
files, err := filepath.Glob(filepath.Join("..", "..", "..", "fixtures", "test-users", "avatars", "*.png"))
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Skip("optional portrait fixtures are not included")
|
||||
}
|
||||
source, err := os.ReadFile(files[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := createAvatarImages(context.Background(), bytes.NewReader(source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Thumbnail)*10 >= len(source) || len(result.Display)*10 >= len(source) {
|
||||
t.Fatal("portrait fixture should be substantially reduced")
|
||||
}
|
||||
t.Logf("portrait %d bytes -> display %d bytes; thumbnail %d bytes (%.1f%% smaller)", len(source), len(result.Display), len(result.Thumbnail), 100*(1-float64(len(result.Thumbnail))/float64(len(source))))
|
||||
}
|
||||
|
||||
func TestAvatarThumbnailURLOnlyRewritesPublishedFamilies(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://cdn.example.com/media/42-1-av1.jpg": "https://cdn.example.com/media/42-1-av1-thumb.jpg",
|
||||
"https://cdn.example.com/media/test-users/batch/hash-user.png": "https://cdn.example.com/media/test-users/batch/hash-user-thumb.jpg",
|
||||
"https://cdn.example.com/media/test-users/batch/hash-user-thumb.jpg": "https://cdn.example.com/media/test-users/batch/hash-user-thumb.jpg",
|
||||
"https://oauth.example.com/avatar.png": "https://oauth.example.com/avatar.png",
|
||||
"https://cdn.example.com/42-1-av1.jpg?signature=x": "https://cdn.example.com/42-1-av1.jpg?signature=x",
|
||||
}
|
||||
for source, want := range tests {
|
||||
if got := avatarThumbnailURL(source); got != want {
|
||||
t.Errorf("avatarThumbnailURL(%q) = %q, want %q", source, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user