254 lines
7.4 KiB
Go
254 lines
7.4 KiB
Go
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)
|