mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
c0976a1e1d
* feat(preview): implement object preview functionality with token-based access * docs: update README to include inline object preview feature * test: add range read error handling and attachment content disposition tests
1341 lines
45 KiB
Go
1341 lines
45 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"Noooste/garage-ui/internal/models"
|
|
"Noooste/garage-ui/internal/services"
|
|
"Noooste/garage-ui/internal/services/mocks"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
)
|
|
|
|
// mintStub satisfies PreviewTokenMinter for handler tests.
|
|
type mintStub struct {
|
|
fn func(bucket, key string, ttl time.Duration) (string, time.Time, error)
|
|
}
|
|
|
|
func (m *mintStub) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
|
|
if m.fn == nil {
|
|
return "test-token", time.Now().Add(ttl), nil
|
|
}
|
|
return m.fn(bucket, key, ttl)
|
|
}
|
|
|
|
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
|
|
app, s3, _ := newObjectsTestAppWithMinter(t)
|
|
return app, s3
|
|
}
|
|
|
|
func newObjectsTestAppWithMinter(t *testing.T) (*fiber.App, *mocks.S3Mock, *mintStub) {
|
|
t.Helper()
|
|
s3 := &mocks.S3Mock{}
|
|
minter := &mintStub{}
|
|
h := NewObjectHandler(s3, minter)
|
|
app := fiber.New()
|
|
app.Get("/buckets/:bucket/objects", h.ListObjects)
|
|
app.Post("/buckets/:bucket/objects", h.UploadObject)
|
|
app.Post("/buckets/:bucket/directories", h.CreateDirectory)
|
|
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
|
|
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
|
|
// Wildcard endpoints. Mount under :key for tests. Handlers prefer
|
|
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
|
|
app.Get("/buckets/:bucket/objects/:key", h.GetObject)
|
|
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
|
|
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
|
|
app.Get("/buckets/:bucket/objects/:key/preview-url", h.GetPreviewURL)
|
|
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
|
|
return app, s3, minter
|
|
}
|
|
|
|
// --- ListObjects ---
|
|
|
|
func TestListObjects_Success(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) {
|
|
if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" {
|
|
t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok)
|
|
}
|
|
return &models.ObjectListResponse{
|
|
Bucket: bucket, Count: 1,
|
|
Objects: []models.ObjectInfo{{Key: "k1", Size: 1}},
|
|
}, nil
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data models.ObjectListResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &body)
|
|
if body.Data.Count != 1 {
|
|
t.Errorf("count = %d", body.Data.Count)
|
|
}
|
|
}
|
|
|
|
func TestListObjects_DefaultMaxKeys(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) {
|
|
if max != 100 {
|
|
t.Errorf("max = %d, want default 100", max)
|
|
}
|
|
return &models.ObjectListResponse{}, nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestListObjects_InvalidMaxKeys400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
cases := []string{"0", "-1", "abc"}
|
|
for _, mk := range cases {
|
|
t.Run(mk, func(t *testing.T) {
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListObjects_ServiceError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestListObjects_SearchRoutesToSearchObjects(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
// Intentionally leave ListObjectsFn unset: if the handler wrongly falls
|
|
// through to a normal listing, the mock returns an error and this fails.
|
|
s3.SearchObjectsFn = func(_ context.Context, bucket, prefix, search string) (*models.ObjectListResponse, error) {
|
|
if bucket != "b1" || prefix != "docs/" || search != "target" {
|
|
t.Errorf("args = (%q, %q, %q)", bucket, prefix, search)
|
|
}
|
|
return &models.ObjectListResponse{
|
|
Bucket: bucket, Count: 1,
|
|
Objects: []models.ObjectInfo{{Key: "docs/target.pdf", Size: 20}},
|
|
}, nil
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=docs/&search=target", nil)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data models.ObjectListResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &body)
|
|
if body.Data.Count != 1 || len(body.Data.Objects) != 1 || body.Data.Objects[0].Key != "docs/target.pdf" {
|
|
t.Errorf("unexpected search results: %+v", body.Data)
|
|
}
|
|
}
|
|
|
|
func TestListObjects_SearchError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.SearchObjectsFn = func(_ context.Context, _, _, _ string) (*models.ObjectListResponse, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?search=target", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- GetObjectMetadata ---
|
|
|
|
func TestGetObjectMetadata_Success(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) {
|
|
return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data models.ObjectInfo `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &body)
|
|
if body.Data.Size != 42 {
|
|
t.Errorf("size = %d", body.Data.Size)
|
|
}
|
|
}
|
|
|
|
func TestGetObjectMetadata_NotFound404(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
|
|
return nil, errors.New("not found")
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- DeleteObject ---
|
|
|
|
func TestDeleteObject_Success(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
|
s3.DeleteObjectFn = func(_ context.Context, b, k string) error {
|
|
if b != "b1" || k != "k1" {
|
|
t.Errorf("args = (%q, %q)", b, k)
|
|
}
|
|
return nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteObject_NotExists404(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/nope", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteObject_ExistsCheckError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") }
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteObject_DeleteError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
|
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error { return errors.New("boom") }
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- GetPresignedURL ---
|
|
|
|
func TestGetPresignedURL_DefaultExpiration(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
|
s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) {
|
|
if exp != 3600*time.Second {
|
|
t.Errorf("exp = %v, want 1h", exp)
|
|
}
|
|
return "https://example/signed", nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data models.PresignedURLResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &body)
|
|
if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 {
|
|
t.Errorf("body = %+v", body.Data)
|
|
}
|
|
}
|
|
|
|
func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
|
s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) {
|
|
if exp != 60*time.Second {
|
|
t.Errorf("exp = %v, want 60s", exp)
|
|
}
|
|
return "u", nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestGetPresignedURL_InvalidExpiration400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
cases := []string{"0", "-1", "604801", "abc"}
|
|
for _, val := range cases {
|
|
t.Run(val, func(t *testing.T) {
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- GetPreviewURL ---
|
|
|
|
func TestGetPreviewURL_Success(t *testing.T) {
|
|
app, _, minter := newObjectsTestAppWithMinter(t)
|
|
fixed := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
|
minter.fn = func(bucket, key string, ttl time.Duration) (string, time.Time, error) {
|
|
if bucket != "b1" || key != "clip.mp4" {
|
|
t.Errorf("mint args = (%q, %q)", bucket, key)
|
|
}
|
|
if ttl != time.Hour {
|
|
t.Errorf("ttl = %v, want 1h", ttl)
|
|
}
|
|
return "tok123", fixed, nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4/preview-url", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data models.PreviewURLResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &body)
|
|
if body.Data.URL != "/api/v1/buckets/b1/objects/clip.mp4?pt=tok123" {
|
|
t.Errorf("url = %q", body.Data.URL)
|
|
}
|
|
if body.Data.ExpiresAt != "2026-07-11T12:00:00Z" {
|
|
t.Errorf("expires_at = %q", body.Data.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestGetPreviewURL_EscapesKeyInURL(t *testing.T) {
|
|
// Production sets the decoded key in locals via the wildcard dispatcher,
|
|
// so mirror that here instead of relying on :key param decoding.
|
|
s3 := &mocks.S3Mock{}
|
|
minter := &mintStub{}
|
|
minter.fn = func(_, key string, _ time.Duration) (string, time.Time, error) {
|
|
if key != "dir/my file.mp4" {
|
|
t.Errorf("key = %q", key)
|
|
}
|
|
return "tok", time.Now().Add(time.Hour), nil
|
|
}
|
|
h := NewObjectHandler(s3, minter)
|
|
app := fiber.New()
|
|
app.Get("/buckets/:bucket/preview-url", func(c fiber.Ctx) error {
|
|
c.Locals("objectKey", "dir/my file.mp4")
|
|
return h.GetPreviewURL(c)
|
|
})
|
|
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/preview-url", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Data models.PreviewURLResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &body)
|
|
if !strings.HasPrefix(body.Data.URL, "/api/v1/buckets/b1/objects/dir%2Fmy%20file.mp4?pt=") {
|
|
t.Errorf("url = %q, want the key percent-encoded whole", body.Data.URL)
|
|
}
|
|
}
|
|
|
|
func TestGetPreviewURL_MissingBucketAndKey400(t *testing.T) {
|
|
// Mount on a route with no :bucket param and no objectKey local, so both
|
|
// bucket and key are empty and the handler short-circuits with 400.
|
|
s3 := &mocks.S3Mock{}
|
|
minter := &mintStub{}
|
|
h := NewObjectHandler(s3, minter)
|
|
app := fiber.New()
|
|
app.Get("/preview-url-nobucket", h.GetPreviewURL)
|
|
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/preview-url-nobucket", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestGetPreviewURL_MintError500(t *testing.T) {
|
|
app, _, minter := newObjectsTestAppWithMinter(t)
|
|
minter.fn = func(_, _ string, _ time.Duration) (string, time.Time, error) {
|
|
return "", time.Time{}, errors.New("boom")
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f/preview-url", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- GetObject ---
|
|
|
|
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
content := []byte("hello world")
|
|
s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{
|
|
Key: k, Size: int64(len(content)), ETag: `"etag-1"`,
|
|
ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC),
|
|
}, nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Type"); got != "image/png" {
|
|
t.Errorf("Content-Type = %q", got)
|
|
}
|
|
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options = %q", got)
|
|
}
|
|
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) {
|
|
t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if !bytes.Equal(body, content) {
|
|
t.Errorf("body = %q, want %q", body, content)
|
|
}
|
|
}
|
|
|
|
func TestGetObject_RewritesExecutableContentType(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
return io.NopCloser(strings.NewReader("<script>alert(1)</script>")),
|
|
&models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"},
|
|
nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" {
|
|
t.Errorf("Content-Type = %q, want application/octet-stream", got)
|
|
}
|
|
}
|
|
|
|
func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") {
|
|
t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition"))
|
|
}
|
|
}
|
|
|
|
func TestGetObject_ServiceErrorReturns404(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
return nil, nil, errors.New("not found")
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// buildMultipart builds a multipart body with a single file field plus
|
|
// optional additional form fields. Returns body bytes and the Content-Type
|
|
// header value (which includes the boundary).
|
|
func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct {
|
|
Filename string
|
|
Content []byte
|
|
ContentType string
|
|
}) ([]byte, string) {
|
|
t.Helper()
|
|
buf := &bytes.Buffer{}
|
|
w := multipart.NewWriter(buf)
|
|
for k, v := range fields {
|
|
if err := w.WriteField(k, v); err != nil {
|
|
t.Fatalf("WriteField: %v", err)
|
|
}
|
|
}
|
|
for name, f := range files {
|
|
h := make(map[string][]string)
|
|
h["Content-Disposition"] = []string{
|
|
`form-data; name="` + name + `"; filename="` + f.Filename + `"`,
|
|
}
|
|
ct := f.ContentType
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
h["Content-Type"] = []string{ct}
|
|
part, err := w.CreatePart(h)
|
|
if err != nil {
|
|
t.Fatalf("CreatePart: %v", err)
|
|
}
|
|
if _, err := part.Write(f.Content); err != nil {
|
|
t.Fatalf("part.Write: %v", err)
|
|
}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("writer.Close: %v", err)
|
|
}
|
|
return buf.Bytes(), w.FormDataContentType()
|
|
}
|
|
|
|
func TestUploadObject_Success(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) {
|
|
if bucket != "b1" || key != "myfile.bin" {
|
|
t.Errorf("args = (%q, %q)", bucket, key)
|
|
}
|
|
if ct != "application/octet-stream" {
|
|
t.Errorf("contentType = %q", ct)
|
|
}
|
|
b, _ := io.ReadAll(body)
|
|
if string(b) != "payload" {
|
|
t.Errorf("body = %q, want 'payload'", b)
|
|
}
|
|
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil
|
|
}
|
|
body, ct := buildMultipart(t, nil, map[string]struct {
|
|
Filename string
|
|
Content []byte
|
|
ContentType string
|
|
}{
|
|
"file": {Filename: "myfile.bin", Content: []byte("payload")},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
|
|
}
|
|
}
|
|
|
|
func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
|
|
if key != "custom/key.txt" {
|
|
t.Errorf("key = %q, want custom/key.txt", key)
|
|
}
|
|
return &models.ObjectUploadResponse{Key: key}, nil
|
|
}
|
|
body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct {
|
|
Filename string
|
|
Content []byte
|
|
ContentType string
|
|
}{
|
|
"file": {Filename: "whatever.txt", Content: []byte("x")},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUploadObject_MissingFileReturns400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUploadObject_ServiceError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
body, ct := buildMultipart(t, nil, map[string]struct {
|
|
Filename string
|
|
Content []byte
|
|
ContentType string
|
|
}{"file": {Filename: "f.bin", Content: []byte("x")}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_Success(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) (int, error) {
|
|
if bucket != "b1" || len(keys) != 3 {
|
|
t.Errorf("args = (%q, %v)", bucket, keys)
|
|
}
|
|
return len(keys), nil
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &out)
|
|
if out.Data.Deleted != 3 {
|
|
t.Errorf("Deleted = %d, want 3", out.Data.Deleted)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_Prefixes_Recursive(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.DeleteObjectsByPrefixFn = func(_ context.Context, bucket, prefix string) (int, error) {
|
|
if bucket != "b1" || prefix != "docs/" {
|
|
t.Errorf("args = (%q, %q)", bucket, prefix)
|
|
}
|
|
return 4, nil
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &out)
|
|
if out.Data.Deleted != 4 {
|
|
t.Errorf("Deleted = %d, want 4", out.Data.Deleted)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_KeysAndPrefixes(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, keys []string) (int, error) {
|
|
if len(keys) != 2 {
|
|
t.Errorf("keys = %v", keys)
|
|
}
|
|
return len(keys), nil
|
|
}
|
|
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) { return 3, nil }
|
|
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b"}, "prefixes": []string{"docs/"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &out)
|
|
if out.Data.Deleted != 5 {
|
|
t.Errorf("Deleted = %d, want 5 (2 keys + 3 under prefix)", out.Data.Deleted)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_PrefixError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
|
|
return 0, errors.New("boom")
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"prefixes": []string{"docs/"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
body, _ := json.Marshal(map[string]any{"keys": []string{}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_BlankPrefix400(t *testing.T) {
|
|
// A blank/whitespace-only prefix must be rejected with a 4XX before any
|
|
// delete is attempted — it would otherwise target the whole bucket.
|
|
for _, prefix := range []string{"", " "} {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, _ string) (int, error) {
|
|
t.Errorf("DeleteObjectsByPrefix must not be called for blank prefix %q", prefix)
|
|
return 0, nil
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"prefixes": []string{prefix}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("prefix %q: status = %d, want 400", prefix, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_PrefixNormalizedToTrailingSlash(t *testing.T) {
|
|
// A prefix without a trailing slash must be normalized so it only deletes
|
|
// its own folder ("photos/2024/"), not siblings like "photos/2024-old/".
|
|
app, s3 := newObjectsTestApp(t)
|
|
var gotPrefix string
|
|
s3.DeleteObjectsByPrefixFn = func(_ context.Context, _, prefix string) (int, error) {
|
|
gotPrefix = prefix
|
|
return 1, nil
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"prefixes": []string{"photos/2024"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if gotPrefix != "photos/2024/" {
|
|
t.Errorf("prefix passed to service = %q, want %q", gotPrefix, "photos/2024/")
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) (int, error) { return 0, errors.New("boom") }
|
|
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// buildMultipartMulti builds a body with N "files" parts.
|
|
func buildMultipartMulti(t *testing.T, files []struct {
|
|
Filename, ContentType string
|
|
Content []byte
|
|
}) ([]byte, string) {
|
|
t.Helper()
|
|
buf := &bytes.Buffer{}
|
|
w := multipart.NewWriter(buf)
|
|
for _, f := range files {
|
|
h := map[string][]string{
|
|
"Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`},
|
|
"Content-Type": {f.ContentType},
|
|
}
|
|
part, err := w.CreatePart(h)
|
|
if err != nil {
|
|
t.Fatalf("CreatePart: %v", err)
|
|
}
|
|
_, _ = part.Write(f.Content)
|
|
}
|
|
_ = w.Close()
|
|
return buf.Bytes(), w.FormDataContentType()
|
|
}
|
|
|
|
func TestUploadMultiple_AllSuccess201(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}) []services.UploadResult {
|
|
if bucket != "b1" || len(files) != 2 {
|
|
t.Errorf("got bucket=%q files=%d", bucket, len(files))
|
|
}
|
|
out := make([]services.UploadResult, len(files))
|
|
for i, f := range files {
|
|
out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1}
|
|
}
|
|
return out
|
|
}
|
|
body, ct := buildMultipartMulti(t, []struct {
|
|
Filename, ContentType string
|
|
Content []byte
|
|
}{
|
|
{"a.txt", "text/plain", []byte("a")},
|
|
{"b.txt", "text/plain", []byte("b")},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
|
|
}
|
|
var out struct {
|
|
Data models.ObjectUploadMultipleResponse `json:"data"`
|
|
}
|
|
decodeJSON(t, resp.Body, &out)
|
|
if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 {
|
|
t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount)
|
|
}
|
|
}
|
|
|
|
func TestUploadMultiple_PartialReturns207(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}) []services.UploadResult {
|
|
return []services.UploadResult{
|
|
{Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType},
|
|
{Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType},
|
|
}
|
|
}
|
|
body, ct := buildMultipartMulti(t, []struct {
|
|
Filename, ContentType string
|
|
Content []byte
|
|
}{
|
|
{"a.txt", "text/plain", []byte("a")},
|
|
{"b.txt", "text/plain", []byte("b")},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusMultiStatus {
|
|
t.Fatalf("status = %d, want 207", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUploadMultiple_AllFailReturns500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}) []services.UploadResult {
|
|
out := make([]services.UploadResult, len(files))
|
|
for i, f := range files {
|
|
out[i] = services.UploadResult{Key: f.Key, Success: false, Error: errors.New("boom")}
|
|
}
|
|
return out
|
|
}
|
|
body, ct := buildMultipartMulti(t, []struct {
|
|
Filename, ContentType string
|
|
Content []byte
|
|
}{{"a.txt", "text/plain", []byte("a")}})
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUploadMultiple_NoFiles400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
body, ct := buildMultipartMulti(t, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", ct)
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestUploadMultiple_DefaultsContentType(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}) []services.UploadResult {
|
|
if files[0].ContentType != "application/octet-stream" {
|
|
t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType)
|
|
}
|
|
return []services.UploadResult{{Key: files[0].Key, Success: true}}
|
|
}
|
|
// Write a part with an empty Content-Type header explicitly.
|
|
buf := &bytes.Buffer{}
|
|
w := multipart.NewWriter(buf)
|
|
part, err := w.CreatePart(map[string][]string{
|
|
"Content-Disposition": {`form-data; name="files"; filename="a.txt"`},
|
|
"Content-Type": {""},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePart: %v", err)
|
|
}
|
|
_, _ = part.Write([]byte("x"))
|
|
_ = w.Close()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes()))
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- CreateDirectory ---
|
|
|
|
func TestCreateDirectory_Success_AppendsTrailingSlash(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
var gotKey string
|
|
s3.CreateDirectoryMarkerFn = func(_ context.Context, bucket, key string) (*models.ObjectUploadResponse, error) {
|
|
gotKey = key
|
|
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: 0, ContentType: "application/x-directory"}, nil
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"key": "photos/2024"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
|
}
|
|
if gotKey != "photos/2024/" {
|
|
t.Errorf("service key = %q, want trailing slash appended", gotKey)
|
|
}
|
|
}
|
|
|
|
func TestCreateDirectory_StripsLeadingSlashes(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
var gotKey string
|
|
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, key string) (*models.ObjectUploadResponse, error) {
|
|
gotKey = key
|
|
return &models.ObjectUploadResponse{Key: key}, nil
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"key": "///already/"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
|
}
|
|
if gotKey != "already/" {
|
|
t.Errorf("service key = %q, want 'already/'", gotKey)
|
|
}
|
|
}
|
|
|
|
func TestCreateDirectory_MissingKey400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
body := bytes.NewBufferString(`{"key": ""}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCreateDirectory_MalformedJSON400(t *testing.T) {
|
|
app, _ := newObjectsTestApp(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", bytes.NewBufferString("not json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCreateDirectory_ServiceError500(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
body := bytes.NewBufferString(`{"key": "x/"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- GetObject Range support ---
|
|
|
|
func TestGetObject_NoRangeHeaderAdvertisesAcceptRanges(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, ContentType: "text/plain", LastModified: time.Now()}, nil
|
|
}
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.txt", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
|
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
|
|
}
|
|
}
|
|
|
|
func TestGetObject_RangeRequestServes206(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
now := time.Now()
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
|
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4", ETag: "e1", LastModified: now}, nil
|
|
}
|
|
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
|
|
if start != 2 || end != 6 {
|
|
t.Errorf("range = %d-%d, want 2-6", start, end)
|
|
}
|
|
return io.NopCloser(strings.NewReader("23456")), nil
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
|
|
req.Header.Set("Range", "bytes=2-6")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Range"); got != "bytes 2-6/10" {
|
|
t.Errorf("Content-Range = %q, want %q", got, "bytes 2-6/10")
|
|
}
|
|
if got := resp.Header.Get("Content-Length"); got != "5" {
|
|
t.Errorf("Content-Length = %q, want %q", got, "5")
|
|
}
|
|
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
|
t.Errorf("Accept-Ranges = %q, want %q", got, "bytes")
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if string(body) != "23456" {
|
|
t.Errorf("body = %q, want %q", body, "23456")
|
|
}
|
|
}
|
|
|
|
func TestGetObject_UnsatisfiableRangeServes416(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
|
return &models.ObjectInfo{Key: key, Size: 10}, nil
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
|
|
req.Header.Set("Range", "bytes=50-")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
|
t.Fatalf("status = %d, want 416", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Range"); got != "bytes */10" {
|
|
t.Errorf("Content-Range = %q, want %q", got, "bytes */10")
|
|
}
|
|
}
|
|
|
|
func TestGetObject_MultiRangeFallsBackToFullResponse(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
|
return &models.ObjectInfo{Key: key, Size: 10}, nil
|
|
}
|
|
s3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
return io.NopCloser(strings.NewReader("0123456789")), &models.ObjectInfo{Key: key, Size: 10, LastModified: time.Now()}, nil
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/f.bin", nil)
|
|
req.Header.Set("Range", "bytes=0-1,3-4")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if string(body) != "0123456789" {
|
|
t.Errorf("body = %q, want the full object", body)
|
|
}
|
|
}
|
|
|
|
func TestGetObject_RangeForMissingObjectIs404(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
|
|
return nil, errors.New("no such key")
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/gone.bin", nil)
|
|
req.Header.Set("Range", "bytes=0-5")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A ranged read whose metadata resolves but whose byte fetch fails, for example
|
|
// when the object is deleted between the two calls, returns 404.
|
|
func TestGetObject_RangeReadErrorIs404(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
|
return &models.ObjectInfo{Key: key, Size: 10}, nil
|
|
}
|
|
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
|
|
return nil, errors.New("read failed")
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4", nil)
|
|
req.Header.Set("Range", "bytes=0-5")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A ranged request with download=true still streams 206 but marks the body as
|
|
// an attachment instead of inline.
|
|
func TestGetObject_RangeWithDownloadSetsAttachment(t *testing.T) {
|
|
app, s3 := newObjectsTestApp(t)
|
|
s3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
|
|
return &models.ObjectInfo{Key: key, Size: 10, ContentType: "video/mp4"}, nil
|
|
}
|
|
s3.GetObjectRangeFn = func(_ context.Context, _, _ string, _, _ int64) (io.ReadCloser, error) {
|
|
return io.NopCloser(strings.NewReader("01234")), nil
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/clip.mp4?download=true", nil)
|
|
req.Header.Set("Range", "bytes=0-4")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
|
|
t.Errorf("Content-Disposition = %q, want attachment", got)
|
|
}
|
|
}
|
|
|
|
// GetObject rejects a request that resolves to an empty object key with 400.
|
|
// This guards the wildcard dispatch path where the key comes from locals.
|
|
func TestGetObject_EmptyKeyIsBadRequest(t *testing.T) {
|
|
s3 := &mocks.S3Mock{}
|
|
h := NewObjectHandler(s3, &mintStub{})
|
|
app := fiber.New()
|
|
// Mounted without a :key param so the handler resolves an empty key.
|
|
app.Get("/buckets/:bucket/object", h.GetObject)
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/object", nil))
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|