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, `%s%s`, 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) } }