package workbench import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "io" "os" "testing" "aigateway.local/core/internal/platform/config" "aigateway.local/core/internal/platform/database" "aigateway.local/core/internal/platform/storage" ) // TestFileObjectLifecycle runs against real PostgreSQL + MinIO. Set // WORKBENCH_TEST_DATABASE_URL and WORKBENCH_TEST_S3_ENDPOINT to enable it; with // the deploy compose up these are reachable from the build container via the // deploy_default network. func TestFileObjectLifecycle(t *testing.T) { databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL") s3Endpoint := os.Getenv("WORKBENCH_TEST_S3_ENDPOINT") if databaseURL == "" || s3Endpoint == "" { t.Skip("WORKBENCH_TEST_DATABASE_URL and WORKBENCH_TEST_S3_ENDPOINT are not set") } ctx := context.Background() pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0}) if err != nil { t.Fatal(err) } defer pool.Close() store, err := storage.NewClient(storage.Config{ Endpoint: s3Endpoint, AccessKeyID: os.Getenv("WORKBENCH_TEST_S3_ACCESS_KEY"), SecretAccessKey: os.Getenv("WORKBENCH_TEST_S3_SECRET_KEY"), Bucket: os.Getenv("WORKBENCH_TEST_S3_BUCKET"), Region: "us-east-1", MaxFileBytes: 8 << 20, }) if err != nil { t.Fatal(err) } if err := store.EnsureBucket(ctx); err != nil { t.Fatal(err) } adminID := "22222222-2222-4222-8222-222222222222" portalID := "33333333-3333-4333-8333-333333333333" if _, err := pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'m8-files-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, adminID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,password_hash,name,active) VALUES($1,'m8-files-user','test','m8',true) ON CONFLICT(id) DO NOTHING`, portalID); err != nil { t.Fatal(err) } cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM gateway.file_objects`) } cleanup() defer cleanup() files := NewFileService(NewService(pool), store) content := []byte("M8 对象存储文件管理集成测试 payload\nline two\n") owner := portalID sysObj, err := files.Upload(ctx, "system", nil, adminID, "报告.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", bytes.NewReader(content)) if err != nil { t.Fatal(err) } personalObj, err := files.Upload(ctx, "personal", &owner, portalID, "notes.txt", "text/plain", bytes.NewReader(content)) if err != nil { t.Fatal(err) } // sha256 往返一致 hasher := sha256.Sum256(content) if personalObj.ContentSHA256 != hex.EncodeToString(hasher[:]) || personalObj.SizeBytes != int64(len(content)) { t.Fatalf("integrity mismatch: sha=%s size=%d", personalObj.ContentSHA256, personalObj.SizeBytes) } // 流式读回 reader, size, err := files.Open(ctx, personalObj) if err != nil { t.Fatal(err) } got, err := io.ReadAll(reader) _ = reader.Close() if err != nil || int64(len(got)) != size || !bytes.Equal(got, content) { t.Fatalf("roundtrip mismatch: len=%d size=%d err=%v", len(got), size, err) } // 列表按范围隔离 sysList, err := files.ListSystem(ctx) if err != nil || len(sysList) != 1 || sysList[0].ID != sysObj.ID { t.Fatalf("system list mismatch: %#v err=%v", sysList, err) } myList, err := files.ListPersonal(ctx, portalID) if err != nil || len(myList) != 1 || myList[0].ID != personalObj.ID { t.Fatalf("personal list mismatch: %#v err=%v", myList, err) } otherList, err := files.ListPersonal(ctx, "44444444-4444-4444-8444-444444444444") if err != nil || len(otherList) != 0 { t.Fatalf("other personal list should be empty: %#v err=%v", otherList, err) } // 归属隔离:A 不能读 B 的文件 if _, err := files.GetPersonal(ctx, adminID, personalObj.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound for cross-owner read, got %v", err) } if _, err := files.GetSystem(ctx, personalObj.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound for system read of personal file, got %v", err) } // 超限上传被拒(8MiB 上限,用无 EOF 占位 reader 凑 9MiB) if _, err := files.Upload(ctx, "system", nil, adminID, "big.bin", "application/octet-stream", io.LimitReader(zeroReader{}, 9<<20)); err == nil { t.Fatal("expected oversized upload to fail") } // 删除后元数据与对象都消失 if err := files.DeleteSystem(ctx, sysObj.ID); err != nil { t.Fatal(err) } if _, err := files.GetSystem(ctx, sysObj.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound after delete, got %v", err) } if err := files.DeletePersonal(ctx, portalID, personalObj.ID); err != nil { t.Fatal(err) } if _, err := files.GetPersonal(ctx, portalID, personalObj.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound after personal delete, got %v", err) } } type zeroReader struct{} func (zeroReader) Read(p []byte) (int, error) { for i := range p { p[i] = 0 } return len(p), nil }