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