package storage import ( "context" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" "gitea.maximumdirect.net/eric/narratio/internal/config" ) type fakeS3API struct { listOut *s3.ListObjectsV2Output listErr error getBody io.ReadCloser getErr error putOut *s3.PutObjectOutput putErr error headErr error lastList *s3.ListObjectsV2Input lastGet *s3.GetObjectInput lastPut *s3.PutObjectInput lastHead *s3.HeadObjectInput } func (f *fakeS3API) ListObjectsV2(_ context.Context, params *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { f.lastList = params if f.listErr != nil { return nil, f.listErr } if f.listOut == nil { return &s3.ListObjectsV2Output{}, nil } return f.listOut, nil } func (f *fakeS3API) GetObject(_ context.Context, params *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { f.lastGet = params if f.getErr != nil { return nil, f.getErr } body := f.getBody if body == nil { body = io.NopCloser(strings.NewReader("")) } return &s3.GetObjectOutput{Body: body}, nil } func (f *fakeS3API) PutObject(_ context.Context, params *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { f.lastPut = params if f.putErr != nil { return nil, f.putErr } if f.putOut == nil { return &s3.PutObjectOutput{}, nil } return f.putOut, nil } func (f *fakeS3API) HeadObject(_ context.Context, params *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { f.lastHead = params if f.headErr != nil { return nil, f.headErr } return &s3.HeadObjectOutput{}, nil } func TestS3BackendListAndKeyNormalization(t *testing.T) { lastModified := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) client := &fakeS3API{ listOut: &s3.ListObjectsV2Output{ Contents: []types.Object{ {Key: strPtr(`dnd\campaigns\forsaken\a.flac`), Size: int64Ptr(7), ETag: strPtr(`"abc"`), LastModified: &lastModified}, }, }, } backend := &S3Backend{bucket: "bucket-1", client: client} items, err := backend.List(context.Background(), `dnd\campaigns\`) if err != nil { t.Fatalf("List() error = %v", err) } if len(items) != 1 { t.Fatalf("List() len = %d, want 1", len(items)) } if items[0].Key != "dnd/campaigns/forsaken/a.flac" { t.Fatalf("List() key = %q, want normalized slash key", items[0].Key) } if items[0].ETag != "abc" { t.Fatalf("List() ETag = %q, want %q", items[0].ETag, "abc") } if client.lastList == nil || *client.lastList.Prefix != "dnd/campaigns/" { t.Fatalf("List() prefix = %#v, want normalized prefix", client.lastList) } } func TestS3BackendDownloadCreatesParentDirectory(t *testing.T) { client := &fakeS3API{getBody: io.NopCloser(strings.NewReader("audio"))} backend := &S3Backend{bucket: "bucket-1", client: client} dst := filepath.Join(t.TempDir(), "nested", "clip.flac") if err := backend.Download(context.Background(), `audio\clip.flac`, dst); err != nil { t.Fatalf("Download() error = %v", err) } data, err := os.ReadFile(dst) if err != nil { t.Fatalf("ReadFile() error = %v", err) } if string(data) != "audio" { t.Fatalf("downloaded content = %q, want %q", string(data), "audio") } if client.lastGet == nil || *client.lastGet.Key != "audio/clip.flac" { t.Fatalf("GetObject key = %#v, want normalized key", client.lastGet) } } func TestS3BackendUploadAndExists(t *testing.T) { client := &fakeS3API{putOut: &s3.PutObjectOutput{ETag: strPtr(`"etag123"`)}} backend := &S3Backend{bucket: "bucket-1", client: client} local := filepath.Join(t.TempDir(), "artifact.txt") if err := os.WriteFile(local, []byte("artifact"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } info, err := backend.Upload(context.Background(), local, `runs\id\artifact.txt`, UploadOptions{ Metadata: map[string]string{"kind": "artifact"}, }) if err != nil { t.Fatalf("Upload() error = %v", err) } if info.Key != "runs/id/artifact.txt" { t.Fatalf("Upload key = %q, want normalized key", info.Key) } if info.ETag != "etag123" { t.Fatalf("Upload ETag = %q, want %q", info.ETag, "etag123") } if client.lastPut == nil || *client.lastPut.Key != "runs/id/artifact.txt" { t.Fatalf("PutObject key = %#v, want normalized key", client.lastPut) } ok, err := backend.Exists(context.Background(), "runs/id/artifact.txt") if err != nil { t.Fatalf("Exists() error = %v", err) } if !ok { t.Fatal("Exists() = false, want true") } } func TestS3BackendUploadMissingLocalFile(t *testing.T) { backend := &S3Backend{bucket: "bucket-1", client: &fakeS3API{}} _, err := backend.Upload(context.Background(), filepath.Join(t.TempDir(), "missing.txt"), "key.txt", UploadOptions{}) if err == nil || !strings.Contains(err.Error(), "no such file") { t.Fatalf("Upload() error = %v, want missing local file error", err) } } func TestS3BackendExistsNotFound(t *testing.T) { backend := &S3Backend{ bucket: "bucket-1", client: &fakeS3API{ headErr: &smithy.GenericAPIError{Code: "NotFound", Message: "missing"}, }, } ok, err := backend.Exists(context.Background(), "missing-key") if err != nil { t.Fatalf("Exists() error = %v", err) } if ok { t.Fatal("Exists() = true, want false") } } func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) { original := newS3Client t.Cleanup(func() { newS3Client = original }) t.Setenv("OBJECT_STORAGE_KEY_ID", "id-123") t.Setenv("OBJECT_STORAGE_KEY", "secret-abc") var got s3ClientOptions newS3Client = func(_ context.Context, opts s3ClientOptions) (s3API, error) { got = opts return &fakeS3API{}, nil } backend, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{ Bucket: "my-archive", Region: "us-east-1", Endpoint: "http://localhost:9000", ForcePathStyle: true, }) if err != nil { t.Fatalf("NewS3BackendFromConfig() error = %v", err) } if backend.bucket != "my-archive" { t.Fatalf("backend.bucket = %q, want %q", backend.bucket, "my-archive") } if got.Region != "us-east-1" || got.Endpoint != "http://localhost:9000" || !got.ForcePathStyle { t.Fatalf("client options = %#v, want region/endpoint/path-style values", got) } if got.AccessKeyID != "id-123" || got.SecretKey != "secret-abc" { t.Fatalf("client options credentials = %#v, want env-resolved static credentials", got) } } func TestNewS3BackendFromConfigRequiresBucket(t *testing.T) { _, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{}) if err == nil || !strings.Contains(err.Error(), "bucket is required") { t.Fatalf("NewS3BackendFromConfig() error = %v, want bucket validation", err) } } func TestNewS3BackendFromConfigFallsBackWhenCredentialEnvMissing(t *testing.T) { original := newS3Client t.Cleanup(func() { newS3Client = original }) var got s3ClientOptions newS3Client = func(_ context.Context, opts s3ClientOptions) (s3API, error) { got = opts return &fakeS3API{}, nil } _, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{ Bucket: "my-archive", Region: "us-east-1", AccessKeyIDEnv: "MISSING_ACCESS_KEY_ID", SecretKeyEnv: "MISSING_SECRET_KEY", }) if err != nil { t.Fatalf("NewS3BackendFromConfig() error = %v", err) } if got.AccessKeyID != "" || got.SecretKey != "" { t.Fatalf("client options credentials = %#v, want empty fallback values", got) } } func strPtr(v string) *string { return &v } func int64Ptr(v int64) *int64 { return &v } var _ s3API = (*fakeS3API)(nil)