Files
RenewCMS/test/s3_image_storage_test.go

548 lines
16 KiB
Go

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)
}
}