Files
oci-portal/internal/service/objectstorage_test.go
T
2026-07-22 16:51:23 +08:00

542 lines
18 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"testing"
"time"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
type osStubClient struct {
*fakeClient
buckets []oci.Bucket
createdPAR oci.CreatePARInput
renamed [2]string
// 对象内容中转 stub 状态
objectData []byte
objectEtag string
putContentType string
putIfMatch string
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
mu sync.Mutex
versions []oci.ObjectVersion
pars []oci.PAR
bucketDeleted bool
multipartAborted bool
listCalls int
}
func (f *osStubClient) AbortAllMultipartUploads(ctx context.Context, cred oci.Credentials, region, bucket string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.multipartAborted = true
return nil
}
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
return f.buckets, nil
}
func (f *osStubClient) CreateBucket(ctx context.Context, cred oci.Credentials, region string, in oci.CreateBucketInput) (oci.Bucket, error) {
b := oci.Bucket{Name: in.Name, Namespace: "ns", VersioningOn: in.VersioningOn}
f.buckets = append(f.buckets, b)
return b, nil
}
func (f *osStubClient) RenameObject(ctx context.Context, cred oci.Credentials, region, bucket, src, dst string) error {
f.renamed = [2]string{src, dst}
return nil
}
func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, region, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
// 模拟 OCI 真实行为:残留对象版本或活跃 PAR 都会拒绝删桶
if len(f.versions) > 0 || len(f.pars) > 0 {
return fmt.Errorf("delete bucket %s: %w", name, oci.ErrBucketNotEmpty)
}
f.bucketDeleted = true
return nil
}
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.listCalls++
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
}
func (f *osStubClient) DeleteObjectVersion(ctx context.Context, cred oci.Credentials, region, bucket, object, versionID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.versions[:0]
for _, v := range f.versions {
if !(v.Name == object && v.VersionID == versionID) {
kept = append(kept, v)
}
}
f.versions = kept
return nil
}
func (f *osStubClient) ListPARs(ctx context.Context, cred oci.Credentials, region, bucket string) ([]oci.PAR, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]oci.PAR(nil), f.pars...), nil
}
// ListPARsPage 以数组下标模拟游标:page 为起始下标,next 为下一段起始下标。
func (f *osStubClient) ListPARsPage(ctx context.Context, cred oci.Credentials, region, bucket, page string, limit int) ([]oci.PAR, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
start, _ := strconv.Atoi(page)
if start >= len(f.pars) || limit <= 0 {
return nil, "", nil
}
end := start + limit
next := ""
if end < len(f.pars) {
next = strconv.Itoa(end)
} else {
end = len(f.pars)
}
return append([]oci.PAR(nil), f.pars[start:end]...), next, nil
}
func (f *osStubClient) DeletePAR(ctx context.Context, cred oci.Credentials, region, bucket, parID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.pars[:0]
for _, p := range f.pars {
if p.ID != parID {
kept = append(kept, p)
}
}
f.pars = kept
return nil
}
func (f *osStubClient) purgeState() (deleted bool, versions, pars int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.bucketDeleted, len(f.versions), len(f.pars)
}
func (f *osStubClient) GetObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, maxBytes int64) (oci.ObjectContent, error) {
if int64(len(f.objectData)) > maxBytes {
return oci.ObjectContent{}, fmt.Errorf("get object %s: %w", object, oci.ErrObjectTooLarge)
}
return oci.ObjectContent{Data: f.objectData, ContentType: "text/plain", Etag: f.objectEtag}, nil
}
func (f *osStubClient) PutObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
f.objectData = data
f.putContentType = contentType
f.putIfMatch = ifMatch
return "etag-new", nil
}
func (f *osStubClient) CreatePAR(ctx context.Context, cred oci.Credentials, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
f.createdPAR = in
return oci.PAR{ID: "par-1", Name: in.Name, AccessType: in.AccessType, FullURL: "https://os.example/p/x/n/ns/b/o"}, nil
}
func TestObjectStorageValidation(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
tests := []struct {
name string
run func() error
wantErr string
}{
{name: "桶名非法字符", run: func() error {
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "有中文"})
return err
}, wantErr: "invalid character"},
{name: "桶名空", run: func() error {
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: ""})
return err
}, wantErr: "length"},
{name: "合法桶名", run: func() error {
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "my-bucket_01"})
return err
}},
{name: "重命名缺参", run: func() error {
return svc.RenameObject(ctx, cfg.ID, "r", "b", "a.txt", "a.txt")
}, wantErr: "distinct"},
{name: "PAR 类型不支持", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
return err
}, wantErr: "unsupported access type"},
{name: "PAR 时长过短", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0})
return err
}, wantErr: "1-876000"},
// 旧 30 天上限已放开:10 年应放行
{name: "PAR 超长有效期放行", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 24 * 3650})
return err
}},
// 防 time.Duration 溢出的安全上限(100 年)仍要拦截
{name: "PAR 超安全上限拒绝", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1})
return err
}, wantErr: "1-876000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.run()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
func TestDeleteBucketEmptySync(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || queued {
t.Fatalf("DeleteBucket 空桶 = queued %v, %v, want 同步删除", queued, err)
}
if deleted, _, _ := client.purgeState(); !deleted {
t.Error("空桶应同步删除")
}
}
func TestDeleteBucketPurgeQueued(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{
{Name: "a.txt", VersionID: "v1"},
{Name: "a.txt", VersionID: ""},
{Name: "dir/b.bin", VersionID: "v9"},
},
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("DeleteBucket 非空桶 = queued %v, %v, want queued", queued, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if deleted, nv, np := client.purgeState(); deleted && nv == 0 && np == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
deleted, nv, np := client.purgeState()
t.Fatalf("purge 未完成: deleted=%v versions=%d pars=%d", deleted, nv, np)
}
// TestDeleteBucketPAROnlyQueued 覆盖真实环境踩到的场景:桶内无对象,
// 仅剩活跃 PAR 时 OCI 也拒绝删桶,应同样走后台清空后重删。
func TestDeleteBucketPAROnlyQueued(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("DeleteBucket 仅 PAR 桶 = queued %v, %v, want queued", queued, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if deleted, _, np := client.purgeState(); deleted && np == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
deleted, _, np := client.purgeState()
t.Fatalf("purge 未完成: deleted=%v pars=%d", deleted, np)
}
func TestDeleteAllPARsConcurrent(t *testing.T) {
pars := make([]oci.PAR, 40)
for i := range pars {
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i), Name: fmt.Sprintf("par-%d", i)}
}
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
n, err := svc.DeleteAllPARs(context.Background(), cfg.ID, "r", "b")
if err != nil || n != 40 {
t.Fatalf("DeleteAllPARs = %d, %v, want 40 deleted", n, err)
}
if _, _, np := client.purgeState(); np != 0 {
t.Errorf("remaining pars = %d, want 0", np)
}
}
func TestPARsPage(t *testing.T) {
pars := make([]oci.PAR, 5)
for i := range pars {
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i)}
}
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
tests := []struct {
name string
page string
limit int
wantIDs int
wantNext string
}{
{name: "首页带游标", page: "", limit: 2, wantIDs: 2, wantNext: "2"},
{name: "中间页", page: "2", limit: 2, wantIDs: 2, wantNext: "4"},
{name: "末页无游标", page: "4", limit: 2, wantIDs: 1, wantNext: ""},
{name: "limit 0 归一为 100", page: "", limit: 0, wantIDs: 5, wantNext: ""},
{name: "limit 越界归一为 100", page: "", limit: 5000, wantIDs: 5, wantNext: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
items, next, err := svc.PARsPage(context.Background(), cfg.ID, "r", "b", tt.page, tt.limit)
if err != nil {
t.Fatalf("PARsPage: %v", err)
}
if len(items) != tt.wantIDs || next != tt.wantNext {
t.Fatalf("PARsPage = %d 条, next=%q; want %d 条, next=%q", len(items), next, tt.wantIDs, tt.wantNext)
}
})
}
}
func TestForEachConcurrently(t *testing.T) {
items := make([]int, 20)
for i := range items {
items[i] = i
}
tests := []struct {
name string
items []int
failOn int // 值为 -1 表示不注入失败
wantErr bool
wantDone int
}{
{name: "全部成功", items: items, failOn: -1, wantDone: 20},
{name: "个别失败不中断其余", items: items, failOn: 3, wantErr: true, wantDone: 19},
{name: "空列表", items: nil, failOn: -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
mu sync.Mutex
done int
)
err := forEachConcurrently(4, tt.items, func(i int) error {
if i == tt.failOn {
return errors.New("boom")
}
mu.Lock()
done++
mu.Unlock()
return nil
})
if (err != nil) != tt.wantErr || done != tt.wantDone {
t.Fatalf("forEachConcurrently err=%v done=%d, want err=%v done=%d", err, done, tt.wantErr, tt.wantDone)
}
})
}
}
func TestObjectContentRoundTrip(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
objectData: []byte("hello"), objectEtag: "e1",
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
got, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", "a.txt")
if err != nil || string(got.Data) != "hello" || got.Etag != "e1" {
t.Fatalf("ObjectContent = %q etag=%q, %v; want hello/e1", got.Data, got.Etag, err)
}
etag, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a.txt", []byte("world"), "text/plain", "e1")
if err != nil || etag != "etag-new" {
t.Fatalf("PutObjectContent = %q, %v; want etag-new", etag, err)
}
if client.putIfMatch != "e1" || client.putContentType != "text/plain" {
t.Errorf("ifMatch=%q contentType=%q, want e1/text-plain 透传", client.putIfMatch, client.putContentType)
}
}
func TestObjectContentLimits(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
if _, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", ""); err == nil {
t.Error("空对象名应报错")
}
big := make([]byte, maxObjectPutBytes+1)
if _, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a", big, "", ""); !errors.Is(err, oci.ErrObjectTooLarge) {
t.Errorf("超限 PUT err = %v, want ErrObjectTooLarge", err)
}
}
func TestCreatePARDefaultsName(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
par, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
oci.CreatePARInput{ObjectName: "db/app.sql.gz", AccessType: "ObjectRead", ExpiresHours: 24})
if err != nil {
t.Fatalf("CreatePAR: %v", err)
}
if client.createdPAR.Name != "par-db-app.sql.gz" {
t.Errorf("default name = %q, want par-db-app.sql.gz", client.createdPAR.Name)
}
if par.FullURL == "" {
t.Errorf("FullURL empty, want populated")
}
}
// TestCreatePARAccessTypes 桶级 AnyObject* 必须放行(分享桶 500 复盘),非法类型拒绝。
func TestCreatePARAccessTypes(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
cases := []struct {
accessType string
wantErr bool
}{
{"AnyObjectRead", false}, {"AnyObjectWrite", false}, {"AnyObjectReadWrite", false},
{"BucketRead", true}, {"", true},
}
for _, tc := range cases {
_, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
oci.CreatePARInput{AccessType: tc.accessType, ExpiresHours: 24})
if (err != nil) != tc.wantErr {
t.Errorf("CreatePAR(%q) err = %v, wantErr %v", tc.accessType, err, tc.wantErr)
}
}
if client.createdPAR.Name != "par-bucket-b" {
t.Errorf("bucket par default name = %q, want par-bucket-b", client.createdPAR.Name)
}
}
func TestCreatePARValidationSentinels(t *testing.T) {
svc := &OciConfigService{}
cases := []struct {
name string
in oci.CreatePARInput
want error
}{
{"access type", oci.CreatePARInput{AccessType: "BucketRead", ExpiresHours: 1}, ErrPARInvalidAccessType},
{"expiration low", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}, ErrPARInvalidExpiration},
{"expiration high", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}, ErrPARInvalidExpiration},
}
for _, tc := range cases {
_, err := svc.CreatePAR(context.Background(), 1, "r", "b", tc.in)
if !errors.Is(err, tc.want) {
t.Errorf("%s err = %v, want errors.Is(%v)", tc.name, err, tc.want)
}
}
}
// TestDeleteBucketPurgeSingleflight 锁定同桶清空单飞:任务在途时重复删除请求
// 按已排队返回,不并发起第二个清空任务。
func TestDeleteBucketPurgeSingleflight(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
key := fmt.Sprintf("%d/r/b", cfg.ID)
if !svc.beginPurge(key) {
t.Fatal("beginPurge 首次抢占失败")
}
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("在途时 DeleteBucket = queued %v, %v, want queued", queued, err)
}
time.Sleep(50 * time.Millisecond)
client.mu.Lock()
calls := client.listCalls
client.mu.Unlock()
if calls != 0 {
t.Fatalf("在途期间起了新清空任务: listCalls=%d, want 0", calls)
}
svc.endPurge(key)
if !svc.beginPurge(key) {
t.Error("endPurge 后应可重新抢占")
}
svc.endPurge(key)
}
// TestDeleteBucketPurgeAbortsMultipart 锁定清空顺序包含中止未完成分片上传。
func TestDeleteBucketPurgeAbortsMultipart(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
if queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b"); err != nil || !queued {
t.Fatalf("DeleteBucket = queued %v, %v, want queued", queued, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
client.mu.Lock()
done := client.bucketDeleted && client.multipartAborted
client.mu.Unlock()
if done {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("purge 未在期限内完成分片中止与删桶")
}
// TestImportRejectsMissingProxy 锁定导入防线:关联不存在的代理直接拒绝,
// 不落库也不发起绕过代理的直连测活。
func TestImportRejectsMissingProxy(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
in := trialImportInput()
missing := uint(999)
in.ProxyID = &missing
if _, err := svc.Import(context.Background(), in); err == nil {
t.Fatal("Import 关联不存在的代理应报错")
}
var n int64
if err := svc.db.Model(&model.OciConfig{}).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("拒绝导入后配置数 = %d (%v), want 0", n, err)
}
}