feat: optional S3-compatible storage for uploaded images, instead of the local upload directory
This commit is contained in:
1 parent
d752a61e52
commit
eb9bfabed5
23 files changed
+1962
-7
No files matched your search
@@ -0,0 +1,547 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/storage"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
s3TestBucket = "media"
|
||||
s3TestSecret = "TOPSECRET"
|
||||
s3TestAccessKey = "AKIATESTKEY"
|
||||
)
|
||||
|
||||
var s3TestModTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
type fakeS3Object struct {
|
||||
data []byte
|
||||
contentType string
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
type fakeS3Request struct {
|
||||
method string
|
||||
key string
|
||||
rng string
|
||||
}
|
||||
|
||||
type fakeS3Server struct {
|
||||
server *httptest.Server
|
||||
mu sync.Mutex
|
||||
objects map[string]fakeS3Object
|
||||
bucketStatus int
|
||||
failures map[string]int
|
||||
requests []fakeS3Request
|
||||
}
|
||||
|
||||
func newFakeS3Server(t *testing.T) *fakeS3Server {
|
||||
t.Helper()
|
||||
fake := &fakeS3Server{
|
||||
objects: map[string]fakeS3Object{},
|
||||
bucketStatus: http.StatusOK,
|
||||
failures: map[string]int{},
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
fake.server = httptest.NewUnstartedServer(http.HandlerFunc(fake.handle))
|
||||
fake.server.Listener = listener
|
||||
fake.server.Start()
|
||||
t.Cleanup(fake.server.Close)
|
||||
return fake
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) put(key string, data []byte, contentType string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.objects[key] = fakeS3Object{data: data, contentType: contentType, modTime: s3TestModTime}
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) object(key string) (fakeS3Object, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
object, ok := f.objects[key]
|
||||
return object, ok
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) failMethod(method string, status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.failures[method] = status
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) setBucketStatus(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.bucketStatus = status
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) recorded() []fakeS3Request {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]fakeS3Request(nil), f.requests...)
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) requestedKeys(method string) []string {
|
||||
var keys []string
|
||||
for _, request := range f.recorded() {
|
||||
if request.method == method {
|
||||
keys = append(keys, request.key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
trimmed := strings.TrimPrefix(r.URL.Path, "/"+s3TestBucket)
|
||||
if r.URL.Path != "/"+s3TestBucket && trimmed == r.URL.Path {
|
||||
http.Error(w, "unknown bucket", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(trimmed, "/")
|
||||
if key == "" {
|
||||
w.WriteHeader(f.bucketStatus)
|
||||
return
|
||||
}
|
||||
f.requests = append(f.requests, fakeS3Request{method: r.Method, key: key, rng: r.Header.Get("Range")})
|
||||
if status, failing := f.failures[r.Method]; failing {
|
||||
writeFakeS3Error(w, r, status, "InternalError")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
f.objects[key] = fakeS3Object{data: data, contentType: r.Header.Get("Content-Type"), modTime: s3TestModTime}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodDelete:
|
||||
delete(f.objects, key)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case http.MethodGet, http.MethodHead:
|
||||
object, ok := f.objects[key]
|
||||
if !ok {
|
||||
writeFakeS3Error(w, r, http.StatusNotFound, "NoSuchKey")
|
||||
return
|
||||
}
|
||||
writeFakeS3Object(w, r, object)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeS3Error(w http.ResponseWriter, r *http.Request, status int, code string) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(status)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><Error><Code>%s</Code><Message>%s</Message></Error>`, code, code)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeS3Object(w http.ResponseWriter, r *http.Request, object fakeS3Object) {
|
||||
data := object.data
|
||||
status := http.StatusOK
|
||||
w.Header().Set("Content-Type", object.contentType)
|
||||
w.Header().Set("Last-Modified", object.modTime.UTC().Format(http.TimeFormat))
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
var start int
|
||||
spec := strings.TrimPrefix(rng, "bytes=")
|
||||
bounds := strings.SplitN(spec, "-", 2)
|
||||
start, _ = strconv.Atoi(bounds[0])
|
||||
end := len(data) - 1
|
||||
if bounds[1] != "" {
|
||||
end, _ = strconv.Atoi(bounds[1])
|
||||
}
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data)))
|
||||
data = data[start : end+1]
|
||||
status = http.StatusPartialContent
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(status)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeS3Server) config(prefix string) storage.S3Config {
|
||||
return storage.S3Config{
|
||||
Bucket: s3TestBucket,
|
||||
Region: "us-east-1",
|
||||
Endpoint: f.server.URL,
|
||||
AccessKeyID: s3TestAccessKey,
|
||||
SecretAccessKey: s3TestSecret,
|
||||
KeyPrefix: prefix,
|
||||
PathStyle: true,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func openFakeS3Storage(t *testing.T, fake *fakeS3Server, prefix string) domainImage.Storage {
|
||||
t.Helper()
|
||||
store, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: fake.config(prefix)})
|
||||
if err != nil {
|
||||
t.Fatalf("storage.Open failed: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func readAllBlob(t *testing.T, blob domainImage.Blob) []byte {
|
||||
t.Helper()
|
||||
defer blob.Content.Close()
|
||||
data, err := io.ReadAll(blob.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestS3StorageSeekThenReadUsesRangedRequests(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
fake.put("a1b2.png", []byte("0123456789"), "image/png")
|
||||
store := openFakeS3Storage(t, fake, "")
|
||||
|
||||
blob, err := store.Open("a1b2.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Open failed: %v", err)
|
||||
}
|
||||
defer blob.Content.Close()
|
||||
seeker, ok := blob.Content.(io.ReadSeeker)
|
||||
if !ok {
|
||||
t.Fatal("Content is not an io.ReadSeeker")
|
||||
}
|
||||
|
||||
end, err := seeker.Seek(0, io.SeekEnd)
|
||||
if err != nil || end != 10 {
|
||||
t.Fatalf("Seek to end = %d, %v", end, err)
|
||||
}
|
||||
if _, err := seeker.Seek(4, io.SeekStart); err != nil {
|
||||
t.Fatalf("Seek failed: %v", err)
|
||||
}
|
||||
chunk := make([]byte, 3)
|
||||
if _, err := io.ReadFull(seeker, chunk); err != nil || string(chunk) != "456" {
|
||||
t.Fatalf("read after seek = %q, %v", chunk, err)
|
||||
}
|
||||
rest, _ := io.ReadAll(seeker)
|
||||
if string(rest) != "789" {
|
||||
t.Errorf("remaining bytes = %q", rest)
|
||||
}
|
||||
|
||||
var ranges []string
|
||||
for _, request := range fake.recorded() {
|
||||
if request.method == http.MethodGet {
|
||||
ranges = append(ranges, request.rng)
|
||||
}
|
||||
}
|
||||
if len(ranges) != 2 || ranges[0] != "" || ranges[1] != "bytes=4-" {
|
||||
t.Errorf("unexpected GET ranges %q", ranges)
|
||||
}
|
||||
|
||||
if _, err := seeker.Seek(-1, io.SeekStart); err == nil {
|
||||
t.Error("a negative seek must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageNamesThatAreNotPlainAreNeverRequested(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
store := openFakeS3Storage(t, fake, "uploads/")
|
||||
for _, name := range []string{"", ".", "..", "../x.png", "a/b.png"} {
|
||||
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
|
||||
t.Errorf("Open(%q) = %v, want ErrBlobNotFound", name, err)
|
||||
}
|
||||
if err := store.Delete(name); err != nil {
|
||||
t.Errorf("Delete(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
if requests := fake.recorded(); len(requests) != 0 {
|
||||
t.Errorf("no object request expected, got %v", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageErrorsNeverContainTheSecret(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
store := openFakeS3Storage(t, fake, "")
|
||||
fake.failMethod(http.MethodPut, http.StatusInternalServerError)
|
||||
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
|
||||
fake.failMethod(http.MethodDelete, http.StatusInternalServerError)
|
||||
|
||||
_, putErr := store.Put("image/png", bytes.NewReader([]byte("abc")))
|
||||
_, openErr := store.Open("a1b2.png")
|
||||
deleteErr := store.Delete("a1b2.png")
|
||||
for name, err := range map[string]error{"Put": putErr, "Open": openErr, "Delete": deleteErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("%s must fail", name)
|
||||
}
|
||||
if strings.Contains(err.Error(), s3TestSecret) {
|
||||
t.Errorf("%s error leaks the secret: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageOpenWithUnreachableEndpointIsSecretFree(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
endpoint := "http://" + listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
|
||||
_, err = storage.Open(storage.Config{Driver: storage.DriverS3, S3: storage.S3Config{
|
||||
Bucket: s3TestBucket, Region: "us-east-1", Endpoint: endpoint, PathStyle: true, MaxAttempts: 1,
|
||||
AccessKeyID: s3TestAccessKey, SecretAccessKey: s3TestSecret,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), s3TestBucket) || strings.Contains(err.Error(), s3TestSecret) {
|
||||
t.Errorf("expected a bucket-named, secret-free error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageEndpointWithoutSchemeFollowsTheTLSSetting(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
settings := fake.config("")
|
||||
settings.Endpoint = strings.TrimPrefix(fake.server.URL, "http://")
|
||||
settings.TLS = false
|
||||
store, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: settings})
|
||||
if err != nil {
|
||||
t.Fatalf("plain HTTP endpoint without scheme must open, got %v", err)
|
||||
}
|
||||
if _, err := store.Open("missing.png"); !errors.Is(err, domainImage.ErrBlobNotFound) {
|
||||
t.Errorf("expected not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageSettingsFromEnv(t *testing.T) {
|
||||
setBaseSettingsEnv(t)
|
||||
for _, name := range []string{"STORAGE_DRIVER", "S3_BUCKET", "S3_REGION", "S3_ENDPOINT", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_KEY_PREFIX", "S3_PATH_STYLE", "S3_TLS"} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
|
||||
t.Run("Every S3 variable is read", func(t *testing.T) {
|
||||
t.Setenv("STORAGE_DRIVER", "s3")
|
||||
t.Setenv("S3_BUCKET", "media")
|
||||
t.Setenv("S3_REGION", "eu-west-3")
|
||||
t.Setenv("S3_ENDPOINT", "minio:9000")
|
||||
t.Setenv("S3_ACCESS_KEY_ID", "id")
|
||||
t.Setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
t.Setenv("S3_KEY_PREFIX", "cms/")
|
||||
t.Setenv("S3_PATH_STYLE", "true")
|
||||
t.Setenv("S3_TLS", "false")
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
want := config.S3Settings{Bucket: "media", Region: "eu-west-3", Endpoint: "minio:9000", AccessKeyID: "id", SecretAccessKey: "secret", KeyPrefix: "cms/", PathStyle: true, TLS: false}
|
||||
if settings.Storage.Driver != "s3" || settings.Storage.S3 != want {
|
||||
t.Errorf("unexpected storage settings %+v", settings.Storage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Defaults apply", func(t *testing.T) {
|
||||
t.Setenv("STORAGE_DRIVER", "s3")
|
||||
t.Setenv("S3_BUCKET", "media")
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
s3 := settings.Storage.S3
|
||||
if s3.Region != "us-east-1" || s3.PathStyle || !s3.TLS || s3.KeyPrefix != "" || s3.Endpoint != "" {
|
||||
t.Errorf("unexpected defaults %+v", s3)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid booleans are rejected", func(t *testing.T) {
|
||||
t.Setenv("STORAGE_DRIVER", "s3")
|
||||
t.Setenv("S3_BUCKET", "media")
|
||||
t.Setenv("S3_PATH_STYLE", "maybe")
|
||||
if _, err := config.NewSettingsFromEnv(); err == nil || !strings.Contains(err.Error(), "S3_PATH_STYLE") {
|
||||
t.Errorf("expected an S3_PATH_STYLE error, got %v", err)
|
||||
}
|
||||
t.Setenv("S3_PATH_STYLE", "")
|
||||
t.Setenv("S3_TLS", "maybe")
|
||||
if _, err := config.NewSettingsFromEnv(); err == nil || !strings.Contains(err.Error(), "S3_TLS") {
|
||||
t.Errorf("expected an S3_TLS error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Required variables by driver", func(t *testing.T) {
|
||||
if got := config.StorageRequiredEnvVars("s3"); len(got) != 1 || got[0] != "S3_BUCKET" {
|
||||
t.Errorf("unexpected required variables %v", got)
|
||||
}
|
||||
if got := config.StorageRequiredEnvVars("local"); len(got) != 0 {
|
||||
t.Errorf("local requires nothing, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func countS3Gets(fake *fakeS3Server) int {
|
||||
return len(fake.requestedKeys(http.MethodGet))
|
||||
}
|
||||
|
||||
func openFakeS3Seeker(t *testing.T, fake *fakeS3Server, name string) io.ReadSeekCloser {
|
||||
t.Helper()
|
||||
store := openFakeS3Storage(t, fake, "")
|
||||
blob, err := store.Open(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Open failed: %v", err)
|
||||
}
|
||||
seeker, ok := blob.Content.(io.ReadSeekCloser)
|
||||
if !ok {
|
||||
t.Fatal("Content is not an io.ReadSeekCloser")
|
||||
}
|
||||
return seeker
|
||||
}
|
||||
|
||||
func TestS3StorageSequentialReadUsesASingleRequest(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
fake.put("a1b2.png", []byte("0123456789"), "image/png")
|
||||
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
|
||||
defer seeker.Close()
|
||||
|
||||
first := make([]byte, 4)
|
||||
if _, err := io.ReadFull(seeker, first); err != nil || string(first) != "0123" {
|
||||
t.Fatalf("first read = %q, %v", first, err)
|
||||
}
|
||||
rest, err := io.ReadAll(seeker)
|
||||
if err != nil || string(rest) != "456789" {
|
||||
t.Fatalf("rest = %q, %v", rest, err)
|
||||
}
|
||||
if gets := countS3Gets(fake); gets != 1 {
|
||||
t.Errorf("sequential read must use 1 GET, got %d", gets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageReadAtEndReturnsEOFWithoutRequest(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
fake.put("a1b2.png", []byte("0123456789"), "image/png")
|
||||
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
|
||||
defer seeker.Close()
|
||||
|
||||
if _, err := seeker.Seek(0, io.SeekEnd); err != nil {
|
||||
t.Fatalf("Seek failed: %v", err)
|
||||
}
|
||||
n, err := seeker.Read(make([]byte, 4))
|
||||
if n != 0 || err != io.EOF {
|
||||
t.Errorf("Read at end = %d, %v, want 0, EOF", n, err)
|
||||
}
|
||||
if gets := countS3Gets(fake); gets != 1 {
|
||||
t.Errorf("reading at the end must not issue a request, got %d GETs", gets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageSeekWhenceVariants(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
fake.put("a1b2.png", []byte("0123456789"), "image/png")
|
||||
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
|
||||
defer seeker.Close()
|
||||
|
||||
if pos, err := seeker.Seek(-3, io.SeekEnd); err != nil || pos != 7 {
|
||||
t.Fatalf("SeekEnd(-3) = %d, %v", pos, err)
|
||||
}
|
||||
if pos, err := seeker.Seek(-2, io.SeekCurrent); err != nil || pos != 5 {
|
||||
t.Fatalf("SeekCurrent(-2) = %d, %v", pos, err)
|
||||
}
|
||||
chunk := make([]byte, 2)
|
||||
if _, err := io.ReadFull(seeker, chunk); err != nil || string(chunk) != "56" {
|
||||
t.Errorf("read after seeks = %q, %v", chunk, err)
|
||||
}
|
||||
if _, err := seeker.Seek(0, 99); err == nil {
|
||||
t.Error("an invalid whence must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageReadAfterCloseReopensTheObject(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
fake.put("a1b2.png", []byte("0123456789"), "image/png")
|
||||
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
|
||||
|
||||
head := make([]byte, 3)
|
||||
if _, err := io.ReadFull(seeker, head); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if err := seeker.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
rest, err := io.ReadAll(seeker)
|
||||
if err != nil || string(rest) != "3456789" {
|
||||
t.Errorf("read after Close = %q, %v", rest, err)
|
||||
}
|
||||
_ = seeker.Close()
|
||||
if gets := countS3Gets(fake); gets != 2 {
|
||||
t.Errorf("expected 2 GETs, got %d", gets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageMaxAttemptsLimitsRetries(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
store := openFakeS3Storage(t, fake, "")
|
||||
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
|
||||
|
||||
if _, err := store.Open("a1b2.png"); err == nil {
|
||||
t.Fatal("Open must fail")
|
||||
}
|
||||
if gets := countS3Gets(fake); gets != 1 {
|
||||
t.Errorf("MaxAttempts=1 must issue exactly 1 GET, got %d", gets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageTLSEndpointWithoutSchemeUsesHTTPS(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
settings := fake.config("")
|
||||
settings.Endpoint = strings.TrimPrefix(fake.server.URL, "http://")
|
||||
settings.TLS = true
|
||||
_, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: settings})
|
||||
if err == nil || !strings.Contains(err.Error(), s3TestBucket) {
|
||||
t.Errorf("https against a plain HTTP server must fail naming the bucket, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageRedactsSecretsFoundInTransportErrors(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
endpoint := "http://" + listener.Addr().String() + "/" + s3TestAccessKey + "/" + s3TestSecret
|
||||
_ = listener.Close()
|
||||
|
||||
_, err = storage.Open(storage.Config{Driver: storage.DriverS3, S3: storage.S3Config{
|
||||
Bucket: s3TestBucket, Region: "us-east-1", Endpoint: endpoint, PathStyle: true, MaxAttempts: 1,
|
||||
AccessKeyID: s3TestAccessKey, SecretAccessKey: s3TestSecret,
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), s3TestSecret) || strings.Contains(err.Error(), s3TestAccessKey) {
|
||||
t.Errorf("credentials leaked: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "***") {
|
||||
t.Errorf("expected redaction markers, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorageReadFailsWhenTheRangedRequestFails(t *testing.T) {
|
||||
fake := newFakeS3Server(t)
|
||||
fake.put("a1b2.png", []byte("0123456789"), "image/png")
|
||||
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
|
||||
defer seeker.Close()
|
||||
|
||||
if _, err := seeker.Seek(4, io.SeekStart); err != nil {
|
||||
t.Fatalf("Seek failed: %v", err)
|
||||
}
|
||||
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
|
||||
n, err := seeker.Read(make([]byte, 3))
|
||||
if err == nil || n != 0 {
|
||||
t.Errorf("Read = %d, %v, want a failure with no bytes", n, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user