261 lines
10 KiB
Go
261 lines
10 KiB
Go
package assets
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPlatformScopeOwnerIsolationAndDefaults(t *testing.T) {
|
|
cat := &memoryCatalog{}
|
|
svc := NewService(cat, nil, nil, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }, func(string) string { return "asset-1" })
|
|
scope := PlatformScope("owner-a")
|
|
created, err := svc.CreateExternal(context.Background(), scope, CreateExternalCommand{URL: "https://example.test/a.png"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created.OwnerID != "owner-a" || created.Name != "外部图片" || created.Kind != KindImage || created.Source != SourceExternal || !reflect.DeepEqual(created.Tags, []string{"external"}) || created.Metadata["registeredFrom"] != "api" {
|
|
t.Fatalf("created = %#v", created)
|
|
}
|
|
if _, err := svc.Get(context.Background(), PlatformScope("owner-b"), created.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("cross-owner Get error = %v", err)
|
|
}
|
|
if _, err := svc.Delete(context.Background(), PlatformScope("owner-b"), created.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("cross-owner Delete error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPlatformExplicitEmptyTagsArePreserved(t *testing.T) {
|
|
cat := &memoryCatalog{}
|
|
svc := NewService(cat, nil, nil, time.Now, func(string) string { return "asset-empty-tags" })
|
|
created, err := svc.CreateExternal(context.Background(), PlatformScope("owner-a"), CreateExternalCommand{URL: "https://example.test/a.png", Tags: []string{}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created.Tags == nil || len(created.Tags) != 0 {
|
|
t.Fatalf("tags = %#v, want explicit empty array", created.Tags)
|
|
}
|
|
}
|
|
|
|
func TestPublicScopeAddsClientIdentityAndUsesCatalogVisibility(t *testing.T) {
|
|
cat := &memoryCatalog{}
|
|
svc := NewService(cat, nil, nil, time.Now, func(string) string { return "asset-public" })
|
|
created, err := svc.CreateExternal(context.Background(), PublicScope("agent-a"), CreateExternalCommand{URL: "https://example.test/a.png", Tags: []string{"campaign"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantTags := []string{"campaign", "external", "public-api", "api-client:agent-a"}
|
|
if created.OwnerID != "api:agent-a" || created.Name != "外部素材" || !reflect.DeepEqual(created.Tags, wantTags) || created.Metadata["externalClientId"] != "agent-a" || cat.lastPublicLimit != 0 {
|
|
t.Fatalf("created = %#v", created)
|
|
}
|
|
cat.publicVisible = map[string]bool{created.ID: true}
|
|
if _, err := svc.Get(context.Background(), PublicScope("agent-a"), created.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cat.lastPublicClient != "agent-a" || cat.lastPublicLimit != PublicJobLookupLimit {
|
|
t.Fatalf("public lookup = %q, %d", cat.lastPublicClient, cat.lastPublicLimit)
|
|
}
|
|
}
|
|
|
|
func TestUploadCompensatesBlobWhenCatalogCreateFails(t *testing.T) {
|
|
cat := &memoryCatalog{createErr: errors.New("database down")}
|
|
blobs := &memoryBlobs{}
|
|
svc := NewService(cat, blobs, nil, time.Now, func(prefix string) string { return prefix + "-1" })
|
|
_, err := svc.Upload(context.Background(), PlatformScope("owner-a"), UploadCommand{Bytes: []byte("png"), FileName: "poster.png", ContentType: "image/png", Origin: "https://app.test"})
|
|
if !errors.Is(err, cat.createErr) {
|
|
t.Fatalf("Upload error = %v", err)
|
|
}
|
|
if len(blobs.deleted) != 1 || blobs.deleted[0] != blobs.putKey {
|
|
t.Fatalf("compensating deletes = %#v, put key %q", blobs.deleted, blobs.putKey)
|
|
}
|
|
}
|
|
|
|
func TestImportGeneratedDownloadsStoresAndPersistsGeneratedMetadata(t *testing.T) {
|
|
cat := &memoryCatalog{}
|
|
blobs := &memoryBlobs{}
|
|
remote := &memoryRemote{blob: Blob{Body: io.NopCloser(bytes.NewReader([]byte("png"))), ContentType: "image/png", Size: 3}}
|
|
ids := []string{"file-1", "asset-1"}
|
|
svc := NewService(cat, blobs, remote, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }, func(string) string {
|
|
id := ids[0]
|
|
ids = ids[1:]
|
|
return id
|
|
})
|
|
created, err := svc.ImportGenerated(context.Background(), PlatformScope("owner-a"), ImportGeneratedCommand{URL: "https://provider.test/output.png?token=x", Capability: "image.generate", JobID: "job-1", Kind: KindImage, Tags: []string{"generated", "job:job-1"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created.Source != SourceGenerated || created.StoragePath != "generated-results/2026-08-13/file-1-output.png" || created.Metadata["importedFrom"] == nil || created.Metadata["jobId"] != "job-1" || !remote.called {
|
|
t.Fatalf("created = %#v", created)
|
|
}
|
|
}
|
|
|
|
func TestImportGeneratedCompensatesBlobWhenCatalogFails(t *testing.T) {
|
|
cat := &memoryCatalog{createErr: errors.New("database unavailable")}
|
|
blobs := &memoryBlobs{}
|
|
remote := &memoryRemote{blob: Blob{Body: io.NopCloser(bytes.NewReader([]byte("video"))), ContentType: "video/mp4", Size: 5}}
|
|
svc := NewService(cat, blobs, remote, time.Now, func(prefix string) string { return prefix + "-1" })
|
|
_, err := svc.ImportGenerated(context.Background(), PlatformScope("owner-a"), ImportGeneratedCommand{URL: "https://provider.test/output.mp4", Capability: "video.generate", JobID: "job-1"})
|
|
if !errors.Is(err, cat.createErr) || len(blobs.deleted) != 1 || blobs.deleted[0] != blobs.putKey {
|
|
t.Fatalf("error=%v deleted=%#v key=%q", err, blobs.deleted, blobs.putKey)
|
|
}
|
|
}
|
|
|
|
func TestDeleteLeavesCatalogWhenBlobDeletionFails(t *testing.T) {
|
|
cat := &memoryCatalog{assets: []Asset{{ID: "a", OwnerID: "o", StoragePath: "uploads/a"}}}
|
|
blobs := &memoryBlobs{deleteErr: errors.New("storage down")}
|
|
svc := NewService(cat, blobs, nil, time.Now, nil)
|
|
if _, err := svc.Delete(context.Background(), PlatformScope("o"), "a"); !errors.Is(err, blobs.deleteErr) {
|
|
t.Fatalf("Delete error = %v", err)
|
|
}
|
|
if _, found, _ := cat.GetOwner(context.Background(), "o", "a"); !found {
|
|
t.Fatal("catalog row deleted after blob failure")
|
|
}
|
|
}
|
|
|
|
func TestDownloadUsesBlobBeforeRemoteFetcher(t *testing.T) {
|
|
cat := &memoryCatalog{assets: []Asset{{ID: "a", OwnerID: "o", Name: "a.png", URL: "https://remote.test/a.png", StoragePath: "uploads/a.png", Metadata: map[string]any{"contentType": "image/png"}}}}
|
|
blobs := &memoryBlobs{read: Blob{Body: io.NopCloser(bytes.NewReader([]byte("local"))), ContentType: "image/png", Size: 5}}
|
|
remote := &memoryRemote{blob: Blob{Body: io.NopCloser(bytes.NewReader([]byte("remote")))}}
|
|
svc := NewService(cat, blobs, remote, time.Now, nil)
|
|
got, err := svc.Download(context.Background(), PlatformScope("o"), "a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer got.Body.Close()
|
|
b, _ := io.ReadAll(got.Body)
|
|
if string(b) != "local" || remote.called {
|
|
t.Fatalf("download = %q remoteCalled=%v", b, remote.called)
|
|
}
|
|
}
|
|
|
|
func TestProviderAssetURLSignsStoredObjectWithoutPersistingSignature(t *testing.T) {
|
|
cat := &memoryCatalog{assets: []Asset{{
|
|
ID: "asset-private", OwnerID: "owner-a", URL: "https://private-bucket.test/uploads/a.png", StoragePath: "uploads/a.png",
|
|
}}}
|
|
blobs := &signedMemoryBlobs{memoryBlobs: &memoryBlobs{}, signedURL: "https://private-bucket.test/uploads/a.png?Signature=temporary"}
|
|
svc := NewService(cat, blobs, nil, time.Now, nil)
|
|
|
|
sourceURL, accessURL, err := svc.ResolveProviderAssetURL(context.Background(), "owner-a", "asset-private", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sourceURL != cat.assets[0].URL || accessURL != blobs.signedURL || blobs.signedKey != "uploads/a.png" || blobs.signedTTL != time.Hour {
|
|
t.Fatalf("resolved URLs = %q / %q, signer = %q / %s", sourceURL, accessURL, blobs.signedKey, blobs.signedTTL)
|
|
}
|
|
if strings.Contains(cat.assets[0].URL, "Signature=") {
|
|
t.Fatalf("temporary signature was persisted: %q", cat.assets[0].URL)
|
|
}
|
|
}
|
|
|
|
type memoryCatalog struct {
|
|
assets []Asset
|
|
createErr error
|
|
publicVisible map[string]bool
|
|
lastPublicClient string
|
|
lastPublicLimit int
|
|
}
|
|
|
|
func (m *memoryCatalog) ListOwner(_ context.Context, owner string) ([]Asset, error) {
|
|
var out []Asset
|
|
for _, a := range m.assets {
|
|
if a.OwnerID == owner {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (m *memoryCatalog) GetOwner(_ context.Context, owner, id string) (Asset, bool, error) {
|
|
for _, a := range m.assets {
|
|
if a.OwnerID == owner && a.ID == id {
|
|
return a, true, nil
|
|
}
|
|
}
|
|
return Asset{}, false, nil
|
|
}
|
|
func (m *memoryCatalog) ListPublic(ctx context.Context, owner, client string, limit int) ([]Asset, error) {
|
|
m.lastPublicClient = client
|
|
m.lastPublicLimit = limit
|
|
all, _ := m.ListOwner(ctx, owner)
|
|
var out []Asset
|
|
for _, a := range all {
|
|
if m.publicVisible[a.ID] || contains(a.Tags, ClientTag(client)) {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (m *memoryCatalog) GetPublic(_ context.Context, owner, client, id string, limit int) (Asset, bool, error) {
|
|
m.lastPublicClient = client
|
|
m.lastPublicLimit = limit
|
|
for _, a := range m.assets {
|
|
if a.OwnerID == owner && a.ID == id && (m.publicVisible[id] || contains(a.Tags, ClientTag(client))) {
|
|
return a, true, nil
|
|
}
|
|
}
|
|
return Asset{}, false, nil
|
|
}
|
|
func (m *memoryCatalog) Create(_ context.Context, a Asset) (Asset, error) {
|
|
if m.createErr != nil {
|
|
return Asset{}, m.createErr
|
|
}
|
|
m.assets = append(m.assets, a)
|
|
return a, nil
|
|
}
|
|
func (m *memoryCatalog) DeleteOwner(_ context.Context, owner, id string) (Asset, bool, error) {
|
|
for i, a := range m.assets {
|
|
if a.OwnerID == owner && a.ID == id {
|
|
m.assets = append(m.assets[:i], m.assets[i+1:]...)
|
|
return a, true, nil
|
|
}
|
|
}
|
|
return Asset{}, false, nil
|
|
}
|
|
|
|
type memoryBlobs struct {
|
|
putKey string
|
|
putBody []byte
|
|
putContentType string
|
|
deleted []string
|
|
read Blob
|
|
deleteErr error
|
|
}
|
|
|
|
type signedMemoryBlobs struct {
|
|
*memoryBlobs
|
|
signedURL string
|
|
signedKey string
|
|
signedTTL time.Duration
|
|
}
|
|
|
|
func (m *signedMemoryBlobs) SignReadURL(key string, ttl time.Duration) (string, error) {
|
|
m.signedKey, m.signedTTL = key, ttl
|
|
return m.signedURL, nil
|
|
}
|
|
|
|
func (m *memoryBlobs) Put(_ context.Context, key string, body io.Reader, _ int64, contentType string) (StoredObject, error) {
|
|
m.putKey = key
|
|
m.putBody, _ = io.ReadAll(body)
|
|
m.putContentType = contentType
|
|
return StoredObject{Key: key, URL: "https://app.test/" + key}, nil
|
|
}
|
|
func (m *memoryBlobs) Read(context.Context, string) (Blob, error) { return m.read, nil }
|
|
func (m *memoryBlobs) Delete(_ context.Context, key string) error {
|
|
m.deleted = append(m.deleted, key)
|
|
return m.deleteErr
|
|
}
|
|
|
|
type memoryRemote struct {
|
|
called bool
|
|
blob Blob
|
|
}
|
|
|
|
func (m *memoryRemote) Fetch(context.Context, string) (Blob, error) {
|
|
m.called = true
|
|
return m.blob, nil
|
|
}
|