package service import ( "bytes" "image" "image/color" "image/gif" "image/jpeg" "image/png" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/freefire/jiang13-bbs/config" webpenc "github.com/gen2brain/webp" xwebp "golang.org/x/image/webp" ) // solidImg 生成渐变纯色测试图 func solidImg(w, h int) *image.RGBA { src := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255}) } } return src } // writeVariantSource 把按编码器生成的文件写到 uploads 目录 func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) { t.Helper() abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/"))) if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } f, err := os.Create(abs) if err != nil { t.Fatalf("create: %v", err) } defer f.Close() if err := encode(f); err != nil { t.Fatalf("encode: %v", err) } } func encodeJPEG(img image.Image) func(io.Writer) error { return func(w io.Writer) error { return jpeg.Encode(w, img, &jpeg.Options{Quality: 85}) } } func encodePNG(img image.Image) func(io.Writer) error { return func(w io.Writer) error { return png.Encode(w, img) } } func encodeWebP(img image.Image) func(io.Writer) error { return func(w io.Writer) error { return webpenc.Encode(w, img, webpenc.Options{Quality: 85}) } } func encodeGIF(img image.Image) func(io.Writer) error { return func(w io.Writer) error { return gif.Encode(w, img, nil) } } // decodeVariant 解码返回的 WebP 变体 func decodeVariant(t *testing.T, data []byte) image.Image { t.Helper() img, err := xwebp.Decode(bytes.NewReader(data)) if err != nil { t.Fatalf("decode variant webp: %v", err) } return img } func TestIsVariantWidth(t *testing.T) { if !IsVariantWidth(48) || !IsVariantWidth(1920) { t.Fatal("whitelist endpoints should be valid") } if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) { t.Fatal("non-whitelist width should be invalid") } } func TestVariantRejectsInvalidInput(t *testing.T) { s, _ := newUploadTestService(t) cases := []struct { name string url string w int }{ {"非法尺寸", "/uploads/images/a.webp", 100}, {"路径穿越", "/uploads/images/../../etc/passwd", 96}, {"非白名单目录", "/uploads/secret/a.webp", 96}, {"非图片扩展名", "/uploads/images/a.txt", 96}, {"外部地址", "https://evil.com/a.webp", 96}, {"空地址", "", 96}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, _, err := s.Variant(tc.url, tc.w); err == nil { t.Fatal("want error, got nil") } }) } } func TestVariantMissingFile(t *testing.T) { s, _ := newUploadTestService(t) if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil { t.Fatal("missing source should error") } } func TestVariantPassthroughSmallImage(t *testing.T) { s, dir := newUploadTestService(t) url := "/uploads/images/small.webp" writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48))) data, m, err := s.Variant(url, 96) if err != nil { t.Fatalf("variant: %v", err) } if m != "image/webp" { t.Fatalf("want webp mime, got %s", m) } src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp"))) if !bytes.Equal(data, src) { t.Fatal("small image should be returned byte-for-byte") } } func TestVariantResizesByWidthLandscape(t *testing.T) { s, dir := newUploadTestService(t) url := "/uploads/images/wide.jpg" writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500))) data, m, err := s.Variant(url, 384) if err != nil { t.Fatalf("variant: %v", err) } if m != "image/webp" { t.Fatalf("want webp, got %s", m) } b := decodeVariant(t, data).Bounds() if b.Dx() != 384 || b.Dy() != 192 { t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy()) } } func TestVariantResizesByWidthPortrait(t *testing.T) { s, dir := newUploadTestService(t) url := "/uploads/images/tall.png" writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000))) data, _, err := s.Variant(url, 256) if err != nil { t.Fatalf("variant: %v", err) } b := decodeVariant(t, data).Bounds() if b.Dx() != 256 || b.Dy() != 512 { t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy()) } } func TestVariantPassthroughGIFSVGICO(t *testing.T) { s, dir := newUploadTestService(t) cases := []struct { name string url string mime string body []byte }{ {"gif", "/uploads/images/a.gif", "image/gif", nil}, {"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("")}, {"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if tc.body != nil { writeVariantSource(t, dir, tc.url, func(w io.Writer) error { _, err := w.Write(tc.body) return err }) } else { writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16))) } data, m, err := s.Variant(tc.url, 96) if err != nil { t.Fatalf("variant: %v", err) } if m != tc.mime { t.Fatalf("want %s, got %s", tc.mime, m) } src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/")))) if !bytes.Equal(data, src) { t.Fatalf("%s should pass through unchanged", tc.name) } }) } } func TestVariantCachedAndReused(t *testing.T) { s, dir := newUploadTestService(t) url := "/uploads/images/cache.jpg" writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500))) first, m, err := s.Variant(url, 384) if err != nil { t.Fatalf("first variant: %v", err) } if m != "image/webp" { t.Fatalf("want webp, got %s", m) } cache := s.variantCacheFile(url, 384) if info, err := os.Stat(cache); err != nil || info.IsDir() { t.Fatalf("cache file should exist: %v", err) } second, _, err := s.Variant(url, 384) if err != nil { t.Fatalf("second variant: %v", err) } if !bytes.Equal(first, second) { t.Fatal("cached variant should be byte-identical") } } func TestVariantCacheFreshness(t *testing.T) { _, dir := newUploadTestService(t) cache := filepath.Join(dir, ".thumbs", "v", "x.webp") if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil { t.Fatal(err) } // 远程对象:modTime 零值,存在即新鲜 if !variantCacheFresh(cache, time.Time{}) { t.Fatal("remote cache should be fresh when file exists") } // 本地源:mtime 新于缓存 → 不新鲜 future := time.Now().Add(1 * time.Hour) if variantCacheFresh(cache, future) { t.Fatal("cache should be stale vs newer source mtime") } } func TestVariantRemoteRejectsBadID(t *testing.T) { s, _ := newUploadTestService(t) s.ops = NewOperations(s.db, &config.Config{}) if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil { t.Fatal("id with slash should be rejected") } } func TestVariantRemoteUnavailable(t *testing.T) { s, _ := newUploadTestService(t) s.ops = NewOperations(s.db, &config.Config{}) // 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节 if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil { t.Fatal("missing remote object should error") } } func TestVariantRemoteWithoutOps(t *testing.T) { s, _ := newUploadTestService(t) if _, _, err := s.Variant("/api/media/abc", 96); err == nil { t.Fatal("remote url without ops wired should error") } }