package storage import ( "bytes" "context" "errors" "io" "net/http" "net/http/httptest" "os" "strconv" "strings" "testing" "time" "galo/internal/config" ) func newTestS3(t *testing.T) (*S3, *Signer) { t.Helper() endpoint := os.Getenv("S3_ENDPOINT") if endpoint == "" { t.Skip("S3_ENDPOINT not set") } signer, err := NewSigner("http://app.test", "secret", time.Hour) if err != nil { t.Fatalf("signer: %v", err) } cl, err := NewS3(context.Background(), config.S3{ Endpoint: endpoint, Region: os.Getenv("S3_REGION"), AccessKey: os.Getenv("S3_ACCESS_KEY"), SecretKey: os.Getenv("S3_SECRET_KEY"), Bucket: os.Getenv("S3_BUCKET"), UsePathStyle: true, }, signer) if err != nil { t.Skipf("storage init: %v", err) } return cl, signer } func signedQuery(signer *Signer, key string, exp int64) string { return "?e=" + strconv.FormatInt(exp, 10) + "&s=" + signer.sign(key, exp) } func TestS3ServeObject(t *testing.T) { ctx := context.Background() cl, signer := newTestS3(t) key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg" data := []byte("0123456789") if err := cl.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil { t.Fatalf("put: %v", err) } t.Cleanup(func() { _ = cl.Delete(context.Background(), key) }) exp := time.Now().Add(time.Hour).Unix() base := "/api/objects/" + key + signedQuery(signer, key, exp) t.Run("full", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, base, nil) if err := cl.ServeObject(rec, req, key); err != nil { t.Fatalf("serve: %v", err) } if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !bytes.Equal(rec.Body.Bytes(), data) { t.Fatalf("body = %q", rec.Body.String()) } }) t.Run("range", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, base, nil) req.Header.Set("Range", "bytes=2-5") if err := cl.ServeObject(rec, req, key); err != nil { t.Fatalf("serve range: %v", err) } if rec.Code != http.StatusPartialContent { t.Fatalf("status = %d, want 206", rec.Code) } if body, _ := io.ReadAll(rec.Body); string(body) != "2345" { t.Fatalf("range body = %q, want 2345", body) } }) t.Run("invalid range", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, base, nil) req.Header.Set("Range", "bytes=1000-2000") err := cl.ServeObject(rec, req, key) if !errors.Is(err, ErrRangeNotSatisfiable) { t.Fatalf("err = %v, want ErrRangeNotSatisfiable", err) } }) t.Run("missing", func(t *testing.T) { missingKey := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg" target := "/api/objects/" + missingKey + signedQuery(signer, missingKey, exp) rec := httptest.NewRecorder() err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), missingKey) if !errors.Is(err, ErrObjectNotFound) { t.Fatalf("err = %v, want ErrObjectNotFound", err) } }) t.Run("expired signature", func(t *testing.T) { past := time.Now().Add(-time.Minute).Unix() target := "/api/objects/" + key + signedQuery(signer, key, past) rec := httptest.NewRecorder() err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), key) if !errors.Is(err, ErrSignedURLInvalid) { t.Fatalf("err = %v, want ErrSignedURLInvalid", err) } }) } func TestS3List(t *testing.T) { ctx := context.Background() cl, _ := newTestS3(t) prefix := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/" keys := []string{prefix + "original.jpg", prefix + "thumb.webp"} for _, k := range keys { if err := cl.Put(ctx, k, strings.NewReader("x"), 1, "image/jpeg"); err != nil { t.Fatalf("put %s: %v", k, err) } } t.Cleanup(func() { _ = cl.Delete(context.Background(), keys...) }) got, err := cl.List(ctx, prefix) if err != nil { t.Fatalf("list: %v", err) } found := map[string]bool{} for _, o := range got { found[o.Key] = true } for _, k := range keys { if !found[k] { t.Fatalf("key %s not listed: %+v", k, got) } } } func TestS3ServeObjectHead(t *testing.T) { ctx := context.Background() cl, signer := newTestS3(t) key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg" data := []byte("head-check") if err := cl.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)), "image/jpeg"); err != nil { t.Fatalf("put: %v", err) } t.Cleanup(func() { _ = cl.Delete(context.Background(), key) }) exp := time.Now().Add(time.Hour).Unix() target := "/api/objects/" + key + signedQuery(signer, key, exp) rec := httptest.NewRecorder() if err := cl.ServeObject(rec, httptest.NewRequest(http.MethodHead, target, nil), key); err != nil { t.Fatalf("head: %v", err) } if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if rec.Body.Len() != 0 { t.Fatalf("HEAD body should be empty, got %d bytes", rec.Body.Len()) } }