feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
package assetstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
stddraw "image/draw"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxInputBytes int64 = 20 << 20
|
||||
MaxSourceDimension = 10000
|
||||
MaxSourcePixels int64 = 25_000_000
|
||||
MaxOutputDimension = 2048
|
||||
JPEGQuality = 90
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func New(root string) (*Store, error) {
|
||||
absoluteRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, errors.New("resolve asset storage root")
|
||||
}
|
||||
if err := os.MkdirAll(absoluteRoot, 0o700); err != nil {
|
||||
return nil, errors.New("create asset storage root")
|
||||
}
|
||||
info, err := os.Stat(absoluteRoot)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, errors.New("asset storage root is not a directory")
|
||||
}
|
||||
return &Store{root: absoluteRoot}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Put(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
declaredMediaType string,
|
||||
content io.Reader,
|
||||
) (usecase.NormalizedReferenceImage, error) {
|
||||
if content == nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorInvalid,
|
||||
errors.New("image content is required"),
|
||||
)
|
||||
}
|
||||
inputFile, err := os.CreateTemp(s.root, ".input-*")
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
inputPath := inputFile.Name()
|
||||
defer func() {
|
||||
_ = inputFile.Close()
|
||||
_ = os.Remove(inputPath)
|
||||
}()
|
||||
|
||||
inputHash := sha256.New()
|
||||
written, err := io.Copy(
|
||||
io.MultiWriter(inputFile, inputHash),
|
||||
io.LimitReader(&contextReader{ctx: ctx, reader: content}, MaxInputBytes+1),
|
||||
)
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if written > MaxInputBytes {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorTooLarge,
|
||||
errors.New("input image exceeds byte limit"),
|
||||
)
|
||||
}
|
||||
if written == 0 {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorInvalid,
|
||||
errors.New("input image is empty"),
|
||||
)
|
||||
}
|
||||
if err := inputFile.Sync(); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if _, err := inputFile.Seek(0, io.SeekStart); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
config, format, err := image.DecodeConfig(inputFile)
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorInvalid,
|
||||
err,
|
||||
)
|
||||
}
|
||||
inputMediaType, ok := mediaTypeForFormat(format)
|
||||
if !ok {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnsupported,
|
||||
errors.New("unsupported decoded image format"),
|
||||
)
|
||||
}
|
||||
if !declaredTypeMatches(declaredMediaType, inputMediaType) {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnsupported,
|
||||
errors.New("declared and decoded media types differ"),
|
||||
)
|
||||
}
|
||||
if config.Width <= 0 || config.Height <= 0 ||
|
||||
config.Width > MaxSourceDimension ||
|
||||
config.Height > MaxSourceDimension ||
|
||||
int64(config.Width)*int64(config.Height) > MaxSourcePixels {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorInvalid,
|
||||
errors.New("input image dimensions are invalid"),
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := inputFile.Seek(0, io.SeekStart); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
decoded, decodedFormat, err := image.Decode(inputFile)
|
||||
if err != nil || decodedFormat != format {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorInvalid,
|
||||
errors.New("input image cannot be fully decoded"),
|
||||
)
|
||||
}
|
||||
normalized := normalizeToWhiteBackground(decoded)
|
||||
|
||||
storageKey, err := randomStorageKey()
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
finalPath, err := s.resolve(storageKey)
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(finalPath), 0o700); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
outputFile, err := os.CreateTemp(filepath.Dir(finalPath), ".normalized-*")
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
outputPath := outputFile.Name()
|
||||
outputClosed := false
|
||||
defer func() {
|
||||
if !outputClosed {
|
||||
_ = outputFile.Close()
|
||||
}
|
||||
_ = os.Remove(outputPath)
|
||||
}()
|
||||
outputHash := sha256.New()
|
||||
if err := jpeg.Encode(
|
||||
io.MultiWriter(outputFile, outputHash),
|
||||
normalized,
|
||||
&jpeg.Options{Quality: JPEGQuality},
|
||||
); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := outputFile.Sync(); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := outputFile.Close(); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
outputClosed = true
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := os.Rename(outputPath, finalPath); err != nil {
|
||||
return usecase.NormalizedReferenceImage{}, storeError(
|
||||
usecase.ImageStoreErrorUnavailable,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return usecase.NormalizedReferenceImage{
|
||||
StorageKey: storageKey,
|
||||
InputMediaType: inputMediaType,
|
||||
InputSHA256: hex.EncodeToString(inputHash.Sum(nil)),
|
||||
MediaType: domain.NormalizedImageMediaType,
|
||||
SizeBytes: info.Size(),
|
||||
SHA256: hex.EncodeToString(outputHash.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Open(
|
||||
ctx context.Context,
|
||||
storageKey string,
|
||||
) (io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, storeError(usecase.ImageStoreErrorUnavailable, err)
|
||||
}
|
||||
path, err := s.resolve(storageKey)
|
||||
if err != nil {
|
||||
return nil, storeError(usecase.ImageStoreErrorNotFound, err)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, storeError(usecase.ImageStoreErrorNotFound, err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, storeError(usecase.ImageStoreErrorUnavailable, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(
|
||||
ctx context.Context,
|
||||
storageKey string,
|
||||
) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storeError(usecase.ImageStoreErrorUnavailable, err)
|
||||
}
|
||||
path, err := s.resolve(storageKey)
|
||||
if err != nil {
|
||||
return storeError(usecase.ImageStoreErrorNotFound, err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return storeError(usecase.ImageStoreErrorUnavailable, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeToWhiteBackground(source image.Image) *image.RGBA {
|
||||
sourceBounds := source.Bounds()
|
||||
width := sourceBounds.Dx()
|
||||
height := sourceBounds.Dy()
|
||||
outputWidth, outputHeight := boundedDimensions(width, height)
|
||||
output := image.NewRGBA(image.Rect(0, 0, outputWidth, outputHeight))
|
||||
stddraw.Draw(
|
||||
output,
|
||||
output.Bounds(),
|
||||
&image.Uniform{C: color.White},
|
||||
image.Point{},
|
||||
stddraw.Src,
|
||||
)
|
||||
if outputWidth == width && outputHeight == height {
|
||||
stddraw.Draw(
|
||||
output,
|
||||
output.Bounds(),
|
||||
source,
|
||||
sourceBounds.Min,
|
||||
stddraw.Over,
|
||||
)
|
||||
return output
|
||||
}
|
||||
xdraw.CatmullRom.Scale(
|
||||
output,
|
||||
output.Bounds(),
|
||||
source,
|
||||
sourceBounds,
|
||||
stddraw.Over,
|
||||
nil,
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
func boundedDimensions(width, height int) (int, int) {
|
||||
longest := width
|
||||
if height > longest {
|
||||
longest = height
|
||||
}
|
||||
if longest <= MaxOutputDimension {
|
||||
return width, height
|
||||
}
|
||||
if width >= height {
|
||||
return MaxOutputDimension, maxInt(
|
||||
1,
|
||||
int((int64(height)*MaxOutputDimension)/int64(width)),
|
||||
)
|
||||
}
|
||||
return maxInt(
|
||||
1,
|
||||
int((int64(width)*MaxOutputDimension)/int64(height)),
|
||||
), MaxOutputDimension
|
||||
}
|
||||
|
||||
func declaredTypeMatches(declared string, actual string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(declared))
|
||||
return err == nil && strings.EqualFold(mediaType, actual)
|
||||
}
|
||||
|
||||
func mediaTypeForFormat(format string) (string, bool) {
|
||||
switch strings.ToLower(format) {
|
||||
case "jpeg":
|
||||
return "image/jpeg", true
|
||||
case "png":
|
||||
return "image/png", true
|
||||
case "webp":
|
||||
return "image/webp", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func randomStorageKey() (string, error) {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := hex.EncodeToString(value[:])
|
||||
return encoded[:2] + "/" + encoded + ".jpg", nil
|
||||
}
|
||||
|
||||
func (s *Store) resolve(storageKey string) (string, error) {
|
||||
if storageKey == "" ||
|
||||
strings.Contains(storageKey, `\`) ||
|
||||
strings.HasPrefix(storageKey, "/") {
|
||||
return "", errors.New("unsafe storage key")
|
||||
}
|
||||
cleanKey := filepath.Clean(filepath.FromSlash(storageKey))
|
||||
if cleanKey == "." || filepath.IsAbs(cleanKey) {
|
||||
return "", errors.New("unsafe storage key")
|
||||
}
|
||||
resolved := filepath.Join(s.root, cleanKey)
|
||||
relative, err := filepath.Rel(s.root, resolved)
|
||||
if err != nil ||
|
||||
relative == ".." ||
|
||||
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("unsafe storage key")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func storeError(
|
||||
kind usecase.ImageStoreErrorKind,
|
||||
cause error,
|
||||
) error {
|
||||
return &usecase.ImageStoreError{Kind: kind, Cause: cause}
|
||||
}
|
||||
|
||||
type contextReader struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (r *contextReader) Read(buffer []byte) (int, error) {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.reader.Read(buffer)
|
||||
}
|
||||
|
||||
func maxInt(first, second int) int {
|
||||
if first > second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
var _ usecase.ReferenceImageStore = (*Store)(nil)
|
||||
@@ -0,0 +1,255 @@
|
||||
package assetstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func TestPutNormalizesPNGToBoundedWhiteJPEG(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := mustStore(t, root)
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 3000, 1200))
|
||||
for y := 0; y < source.Bounds().Dy(); y++ {
|
||||
for x := 0; x < source.Bounds().Dx(); x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{R: 20, G: 80, B: 140, A: 0})
|
||||
}
|
||||
}
|
||||
var input bytes.Buffer
|
||||
if err := png.Encode(&input, source); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := store.Put(
|
||||
context.Background(),
|
||||
"ignored-object-id",
|
||||
"image/png",
|
||||
bytes.NewReader(input.Bytes()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Put() error = %v", err)
|
||||
}
|
||||
if result.InputMediaType != "image/png" ||
|
||||
result.MediaType != "image/jpeg" ||
|
||||
len(result.InputSHA256) != 64 ||
|
||||
len(result.SHA256) != 64 ||
|
||||
result.SizeBytes <= 0 ||
|
||||
strings.Contains(result.StorageKey, `\`) ||
|
||||
strings.Contains(result.StorageKey, "..") {
|
||||
t.Fatalf("Put() result = %+v", result)
|
||||
}
|
||||
|
||||
content, err := store.Open(context.Background(), result.StorageKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
defer content.Close()
|
||||
normalized, format, err := image.Decode(content)
|
||||
if err != nil {
|
||||
t.Fatalf("image.Decode() error = %v", err)
|
||||
}
|
||||
if format != "jpeg" ||
|
||||
normalized.Bounds().Dx() != MaxOutputDimension ||
|
||||
normalized.Bounds().Dy() != 819 {
|
||||
t.Fatalf("normalized bounds/format = %v/%s", normalized.Bounds(), format)
|
||||
}
|
||||
red, green, blue, _ := normalized.At(20, 20).RGBA()
|
||||
if red < 0xf000 || green < 0xf000 || blue < 0xf000 {
|
||||
t.Fatalf(
|
||||
"transparent pixel was not composited on white: %x %x %x",
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAcceptsDecodableJPEGAndWebP(t *testing.T) {
|
||||
store := mustStore(t, t.TempDir())
|
||||
var jpegInput bytes.Buffer
|
||||
if err := jpeg.Encode(
|
||||
&jpegInput,
|
||||
image.NewRGBA(image.Rect(0, 0, 4, 3)),
|
||||
&jpeg.Options{Quality: 80},
|
||||
); err != nil {
|
||||
t.Fatalf("jpeg.Encode() error = %v", err)
|
||||
}
|
||||
if _, err := store.Put(
|
||||
context.Background(),
|
||||
"jpeg",
|
||||
"image/jpeg",
|
||||
bytes.NewReader(jpegInput.Bytes()),
|
||||
); err != nil {
|
||||
t.Fatalf("JPEG Put() error = %v", err)
|
||||
}
|
||||
|
||||
webpBytes, err := base64.StdEncoding.DecodeString(
|
||||
"UklGRrIBAABXRUJQVlA4TKUBAAAvSsAYAA8w//M///MfeJAkbXvaSG7m8Q3GfYSB" +
|
||||
"JekwQztm/IcZlgwnmWImn2BK7aFmBtnVir6q//8VOkFE/xm4baTIu8c48ArEo6+B" +
|
||||
"3zFKYln3pqClSCKX0begFTAXFOLXHSyF8cCNcZEG4OywuA4KVVfJCiArU7GAgJI8" +
|
||||
"+lJP/OKMT/fBAjevg1cYB7YVkFuWga2lyPi5I0HFy5YTpWIHg0RZpkniRVW9odHA" +
|
||||
"KOwosWuOGdxIyn2OvaCDvhg/we6TwadPBPbqBV58MsLmMJ8yZnOWk8SRz4N+QoyP" +
|
||||
"L+MnamzMvcE1rHNEr91F9GKZPVUcS9w7PhhH36suB9qPeYb/oLk6cuTiJ0wOK3m" +
|
||||
"5h1cKjW6EVZCYMK7dxcKCBdgP9HkKr9gkAO2P8GKZGWVdIAatQa+1IDpt6qyorVw" +
|
||||
"dy01xdW8Jkfk6xjEXmVQQ+HQdFr6OKhIN34dXWq0+0qr6EJSCeeVLH9+gvGTLyqM" +
|
||||
"65PQ44ihzlTXxQKjKbAvshXgir7Lil9w4L2bvMycmjQcqXaMCO6BlY28i+FOLzbf" +
|
||||
"I1vEqxAhotocAAA==",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("decode WebP fixture: %v", err)
|
||||
}
|
||||
result, err := store.Put(
|
||||
context.Background(),
|
||||
"webp",
|
||||
"image/webp",
|
||||
bytes.NewReader(webpBytes),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WebP Put() error = %v", err)
|
||||
}
|
||||
if result.InputMediaType != "image/webp" {
|
||||
t.Fatalf("WebP input type = %q", result.InputMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejectsMismatchCorruptionAndOversizeAndCleansTemps(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := mustStore(t, root)
|
||||
var valid bytes.Buffer
|
||||
if err := png.Encode(
|
||||
&valid,
|
||||
image.NewRGBA(image.Rect(0, 0, 2, 2)),
|
||||
); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := store.Put(
|
||||
context.Background(),
|
||||
"mismatch",
|
||||
"image/jpeg",
|
||||
bytes.NewReader(valid.Bytes()),
|
||||
)
|
||||
assertStoreError(t, err, usecase.ImageStoreErrorUnsupported)
|
||||
|
||||
_, err = store.Put(
|
||||
context.Background(),
|
||||
"corrupt",
|
||||
"image/png",
|
||||
bytes.NewReader([]byte("not an image")),
|
||||
)
|
||||
assertStoreError(t, err, usecase.ImageStoreErrorInvalid)
|
||||
|
||||
_, err = store.Put(
|
||||
context.Background(),
|
||||
"oversize",
|
||||
"image/png",
|
||||
io.LimitReader(zeroReader{}, MaxInputBytes+1),
|
||||
)
|
||||
assertStoreError(t, err, usecase.ImageStoreErrorTooLarge)
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir() error = %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".input-") ||
|
||||
strings.HasPrefix(entry.Name(), ".normalized-") {
|
||||
t.Fatalf("temporary file remained: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDeleteRejectTraversalAndDeleteIsIdempotent(t *testing.T) {
|
||||
store := mustStore(t, t.TempDir())
|
||||
for _, key := range []string{
|
||||
"../secret",
|
||||
"/absolute",
|
||||
`nested\file`,
|
||||
} {
|
||||
if _, err := store.Open(context.Background(), key); err == nil {
|
||||
t.Fatalf("Open(%q) error = nil", key)
|
||||
}
|
||||
}
|
||||
|
||||
var input bytes.Buffer
|
||||
if err := jpeg.Encode(
|
||||
&input,
|
||||
image.NewRGBA(image.Rect(0, 0, 2, 2)),
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatalf("jpeg.Encode() error = %v", err)
|
||||
}
|
||||
result, err := store.Put(
|
||||
context.Background(),
|
||||
"delete",
|
||||
"image/jpeg",
|
||||
bytes.NewReader(input.Bytes()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Put() error = %v", err)
|
||||
}
|
||||
if err := store.Delete(context.Background(), result.StorageKey); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if err := store.Delete(context.Background(), result.StorageKey); err != nil {
|
||||
t.Fatalf("second Delete() error = %v", err)
|
||||
}
|
||||
if _, err := store.Open(context.Background(), result.StorageKey); err == nil {
|
||||
t.Fatal("Open() after Delete() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsFileAsRoot(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "root-file")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if _, err := New(path); err == nil {
|
||||
t.Fatal("New(file) error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func mustStore(t *testing.T, root string) *Store {
|
||||
t.Helper()
|
||||
store, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func assertStoreError(
|
||||
t *testing.T,
|
||||
err error,
|
||||
kind usecase.ImageStoreErrorKind,
|
||||
) {
|
||||
t.Helper()
|
||||
var typed *usecase.ImageStoreError
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %v, want ImageStoreError", err)
|
||||
}
|
||||
if typed.Kind != kind {
|
||||
t.Fatalf("error kind = %s, want %s", typed.Kind, kind)
|
||||
}
|
||||
}
|
||||
|
||||
type zeroReader struct{}
|
||||
|
||||
func (zeroReader) Read(buffer []byte) (int, error) {
|
||||
for index := range buffer {
|
||||
buffer[index] = 0
|
||||
}
|
||||
return len(buffer), nil
|
||||
}
|
||||
Reference in New Issue
Block a user