1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-03-26 07:52:18 +02:00
2025-03-08 22:32:00 +02:00

519 lines
13 KiB
Go

package s3blob_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestNew(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
s3Client *s3.S3
expectError bool
}{
{
"blank",
&s3.S3{},
true,
},
{
"no bucket",
&s3.S3{Region: "b", Endpoint: "c"},
true,
},
{
"no endpoint",
&s3.S3{Bucket: "a", Region: "b"},
true,
},
{
"no region",
&s3.S3{Bucket: "a", Endpoint: "c"},
true,
},
{
"with bucket, endpoint and region",
&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"},
false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
drv, err := s3blob.New(s.s3Client)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if err == nil && drv == nil {
t.Fatal("Expected non-nil driver instance")
}
})
}
}
func TestDriverClose(t *testing.T) {
t.Parallel()
drv, err := s3blob.New(&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"})
if err != nil {
t.Fatal(err)
}
err = drv.Close()
if err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
}
func TestDriverNormilizeError(t *testing.T) {
t.Parallel()
drv, err := s3blob.New(&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"})
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
err error
expectErrNotFound bool
}{
{
"plain error",
errors.New("test"),
false,
},
{
"response error with custom code",
s3.ResponseError{Code: "test"},
false,
},
{
"response error with NoSuchBucket code",
s3.ResponseError{Code: "NoSuchBucket"},
true,
},
{
"response error with NoSuchKey code",
s3.ResponseError{Code: "NoSuchKey"},
true,
},
{
"response error with NotFound code",
s3.ResponseError{Code: "NotFound"},
true,
},
{
"wrapped response error with NotFound code", // ensures that the entire error's tree is checked
fmt.Errorf("test: %w", s3.ResponseError{Code: "NotFound"}),
true,
},
{
"already normalized error",
fmt.Errorf("test: %w", blob.ErrNotFound),
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := drv.NormalizeError(s.err)
if err == nil {
t.Fatal("Expected non-nil error")
}
isErrNotFound := errors.Is(err, blob.ErrNotFound)
if isErrNotFound != s.expectErrNotFound {
t.Fatalf("Expected isErrNotFound %v, got %v (%v)", s.expectErrNotFound, isErrNotFound, err)
}
})
}
}
func TestDriverDeleteEscaping(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(&tests.RequestStub{
Method: http.MethodDelete,
URL: "https://test_bucket.example.com/..__0x2f__abc/test/",
})
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
err = drv.Delete(context.Background(), "../abc/test/")
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverCopyEscaping(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(&tests.RequestStub{
Method: http.MethodPut,
URL: "https://test_bucket.example.com/..__0x2f__a/",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"x-amz-copy-source": "test_bucket%2F..__0x2f__b%2F",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`<CopyObjectResult></CopyObjectResult>`)),
},
})
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
err = drv.Copy(context.Background(), "../a/", "../b/")
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverAttributes(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(&tests.RequestStub{
Method: http.MethodHead,
URL: "https://test_bucket.example.com/..__0x2f__a/",
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Cache-Control": []string{"test_cache"},
"Content-Disposition": []string{"test_disposition"},
"Content-Encoding": []string{"test_encoding"},
"Content-Language": []string{"test_language"},
"Content-Type": []string{"test_type"},
"Content-Range": []string{"test_range"},
"Etag": []string{`"ce5be8b6f53645c596306c4572ece521"`},
"Content-Length": []string{"100"},
"x-amz-meta-AbC%40": []string{"%40test_meta_a"},
"x-amz-meta-Def": []string{"test_meta_b"},
},
Body: http.NoBody,
},
})
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
attrs, err := drv.Attributes(context.Background(), "../a/")
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(attrs)
if err != nil {
t.Fatal(err)
}
expected := `{"cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","metadata":{"abc@":"@test_meta_a","def":"test_meta_b"},"createTime":"0001-01-01T00:00:00Z","modTime":"2025-02-01T03:04:05Z","size":100,"md5":"zlvotvU2RcWWMGxFcuzlIQ==","etag":"\"ce5be8b6f53645c596306c4572ece521\""}`
if str := string(raw); str != expected {
t.Fatalf("Expected attributes\n%s\ngot\n%s", expected, str)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverListPaged(t *testing.T) {
t.Parallel()
listResponse := func() *http.Response {
return &http.Response{
Body: io.NopCloser(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>example</Name>
<ContinuationToken>ct</ContinuationToken>
<NextContinuationToken>test_next</NextContinuationToken>
<StartAfter>example0.txt</StartAfter>
<KeyCount>1</KeyCount>
<MaxKeys>3</MaxKeys>
<Contents>
<Key>..__0x2f__prefixB/test/example.txt</Key>
<LastModified>2025-01-01T01:02:03.123Z</LastModified>
<ETag>"ce5be8b6f53645c596306c4572ece521"</ETag>
<Size>123</Size>
</Contents>
<Contents>
<Key>prefixA/..__0x2f__escape.txt</Key>
<LastModified>2025-01-02T01:02:03.123Z</LastModified>
<Size>456</Size>
</Contents>
<CommonPrefixes>
<Prefix>prefixA</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>..__0x2f__prefixB</Prefix>
</CommonPrefixes>
</ListBucketResult>
`)),
}
}
expectedPage := `{"objects":[{"key":"../prefixB","modTime":"0001-01-01T00:00:00Z","size":0,"md5":null,"isDir":true},{"key":"../prefixB/test/example.txt","modTime":"2025-01-01T01:02:03.123Z","size":123,"md5":"zlvotvU2RcWWMGxFcuzlIQ==","isDir":false},{"key":"prefixA","modTime":"0001-01-01T00:00:00Z","size":0,"md5":null,"isDir":true},{"key":"prefixA/../escape.txt","modTime":"2025-01-02T01:02:03.123Z","size":456,"md5":null,"isDir":false}],"nextPageToken":"dGVzdF9uZXh0"}`
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/?list-type=2&max-keys=1000",
Response: listResponse(),
},
&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/?continuation-token=test_token&delimiter=test_delimiter&list-type=2&max-keys=123&prefix=test_prefix",
Response: listResponse(),
},
)
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
opts *blob.ListOptions
expected string
}{
{
"empty options",
&blob.ListOptions{},
expectedPage,
},
{
"filled options",
&blob.ListOptions{Prefix: "test_prefix", Delimiter: "test_delimiter", PageSize: 123, PageToken: []byte("test_token")},
expectedPage,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
page, err := drv.ListPaged(context.Background(), s.opts)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if str := string(raw); s.expected != str {
t.Fatalf("Expected page result\n%s\ngot\n%s", s.expected, str)
}
})
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverNewRangeReader(t *testing.T) {
t.Parallel()
scenarios := []struct {
offset int64
length int64
httpClient *tests.Client
expectedAttrs string
}{
{
0,
0,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=0-0",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
"Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":123}`,
},
{
10,
-1,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=10-",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
"Content-Range": []string{"bytes 1-1/456"}, // should take precedence over content-length
"Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":456}`,
},
{
10,
0,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=10-10",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
// no range and length headers
// "Content-Range": []string{"bytes 1-1/456"},
// "Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":0}`,
},
{
10,
20,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=10-29",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
// with range header but invalid format -> content-length takes precedence
"Content-Range": []string{"bytes invalid-456"},
"Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":123}`,
},
}
for _, s := range scenarios {
t.Run(fmt.Sprintf("offset_%d_length_%d", s.offset, s.length), func(t *testing.T) {
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "tesst_region",
Endpoint: "https://example.com",
Client: s.httpClient,
})
if err != nil {
t.Fatal(err)
}
r, err := drv.NewRangeReader(context.Background(), "../abc/test.txt", s.offset, s.length)
if err != nil {
t.Fatal(err)
}
defer r.Close()
// the response body should be always replaced with http.NoBody
if s.length == 0 {
body := make([]byte, 1)
n, err := r.Read(body)
if n != 0 || !errors.Is(err, io.EOF) {
t.Fatalf("Expected body to be http.NoBody, got %v (%v)", body, err)
}
}
rawAttrs, err := json.Marshal(r.Attributes())
if err != nil {
t.Fatal(err)
}
if str := string(rawAttrs); str != s.expectedAttrs {
t.Fatalf("Expected attributes\n%s\ngot\n%s", s.expectedAttrs, str)
}
err = s.httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
})
}
}