diff --git a/controller/internal_user_migration.go b/controller/internal_user_migration.go index d471482..f115d24 100644 --- a/controller/internal_user_migration.go +++ b/controller/internal_user_migration.go @@ -1,10 +1,11 @@ package controller import ( + "errors" "net/http" "strconv" - "strings" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" user_migration "github.com/QuantumNous/new-api/service/user_migration" "github.com/gin-gonic/gin" @@ -43,6 +44,10 @@ func GetMigrationUser(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"}) return } + if user.Role == common.RoleRootUser { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"}) + return + } bindings, _ := model.GetUserOAuthBindingsByUserId(userId) c.JSON(http.StatusOK, gin.H{"success": true, "data": buildRemoteUserSnapshot(user, bindings)}) @@ -62,7 +67,8 @@ func ConvertMigrationUserToSynced(c *gin.Context) { } if err := model.ConvertUserToSynced(userId, req.RemoteUserId, req.SyncedQuota); err != nil { - if strings.Contains(err.Error(), "already synced") || strings.Contains(err.Error(), "cannot change") { + if errors.Is(err, model.ErrConvertRootUserToSynced) || + errors.Is(err, model.ErrSyncedUserRemoteUserIDImmutable) { c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) return } diff --git a/controller/internal_user_migration_test.go b/controller/internal_user_migration_test.go index b089083..a9d70b2 100644 --- a/controller/internal_user_migration_test.go +++ b/controller/internal_user_migration_test.go @@ -60,6 +60,14 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) { Source: common.UserSourceSynced, RemoteUserId: 1, }).Error) + require.NoError(t, db.Create(&model.User{ + Id: 10000003, + Username: "root", + Password: "hash", + AffCode: "A3", + Source: common.UserSourceLocal, + Role: common.RoleRootUser, + }).Error) req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20", nil) w := httptest.NewRecorder() @@ -72,6 +80,82 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) { require.EqualValues(t, 1, body["total"]) } +func TestGetMigrationUser_RootExcluded(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.GET("/api/internal/migration/users/:id", GetMigrationUser) + + db := setupInternalMigrationDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 10000011, + Username: "root", + Password: "hash", + AffCode: "ROOT1", + Source: common.UserSourceLocal, + Role: common.RoleRootUser, + }).Error) + + req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/10000011", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) +} + +func TestGetMigrationUser_SyncedUserStillReadableForVerify(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.GET("/api/internal/migration/users/:id", GetMigrationUser) + + db := setupInternalMigrationDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 10000021, + Username: "synced-user", + Password: "hash", + AffCode: "SYNC1", + Source: common.UserSourceSynced, + RemoteUserId: 88, + SyncedQuota: 123, + Role: common.RoleCommonUser, + }).Error) + + req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/10000021", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.True(t, body["success"].(bool)) +} + +func TestGetMigrationUser_InvalidID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.GET("/api/internal/migration/users/:id", GetMigrationUser) + + req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/not-a-number", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + func TestConvertMigrationUserToSynced_Idempotent(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() @@ -138,6 +222,140 @@ func TestConvertMigrationUserToSynced_ConflictRemoteId(t *testing.T) { require.Equal(t, http.StatusConflict, w.Code) } +func TestConvertMigrationUserToSynced_ConflictUsesSentinelErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) + + db := setupInternalMigrationDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 10000013, + Username: "ov-13", + Password: "hash", + AffCode: "A13", + Source: common.UserSourceSynced, + RemoteUserId: 5001, + }).Error) + + body := `{"remote_user_id":9999,"synced_quota":100}` + req := httptest.NewRequest(http.MethodPost, + "/api/internal/migration/users/10000013/convert-to-synced", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusConflict, w.Code) +} + +func TestConvertMigrationUserToSynced_RejectsRootUser(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) + + db := setupInternalMigrationDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 10000012, + Username: "root", + Password: "hash", + AffCode: "ROOT2", + Source: common.UserSourceLocal, + Role: common.RoleRootUser, + }).Error) + + body := `{"remote_user_id":9999,"synced_quota":100}` + req := httptest.NewRequest(http.MethodPost, + "/api/internal/migration/users/10000012/convert-to-synced", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusConflict, w.Code) +} + +func TestConvertMigrationUserToSynced_InvalidRemoteUserID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) + + db := setupInternalMigrationDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 10000014, + Username: "ov-14", + Password: "hash", + AffCode: "A14", + Source: common.UserSourceLocal, + }).Error) + + body := `{"remote_user_id":0,"synced_quota":100}` + req := httptest.NewRequest(http.MethodPost, + "/api/internal/migration/users/10000014/convert-to-synced", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestConvertMigrationUserToSynced_PersistsSyncedFields(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testSyncAuthMiddleware()) + router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) + + db := setupInternalMigrationDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 10000015, + Username: "ov-15", + Password: "hash", + AffCode: "A15", + Source: common.UserSourceLocal, + Quota: 88, + }).Error) + + body := `{"remote_user_id":2015,"synced_quota":166}` + req := httptest.NewRequest(http.MethodPost, + "/api/internal/migration/users/10000015/convert-to-synced", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var saved model.User + require.NoError(t, db.First(&saved, 10000015).Error) + require.Equal(t, common.UserSourceSynced, saved.Source) + require.Equal(t, 2015, saved.RemoteUserId) + require.Equal(t, 166, saved.SyncedQuota) + require.Greater(t, saved.LastSyncAt, int64(0)) +} + func TestCheckSyncedCopy(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/controller/user.go b/controller/user.go index c82118a..2797325 100644 --- a/controller/user.go +++ b/controller/user.go @@ -562,9 +562,15 @@ func UpdateUser(c *gin.Context) { common.ApiError(c, err) return } - if originUser.IsSyncedUser() && originUser.Quota != updatedUser.Quota { - common.ApiErrorI18n(c, i18n.MsgSyncedUserQuotaCannotModify) - return + if originUser.IsSyncedUser() { + // 对同步用户而言,界面展示和用户感知的“余额”是 synced_quota, + // 不能用本地 quota 列做比较,否则在 quota / synced_quota 漂移后会误判。 + if originUser.SyncedQuota != updatedUser.Quota { + common.ApiErrorI18n(c, i18n.MsgSyncedUserQuotaCannotModify) + return + } + // 提交其他字段编辑时,保留本地 quota 原值,避免把展示用 synced_quota 回写到 quota 列。 + updatedUser.Quota = originUser.Quota } myRole := c.GetInt("role") if myRole <= originUser.Role && myRole != common.RoleRootUser { diff --git a/controller/user_migration.go b/controller/user_migration.go index 372981c..1f522bd 100644 --- a/controller/user_migration.go +++ b/controller/user_migration.go @@ -10,6 +10,14 @@ import ( "github.com/gin-gonic/gin" ) +type migrationVerifier interface { + VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) +} + +var newMigrationVerifier = func() migrationVerifier { + return newMigrationService() +} + func CreateUserMigrationBatch(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` @@ -202,7 +210,7 @@ func VerifyUserMigrationBatch(c *gin.Context) { var items []model.UserMigrationItem model.DB.Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusMigrated).Find(&items) - svc := newMigrationService() + svc := newMigrationVerifier() results := make([]map[string]any, 0, len(items)) for _, item := range items { result, err := svc.VerifyItem(&item) diff --git a/controller/user_migration_test.go b/controller/user_migration_test.go index fef6dec..55235f1 100644 --- a/controller/user_migration_test.go +++ b/controller/user_migration_test.go @@ -3,11 +3,13 @@ package controller import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" "github.com/QuantumNous/new-api/model" + user_migration "github.com/QuantumNous/new-api/service/user_migration" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" @@ -26,6 +28,8 @@ func setupUserMigrationControllerDB(t *testing.T) *gorm.DB { require.NoError(t, db.AutoMigrate( &model.User{}, + &model.CustomOAuthProvider{}, + &model.UserOAuthBinding{}, &model.UserMigrationBatch{}, &model.UserMigrationItem{}, &model.MigrationQuotaGrant{}, @@ -42,6 +46,21 @@ func testRootAuthMiddleware() gin.HandlerFunc { } } +type fakeMigrationVerifier struct { + results map[int]*user_migration.VerifyResult + errs map[int]error +} + +func (f *fakeMigrationVerifier) VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) { + if err, ok := f.errs[item.SourceUserId]; ok { + return nil, err + } + if result, ok := f.results[item.SourceUserId]; ok { + return result, nil + } + return nil, errors.New("unexpected source user") +} + func TestCreateUserMigrationBatch_Success(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() @@ -75,6 +94,105 @@ func TestCreateUserMigrationBatch_Success(t *testing.T) { require.NotNil(t, resp["data"]) } +func TestListUserMigrationBatches_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.GET("/api/user-migrations/batches", ListUserMigrationBatches) + + db := setupUserMigrationControllerDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 1, + Name: "batch-1", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 2, + Name: "batch-2", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + }).Error) + + req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp["success"].(bool)) + require.Len(t, resp["data"], 2) +} + +func TestGetUserMigrationBatch_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.GET("/api/user-migrations/batches/:id", GetUserMigrationBatch) + + db := setupUserMigrationControllerDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 1, + Name: "detail-batch", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + BatchId: 1, + SourceUserId: 1001, + SourceUsername: "ov-u1", + Status: model.UserMigrationItemStatusReady, + }).Error) + + req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches/1?page=0&page_size=50", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp["success"].(bool)) + require.NotNil(t, resp["data"]) + require.Len(t, resp["items"], 1) + require.EqualValues(t, 1, resp["total"]) +} + +func TestGetUserMigrationBatch_NotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.GET("/api/user-migrations/batches/:id", GetUserMigrationBatch) + + db := setupUserMigrationControllerDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches/999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) +} + func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() @@ -104,6 +222,37 @@ func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) { require.Equal(t, http.StatusConflict, w.Code) } +func TestExecuteUserMigrationBatch_InvalidStateRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.POST("/api/user-migrations/batches/:id/execute", ExecuteUserMigrationBatch) + + db := setupUserMigrationControllerDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + for _, status := range []string{model.UserMigrationBatchStatusDraft, model.UserMigrationBatchStatusScanned} { + require.NoError(t, db.Exec("DELETE FROM user_migration_batches").Error) + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 1, + Name: "invalid-exec", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + Status: status, + }).Error) + + req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusConflict, w.Code) + } +} + func TestResolveUserMigrationItem_InvalidStrategy(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() @@ -179,3 +328,143 @@ func TestResolveUserMigrationItem_RefreshesBatchStatusToReady(t *testing.T) { require.NoError(t, db.First(&refreshed, batch.Id).Error) require.Equal(t, model.UserMigrationBatchStatusReady, refreshed.Status) } + +func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.POST("/api/user-migrations/batches/:id/retry", RetryUserMigrationBatch) + + db := setupUserMigrationControllerDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 1, + Name: "retry-batch", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + Status: model.UserMigrationBatchStatusRunning, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 1, + BatchId: 1, + SourceUserId: 1001, + SourceUsername: "failed-user", + Status: model.UserMigrationItemStatusFailed, + ErrorMessage: "boom", + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 2, + BatchId: 1, + SourceUserId: 1002, + SourceUsername: "migrated-user", + Status: model.UserMigrationItemStatusMigrated, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 3, + BatchId: 1, + SourceUserId: 1003, + SourceUsername: "skipped-user", + Status: model.UserMigrationItemStatusSkipped, + }).Error) + + req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/retry", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusConflict, w.Code) + + var failedItem, migratedItem, skippedItem model.UserMigrationItem + require.NoError(t, db.First(&failedItem, 1).Error) + require.NoError(t, db.First(&migratedItem, 2).Error) + require.NoError(t, db.First(&skippedItem, 3).Error) + + require.Equal(t, model.UserMigrationItemStatusReady, failedItem.Status) + require.Empty(t, failedItem.ErrorMessage) + require.Equal(t, model.UserMigrationItemStatusMigrated, migratedItem.Status) + require.Equal(t, model.UserMigrationItemStatusSkipped, skippedItem.Status) +} + +func TestVerifyUserMigrationBatch_AggregatesResult(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.POST("/api/user-migrations/batches/:id/verify", VerifyUserMigrationBatch) + + db := setupUserMigrationControllerDB(t) + orig := model.DB + origFactory := newMigrationVerifier + model.DB = db + defer func() { + model.DB = orig + newMigrationVerifier = origFactory + }() + + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 1, + BatchId: 1, + SourceUserId: 1001, + TargetUserId: 1, + Status: model.UserMigrationItemStatusMigrated, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 2, + BatchId: 1, + SourceUserId: 1002, + TargetUserId: 2, + Status: model.UserMigrationItemStatusMigrated, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 3, + BatchId: 1, + SourceUserId: 1003, + TargetUserId: 3, + Status: model.UserMigrationItemStatusMigrated, + }).Error) + + newMigrationVerifier = func() migrationVerifier { + return &fakeMigrationVerifier{ + results: map[int]*user_migration.VerifyResult{ + 1001: { + TargetUserExists: true, + RemoteConverted: true, + QuotaMatched: true, + }, + 1002: { + TargetUserExists: false, + RemoteConverted: true, + QuotaMatched: false, + }, + }, + errs: map[int]error{ + 1003: errors.New("remote user not found"), + }, + } + } + + req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/verify", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Success bool `json:"success"` + Data []map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp.Success) + require.Len(t, resp.Data, 3) + + bySource := make(map[int]map[string]any, len(resp.Data)) + for _, entry := range resp.Data { + bySource[int(entry["source_user_id"].(float64))] = entry + } + + require.NotNil(t, bySource[1001]["result"]) + require.NotNil(t, bySource[1002]["result"]) + require.Equal(t, "remote user not found", bySource[1003]["error"]) +} diff --git a/controller/user_sync_guard_test.go b/controller/user_sync_guard_test.go new file mode 100644 index 0000000..15e8a0d --- /dev/null +++ b/controller/user_sync_guard_test.go @@ -0,0 +1,146 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupUserControllerDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + require.NoError(t, db.AutoMigrate(&model.User{})) + return db +} + +func TestUpdateUser_SyncedUserAllowsNonQuotaEditsWithoutOverwritingQuota(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.PUT("/api/user/", UpdateUser) + + db := setupUserControllerDB(t) + orig := model.DB + origRedisEnabled := common.RedisEnabled + model.DB = db + defer func() { + model.DB = orig + common.RedisEnabled = origRedisEnabled + }() + common.RedisEnabled = false + + require.NoError(t, db.Create(&model.User{ + Id: 1001, + Username: "synced-user", + Password: "hashed-password", + DisplayName: "Old Name", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + Quota: 0, + SyncedQuota: 100000, + Source: common.UserSourceSynced, + AffCode: "SYNC1", + }).Error) + + body := map[string]any{ + "id": 1001, + "username": "synced-user", + "display_name": "New Name", + "password": "", + "group": "default", + "quota": 100000, + } + bodyBytes, err := json.Marshal(body) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPut, "/api/user/", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.True(t, resp["success"].(bool)) + + var saved model.User + require.NoError(t, db.First(&saved, 1001).Error) + require.Equal(t, "New Name", saved.DisplayName) + require.Equal(t, 0, saved.Quota) + require.Equal(t, 100000, saved.SyncedQuota) +} + +func TestUpdateUser_SyncedUserQuotaChangeRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(testRootAuthMiddleware()) + router.PUT("/api/user/", UpdateUser) + + db := setupUserControllerDB(t) + orig := model.DB + origRedisEnabled := common.RedisEnabled + model.DB = db + defer func() { + model.DB = orig + common.RedisEnabled = origRedisEnabled + }() + common.RedisEnabled = false + + require.NoError(t, db.Create(&model.User{ + Id: 1002, + Username: "synced-user-2", + Password: "hashed-password", + DisplayName: "Synced User 2", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + Quota: 0, + SyncedQuota: 100000, + Source: common.UserSourceSynced, + AffCode: "SYNC2", + }).Error) + + body := map[string]any{ + "id": 1002, + "username": "synced-user-2", + "display_name": "Synced User 2", + "password": "", + "group": "default", + "quota": 100001, + } + bodyBytes, err := json.Marshal(body) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPut, "/api/user/", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.False(t, resp["success"].(bool)) + require.Equal(t, "user.synced_user_quota_cannot_modify", resp["message"]) + + var saved model.User + require.NoError(t, db.First(&saved, 1002).Error) + require.Equal(t, 0, saved.Quota) + require.Equal(t, 100000, saved.SyncedQuota) +} diff --git a/docs/testing/2026-06-09-user-migration-delivery-plan.md b/docs/testing/2026-06-09-user-migration-delivery-plan.md new file mode 100644 index 0000000..ba082c8 --- /dev/null +++ b/docs/testing/2026-06-09-user-migration-delivery-plan.md @@ -0,0 +1,131 @@ +# User Migration Delivery Plan + +## 背景 + +当前 `feat-overseas-user-migration` worktree 已经完成海外用户迁移主链路实现,并补齐了: + +- 模型层迁移表与状态机 +- 海外内部迁移接口 +- 国内管理端迁移 API +- 迁移中心前端 +- 单元测试 / 控制器测试 / 路由测试 +- API E2E +- Playwright UI E2E + +本计划的目标不是继续扩功能,而是把当前能力收口到“可交付、可审阅、可部署”的状态。 + +## 当前已验证通过的内容 + +以下能力已经通过真实测试验证: + +- `go test ./service/user_migration/... -v` +- `go test ./controller -run "Test.*Migration|Test.*Synced" -v` +- `go test ./model -run "Test.*Migration|Test.*Synced" -v` +- `go test ./router -run "TestUserMigrationRoutes" -v` +- `go test -v ./test/e2e -count=1` +- `cd web && bunx playwright test e2e/user-migration.spec.ts` +- `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/run-user-migration-e2e.ps1 -All` + +这些验证已经覆盖: + +- 扫描层冲突分析 +- create_new / merge / skip +- fast path 恢复与 drift +- root-only 访问控制 +- verify 聚合结果 +- 内部迁移 API 边界 +- UI 迁移中心主流程 + +## 剩余工作目标 + +把当前 worktree 收敛为: + +1. 改动范围清晰 +2. 非主线噪音最小 +3. 文档完整 +4. 提交路径明确 + +## 需要处理的剩余事项 + +### 1. 清理非主线前端脏 diff + +当前与迁移主线无强耦合、但会影响审阅质量的内容: + +- `web/src/i18n/locales/en.json` +- `web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx` + +目标: + +- 只保留本轮确有必要的改动 +- 避免大面积格式化/编码噪音 +- 确保 `bun run build` 继续通过 + +### 2. 产出测试覆盖矩阵文档 + +新增一份文档,明确: + +- 迁移功能需求点 +- 对应自动化测试名 +- 测试层级(unit / controller / router / api-e2e / ui-e2e) + +建议文件: + +- `docs/testing/user-migration-test-matrix.md` + +### 3. 收敛提交范围 + +建议把当前 worktree 的改动按下面 3 组看待: + +#### A. 核心后端与测试 + +- `model/*migration*` +- `service/user_migration/*` +- `controller/internal_user_migration*` +- `controller/user_migration*` +- `router/user_migration_router_test.go` + +#### B. 前端迁移中心 + +- `web/src/components/table/users/migration/*` +- `web/src/hooks/users/useUserMigrationData.jsx` +- `web/src/pages/User/index.jsx` +- `web/e2e/*` +- `web/playwright.config.ts` + +#### C. E2E 基础设施与文档 + +- `test/e2e/*` +- `scripts/run-user-migration-e2e.ps1` +- `docs/testing/*` + +### 4. 最终交付前整体验证 + +在准备提交前至少再次执行: + +```bash +go test ./service/user_migration/... -v +go test ./controller -run "Test.*Migration|Test.*Synced" -v +go test ./model -run "Test.*Migration|Test.*Synced" -v +go test ./router -run "TestUserMigrationRoutes" -v +go test -v ./test/e2e -count=1 +cd web && bun run build +cd web && bunx playwright test e2e/user-migration.spec.ts +``` + +## 建议执行顺序 + +1. 清理 `en.json` 和 `ModelRationNotSetEditor.jsx` +2. 产出测试覆盖矩阵文档 +3. 再跑一轮最终回归 +4. 审查 `git diff --stat` +5. 进入提交阶段 + +## 完成标准 + +只有同时满足以下条件,才算本轮迁移能力真正收尾: + +- 迁移主链路代码无新增功能性缺口 +- Go 测试、API E2E、UI E2E 全绿 +- worktree 中无明显无关脏改动 +- 测试覆盖矩阵可供审阅 +- 提交范围已经可清晰切分 diff --git a/docs/testing/user-migration-e2e-execution-plan.md b/docs/testing/user-migration-e2e-execution-plan.md new file mode 100644 index 0000000..f5a9bb7 --- /dev/null +++ b/docs/testing/user-migration-e2e-execution-plan.md @@ -0,0 +1,591 @@ +# 用户迁移 E2E 执行计划 + +## 背景 + +本计划用于为“海外历史用户迁移到国内主站”能力补齐完整的 E2E 测试体系。目标不是补几条临时脚本,而是形成一套可以本地稳定复跑、能定位失败原因、后续可接 CI 的测试资产。 + +本计划覆盖四个层面: + +- 双节点真实运行环境 +- API 黑盒 E2E +- UI Smoke E2E +- 一键执行脚本、失败产物与文档 + +## 当前仓库现状 + +| 项目 | 现状 | +|------|------| +| 浏览器 E2E 框架 | 仓库内暂无 Playwright/Cypress | +| 现有 E2E 风格 | 只有零散的 `test/e2e_*.go`、`test/e2e_*.py` | +| 迁移相关单测/集成测 | 已较完整,`service/user_migration/service_test.go` 已覆盖主链路 | +| 自动初始化 | 已支持 `POST /api/setup` 创建 root | +| 自动登录 | 已支持 `POST /api/user/login` | +| region_sync 配置 | 走 root `option` API 写入数据库 | +| CI 工作流 | 当前 worktree 下未见 `.github/workflows/` | +| 现成 `test/e2e/` 目录 | 尚未建立 | + +## 固定约定 + +以下约定在第一版中固定,不在实施过程中反复修改: + +| 项目 | 固定值 | +|------|--------| +| CN 节点端口 | `3100` | +| OV 节点端口 | `3101` | +| CN 数据库 | `test-artifacts/user-migration-e2e/cn.db` | +| OV 数据库 | `test-artifacts/user-migration-e2e/ov.db` | +| 产物目录 | `test-artifacts/user-migration-e2e/` | +| CN session 名 | `cn_session` | +| OV session 名 | `ov_session` | +| Sync API Key | `e2e-sync-key` | +| 第一版运行方式 | 双进程 + 双 SQLite,不先上 Docker | +| 第一版 UI 覆盖 | Playwright smoke,不做重业务断言 | + +## 非目标 + +本计划明确不做以下事情: + +- 第一版不引入 Docker Compose +- 第一版不把所有迁移正确性断言都放到浏览器层 +- 第一版不先接 CI,再回头补本地一键执行 +- 第一版不做全站级别的通用 E2E 基建重构 + +## 交付物 + +本计划完成后,仓库中应至少新增以下内容: + +- `test/e2e/` Go API 黑盒 E2E +- `test/e2e/helpers/` 编排、登录、配置、seed、断言 helper +- `web/e2e/` Playwright UI smoke 测试 +- `web/playwright.config.ts` +- `scripts/run-user-migration-e2e.ps1` +- `test-artifacts/user-migration-e2e/.gitignore` +- `docs/testing/user-migration-e2e.md` + +## 总体执行顺序 + +严格按以下顺序推进,避免返工: + +1. 建目录与产物骨架 +2. 做双节点进程编排 +3. 做 HTTP / root setup / login / option 配置 helper +4. 做 seed 和断言 helper +5. 做 API E2E 主链路 +6. 做 API E2E 边界场景 +7. 再上 Playwright 基础设施 +8. 做 UI smoke +9. 做一键执行脚本 +10. 做文档 +11. 做全量回归验证 +12. 视情况接 CI + +## 阶段 0:冻结基础决策 + +**目标**:锁定实现边界,避免中途改架构。 + +**任务** + +- [ ] 确认 API E2E 使用 Go +- [ ] 确认 UI E2E 使用 Playwright +- [ ] 确认第一版仅做 UI smoke +- [ ] 确认双节点端口、数据库文件路径、session 名称 +- [ ] 确认第一版不做 Docker 化 + +**验收** + +- 后续阶段不再调整端口、数据库路径、session 命名 + +## 阶段 1:目录与文档骨架 + +**目标**:先把结构搭起来。 + +**文件** + +- Create: `test/e2e/` +- Create: `test/e2e/helpers/` +- Create: `web/e2e/` +- Create: `test-artifacts/user-migration-e2e/.gitignore` +- Create: `docs/testing/user-migration-e2e.md` + +**任务** + +- [ ] 建立 `test/e2e/` 和 `test/e2e/helpers/` +- [ ] 建立 `web/e2e/` +- [ ] 建立 `test-artifacts/user-migration-e2e/` +- [ ] 写入产物目录 `.gitignore` +- [ ] 创建 E2E 文档骨架 + +**验收** + +- 所有目录和骨架文件已存在 + +**建议提交** + +- `chore(e2e): scaffold user migration e2e directories` + +## 阶段 2:双节点进程编排 + +**目标**:自动拉起 `cn` 和 `ov` 两个真实后端实例。 + +**文件** + +- Create: `test/e2e/helpers/cluster.go` + +**任务** + +- [ ] 定义 `NodeProcess` +- [ ] 定义 `Cluster` +- [ ] 实现 `StartNode` +- [ ] 实现 `StartCluster` +- [ ] 实现 `WaitReady` +- [ ] 实现 `Stop` +- [ ] 将 stdout/stderr 重定向到 `cn.log`、`ov.log` + +**强制环境变量** + +- `PORT` +- `SQLITE_PATH` +- `SESSION_SECRET` +- `SESSION_NAME` +- `SESSION_SECURE=false` +- `SESSION_SAMESITE=lax` +- `GLOBAL_API_RATE_LIMIT_ENABLE=false` +- `GLOBAL_WEB_RATE_LIMIT_ENABLE=false` +- `CRITICAL_RATE_LIMIT_ENABLE=false` +- `GIN_MODE=release` + +**节点差异** + +- CN:`NODE_TYPE=master` +- OV:`NODE_TYPE=slave` + +**验收** + +- `3100` 和 `3101` 均能返回 `/api/status` +- 测试退出时双进程都能被回收 + +**建议提交** + +- `test(e2e): add dual-node cluster runner for migration tests` + +## 阶段 3:HTTP 客户端与认证 helper + +**目标**:封装请求、会话、root 初始化和登录。 + +**文件** + +- Create: `test/e2e/helpers/http_client.go` +- Create: `test/e2e/helpers/auth.go` + +**任务** + +- [ ] 定义 `APIClient` +- [ ] 实现 `GetJSON` +- [ ] 实现 `PostJSON` +- [ ] 实现 `PutJSON` +- [ ] 实现 `DeleteJSON` +- [ ] 支持 cookie jar +- [ ] 失败时落请求/响应 dump +- [ ] 实现 `SetupRoot` +- [ ] 实现 `Login` +- [ ] 实现 root 登录快捷函数 + +**验收** + +- 可通过 `POST /api/setup` 自动创建 root +- 可通过 `POST /api/user/login` 自动拿到会话 + +**建议提交** + +- `test(e2e): add setup and session helpers for migration tests` + +## 阶段 4:region_sync 配置 helper + +**目标**:通过 root `option` API 配好双节点联通。 + +**文件** + +- Create: `test/e2e/helpers/options.go` + +**任务** + +- [ ] 实现 `SetOption` +- [ ] 实现 `ConfigureCNRegionSync` +- [ ] 实现 `ConfigureOVRegionSync` +- [ ] 实现 `WaitOptionApplied` + +**需要写入的配置** + +CN 侧: + +- `region_sync.enabled = true` +- `region_sync.region_id = "cn"` +- `region_sync.is_master = true` +- `region_sync.master_endpoint = "http://127.0.0.1:3100"` +- `region_sync.slave_endpoints = ["http://127.0.0.1:3101"]` +- `region_sync.sync_api_key = "e2e-sync-key"` + +OV 侧: + +- `region_sync.enabled = true` +- `region_sync.region_id = "ov"` +- `region_sync.is_master = false` +- `region_sync.master_endpoint = "http://127.0.0.1:3100"` +- `region_sync.slave_endpoints = []` +- `region_sync.sync_api_key = "e2e-sync-key"` + +**注意** + +- `slave_endpoints` 通过 JSON 字符串写入,不要直接写 Go slice 的字符串表示 + +**验收** + +- CN 能通过真实 HTTP 访问 OV 的内部迁移接口 + +**建议提交** + +- `test(e2e): add region sync configuration helpers` + +## 阶段 5:测试 seed helper + +**目标**:每次测试从固定数据出发,结果稳定。 + +**文件** + +- Create: `test/e2e/helpers/seed.go` + +**任务** + +- [ ] 实现 `ResetDB` +- [ ] 实现 `OpenSQLite` +- [ ] 实现 `SeedCNData` +- [ ] 实现 `SeedOVData` +- [ ] 实现 `SeedOVSyncedCopy` +- [ ] 可选:实现 `SeedCustomOAuthData` + +**固定数据集** + +CN: + +- `cn-merge-target` +- `cn-synced-copy-owner` +- `taken-username` +- `aff-owner` + +OV: + +- `ov-create` +- `ov-merge` +- `ov-hard-synced` +- `ov-username-conflict` +- `ov-aff-soft` + +额外: + +- 一条 OV `source=synced` 用户,`remote_user_id` 指向 `cn-synced-copy-owner` + +**验收** + +- 重复跑测试时,扫描结果和执行结果稳定一致 + +**建议提交** + +- `test(e2e): add migration seed data for cn and ov nodes` + +## 阶段 6:断言与轮询 helper + +**目标**:把复杂轮询和 DB 断言收敛到 helper,主测试保持清晰。 + +**文件** + +- Create: `test/e2e/helpers/assert.go` + +**任务** + +- [ ] 实现 `WaitBatchStatus` +- [ ] 实现 `GetBatchDetail` +- [ ] 实现 `FindItemBySourceUserID` +- [ ] 实现 `AssertCNUserExists` +- [ ] 实现 `AssertOVUserSynced` +- [ ] 实现 `AssertQuota` +- [ ] 实现 `AssertNoDuplicateQuotaGrant` + +**验收** + +- 主测试文件中不再出现重复的轮询和 JSON 解包样板 + +**建议提交** + +- `test(e2e): add assertion helpers for migration flow` + +## 阶段 7:API E2E 主测试骨架 + +**目标**:建立统一生命周期和测试入口。 + +**文件** + +- Create: `test/e2e/user_migration_test.go` + +**任务** + +- [ ] 实现 `TestMain` +- [ ] 在 `TestMain` 中启动 cluster +- [ ] 在 `TestMain` 中 setup root +- [ ] 在 `TestMain` 中 login root +- [ ] 在 `TestMain` 中配置 region sync +- [ ] 为每个测试实现 DB 重建与 seed + +**建议** + +- 为保证稳定性,每个测试单独重建数据库并重启双节点 + +**验收** + +- 测试环境可完全自动初始化,无人工前置 + +**建议提交** + +- `test(e2e): add user migration test main lifecycle` + +## 阶段 8:API E2E P0 主链路 + +**目标**:先证明迁移全链路真实可用。 + +**文件** + +- Modify: `test/e2e/user_migration_test.go` + +**必须实现的用例** + +- [ ] `TestUserMigrationE2E_FullFlow_GrayScenarios` +- [ ] `TestUserMigrationE2E_IdempotentRetry` +- [ ] `TestUserMigrationE2E_ExecutionTimeDriftFails` + +**`FullFlow_GrayScenarios` 覆盖点** + +- 创建批次 +- 调 `scan` +- 检查 5 类用户扫描结果 +- 冲突项人工 `resolve` +- 执行 `execute` +- 调 `verify` +- 校验 CN 用户状态 +- 校验 OV `source=synced` +- 校验 `remote_user_id` +- 校验 `synced_quota == cn.quota` + +**验收命令** + +- `go test -v ./test/e2e -run TestUserMigrationE2E_FullFlow_GrayScenarios -count=1` +- `go test -v ./test/e2e -run TestUserMigrationE2E_IdempotentRetry -count=1` +- `go test -v ./test/e2e -run TestUserMigrationE2E_ExecutionTimeDriftFails -count=1` + +**建议提交** + +- `test(e2e): add core migration flow tests` + +## 阶段 9:API E2E P1 边界与稳定性 + +**目标**:补齐重扫、分页、权限、异常输入等场景。 + +**文件** + +- Modify: `test/e2e/user_migration_test.go` + +**必须实现的用例** + +- [ ] `TestUserMigrationE2E_RescanPreservesHumanDecision` +- [ ] `TestUserMigrationE2E_ScanPagination` +- [ ] `TestUserMigrationE2E_RootPermission` +- [ ] `TestUserMigrationE2E_ResolveInvalidPayload` +- [ ] `TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning` +- [ ] `TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning` +- [ ] `TestUserMigrationE2E_VerifyQuotaMismatchDetected` +- [ ] `TestUserMigrationE2E_SkipNeverConvertsRemote` + +**验收命令** + +- `go test -v ./test/e2e -count=1` + +**建议提交** + +- `test(e2e): add migration edge case coverage` + +## 阶段 10:Playwright 基础设施 + +**目标**:搭 UI smoke 运行底座。 + +**文件** + +- Create: `web/playwright.config.ts` +- Create: `web/e2e/global-setup.ts` +- Create: `web/e2e/global-teardown.ts` +- Create: `web/e2e/fixtures/cluster.ts` + +**任务** + +- [ ] 安装 `@playwright/test` +- [ ] 配置 `testDir` +- [ ] 配置 `trace` +- [ ] 配置 `screenshot` +- [ ] 配置 `video` +- [ ] 在 `globalSetup` 中启动双节点 +- [ ] 在 `globalSetup` 中 setup root +- [ ] 在 `globalSetup` 中创建普通用户 +- [ ] 在 `globalSetup` 中 seed UI 测试数据 +- [ ] 生成 `root-storage.json` +- [ ] 生成 `user-storage.json` +- [ ] 在 `globalTeardown` 中回收进程 + +**验收** + +- `cd web && bunx playwright test --list` 可正常执行 + +**建议提交** + +- `test(ui): add playwright infrastructure for migration smoke tests` + +## 阶段 11:UI Smoke 测试 + +**目标**:验证 root 迁移中心页面真正可用。 + +**文件** + +- Create: `web/e2e/user-migration.spec.ts` +- Modify: `web/src/components/table/users/migration/*`(如需补 `data-testid`) + +**必须实现的用例** + +- [ ] `root sees migration tab` +- [ ] `non-root cannot see migration tab` +- [ ] `can create batch` +- [ ] `scan updates status with polling` +- [ ] `can resolve conflict item` +- [ ] `execute shows freeze-window confirmation` +- [ ] `verify results table is rendered` +- [ ] `page refresh preserves data loading` + +**规则** + +- UI 层只断言页面行为,不做 DB 最终值断言 +- 重业务正确性仍由 API E2E 兜底 + +**验收** + +- `cd web && bunx playwright test` + +**建议提交** + +- `test(ui): add migration center smoke tests` + +## 阶段 12:一键执行脚本 + +**目标**:本地单命令跑完全部 E2E。 + +**文件** + +- Create: `scripts/run-user-migration-e2e.ps1` + +**任务** + +- [ ] 支持 `-ApiOnly` +- [ ] 支持 `-UiOnly` +- [ ] 支持 `-All` +- [ ] 先清理旧产物 +- [ ] 跑 API E2E +- [ ] 跑 UI E2E +- [ ] 汇总结果 +- [ ] 失败时保留日志、DB、trace + +**验收** + +- 一条命令可以跑完整套测试 + +**建议提交** + +- `chore(e2e): add user migration e2e runner` + +## 阶段 13:文档补全 + +**目标**:让别人能跑、能排查、能扩展。 + +**文件** + +- Modify: `docs/testing/user-migration-e2e.md` + +**任务** + +- [ ] 写明测试架构 +- [ ] 写明覆盖范围 +- [ ] 写明本地运行命令 +- [ ] 写明失败产物位置 +- [ ] 写明常见失败与排查方式 +- [ ] 写明如何新增迁移场景 + +**验收** + +- 新同事可按文档独立跑通 + +**建议提交** + +- `docs(e2e): document user migration end-to-end tests` + +## 阶段 14:总体验证 + +**目标**:确认新增 E2E 不破坏现有代码。 + +**必须执行** + +- [ ] `go test ./...` +- [ ] `go test -v ./test/e2e -count=1` +- [ ] `cd web && bun run build` +- [ ] `cd web && bunx playwright test` + +**验收** + +- 后端单测全绿 +- API E2E 全绿 +- 前端构建通过 +- UI smoke 全绿 + +## 阶段 15:可选 CI 接入 + +**目标**:将 E2E 纳入自动化执行。 + +**说明** + +当前仓库中尚未看到 `.github/workflows/`,本阶段默认后置,不作为本轮硬性前置条件。 + +**任务** + +- [ ] 新增 API E2E job +- [ ] 新增 UI smoke job +- [ ] 上传 `test-artifacts/user-migration-e2e/` + +**验收** + +- CI 可独立稳定执行,失败时能下载产物 + +## 开始前仍需要的最少步骤 + +如果要立即开始实施,正式开工前只需要做这些最小动作: + +- [ ] 确认第一批只做 API E2E 基础设施 +- [ ] 确认第一版进程启动方式是 `go run .` +- [ ] 确认允许在迁移前端组件中补 `data-testid` +- [ ] 确认第一版先不接 CI + +确认完后,直接从**阶段 1**开始执行,不需要额外准备。 + +## 最终完成定义 + +只有同时满足以下条件,才算该 E2E 计划完成: + +- `test/e2e` API E2E 可稳定运行 +- `web/e2e` UI smoke 可稳定运行 +- 一键执行脚本可用 +- 失败产物可保留 +- 文档完整 +- `go test ./...` 通过 +- `cd web && bun run build` 通过 + diff --git a/docs/testing/user-migration-e2e.md b/docs/testing/user-migration-e2e.md new file mode 100644 index 0000000..bd07d7e --- /dev/null +++ b/docs/testing/user-migration-e2e.md @@ -0,0 +1,176 @@ +# 用户迁移 E2E + +## 范围 + +当前已落地两层 E2E: + +- API 黑盒 E2E:真实双节点、真实 HTTP、真实 SQLite、真实 `region_sync` +- UI Smoke E2E:Playwright 驱动真实浏览器访问 `/console/user` 的迁移中心 + +对应代码位置: + +- `test/e2e/user_migration_test.go` +- `test/e2e/helpers/` +- `test/e2e/cmd/user_migration_ui_server/main.go` +- `web/playwright.config.ts` +- `web/e2e/` +- `scripts/run-user-migration-e2e.ps1` + +## 已覆盖用例 + +### API E2E + +- `TestUserMigrationE2E_FullFlow_GrayScenarios` +- `TestUserMigrationE2E_IdempotentRetry` +- `TestUserMigrationE2E_ExecutionTimeDriftFails` +- `TestUserMigrationE2E_RescanPreservesHumanDecision` +- `TestUserMigrationE2E_ScanPagination` +- `TestUserMigrationE2E_RootPermission` +- `TestUserMigrationE2E_ResolveInvalidPayload` +- `TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning` +- `TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning` +- `TestUserMigrationE2E_VerifyQuotaMismatchDetected` +- `TestUserMigrationE2E_SkipNeverConvertsRemote` + +### UI Smoke E2E + +- 非 root 管理员看不到“用户迁移”页签 +- root 可以进入迁移中心并创建批次 +- 扫描状态能通过页面轮询从 `scanning` 进入 `scanned` +- 冲突项可以在页面中执行人工决策 +- 执行前会出现冻结窗口确认 +- 执行完成后可以触发 verify 并看到结果表 +- 页面刷新后,批次列表和详情仍可重新加载 + +## 固定约定 + +- CN 端口:`3100` +- OV 端口:`3101` +- CN session:`cn_session` +- OV session:`ov_session` +- Sync API Key:`e2e-sync-key` +- API E2E 运行目录:`test-artifacts/user-migration-e2e/runs//` +- UI E2E 运行目录:`test-artifacts/user-migration-e2e/runs/playwright-ui/` + +## 运行方式 + +只跑 API E2E: + +```powershell +go test -v ./test/e2e -count=1 +``` + +只跑 UI E2E: + +```powershell +cd web +bunx playwright test +``` + +通过统一脚本执行: + +```powershell +./scripts/run-user-migration-e2e.ps1 -ApiOnly +./scripts/run-user-migration-e2e.ps1 -UiOnly +./scripts/run-user-migration-e2e.ps1 -All +``` + +## UI E2E 依赖 + +Playwright 当前使用系统 Edge: + +- `web/playwright.config.ts` 中固定 `channel: 'msedge'` +- 不需要额外下载 Chromium 浏览器 + +首次在新机器上跑 UI E2E,需要安装 Playwright 的 `ffmpeg`,否则失败视频无法生成: + +```powershell +cd web +bunx playwright install ffmpeg +``` + +## UI E2E 启动流程 + +`bunx playwright test` 会自动执行以下流程: + +1. `global-setup.ts` 先清理 `3100/3101` 残留监听进程 +2. 构建前端 `web/dist` +3. 构建 `test/e2e/cmd/user_migration_ui_server` +4. 启动 UI helper,helper 内部调用 `helpers.PrepareEnvironment(...)` +5. helper 完成双节点准备后写入 `test-artifacts/user-migration-e2e/playwright/server-ready.json` +6. `global-setup.ts` 读取 ready 标记,再登录 root,并生成两套 storage state + - `root-storage.json` + - `user-storage.json`(当前实际保存的是“非 root 管理员”会话,因为 `/console/user` 受 `AdminRoute` 保护) +7. 测试结束后由 `global-teardown.ts` 回收 UI helper 进程树 + +## 产物位置 + +### API 产物 + +每个 API 用例的产物都在: + +```text +test-artifacts/user-migration-e2e/runs// +``` + +常见文件: + +- `cn.db` +- `ov.db` +- `cn.log` +- `ov.log` +- `system-logs/cn/*` +- `system-logs/ov/*` + +### UI 产物 + +Playwright 相关产物在: + +```text +test-artifacts/user-migration-e2e/playwright/ +``` + +常见文件: + +- `metadata.json` +- `server-ready.json` +- `ui-server.log` +- `auth/root-storage.json` +- `auth/user-storage.json` +- `html-report/` +- `test-output//trace.zip` +- `test-output//video.webm` +- `test-output//test-failed-1.png` + +## 排查建议 + +如果 API E2E 失败,优先看: + +- `test-artifacts/user-migration-e2e/runs//cn.log` +- `test-artifacts/user-migration-e2e/runs//ov.log` +- `cn.db` 中的 `user_migration_batches` +- `cn.db` 中的 `user_migration_items` +- `ov.db` 中的 `users` + +如果 UI E2E 失败,优先看: + +- `test-artifacts/user-migration-e2e/playwright/ui-server.log` +- `test-artifacts/user-migration-e2e/playwright/test-output//error-context.md` +- `trace.zip` +- 失败截图和视频 + +## 如何新增迁移场景 + +新增 API 迁移场景时,优先按下面顺序扩展: + +1. 在 `test/e2e/helpers/seed.go` 里补固定 seed 或新的冲突样本 +2. 在 `test/e2e/helpers/assert.go` 里补通用断言或轮询逻辑 +3. 在 `test/e2e/user_migration_test.go` 里新增独立测试,尽量复用 `prepareGrayScenarioBatch` 一类的局部 helper +4. 如果是跨节点准备逻辑变化,优先改 `test/e2e/helpers/cluster.go` 或 `options.go` + +新增 UI smoke 场景时,优先按下面顺序扩展: + +1. 先判断是否需要新的稳定选择器;需要时只在迁移中心组件上补 `data-testid` +2. 在 `web/e2e/user-migration.spec.ts` 中新增页面行为断言,不做 DB 最终值断言 +3. 如果需要新的登录态或前置环境,改 `web/e2e/global-setup.ts` +4. 若新增场景改变运行产物或依赖,再同步更新本文档 diff --git a/model/region_migration_user_test.go b/model/region_migration_user_test.go index cc29080..933a8aa 100644 --- a/model/region_migration_user_test.go +++ b/model/region_migration_user_test.go @@ -1,6 +1,7 @@ package model import ( + "errors" "testing" "github.com/QuantumNous/new-api/common" @@ -152,7 +153,7 @@ func TestConvertUserToSynced_DifferentRemoteUserIdFails(t *testing.T) { err := ConvertUserToSynced(10000002, 9999, 100) require.Error(t, err) - require.Contains(t, err.Error(), "already synced") + require.ErrorIs(t, err, ErrSyncedUserRemoteUserIDImmutable) } func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { @@ -178,6 +179,14 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { Source: common.UserSourceSynced, RemoteUserId: 1, }).Error) + require.NoError(t, db.Create(&User{ + Id: 10000003, + Username: "root", + Password: "h", + AffCode: "R1", + Source: common.UserSourceLocal, + Role: common.RoleRootUser, + }).Error) users, total, err := ListLocalUsersForMigration(0, 20) require.NoError(t, err) @@ -185,3 +194,53 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { require.Len(t, users, 1) require.Equal(t, "local-1", users[0].Username) } + +func TestConvertUserToSynced_RejectsRootUser(t *testing.T) { + db := setupRegionMigrationUserDB(t) + orig := DB + DB = db + defer func() { + DB = orig + }() + + require.NoError(t, db.Create(&User{ + Id: 10000010, + Username: "root", + Password: "hash", + AffCode: "ROOT1", + Source: common.UserSourceLocal, + Role: common.RoleRootUser, + }).Error) + + err := ConvertUserToSynced(10000010, 2001, 88) + require.Error(t, err) + require.ErrorIs(t, err, ErrConvertRootUserToSynced) + + var saved User + require.NoError(t, db.First(&saved, 10000010).Error) + require.Equal(t, common.UserSourceLocal, saved.Source) + require.Equal(t, 0, saved.RemoteUserId) +} + +func TestConvertUserToSynced_SentinelErrorsRemainDistinct(t *testing.T) { + db := setupRegionMigrationUserDB(t) + orig := DB + DB = db + defer func() { + DB = orig + }() + + require.NoError(t, db.Create(&User{ + Id: 10000020, + Username: "already-synced", + Password: "hash", + AffCode: "SYNC20", + Source: common.UserSourceSynced, + RemoteUserId: 3001, + }).Error) + + err := ConvertUserToSynced(10000020, 9999, 100) + require.Error(t, err) + require.True(t, errors.Is(err, ErrSyncedUserRemoteUserIDImmutable)) + require.False(t, errors.Is(err, ErrConvertRootUserToSynced)) +} diff --git a/model/user.go b/model/user.go index 8d9a1ce..92db93f 100644 --- a/model/user.go +++ b/model/user.go @@ -1108,7 +1108,9 @@ func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { var users []*User var total int64 - query := DB.Model(&User{}).Where("source = ?", common.UserSourceLocal) + query := DB.Model(&User{}). + Where("source = ?", common.UserSourceLocal). + Where("role <> ?", common.RoleRootUser) if err := query.Count(&total).Error; err != nil { return nil, 0, err } @@ -1124,16 +1126,25 @@ func HasOVSyncedCopyByRemoteId(cnUserId int) (bool, error) { return count > 0, err } +var ( + ErrConvertRootUserToSynced = errors.New("user.convert_root_to_synced_denied") + ErrSyncedUserRemoteUserIDImmutable = errors.New("user.synced_remote_user_id_immutable") +) + func ConvertUserToSynced(userId, remoteUserId, syncedQuota int) error { var user User if err := DB.Where("id = ?", userId).First(&user).Error; err != nil { return err } + if user.Role == common.RoleRootUser { + return ErrConvertRootUserToSynced + } now := common.GetTimestamp() if user.Source == common.UserSourceSynced { if user.RemoteUserId != remoteUserId { - return fmt.Errorf("user %d is already synced to remote_user_id=%d, cannot change to %d", + return fmt.Errorf("%w: user %d is already synced to remote_user_id=%d, cannot change to %d", + ErrSyncedUserRemoteUserIDImmutable, userId, user.RemoteUserId, remoteUserId) } if user.SyncedQuota == syncedQuota { diff --git a/model/user_migration.go b/model/user_migration.go index 38e1607..869e0d4 100644 --- a/model/user_migration.go +++ b/model/user_migration.go @@ -182,13 +182,14 @@ func RefreshUserMigrationBatchStats(batchId int) error { } func RefreshUserMigrationBatchExecutionStats(batchId int) error { - var migrated, failed, pending int64 + var migrated, failed, pending, migrating int64 DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrated).Count(&migrated) DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusFailed).Count(&failed) DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status IN ?", batchId, []string{ UserMigrationItemStatusReady, UserMigrationItemStatusMigrating, }).Count(&pending) + DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrating).Count(&migrating) status := UserMigrationBatchStatusCompleted if failed > 0 { @@ -197,6 +198,9 @@ func RefreshUserMigrationBatchExecutionStats(batchId int) error { if pending > 0 { status = UserMigrationBatchStatusRunning } + if pending == migrating && migrating > 0 { + status = UserMigrationBatchStatusFailed + } return DB.Model(&UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ "migrated_users": int(migrated), diff --git a/model/user_migration_test.go b/model/user_migration_test.go index a1d5aa6..0569437 100644 --- a/model/user_migration_test.go +++ b/model/user_migration_test.go @@ -179,3 +179,36 @@ func TestRefreshBatchStats_ReadyOnlyWhenNoPendingScan(t *testing.T) { require.NoError(t, DB.First(&refreshed, batch.Id).Error) require.Equal(t, UserMigrationBatchStatusScanned, refreshed.Status) } + +func TestRefreshUserMigrationBatchExecutionStats_MigratingLeakHandled(t *testing.T) { + db := setupUserMigrationTestDB(t) + orig := DB + DB = db + defer func() { + DB = orig + }() + + batch := &UserMigrationBatch{ + Name: "migrating-leak", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + Status: UserMigrationBatchStatusRunning, + } + require.NoError(t, CreateUserMigrationBatch(batch)) + + require.NoError(t, DB.Create(&UserMigrationItem{ + BatchId: batch.Id, + SourceUserId: 101, + SourceUsername: "stuck-user", + Status: UserMigrationItemStatusMigrating, + }).Error) + + require.NoError(t, RefreshUserMigrationBatchExecutionStats(batch.Id)) + + var refreshed UserMigrationBatch + require.NoError(t, DB.First(&refreshed, batch.Id).Error) + require.Equal(t, UserMigrationBatchStatusFailed, refreshed.Status) + require.Equal(t, 0, refreshed.MigratedUsers) + require.Equal(t, 0, refreshed.FailedUsers) +} diff --git a/router/user_migration_router_test.go b/router/user_migration_router_test.go new file mode 100644 index 0000000..10e7d0a --- /dev/null +++ b/router/user_migration_router_test.go @@ -0,0 +1,155 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupUserMigrationRouterDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.CustomOAuthProvider{}, + &model.UserOAuthBinding{}, + &model.UserMigrationBatch{}, + &model.UserMigrationItem{}, + &model.MigrationQuotaGrant{}, + )) + + return db +} + +func setupUserMigrationRootRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + store := cookie.NewStore([]byte("test-session-secret")) + store.Options(sessions.Options{ + Path: "/", + MaxAge: 3600, + HttpOnly: true, + Secure: false, + }) + r.Use(sessions.Sessions("session", store)) + + userMigrationRoute := r.Group("/api/user-migrations") + userMigrationRoute.Use(func(c *gin.Context) { + session := sessions.Default(c) + session.Set("username", "root") + session.Set("role", common.RoleRootUser) + session.Set("id", 1) + session.Set("status", common.UserStatusEnabled) + session.Save() + c.Request.Header.Set("New-Api-User", "1") + c.Next() + }, middleware.RootAuth()) + { + userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) + userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) + } + + return r +} + +func setupUserMigrationAdminRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + store := cookie.NewStore([]byte("test-session-secret")) + store.Options(sessions.Options{ + Path: "/", + MaxAge: 3600, + HttpOnly: true, + Secure: false, + }) + r.Use(sessions.Sessions("session", store)) + + userMigrationRoute := r.Group("/api/user-migrations") + userMigrationRoute.Use(func(c *gin.Context) { + session := sessions.Default(c) + session.Set("username", "admin") + session.Set("role", common.RoleAdminUser) + session.Set("id", 2) + session.Set("status", common.UserStatusEnabled) + session.Save() + c.Request.Header.Set("New-Api-User", "2") + c.Next() + }, middleware.RootAuth()) + { + userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) + userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) + } + + return r +} + +func TestUserMigrationRoutes_RootCanAccess(t *testing.T) { + db := setupUserMigrationRouterDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 1, + Name: "router-batch", + SourceRegion: "ov", + TargetRegion: "cn", + OperatorId: 1, + Status: model.UserMigrationBatchStatusDraft, + }).Error) + + router := setupUserMigrationRootRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + require.NotEqual(t, http.StatusUnauthorized, w.Code) +} + +func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { + db := setupUserMigrationRouterDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + router := setupUserMigrationAdminRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "success\":false") + + req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "success\":false") +} diff --git a/scripts/run-user-migration-e2e.ps1 b/scripts/run-user-migration-e2e.ps1 new file mode 100644 index 0000000..a5b4610 --- /dev/null +++ b/scripts/run-user-migration-e2e.ps1 @@ -0,0 +1,63 @@ +param( + [switch]$ApiOnly, + [switch]$UiOnly, + [switch]$All +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$selectedModes = @(@($ApiOnly, $UiOnly, $All) | Where-Object { $_ }) +if ($selectedModes.Count -gt 1) { + throw 'Cannot combine -ApiOnly, -UiOnly, and -All.' +} + +if (-not $ApiOnly -and -not $UiOnly -and -not $All) { + $ApiOnly = $true +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$artifactRoot = Join-Path $repoRoot 'test-artifacts/user-migration-e2e' +$runsDir = Join-Path $artifactRoot 'runs' + +if ($ApiOnly -or $All) { + if (Test-Path $runsDir) { + Remove-Item -Recurse -Force $runsDir + } +} + +Push-Location $repoRoot +try { + if ($ApiOnly -or $All) { + Write-Host '[user-migration-e2e] running API E2E...' + go test -v ./test/e2e -count=1 + } + + if ($All) { + Write-Host '[user-migration-e2e] running full Go test suite...' + go test ./... + + Write-Host '[user-migration-e2e] building frontend...' + Push-Location (Join-Path $repoRoot 'web') + try { + bun run build + } + finally { + Pop-Location + } + } + + if ($UiOnly -or $All) { + Write-Host '[user-migration-e2e] running Playwright UI E2E...' + Push-Location (Join-Path $repoRoot 'web') + try { + bunx playwright test + } + finally { + Pop-Location + } + } +} +finally { + Pop-Location +} diff --git a/service/user_migration/executor.go b/service/user_migration/executor.go index 91b3f98..17a1327 100644 --- a/service/user_migration/executor.go +++ b/service/user_migration/executor.go @@ -14,12 +14,16 @@ func (s *Service) ExecuteBatch(batchId int) error { if err != nil { return err } + providerSlugMap, err := buildProviderSlugMap() + if err != nil { + return err + } for _, item := range items { if item.Status == model.UserMigrationItemStatusMigrated { continue } - if err := s.executeItem(item); err != nil { + if err := s.executeItem(item, providerSlugMap); err != nil { _ = model.MarkUserMigrationItemFailed(item.Id, err.Error()) } } @@ -27,12 +31,12 @@ func (s *Service) ExecuteBatch(batchId int) error { return model.RefreshUserMigrationBatchExecutionStats(batchId) } -func (s *Service) executeItem(item *model.UserMigrationItem) error { +func (s *Service) executeItem(item *model.UserMigrationItem, providerSlugMap map[string]int) error { switch item.ResolutionStrategy { case model.UserMigrationStrategyCreateNew: - return s.executeCreateNew(item) + return s.executeCreateNew(item, providerSlugMap) case model.UserMigrationStrategyMergeExisting: - return s.executeMergeExisting(item) + return s.executeMergeExisting(item, providerSlugMap) case model.UserMigrationStrategySkip: return model.MarkUserMigrationItemSkipped(item.Id) default: @@ -40,8 +44,18 @@ func (s *Service) executeItem(item *model.UserMigrationItem) error { } } -func (s *Service) executeCreateNew(item *model.UserMigrationItem) error { +func (s *Service) executeCreateNew(item *model.UserMigrationItem, providerSlugMap map[string]int) error { if item.TargetUserId != 0 { + snapshot, err := s.client.GetUser(item.SourceUserId) + if err != nil { + return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) + } + if err := s.revalidateCreateNew(item, snapshot, providerSlugMap); err != nil { + return err + } + if err := markItemMigrating(item.Id); err != nil { + return err + } cnUser, err := model.GetUserById(item.TargetUserId, true) if err != nil { return err @@ -53,7 +67,7 @@ func (s *Service) executeCreateNew(item *model.UserMigrationItem) error { if err != nil { return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) } - if err := s.revalidateCreateNew(item, snapshot); err != nil { + if err := s.revalidateCreateNew(item, snapshot, providerSlugMap); err != nil { return err } if err := markItemMigrating(item.Id); err != nil { @@ -113,12 +127,12 @@ func (s *Service) executeCreateNew(item *model.UserMigrationItem) error { return s.ensureRemoteConverted(item, cnUser.Quota) } -func (s *Service) executeMergeExisting(item *model.UserMigrationItem) error { +func (s *Service) executeMergeExisting(item *model.UserMigrationItem, providerSlugMap map[string]int) error { snapshot, err := s.client.GetUser(item.SourceUserId) if err != nil { return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) } - if err := s.revalidateMergeExisting(item, snapshot); err != nil { + if err := s.revalidateMergeExisting(item, snapshot, providerSlugMap); err != nil { return err } if err := markItemMigrating(item.Id); err != nil { @@ -197,24 +211,40 @@ func (s *Service) ensureRemoteConverted(item *model.UserMigrationItem, syncedQuo return markItemMigrated(item.Id) } -func (s *Service) revalidateCreateNew(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot) error { +func (s *Service) revalidateCreateNew(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) error { if snapshot.Source == common.UserSourceSynced { + if item.TargetUserId != 0 && snapshot.RemoteUserId == item.TargetUserId { + return nil + } return fmt.Errorf("execution-time identity drift detected, remote user %d is already synced, rescan required", item.SourceUserId) } - result, err := s.analyzeExecutionSnapshot(snapshot) + result, err := s.analyzeExecutionSnapshot(snapshot, providerSlugMap) if err != nil { return err } - if result.Status != model.UserMigrationItemStatusReady || - result.Strategy != model.UserMigrationStrategyCreateNew || - result.TargetCNUserID != 0 { + if item.TargetUserId == 0 { + if result.Status != model.UserMigrationItemStatusReady || + result.Strategy != model.UserMigrationStrategyCreateNew || + result.TargetCNUserID != 0 { + return fmt.Errorf("execution-time identity drift detected, rescan required") + } + return nil + } + + if result.Status != model.UserMigrationItemStatusReady { return fmt.Errorf("execution-time identity drift detected, rescan required") } - return nil + if result.Strategy == model.UserMigrationStrategyCreateNew && result.TargetCNUserID == 0 { + return nil + } + if result.Strategy == model.UserMigrationStrategyMergeExisting && result.TargetCNUserID == item.TargetUserId { + return nil + } + return fmt.Errorf("execution-time identity drift detected, rescan required") } -func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot) error { +func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) error { if snapshot.Source == common.UserSourceSynced { if snapshot.RemoteUserId == item.TargetUserId { return nil @@ -222,7 +252,7 @@ func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapsho return fmt.Errorf("execution-time identity drift detected, remote user %d is already synced to CN user %d", item.SourceUserId, snapshot.RemoteUserId) } - result, err := s.analyzeExecutionSnapshot(snapshot) + result, err := s.analyzeExecutionSnapshot(snapshot, providerSlugMap) if err != nil { return err } @@ -234,11 +264,7 @@ func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapsho return nil } -func (s *Service) analyzeExecutionSnapshot(snapshot *RemoteUserSnapshot) (*conflictResult, error) { - providerSlugMap, err := buildProviderSlugMap() - if err != nil { - return nil, err - } +func (s *Service) analyzeExecutionSnapshot(snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) (*conflictResult, error) { result, err := analyzeConflicts(snapshot, providerSlugMap, s.client) if err != nil { return nil, fmt.Errorf("revalidate conflicts for user %d: %w", snapshot.Id, err) diff --git a/service/user_migration/service_test.go b/service/user_migration/service_test.go index e6ce378..b608bdd 100644 --- a/service/user_migration/service_test.go +++ b/service/user_migration/service_test.go @@ -210,6 +210,339 @@ func TestScanBatch_EmailConflict_CNHasSyncedCopy_MarksHardConflict(t *testing.T) require.Contains(t, items[0].ConflictFlags, "cn_already_synced_to_ov") } +func TestScanBatch_GitHubId_SameUser_RecommendsMerge(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 11, + Username: "cn-github", + Password: "hash", + AffCode: "CGH1", + GitHubId: "gh-123", + }).Error) + + batch := &model.UserMigrationBatch{Name: "wave-github-merge", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000011, + Username: "ov-github", + GitHubId: "gh-123", + Quota: 88, + AffCode: "OVGH1", + }}, + }, + hasCopyByID: map[int]bool{11: false}, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) + require.Equal(t, 11, items[0].TargetUserId) + require.Contains(t, items[0].ConflictFlags, "github_id") +} + +func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 21, + Username: "cn-github-owner", + Password: "hash", + AffCode: "COA1", + GitHubId: "gh-shared", + }).Error) + require.NoError(t, db.Create(&model.User{ + Id: 22, + Username: "cn-discord-owner", + Password: "hash", + AffCode: "COA2", + DiscordId: "dc-shared", + }).Error) + + batch := &model.UserMigrationBatch{Name: "wave-oauth-conflict", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000012, + Username: "ov-oauth-conflict", + GitHubId: "gh-shared", + DiscordId: "dc-shared", + Quota: 66, + AffCode: "OVOA1", + }}, + }, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) + require.Contains(t, items[0].ConflictFlags, "github_id") + require.Contains(t, items[0].ConflictFlags, "discord_id") + require.Contains(t, items[0].ConflictFlags, "multiple_merge_targets") +} + +func TestScanBatch_CustomOAuthBinding_MatchesExistingUser_RecommendsMerge(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.CustomOAuthProvider{ + Id: 31, + Name: "GitHub Enterprise", + Slug: "gh-enterprise", + Enabled: true, + ClientId: "cid", + AuthorizationEndpoint: "https://auth.example.com", + TokenEndpoint: "https://token.example.com", + UserInfoEndpoint: "https://userinfo.example.com", + }).Error) + require.NoError(t, db.Create(&model.User{ + Id: 32, + Username: "cn-custom-oauth", + Password: "hash", + AffCode: "CCO1", + }).Error) + require.NoError(t, db.Create(&model.UserOAuthBinding{ + UserId: 32, + ProviderId: 31, + ProviderUserId: "provider-user-1", + }).Error) + + batch := &model.UserMigrationBatch{Name: "wave-custom-oauth-merge", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000013, + Username: "ov-custom-oauth", + Quota: 55, + AffCode: "OVCO1", + OAuthBindings: []RemoteOAuthBinding{{ + ProviderSlug: "gh-enterprise", + ProviderUserId: "provider-user-1", + }}, + }}, + }, + hasCopyByID: map[int]bool{32: false}, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) + require.Equal(t, 32, items[0].TargetUserId) + require.Contains(t, items[0].ConflictFlags, "oauth:gh-enterprise") +} + +func TestScanBatch_CustomOAuthBinding_UnknownSlug_Ignored(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + batch := &model.UserMigrationBatch{Name: "wave-custom-oauth-unknown", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000014, + Username: "ov-unknown-oauth", + Quota: 77, + AffCode: "OVUK1", + OAuthBindings: []RemoteOAuthBinding{{ + ProviderSlug: "unknown-provider", + ProviderUserId: "u-1", + }}, + }}, + }, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationStrategyCreateNew, items[0].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) + require.NotContains(t, items[0].ConflictFlags, "oauth:") +} + +func TestScanBatch_EmailAndOAuth_SameUser_RecommendsMerge(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 41, + Username: "cn-same-user", + Password: "hash", + Email: "same@example.com", + AffCode: "CSU1", + GitHubId: "gh-same", + }).Error) + + batch := &model.UserMigrationBatch{Name: "wave-email-oauth-same", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000015, + Username: "ov-same-user", + Email: "same@example.com", + GitHubId: "gh-same", + Quota: 91, + AffCode: "OVSU1", + }}, + }, + hasCopyByID: map[int]bool{41: false}, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) + require.Equal(t, 41, items[0].TargetUserId) + require.Contains(t, items[0].ConflictFlags, "email") + require.Contains(t, items[0].ConflictFlags, "github_id") + require.NotContains(t, items[0].ConflictFlags, "multiple_merge_targets") +} + +func TestScanBatch_EmailAndOAuth_DifferentUsers_Conflicts(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 51, + Username: "cn-email-owner", + Password: "hash", + Email: "combo@example.com", + AffCode: "CEO1", + }).Error) + require.NoError(t, db.Create(&model.User{ + Id: 52, + Username: "cn-oauth-owner", + Password: "hash", + AffCode: "COO1", + GitHubId: "gh-combo", + }).Error) + + batch := &model.UserMigrationBatch{Name: "wave-email-oauth-diff", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000016, + Username: "ov-combo-user", + Email: "combo@example.com", + GitHubId: "gh-combo", + Quota: 92, + AffCode: "OVCO2", + }}, + }, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) + require.Contains(t, items[0].ConflictFlags, "email") + require.Contains(t, items[0].ConflictFlags, "github_id") + require.Contains(t, items[0].ConflictFlags, "multiple_merge_targets") +} + +func TestScanBatch_UsernameConflict_OverridesMergeCandidate(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 61, + Username: "same-name", + Password: "hash", + AffCode: "CSN1", + }).Error) + require.NoError(t, db.Create(&model.User{ + Id: 62, + Username: "other-user", + Password: "hash", + Email: "mergeable@example.com", + AffCode: "CME1", + }).Error) + + batch := &model.UserMigrationBatch{Name: "wave-username-overrides", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} + require.NoError(t, model.CreateUserMigrationBatch(batch)) + + client := &fakeClient{ + listPages: map[int][]RemoteUserSnapshot{ + 0: {{ + Id: 10000017, + Username: "same-name", + Email: "mergeable@example.com", + Quota: 93, + AffCode: "OVUN1", + }}, + }, + hasCopyByID: map[int]bool{62: false}, + } + svc := &Service{client: client} + require.NoError(t, svc.ScanBatch(batch.Id)) + + items, err := model.ListUserMigrationItemsByBatch(batch.Id) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) + require.Contains(t, items[0].ConflictFlags, "username") +} + func TestScanBatch_Paginated(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB @@ -952,3 +1285,218 @@ func TestVerifyItem_QuotaMismatchDetected(t *testing.T) { require.True(t, result.RemoteConverted) require.False(t, result.QuotaMatched) } + +func TestVerifyItem_AllowsPostMigrationQuotaConsumption(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 791, + Username: "cn-consumed", + Password: "hash", + AffCode: "CM3", + Quota: 250, + }).Error) + + item := &model.UserMigrationItem{ + BatchId: 1, + SourceUserId: 10000003, + TargetUserId: 791, + SourceQuota: 200, + Status: model.UserMigrationItemStatusMigrated, + } + + client := &fakeClient{ + getUserResp: &RemoteUserSnapshot{ + Id: 10000003, + Source: common.UserSourceSynced, + RemoteUserId: 791, + SyncedQuota: 200, + }, + } + svc := &Service{client: client} + result, err := svc.VerifyItem(item) + require.NoError(t, err) + require.True(t, result.TargetUserExists) + require.True(t, result.RemoteConverted) + require.True(t, result.QuotaMatched) +} + +func TestExecuteBatch_CreateNewFastPathRevalidatesAndMarksMigrated(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 900, + Username: "cn-existing", + Password: "hash", + AffCode: "CN900", + Quota: 300, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 1, + BatchId: 10, + SourceUserId: 10000010, + TargetUserId: 900, + SourceUsername: "ov-existing", + SourceQuota: 300, + ResolutionStrategy: model.UserMigrationStrategyCreateNew, + Status: model.UserMigrationItemStatusFailed, + MigrationToken: "token-fast-path", + }).Error) + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 10, + Name: "batch-fast-path", + SourceRegion: "ov", + TargetRegion: "cn", + Status: model.UserMigrationBatchStatusFailed, + OperatorId: 1, + }).Error) + + client := &fakeClient{ + getUserByID: map[int]*RemoteUserSnapshot{ + 10000010: { + Id: 10000010, + Username: "ov-existing", + Quota: 300, + Source: common.UserSourceLocal, + OAuthBindings: nil, + }, + }, + } + + svc := &Service{client: client} + require.NoError(t, svc.ExecuteBatch(10)) + require.Len(t, client.convertCalls, 1) + require.Equal(t, 10000010, client.convertCalls[0]) + + items, err := model.ListUserMigrationItemsByBatch(10) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationItemStatusMigrated, items[0].Status) +} + +func TestExecuteBatch_CreateNewFastPath_DriftFails(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 901, + Username: "cn-existing-drift", + Password: "hash", + AffCode: "CND901", + Quota: 320, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 2, + BatchId: 11, + SourceUserId: 10000018, + TargetUserId: 901, + SourceUsername: "ov-fast-drift", + SourceQuota: 320, + ResolutionStrategy: model.UserMigrationStrategyCreateNew, + Status: model.UserMigrationItemStatusFailed, + MigrationToken: "token-fast-path-drift", + }).Error) + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 11, + Name: "batch-fast-path-drift", + SourceRegion: "ov", + TargetRegion: "cn", + Status: model.UserMigrationBatchStatusFailed, + OperatorId: 1, + }).Error) + + client := &fakeClient{ + getUserByID: map[int]*RemoteUserSnapshot{ + 10000018: { + Id: 10000018, + Username: "ov-fast-drift", + Quota: 320, + Source: common.UserSourceSynced, + RemoteUserId: 999, + SyncedQuota: 320, + }, + }, + } + + svc := &Service{client: client} + require.NoError(t, svc.ExecuteBatch(11)) + require.Empty(t, client.convertCalls) + + items, err := model.ListUserMigrationItemsByBatch(11) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status) + require.Contains(t, items[0].ErrorMessage, "already synced") +} + +func TestExecuteBatch_CreateNewFastPath_AlreadySyncedToSameTargetSucceeds(t *testing.T) { + db := setupMigrationServiceDB(t) + orig := model.DB + model.DB = db + defer func() { + model.DB = orig + }() + + require.NoError(t, db.Create(&model.User{ + Id: 902, + Username: "cn-existing-same", + Password: "hash", + AffCode: "CND902", + Quota: 321, + }).Error) + require.NoError(t, db.Create(&model.UserMigrationItem{ + Id: 3, + BatchId: 12, + SourceUserId: 10000019, + TargetUserId: 902, + SourceUsername: "ov-fast-same", + SourceQuota: 321, + ResolutionStrategy: model.UserMigrationStrategyCreateNew, + Status: model.UserMigrationItemStatusFailed, + MigrationToken: "token-fast-path-same", + }).Error) + require.NoError(t, db.Create(&model.UserMigrationBatch{ + Id: 12, + Name: "batch-fast-path-same", + SourceRegion: "ov", + TargetRegion: "cn", + Status: model.UserMigrationBatchStatusFailed, + OperatorId: 1, + }).Error) + + client := &fakeClient{ + getUserByID: map[int]*RemoteUserSnapshot{ + 10000019: { + Id: 10000019, + Username: "ov-fast-same", + Quota: 321, + Source: common.UserSourceSynced, + RemoteUserId: 902, + SyncedQuota: 321, + }, + }, + } + + svc := &Service{client: client} + require.NoError(t, svc.ExecuteBatch(12)) + require.Empty(t, client.convertCalls) + + items, err := model.ListUserMigrationItemsByBatch(12) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, model.UserMigrationItemStatusMigrated, items[0].Status) +} diff --git a/service/user_migration/verifier.go b/service/user_migration/verifier.go index e87fa6a..db2ad38 100644 --- a/service/user_migration/verifier.go +++ b/service/user_migration/verifier.go @@ -28,6 +28,6 @@ func (s *Service) VerifyItem(item *model.UserMigrationItem) (*VerifyResult, erro return &VerifyResult{ TargetUserExists: targetUser != nil, RemoteConverted: remoteConverted, - QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota == targetUser.Quota, + QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota >= item.SourceQuota, }, nil } diff --git a/test/e2e/cmd/user_migration_ui_server/main.go b/test/e2e/cmd/user_migration_ui_server/main.go new file mode 100644 index 0000000..ce84754 --- /dev/null +++ b/test/e2e/cmd/user_migration_ui_server/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/QuantumNous/new-api/common" + helpers "github.com/QuantumNous/new-api/test/e2e/helpers" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + artifactRoot := filepath.Join(repoRoot, "test-artifacts", "user-migration-e2e") + if err := os.MkdirAll(artifactRoot, 0o755); err != nil { + return fmt.Errorf("create artifact root: %w", err) + } + + binaryPath, err := helpers.BuildBinary(repoRoot, artifactRoot) + if err != nil { + return fmt.Errorf("build api binary: %w", err) + } + + env, err := helpers.PrepareEnvironment(repoRoot, artifactRoot, binaryPath, "playwright-ui") + if err != nil { + return fmt.Errorf("prepare playwright environment: %w", err) + } + defer func() { + _ = env.Close() + }() + + playwrightArtifactRoot := filepath.Join(artifactRoot, "playwright") + if err := os.MkdirAll(playwrightArtifactRoot, 0o755); err != nil { + return fmt.Errorf("create playwright artifact root: %w", err) + } + + readyPayload, err := common.Marshal(map[string]any{ + "run_dir": env.RunDir, + "base_url": env.Cluster.CN.BaseURL, + "ov_base_url": env.Cluster.OV.BaseURL, + }) + if err != nil { + return fmt.Errorf("marshal ready payload: %w", err) + } + if err := os.WriteFile( + filepath.Join(playwrightArtifactRoot, "server-ready.json"), + readyPayload, + 0o644, + ); err != nil { + return fmt.Errorf("write ready marker: %w", err) + } + + fmt.Printf( + "user migration ui e2e ready: cn=%s ov=%s run_dir=%s\n", + env.Cluster.CN.BaseURL, + env.Cluster.OV.BaseURL, + env.RunDir, + ) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + return nil +} diff --git a/test/e2e/helpers/assert.go b/test/e2e/helpers/assert.go new file mode 100644 index 0000000..d8d74ab --- /dev/null +++ b/test/e2e/helpers/assert.go @@ -0,0 +1,215 @@ +package helpers + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/QuantumNous/new-api/model" + userMigration "github.com/QuantumNous/new-api/service/user_migration" + "github.com/stretchr/testify/require" +) + +type BatchDetail struct { + Batch model.UserMigrationBatch + Items []model.UserMigrationItem + Total int64 +} + +type batchDetailResponse struct { + Success bool `json:"success"` + Data model.UserMigrationBatch `json:"data"` + Items []model.UserMigrationItem `json:"items"` + Total int64 `json:"total"` +} + +type VerifyEntry struct { + SourceUserID int `json:"source_user_id"` + TargetUserID int `json:"target_user_id"` + Result *userMigration.VerifyResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type verifyResponse struct { + Success bool `json:"success"` + Data []VerifyEntry `json:"data"` +} + +type remoteUserResponse struct { + Success bool `json:"success"` + Data userMigration.RemoteUserSnapshot `json:"data"` +} + +func GetBatchDetail(client *APIClient, batchID int) (*BatchDetail, error) { + var resp batchDetailResponse + if err := client.GetJSON(fmt.Sprintf("/api/user-migrations/batches/%d?page=0&page_size=100", batchID), http.StatusOK, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf("get batch detail %d failed", batchID) + } + return &BatchDetail{ + Batch: resp.Data, + Items: resp.Items, + Total: resp.Total, + }, nil +} + +func WaitBatchStatus(client *APIClient, batchID int, expectedStatus string, timeout time.Duration) (*BatchDetail, error) { + deadline := time.Now().Add(timeout) + lastStatus := "" + + for time.Now().Before(deadline) { + detail, err := GetBatchDetail(client, batchID) + if err == nil { + lastStatus = detail.Batch.Status + if detail.Batch.Status == expectedStatus { + return detail, nil + } + } + time.Sleep(250 * time.Millisecond) + } + + return nil, fmt.Errorf("batch %d did not reach status %q within %s, last status=%q", batchID, expectedStatus, timeout, lastStatus) +} + +func VerifyBatch(client *APIClient, batchID int) ([]VerifyEntry, error) { + var resp verifyResponse + if err := client.PostJSON(fmt.Sprintf("/api/user-migrations/batches/%d/verify", batchID), map[string]any{}, http.StatusOK, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf("verify batch %d failed", batchID) + } + return resp.Data, nil +} + +func FindItemBySourceUserID(items []model.UserMigrationItem, sourceUserID int) *model.UserMigrationItem { + for i := range items { + if items[i].SourceUserId == sourceUserID { + return &items[i] + } + } + return nil +} + +func GetRemoteUserSnapshot(baseURL string, userID int) (*userMigration.RemoteUserSnapshot, error) { + client, err := NewAPIClient(baseURL) + if err != nil { + return nil, err + } + + var resp remoteUserResponse + if err := client.DoJSONWithHeaders( + http.MethodGet, + fmt.Sprintf("/api/internal/migration/users/%d", userID), + nil, + http.StatusOK, + map[string]string{ + "X-Sync-API-Key": DefaultSyncAPIKey, + "X-Sync-Node": "e2e-assert", + }, + &resp, + ); err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf("get remote user snapshot %d failed", userID) + } + return &resp.Data, nil +} + +func AssertCNUserExists(t *testing.T, dbPath, username string) *model.User { + t.Helper() + + db, err := OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, CloseSQLite(db)) + }() + + var user model.User + require.NoError(t, db.Where("username = ?", username).First(&user).Error) + return &user +} + +func LoadCNUserByEmail(t *testing.T, dbPath, email string) *model.User { + t.Helper() + + db, err := OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, CloseSQLite(db)) + }() + + var user model.User + require.NoError(t, db.Where("email = ?", email).First(&user).Error) + return &user +} + +func AssertOVUserSynced(t *testing.T, ovBaseURL string, userID, targetUserID, syncedQuota int) { + t.Helper() + + snapshot, err := GetRemoteUserSnapshot(ovBaseURL, userID) + require.NoError(t, err) + require.Equal(t, "synced", snapshot.Source) + require.Equal(t, targetUserID, snapshot.RemoteUserId) + require.Equal(t, syncedQuota, snapshot.SyncedQuota) +} + +func AssertQuota(t *testing.T, dbPath string, userID, quota int) { + t.Helper() + + db, err := OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, CloseSQLite(db)) + }() + + var user model.User + require.NoError(t, db.Select("id", "quota").First(&user, userID).Error) + require.Equal(t, quota, user.Quota) +} + +func AssertNoDuplicateQuotaGrant(t *testing.T, dbPath, migrationToken string) { + t.Helper() + + db, err := OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, CloseSQLite(db)) + }() + + var count int64 + require.NoError(t, db.Model(&model.MigrationQuotaGrant{}).Where("migration_token = ?", migrationToken).Count(&count).Error) + require.EqualValues(t, 1, count) +} + +func CountUsersByUsername(t *testing.T, dbPath, username string) int64 { + t.Helper() + + db, err := OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, CloseSQLite(db)) + }() + + var count int64 + require.NoError(t, db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error) + return count +} + +func CountUsersByEmail(t *testing.T, dbPath, email string) int64 { + t.Helper() + + db, err := OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, CloseSQLite(db)) + }() + + var count int64 + require.NoError(t, db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error) + return count +} diff --git a/test/e2e/helpers/auth.go b/test/e2e/helpers/auth.go new file mode 100644 index 0000000..ec6c184 --- /dev/null +++ b/test/e2e/helpers/auth.go @@ -0,0 +1,75 @@ +package helpers + +import "fmt" + +type setupStatusResponse struct { + Success bool `json:"success"` + Data struct { + Status bool `json:"status"` + RootInit bool `json:"root_init"` + } `json:"data"` + Message string `json:"message,omitempty"` +} + +type setupResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +type loginResponse struct { + Success bool `json:"success"` + Data struct { + ID int `json:"id"` + } `json:"data"` + Message string `json:"message,omitempty"` +} + +func SetupRoot(client *APIClient, username, password string) error { + var setupStatus setupStatusResponse + if err := client.GetJSON("/api/setup", 200, &setupStatus); err != nil { + return err + } + if setupStatus.Data.Status { + return nil + } + + var resp setupResponse + if err := client.PostJSON("/api/setup", map[string]any{ + "username": username, + "password": password, + "confirmPassword": password, + "SelfUseModeEnabled": false, + "DemoSiteEnabled": false, + }, 200, &resp); err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("setup root failed: %s", resp.Message) + } + return nil +} + +func Login(client *APIClient, username, password string) error { + var resp loginResponse + if err := client.PostJSON("/api/user/login", map[string]any{ + "username": username, + "password": password, + }, 200, &resp); err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("login failed: %s", resp.Message) + } + if resp.Data.ID == 0 { + return fmt.Errorf("login succeeded but user id is empty") + } + client.UserID = resp.Data.ID + return nil +} + +func SetupAndLoginRoot(client *APIClient, username, password string) error { + if err := SetupRoot(client, username, password); err != nil { + return err + } + return Login(client, username, password) +} diff --git a/test/e2e/helpers/cluster.go b/test/e2e/helpers/cluster.go new file mode 100644 index 0000000..e6e5479 --- /dev/null +++ b/test/e2e/helpers/cluster.go @@ -0,0 +1,397 @@ +package helpers + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + NodeTypeMaster = "master" + NodeTypeSlave = "slave" + + DefaultCNPort = 3100 + DefaultOVPort = 3101 + DefaultSessionSecret = "user-migration-e2e-session-secret" + DefaultCNDBFileName = "cn.db" + DefaultOVDBFileName = "ov.db" + DefaultCNLogFileName = "cn.log" + DefaultOVLogFileName = "ov.log" + DefaultCNSessionName = "cn_session" + DefaultOVSessionName = "ov_session" + DefaultSyncAPIKey = "e2e-sync-key" + DefaultRootUsername = "root" + DefaultRootPassword = "Password123!" + defaultNodeReadyTimout = 45 * time.Second +) + +type NodeProcess struct { + Name string + Port int + BaseURL string + DBPath string + SessionName string + NodeType string + LogPath string + + cmd *exec.Cmd + logFile *os.File + waitCh chan error +} + +type Cluster struct { + RepoRoot string + RunDir string + BinaryPath string + CNDBPath string + OVDBPath string + CN *NodeProcess + OV *NodeProcess +} + +type Environment struct { + RepoRoot string + ArtifactRoot string + RunDir string + BinaryPath string + CNDBPath string + OVDBPath string + Cluster *Cluster + CNClient *APIClient + OVClient *APIClient +} + +func BuildBinary(repoRoot, artifactRoot string) (string, error) { + binDir := filepath.Join(artifactRoot, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return "", fmt.Errorf("create binary dir: %w", err) + } + + binaryName := "new-api-e2e" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + binaryPath := filepath.Join(binDir, binaryName) + + cmd := exec.Command("go", "build", "-o", binaryPath, ".") + cmd.Dir = repoRoot + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build e2e binary: %w\n%s", err, string(output)) + } + return binaryPath, nil +} + +func PrepareEnvironment(repoRoot, artifactRoot, binaryPath, scenarioName string) (*Environment, error) { + runDir := filepath.Join(artifactRoot, "runs", sanitizeName(scenarioName)) + if err := os.RemoveAll(runDir); err != nil { + return nil, fmt.Errorf("reset run dir: %w", err) + } + if err := os.MkdirAll(runDir, 0o755); err != nil { + return nil, fmt.Errorf("create run dir: %w", err) + } + + cluster := NewCluster(repoRoot, runDir, binaryPath) + if err := cluster.Start(NodeTypeMaster, NodeTypeMaster); err != nil { + _ = cluster.Stop() + return nil, err + } + + cnClient, err := NewAPIClient(cluster.CN.BaseURL) + if err != nil { + _ = cluster.Stop() + return nil, err + } + ovClient, err := NewAPIClient(cluster.OV.BaseURL) + if err != nil { + _ = cluster.Stop() + return nil, err + } + + if err := SetupRoot(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("setup CN root: %w", err) + } + if err := SetupRoot(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("setup OV root: %w", err) + } + + if err := cluster.Stop(); err != nil { + return nil, fmt.Errorf("stop bootstrap cluster: %w", err) + } + + if err := SeedCNData(cluster.CNDBPath); err != nil { + return nil, fmt.Errorf("seed CN data: %w", err) + } + if err := SeedOVData(cluster.OVDBPath); err != nil { + return nil, fmt.Errorf("seed OV data: %w", err) + } + + if err := cluster.Start(NodeTypeMaster, NodeTypeSlave); err != nil { + _ = cluster.Stop() + return nil, err + } + + cnClient, err = NewAPIClient(cluster.CN.BaseURL) + if err != nil { + _ = cluster.Stop() + return nil, err + } + ovClient, err = NewAPIClient(cluster.OV.BaseURL) + if err != nil { + _ = cluster.Stop() + return nil, err + } + + if err := Login(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("login CN root: %w", err) + } + if err := Login(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("login OV root: %w", err) + } + + if err := ConfigureCNRegionSync(cnClient, cluster.CN.BaseURL, cluster.OV.BaseURL, DefaultSyncAPIKey); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("configure CN region sync: %w", err) + } + if err := ConfigureOVRegionSync(ovClient, cluster.CN.BaseURL, DefaultSyncAPIKey); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("configure OV region sync: %w", err) + } + + if err := WaitOptionApplied(cluster.OV.BaseURL, DefaultSyncAPIKey, 5, 20*time.Second); err != nil { + _ = cluster.Stop() + return nil, fmt.Errorf("wait OV migration API ready: %w", err) + } + + return &Environment{ + RepoRoot: repoRoot, + ArtifactRoot: artifactRoot, + RunDir: runDir, + BinaryPath: binaryPath, + CNDBPath: cluster.CNDBPath, + OVDBPath: cluster.OVDBPath, + Cluster: cluster, + CNClient: cnClient, + OVClient: ovClient, + }, nil +} + +func (e *Environment) Close() error { + if e == nil || e.Cluster == nil { + return nil + } + return e.Cluster.Stop() +} + +func NewCluster(repoRoot, runDir, binaryPath string) *Cluster { + return &Cluster{ + RepoRoot: repoRoot, + RunDir: runDir, + BinaryPath: binaryPath, + CNDBPath: filepath.Join(runDir, DefaultCNDBFileName), + OVDBPath: filepath.Join(runDir, DefaultOVDBFileName), + } +} + +func (c *Cluster) Start(cnNodeType, ovNodeType string) error { + if err := os.MkdirAll(c.RunDir, 0o755); err != nil { + return fmt.Errorf("create run dir: %w", err) + } + + cn, err := c.startNode("cn", DefaultCNPort, c.CNDBPath, DefaultCNSessionName, cnNodeType, DefaultCNLogFileName) + if err != nil { + return err + } + c.CN = cn + + ov, err := c.startNode("ov", DefaultOVPort, c.OVDBPath, DefaultOVSessionName, ovNodeType, DefaultOVLogFileName) + if err != nil { + _ = c.CN.Stop() + c.CN = nil + return err + } + c.OV = ov + + if err := c.CN.WaitReady(defaultNodeReadyTimout); err != nil { + _ = c.Stop() + return err + } + if err := c.OV.WaitReady(defaultNodeReadyTimout); err != nil { + _ = c.Stop() + return err + } + return nil +} + +func (c *Cluster) Stop() error { + var errs []string + if c.OV != nil { + if err := c.OV.Stop(); err != nil { + errs = append(errs, err.Error()) + } + c.OV = nil + } + if c.CN != nil { + if err := c.CN.Stop(); err != nil { + errs = append(errs, err.Error()) + } + c.CN = nil + } + if len(errs) == 0 { + return nil + } + return errors.New(strings.Join(errs, "; ")) +} + +func (c *Cluster) startNode(name string, port int, dbPath, sessionName, nodeType, logFileName string) (*NodeProcess, error) { + logPath := filepath.Join(c.RunDir, logFileName) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("open %s log file: %w", name, err) + } + + systemLogDir := filepath.Join(c.RunDir, "system-logs", name) + if err := os.MkdirAll(systemLogDir, 0o755); err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("create %s system log dir: %w", name, err) + } + + cmd := exec.Command(c.BinaryPath, "--log-dir", systemLogDir) + cmd.Dir = c.RepoRoot + cmd.Env = mergeEnv(os.Environ(), map[string]string{ + "PORT": fmt.Sprintf("%d", port), + "SQLITE_PATH": dbPath, + "SESSION_SECRET": DefaultSessionSecret, + "SESSION_NAME": sessionName, + "SESSION_SECURE": "false", + "SESSION_SAMESITE": "strict", + "GLOBAL_API_RATE_LIMIT_ENABLE": "false", + "GLOBAL_WEB_RATE_LIMIT_ENABLE": "false", + "CRITICAL_RATE_LIMIT_ENABLE": "false", + "GIN_MODE": "release", + "NODE_TYPE": nodeType, + }) + cmd.Stdout = logFile + cmd.Stderr = logFile + + waitCh := make(chan error, 1) + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("start %s node: %w", name, err) + } + go func() { + waitCh <- cmd.Wait() + }() + + return &NodeProcess{ + Name: name, + Port: port, + BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + DBPath: dbPath, + SessionName: sessionName, + NodeType: nodeType, + LogPath: logPath, + cmd: cmd, + logFile: logFile, + waitCh: waitCh, + }, nil +} + +func (n *NodeProcess) WaitReady(timeout time.Duration) error { + client := &http.Client{Timeout: 1 * time.Second} + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + select { + case err := <-n.waitCh: + if err == nil { + return fmt.Errorf("%s node exited before ready; see %s", n.Name, n.LogPath) + } + return fmt.Errorf("%s node exited before ready: %w; see %s", n.Name, err, n.LogPath) + default: + } + + resp, err := client.Get(n.BaseURL + "/api/status") + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(250 * time.Millisecond) + } + + return fmt.Errorf("%s node did not become ready within %s; see %s", n.Name, timeout, n.LogPath) +} + +func (n *NodeProcess) Stop() error { + if n == nil { + return nil + } + + var errs []string + killRequested := false + if n.cmd != nil && n.cmd.Process != nil { + if err := n.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + errs = append(errs, fmt.Sprintf("kill %s node: %v", n.Name, err)) + } else { + killRequested = true + } + select { + case err := <-n.waitCh: + if err != nil && !killRequested && !strings.Contains(strings.ToLower(err.Error()), "signal: killed") { + errs = append(errs, fmt.Sprintf("wait %s node: %v", n.Name, err)) + } + case <-time.After(5 * time.Second): + errs = append(errs, fmt.Sprintf("wait %s node timeout", n.Name)) + } + } + + if n.logFile != nil { + if err := n.logFile.Close(); err != nil { + errs = append(errs, fmt.Sprintf("close %s log: %v", n.Name, err)) + } + n.logFile = nil + } + n.cmd = nil + + if len(errs) == 0 { + return nil + } + return errors.New(strings.Join(errs, "; ")) +} + +func sanitizeName(name string) string { + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") + return replacer.Replace(name) +} + +func mergeEnv(base []string, overrides map[string]string) []string { + filtered := make([]string, 0, len(base)+len(overrides)) + for _, entry := range base { + parts := strings.SplitN(entry, "=", 2) + if len(parts) != 2 { + continue + } + if _, ok := overrides[parts[0]]; ok { + continue + } + filtered = append(filtered, entry) + } + for key, value := range overrides { + filtered = append(filtered, key+"="+value) + } + return filtered +} diff --git a/test/e2e/helpers/http_client.go b/test/e2e/helpers/http_client.go new file mode 100644 index 0000000..3ab24da --- /dev/null +++ b/test/e2e/helpers/http_client.go @@ -0,0 +1,119 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" +) + +type APIClient struct { + BaseURL string + UserID int + client *http.Client +} + +func NewAPIClient(baseURL string) (*APIClient, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("create cookie jar: %w", err) + } + return &APIClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + client: &http.Client{ + Jar: jar, + Timeout: 15 * time.Second, + }, + }, nil +} + +func (c *APIClient) DoJSON(method, path string, requestBody any, expectedStatus int, responseBody any) error { + return c.DoJSONWithHeaders(method, path, requestBody, expectedStatus, nil, responseBody) +} + +func (c *APIClient) DoJSONWithHeaders(method, path string, requestBody any, expectedStatus int, headers map[string]string, responseBody any) error { + var bodyReader io.Reader + var requestDump string + if requestBody != nil { + data, err := common.Marshal(requestBody) + if err != nil { + return fmt.Errorf("marshal request body for %s %s: %w", method, path, err) + } + bodyReader = bytes.NewReader(data) + requestDump = string(data) + } + + req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("create request %s %s: %w", method, path, err) + } + req.Header.Set("Accept", "application/json") + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.UserID != 0 { + req.Header.Set("New-Api-User", fmt.Sprintf("%d", c.UserID)) + } + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("do request %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response %s %s: %w", method, path, err) + } + + if resp.StatusCode != expectedStatus { + return fmt.Errorf( + "%s %s: expected status %d, got %d, request=%s, response=%s", + method, path, expectedStatus, resp.StatusCode, requestDump, string(respData), + ) + } + + if responseBody == nil || len(respData) == 0 { + return nil + } + + if err := common.Unmarshal(respData, responseBody); err != nil { + return fmt.Errorf( + "unmarshal response for %s %s: %w, raw=%s", + method, path, err, string(respData), + ) + } + return nil +} + +func (c *APIClient) GetJSON(path string, expectedStatus int, responseBody any) error { + return c.DoJSON(http.MethodGet, path, nil, expectedStatus, responseBody) +} + +func (c *APIClient) PostJSON(path string, requestBody any, expectedStatus int, responseBody any) error { + return c.DoJSON(http.MethodPost, path, requestBody, expectedStatus, responseBody) +} + +func (c *APIClient) PutJSON(path string, requestBody any, expectedStatus int, responseBody any) error { + return c.DoJSON(http.MethodPut, path, requestBody, expectedStatus, responseBody) +} + +func (c *APIClient) DeleteJSON(path string, expectedStatus int, responseBody any) error { + return c.DoJSON(http.MethodDelete, path, nil, expectedStatus, responseBody) +} + +type Envelope struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} diff --git a/test/e2e/helpers/options.go b/test/e2e/helpers/options.go new file mode 100644 index 0000000..3562bdd --- /dev/null +++ b/test/e2e/helpers/options.go @@ -0,0 +1,115 @@ +package helpers + +import ( + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" +) + +type optionResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +type internalListUsersResponse struct { + Success bool `json:"success"` + Total int `json:"total"` +} + +func SetOption(client *APIClient, key string, value any) error { + var resp optionResponse + if err := client.PutJSON("/api/option/", map[string]any{ + "key": key, + "value": value, + }, http.StatusOK, &resp); err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("set option %s failed: %s", key, resp.Message) + } + return nil +} + +func ConfigureCNRegionSync(client *APIClient, cnBaseURL, ovBaseURL, apiKey string) error { + slaveEndpoints, err := common.Marshal([]string{ovBaseURL}) + if err != nil { + return fmt.Errorf("marshal slave endpoints: %w", err) + } + + options := []struct { + Key string + Value any + }{ + {Key: "region_sync.enabled", Value: true}, + {Key: "region_sync.region_id", Value: "cn"}, + {Key: "region_sync.is_master", Value: true}, + {Key: "region_sync.master_endpoint", Value: cnBaseURL}, + {Key: "region_sync.slave_endpoints", Value: string(slaveEndpoints)}, + {Key: "region_sync.sync_api_key", Value: apiKey}, + } + for _, option := range options { + if err := SetOption(client, option.Key, option.Value); err != nil { + return err + } + } + return nil +} + +func ConfigureOVRegionSync(client *APIClient, cnBaseURL, apiKey string) error { + emptyEndpoints, err := common.Marshal([]string{}) + if err != nil { + return fmt.Errorf("marshal empty slave endpoints: %w", err) + } + + options := []struct { + Key string + Value any + }{ + {Key: "region_sync.enabled", Value: true}, + {Key: "region_sync.region_id", Value: "ov"}, + {Key: "region_sync.is_master", Value: false}, + {Key: "region_sync.master_endpoint", Value: cnBaseURL}, + {Key: "region_sync.slave_endpoints", Value: string(emptyEndpoints)}, + {Key: "region_sync.sync_api_key", Value: apiKey}, + } + for _, option := range options { + if err := SetOption(client, option.Key, option.Value); err != nil { + return err + } + } + return nil +} + +func WaitOptionApplied(baseURL, apiKey string, expectedTotal int, timeout time.Duration) error { + return WaitInternalMigrationReady(baseURL, apiKey, expectedTotal, timeout) +} + +func WaitInternalMigrationReady(baseURL, apiKey string, expectedTotal int, timeout time.Duration) error { + client, err := NewAPIClient(baseURL) + if err != nil { + return err + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + var resp internalListUsersResponse + err := client.DoJSONWithHeaders( + http.MethodGet, + "/api/internal/migration/users?page=0&page_size=1", + nil, + http.StatusOK, + map[string]string{ + "X-Sync-API-Key": apiKey, + "X-Sync-Node": "e2e-ready-check", + }, + &resp, + ) + if err == nil && resp.Success && resp.Total == expectedTotal { + return nil + } + time.Sleep(250 * time.Millisecond) + } + return fmt.Errorf("internal migration API at %s not ready within %s", baseURL, timeout) +} diff --git a/test/e2e/helpers/seed.go b/test/e2e/helpers/seed.go new file mode 100644 index 0000000..f2d2452 --- /dev/null +++ b/test/e2e/helpers/seed.go @@ -0,0 +1,304 @@ +package helpers + +import ( + "fmt" + "os" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +const ( + CNMergeTargetUserID = 11 + CNSyncedCopyOwnerUserID = 12 + CNTakenUsernameUserID = 13 + CNSoftAffOwnerUserID = 14 + OVCreateUserID = 10000001 + OVMergeUserID = 10000002 + OVHardSyncedUserID = 10000003 + OVUsernameConflictUserID = 10000004 + OVSoftAffUserID = 10000005 + OVSyncedCopyUserID = 10000100 + + UsernameCNMergeTarget = "cn-merge-target" + UsernameCNSyncedCopyOwner = "cn-synced-copy-owner" + UsernameCNTakenUsername = "taken-username" + UsernameCNSoftAffOwner = "aff-owner" + UsernameOVCreate = "ov-create" + UsernameOVMerge = "ov-merge" + UsernameOVHardSynced = "ov-hard-synced" + UsernameOVUsernameConflict = "taken-username" + UsernameOVSoftAff = "ov-aff-soft" + UsernameOVSyncedCopy = "ov-existing-synced-copy" + + EmailCNMergeTarget = "merge@example.com" + EmailCNSyncedCopyOwner = "synced@example.com" + EmailCNTakenUsername = "taken@example.com" + EmailCNSoftAffOwner = "soft-owner@example.com" + EmailOVCreate = "create@example.com" + EmailOVMerge = "merge@example.com" + EmailOVHardSynced = "synced@example.com" + EmailOVUsernameConflict = "username-conflict@example.com" + EmailOVSoftAff = "soft@example.com" + EmailOVSyncedCopy = "copy@example.com" + + AffCodeCNMergeTarget = "CNM1" + AffCodeCNSyncedCopyOwner = "CNH3" + AffCodeCNTakenUsername = "CNT1" + AffCodeCNSoftAffOwner = "SOFT1" + AffCodeOVCreate = "OVC1" + AffCodeOVMerge = "OVM2" + AffCodeOVHardSynced = "OVH3" + AffCodeOVUsername = "OVU4" + AffCodeOVSyncedCopy = "OVS1" +) + +func ResetDB(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func OpenSQLite(path string) (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) + if err != nil { + return nil, err + } + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(1) + return db, nil +} + +func CloseSQLite(db *gorm.DB) error { + if db == nil { + return nil + } + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func SeedCNData(dbPath string) error { + db, err := OpenSQLite(dbPath) + if err != nil { + return fmt.Errorf("open CN sqlite: %w", err) + } + defer func() { + _ = CloseSQLite(db) + }() + + passwordHash, err := common.Password2Hash("fixture-password") + if err != nil { + return fmt.Errorf("hash fixture password: %w", err) + } + now := common.GetTimestamp() + + users := []*model.User{ + { + Id: CNMergeTargetUserID, + Username: UsernameCNMergeTarget, + Password: passwordHash, + DisplayName: "CN Merge Target", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailCNMergeTarget, + Group: "default", + Quota: 500, + AffCode: AffCodeCNMergeTarget, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: CNSyncedCopyOwnerUserID, + Username: UsernameCNSyncedCopyOwner, + Password: passwordHash, + DisplayName: "CN Synced Copy Owner", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailCNSyncedCopyOwner, + Group: "default", + Quota: 300, + AffCode: AffCodeCNSyncedCopyOwner, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: CNTakenUsernameUserID, + Username: UsernameCNTakenUsername, + Password: passwordHash, + DisplayName: "CN Username Conflict", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailCNTakenUsername, + Group: "default", + Quota: 50, + AffCode: AffCodeCNTakenUsername, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: CNSoftAffOwnerUserID, + Username: UsernameCNSoftAffOwner, + Password: passwordHash, + DisplayName: "CN Soft Aff Owner", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailCNSoftAffOwner, + Group: "default", + Quota: 80, + AffCode: AffCodeCNSoftAffOwner, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + } + + for _, user := range users { + if err := db.Create(user).Error; err != nil { + return fmt.Errorf("seed CN user %s: %w", user.Username, err) + } + } + return nil +} + +func SeedOVData(dbPath string) error { + db, err := OpenSQLite(dbPath) + if err != nil { + return fmt.Errorf("open OV sqlite: %w", err) + } + defer func() { + _ = CloseSQLite(db) + }() + + passwordHash, err := common.Password2Hash("fixture-password") + if err != nil { + return fmt.Errorf("hash fixture password: %w", err) + } + now := common.GetTimestamp() + + users := []*model.User{ + { + Id: OVCreateUserID, + Username: UsernameOVCreate, + Password: passwordHash, + DisplayName: "OV Create", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailOVCreate, + Group: "default", + Quota: 100, + AffCode: AffCodeOVCreate, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: OVMergeUserID, + Username: UsernameOVMerge, + Password: passwordHash, + DisplayName: "OV Merge", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailOVMerge, + Group: "default", + Quota: 200, + AffCode: AffCodeOVMerge, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: OVHardSyncedUserID, + Username: UsernameOVHardSynced, + Password: passwordHash, + DisplayName: "OV Hard Synced", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailOVHardSynced, + Group: "default", + Quota: 300, + AffCode: AffCodeOVHardSynced, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: OVUsernameConflictUserID, + Username: UsernameOVUsernameConflict, + Password: passwordHash, + DisplayName: "OV Username Conflict", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailOVUsernameConflict, + Group: "default", + Quota: 120, + AffCode: AffCodeOVUsername, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + { + Id: OVSoftAffUserID, + Username: UsernameOVSoftAff, + Password: passwordHash, + DisplayName: "OV Soft Aff", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailOVSoftAff, + Group: "default", + Quota: 150, + AffCode: AffCodeCNSoftAffOwner, + Source: common.UserSourceLocal, + CreatedAt: now, + }, + } + + for _, user := range users { + if err := db.Create(user).Error; err != nil { + return fmt.Errorf("seed OV user %s: %w", user.Username, err) + } + } + + return SeedOVSyncedCopy(dbPath) +} + +func SeedOVSyncedCopy(dbPath string) error { + db, err := OpenSQLite(dbPath) + if err != nil { + return fmt.Errorf("open OV sqlite for synced copy: %w", err) + } + defer func() { + _ = CloseSQLite(db) + }() + + passwordHash, err := common.Password2Hash("fixture-password") + if err != nil { + return fmt.Errorf("hash fixture password for synced copy: %w", err) + } + now := common.GetTimestamp() + + user := &model.User{ + Id: OVSyncedCopyUserID, + Username: UsernameOVSyncedCopy, + Password: passwordHash, + DisplayName: "OV Existing Synced Copy", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Email: EmailOVSyncedCopy, + Group: "default", + Quota: 0, + AffCode: AffCodeOVSyncedCopy, + Source: common.UserSourceSynced, + RemoteUserId: CNSyncedCopyOwnerUserID, + SyncedQuota: 300, + LastSyncAt: now, + CreatedAt: now, + } + if err := db.Create(user).Error; err != nil { + return fmt.Errorf("seed OV synced copy %s: %w", user.Username, err) + } + return nil +} diff --git a/test/e2e/user_migration_test.go b/test/e2e/user_migration_test.go new file mode 100644 index 0000000..32a420a --- /dev/null +++ b/test/e2e/user_migration_test.go @@ -0,0 +1,683 @@ +package e2e + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + helpers "github.com/QuantumNous/new-api/test/e2e/helpers" + "github.com/stretchr/testify/require" +) + +var ( + repoRoot string + artifactRoot string + binaryPath string +) + +type simpleResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +type createBatchResponse struct { + Success bool `json:"success"` + Data model.UserMigrationBatch `json:"data"` +} + +type adminUserResponse struct { + Success bool `json:"success"` + Data model.User `json:"data"` +} + +type batchPageResponse struct { + Success bool `json:"success"` + Data model.UserMigrationBatch `json:"data"` + Items []model.UserMigrationItem `json:"items"` + Total int64 `json:"total"` +} + +type preparedBatch struct { + BatchID int + Detail *helpers.BatchDetail + Items map[int]*model.UserMigrationItem +} + +type executedBatch struct { + Prepared *preparedBatch + Detail *helpers.BatchDetail + CreatedUser *model.User + SoftAffUser *model.User + VerifyResult map[int]helpers.VerifyEntry +} + +func TestMain(m *testing.M) { + var err error + wd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + repoRoot = filepath.Clean(filepath.Join(wd, "..", "..")) + artifactRoot = filepath.Join(repoRoot, "test-artifacts", "user-migration-e2e") + if err := os.MkdirAll(artifactRoot, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + binaryPath, err = helpers.BuildBinary(repoRoot, artifactRoot) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + os.Exit(m.Run()) +} + +func TestUserMigrationE2E_FullFlow_GrayScenarios(t *testing.T) { + env := newEnvironment(t) + result := runFullFlow(t, env) + + require.Equal(t, model.UserMigrationBatchStatusCompleted, result.Detail.Batch.Status) + require.Equal(t, 3, result.Detail.Batch.MigratedUsers) + require.Equal(t, 0, result.Detail.Batch.FailedUsers) + require.Len(t, result.VerifyResult, 3) +} + +func TestUserMigrationE2E_IdempotentRetry(t *testing.T) { + env := newEnvironment(t) + result := runFullFlow(t, env) + + forceRetryableState(t, env.CNDBPath, result.Prepared.BatchID, []int{ + result.Prepared.Items[helpers.OVCreateUserID].Id, + result.Prepared.Items[helpers.OVMergeUserID].Id, + result.Prepared.Items[helpers.OVSoftAffUserID].Id, + }) + + var retryResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/retry", result.Prepared.BatchID), + nil, + 202, + &retryResp, + )) + require.True(t, retryResp.Success) + + retried, err := helpers.WaitBatchStatus(env.CNClient, result.Prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second) + require.NoError(t, err) + + retriedItems := indexItemsBySourceUserID(retried.Items) + require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVCreateUserID].Status) + require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVMergeUserID].Status) + require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVSoftAffUserID].Status) + + require.EqualValues(t, 1, helpers.CountUsersByUsername(t, env.CNDBPath, helpers.UsernameOVCreate)) + require.EqualValues(t, 1, helpers.CountUsersByUsername(t, env.CNDBPath, helpers.UsernameOVSoftAff)) + helpers.AssertQuota(t, env.CNDBPath, helpers.CNMergeTargetUserID, 700) + helpers.AssertNoDuplicateQuotaGrant(t, env.CNDBPath, result.Prepared.Items[helpers.OVMergeUserID].MigrationToken) + + verifyEntries, err := helpers.VerifyBatch(env.CNClient, result.Prepared.BatchID) + require.NoError(t, err) + require.Len(t, verifyEntries, 3) + for _, entry := range verifyEntries { + require.Empty(t, entry.Error) + require.NotNil(t, entry.Result) + require.True(t, entry.Result.TargetUserExists) + require.True(t, entry.Result.RemoteConverted) + require.True(t, entry.Result.QuotaMatched) + } +} + +func TestUserMigrationE2E_ExecutionTimeDriftFails(t *testing.T) { + env := newEnvironment(t) + prepared := prepareGrayScenarioBatch(t, env) + + renameOVUser(t, env, helpers.OVCreateUserID, helpers.UsernameCNMergeTarget) + + var executeResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), + nil, + 202, + &executeResp, + )) + require.True(t, executeResp.Success) + + failedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusFailed, 25*time.Second) + require.NoError(t, err) + + items := indexItemsBySourceUserID(failedDetail.Items) + require.Equal(t, model.UserMigrationItemStatusFailed, items[helpers.OVCreateUserID].Status) + require.Contains(t, items[helpers.OVCreateUserID].ErrorMessage, "drift") + require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVMergeUserID].Status) + require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVSoftAffUserID].Status) + require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVHardSyncedUserID].Status) + require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVUsernameConflictUserID].Status) + + require.EqualValues(t, 0, helpers.CountUsersByEmail(t, env.CNDBPath, helpers.EmailOVCreate)) + + driftedRemote, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVCreateUserID) + require.NoError(t, err) + require.NotEqual(t, common.UserSourceSynced, driftedRemote.Source) +} + +func TestUserMigrationE2E_RescanPreservesHumanDecision(t *testing.T) { + env := newEnvironment(t) + prepared := prepareGrayScenarioBatch(t, env) + + var scanResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/scan", prepared.BatchID), + nil, + 202, + &scanResp, + )) + require.True(t, scanResp.Success) + + rescannedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusScanned, 20*time.Second) + require.NoError(t, err) + + items := indexItemsBySourceUserID(rescannedDetail.Items) + require.Equal(t, model.UserMigrationStrategySkip, items[helpers.OVHardSyncedUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusConflict, items[helpers.OVHardSyncedUserID].Status) + require.Equal(t, model.UserMigrationStrategySkip, items[helpers.OVUsernameConflictUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusConflict, items[helpers.OVUsernameConflictUserID].Status) + require.Equal(t, model.UserMigrationStrategyMergeExisting, items[helpers.OVMergeUserID].ResolutionStrategy) + require.Equal(t, helpers.CNMergeTargetUserID, items[helpers.OVMergeUserID].TargetUserId) +} + +func TestUserMigrationE2E_ScanPagination(t *testing.T) { + env := newEnvironment(t) + + createManagedUsers(t, env.OVClient, "ov-page", 97, common.RoleCommonUser) + + batchID := createBatch(t, env, "pagination") + + var scanResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), + nil, + 202, + &scanResp, + )) + require.True(t, scanResp.Success) + + detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusScanned, 60*time.Second) + require.NoError(t, err) + require.EqualValues(t, 102, detail.Total) + require.Equal(t, 102, detail.Batch.TotalUsers) + + page0 := getBatchPage(t, env.CNClient, batchID, 0, 100) + require.Len(t, page0.Items, 100) + page1 := getBatchPage(t, env.CNClient, batchID, 1, 100) + require.Len(t, page1.Items, 2) + require.EqualValues(t, 102, page1.Total) +} + +func TestUserMigrationE2E_RootPermission(t *testing.T) { + env := newEnvironment(t) + + adminClient := createAndLoginManagedUser(t, env.Cluster.CN.BaseURL, env.CNClient, "migration-admin", helpers.DefaultRootPassword, common.RoleAdminUser) + + var listResp simpleResponse + require.NoError(t, adminClient.GetJSON("/api/user-migrations/batches", 200, &listResp)) + require.False(t, listResp.Success) + require.NotEmpty(t, listResp.Message) + + var createResp simpleResponse + require.NoError(t, adminClient.PostJSON("/api/user-migrations/batches", map[string]any{ + "name": "no-access", + "source_region": "ov", + "target_region": "cn", + }, 200, &createResp)) + require.False(t, createResp.Success) + require.NotEmpty(t, createResp.Message) +} + +func TestUserMigrationE2E_ResolveInvalidPayload(t *testing.T) { + env := newEnvironment(t) + scanned := scanGrayScenarioBatch(t, env, "resolve-invalid") + + conflictItem := scanned.Items[helpers.OVHardSyncedUserID] + + var invalidStrategyResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/items/%d/resolve", conflictItem.Id), + map[string]any{ + "resolution_strategy": "invalid", + }, + 400, + &invalidStrategyResp, + )) + require.False(t, invalidStrategyResp.Success) + require.Contains(t, invalidStrategyResp.Error, "invalid resolution_strategy") + + var mergeWithoutTargetResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/items/%d/resolve", conflictItem.Id), + map[string]any{ + "resolution_strategy": model.UserMigrationStrategyMergeExisting, + }, + 400, + &mergeWithoutTargetResp, + )) + require.False(t, mergeWithoutTargetResp.Success) + require.Contains(t, mergeWithoutTargetResp.Error, "target_user_id") +} + +func TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning(t *testing.T) { + env := newEnvironment(t) + + batchID := createBatch(t, env, "scan-conflict") + setBatchStatus(t, env.CNDBPath, batchID, model.UserMigrationBatchStatusScanning) + + var resp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), + nil, + 409, + &resp, + )) + require.False(t, resp.Success) + require.Contains(t, resp.Error, "already being scanned") +} + +func TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning(t *testing.T) { + env := newEnvironment(t) + prepared := prepareGrayScenarioBatch(t, env) + + setBatchStatus(t, env.CNDBPath, prepared.BatchID, model.UserMigrationBatchStatusRunning) + + var resp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), + nil, + 409, + &resp, + )) + require.False(t, resp.Success) + require.Contains(t, resp.Error, "already running") +} + +func TestUserMigrationE2E_VerifyQuotaMismatchDetected(t *testing.T) { + env := newEnvironment(t) + result := runFullFlow(t, env) + + convertRemoteUserToSynced(t, env.Cluster.OV.BaseURL, helpers.OVMergeUserID, helpers.CNMergeTargetUserID, 199) + + verifyEntries, err := helpers.VerifyBatch(env.CNClient, result.Prepared.BatchID) + require.NoError(t, err) + + verifyBySource := make(map[int]helpers.VerifyEntry, len(verifyEntries)) + for _, entry := range verifyEntries { + verifyBySource[entry.SourceUserID] = entry + } + + require.NotNil(t, verifyBySource[helpers.OVMergeUserID].Result) + require.False(t, verifyBySource[helpers.OVMergeUserID].Result.QuotaMatched) + require.True(t, verifyBySource[helpers.OVCreateUserID].Result.QuotaMatched) + require.True(t, verifyBySource[helpers.OVSoftAffUserID].Result.QuotaMatched) +} + +func TestUserMigrationE2E_SkipNeverConvertsRemote(t *testing.T) { + env := newEnvironment(t) + prepared := prepareGrayScenarioBatch(t, env) + + resolveItem(t, env, prepared.Items[helpers.OVCreateUserID].Id, model.UserMigrationStrategySkip, 0) + + readyDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusReady, 10*time.Second) + require.NoError(t, err) + + var executeResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), + nil, + 202, + &executeResp, + )) + require.True(t, executeResp.Success) + + completedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second) + require.NoError(t, err) + + items := indexItemsBySourceUserID(completedDetail.Items) + require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVCreateUserID].Status) + require.EqualValues(t, 0, helpers.CountUsersByEmail(t, env.CNDBPath, helpers.EmailOVCreate)) + + remoteUser, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVCreateUserID) + require.NoError(t, err) + require.NotEqual(t, common.UserSourceSynced, remoteUser.Source) + require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId) +} + +func newEnvironment(t *testing.T) *helpers.Environment { + t.Helper() + + env, err := helpers.PrepareEnvironment(repoRoot, artifactRoot, binaryPath, t.Name()) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, env.Close()) + }) + return env +} + +func runFullFlow(t *testing.T, env *helpers.Environment) *executedBatch { + t.Helper() + + prepared := prepareGrayScenarioBatch(t, env) + + var executeResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), + nil, + 202, + &executeResp, + )) + require.True(t, executeResp.Success) + + completedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second) + require.NoError(t, err) + require.EqualValues(t, 5, completedDetail.Total) + + items := indexItemsBySourceUserID(completedDetail.Items) + require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVCreateUserID].Status) + require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVMergeUserID].Status) + require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVSoftAffUserID].Status) + require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVHardSyncedUserID].Status) + require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVUsernameConflictUserID].Status) + + createdUser := helpers.AssertCNUserExists(t, env.CNDBPath, helpers.UsernameOVCreate) + require.Equal(t, 100, createdUser.Quota) + + helpers.AssertQuota(t, env.CNDBPath, helpers.CNMergeTargetUserID, 700) + + softAffUser := helpers.AssertCNUserExists(t, env.CNDBPath, helpers.UsernameOVSoftAff) + require.Equal(t, 150, softAffUser.Quota) + require.NotEqual(t, helpers.AffCodeCNSoftAffOwner, softAffUser.AffCode) + + helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVCreateUserID, createdUser.Id, 100) + helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVMergeUserID, helpers.CNMergeTargetUserID, 700) + helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVSoftAffUserID, softAffUser.Id, 150) + + hardSnapshot, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVHardSyncedUserID) + require.NoError(t, err) + require.NotEqual(t, common.UserSourceSynced, hardSnapshot.Source) + + usernameSnapshot, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVUsernameConflictUserID) + require.NoError(t, err) + require.NotEqual(t, common.UserSourceSynced, usernameSnapshot.Source) + + verifyEntries, err := helpers.VerifyBatch(env.CNClient, prepared.BatchID) + require.NoError(t, err) + require.Len(t, verifyEntries, 3) + + verifyBySource := make(map[int]helpers.VerifyEntry, len(verifyEntries)) + for _, entry := range verifyEntries { + require.Empty(t, entry.Error) + require.NotNil(t, entry.Result) + require.True(t, entry.Result.TargetUserExists) + require.True(t, entry.Result.RemoteConverted) + require.True(t, entry.Result.QuotaMatched) + verifyBySource[entry.SourceUserID] = entry + } + + return &executedBatch{ + Prepared: prepared, + Detail: completedDetail, + CreatedUser: createdUser, + SoftAffUser: softAffUser, + VerifyResult: verifyBySource, + } +} + +func prepareGrayScenarioBatch(t *testing.T, env *helpers.Environment) *preparedBatch { + t.Helper() + + scanned := scanGrayScenarioBatch(t, env, "gray-scenarios") + scannedItems := scanned.Items + + require.Equal(t, model.UserMigrationStrategyCreateNew, scannedItems[helpers.OVCreateUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVCreateUserID].Status) + + require.Equal(t, model.UserMigrationStrategyMergeExisting, scannedItems[helpers.OVMergeUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVMergeUserID].Status) + require.Equal(t, helpers.CNMergeTargetUserID, scannedItems[helpers.OVMergeUserID].TargetUserId) + + require.Equal(t, model.UserMigrationItemStatusConflict, scannedItems[helpers.OVHardSyncedUserID].Status) + require.Contains(t, decodeFlags(t, scannedItems[helpers.OVHardSyncedUserID].ConflictFlags), "cn_already_synced_to_ov") + + require.Equal(t, model.UserMigrationItemStatusConflict, scannedItems[helpers.OVUsernameConflictUserID].Status) + require.Contains(t, decodeFlags(t, scannedItems[helpers.OVUsernameConflictUserID].ConflictFlags), "username") + + require.Equal(t, model.UserMigrationStrategyCreateNew, scannedItems[helpers.OVSoftAffUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVSoftAffUserID].Status) + require.Contains(t, decodeFlags(t, scannedItems[helpers.OVSoftAffUserID].ConflictFlags), "aff_code") + + resolveItem(t, env, scannedItems[helpers.OVHardSyncedUserID].Id, model.UserMigrationStrategySkip, 0) + resolveItem(t, env, scannedItems[helpers.OVUsernameConflictUserID].Id, model.UserMigrationStrategySkip, 0) + + readyDetail, err := helpers.WaitBatchStatus(env.CNClient, scanned.BatchID, model.UserMigrationBatchStatusReady, 10*time.Second) + require.NoError(t, err) + + readyItems := indexItemsBySourceUserID(readyDetail.Items) + require.Equal(t, model.UserMigrationStrategySkip, readyItems[helpers.OVHardSyncedUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, readyItems[helpers.OVHardSyncedUserID].Status) + require.Equal(t, model.UserMigrationStrategySkip, readyItems[helpers.OVUsernameConflictUserID].ResolutionStrategy) + require.Equal(t, model.UserMigrationItemStatusReady, readyItems[helpers.OVUsernameConflictUserID].Status) + + return &preparedBatch{ + BatchID: scanned.BatchID, + Detail: readyDetail, + Items: readyItems, + } +} + +func scanGrayScenarioBatch(t *testing.T, env *helpers.Environment, name string) *preparedBatch { + t.Helper() + + batchID := createBatch(t, env, name) + + var scanResp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), + nil, + 202, + &scanResp, + )) + require.True(t, scanResp.Success) + + scannedDetail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusScanned, 20*time.Second) + require.NoError(t, err) + require.EqualValues(t, 5, scannedDetail.Total) + + scannedItems := indexItemsBySourceUserID(scannedDetail.Items) + require.Len(t, scannedItems, 5) + + return &preparedBatch{ + BatchID: batchID, + Detail: scannedDetail, + Items: scannedItems, + } +} + +func createBatch(t *testing.T, env *helpers.Environment, name string) int { + t.Helper() + + var resp createBatchResponse + require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{ + "name": name, + "source_region": "ov", + "target_region": "cn", + }, 200, &resp)) + require.True(t, resp.Success) + require.NotZero(t, resp.Data.Id) + return resp.Data.Id +} + +func resolveItem(t *testing.T, env *helpers.Environment, itemID int, strategy string, targetUserID int) { + t.Helper() + + var resp simpleResponse + require.NoError(t, env.CNClient.PostJSON( + fmt.Sprintf("/api/user-migrations/items/%d/resolve", itemID), + map[string]any{ + "resolution_strategy": strategy, + "target_user_id": targetUserID, + }, + 200, + &resp, + )) + require.True(t, resp.Success) +} + +func renameOVUser(t *testing.T, env *helpers.Environment, userID int, newUsername string) { + t.Helper() + + var getResp adminUserResponse + require.NoError(t, env.OVClient.GetJSON(fmt.Sprintf("/api/user/%d", userID), 200, &getResp)) + require.True(t, getResp.Success) + + var updateResp simpleResponse + require.NoError(t, env.OVClient.PutJSON("/api/user", map[string]any{ + "id": getResp.Data.Id, + "username": newUsername, + "display_name": getResp.Data.DisplayName, + "group": getResp.Data.Group, + "quota": getResp.Data.Quota, + "remark": getResp.Data.Remark, + "capture_relay": getResp.Data.CaptureRelay, + }, 200, &updateResp)) + require.True(t, updateResp.Success) +} + +func createAndLoginManagedUser(t *testing.T, baseURL string, rootClient *helpers.APIClient, username, password string, role int) *helpers.APIClient { + t.Helper() + + var resp simpleResponse + require.NoError(t, rootClient.PostJSON("/api/user/", map[string]any{ + "username": username, + "password": password, + "display_name": username, + "role": role, + }, 200, &resp)) + require.True(t, resp.Success) + + client, err := helpers.NewAPIClient(baseURL) + require.NoError(t, err) + require.NoError(t, helpers.Login(client, username, password)) + return client +} + +func createManagedUsers(t *testing.T, client *helpers.APIClient, prefix string, count int, role int) { + t.Helper() + + for i := 0; i < count; i++ { + username := prefix + email := fmt.Sprintf("%s@example.com", prefix) + displayName := prefix + if count > 1 { + username = fmt.Sprintf("%s-%03d", prefix, i) + email = fmt.Sprintf("%s-%03d@example.com", prefix, i) + displayName = fmt.Sprintf("%s %03d", prefix, i) + } + + var resp simpleResponse + require.NoError(t, client.PostJSON("/api/user/", map[string]any{ + "username": username, + "password": helpers.DefaultRootPassword, + "display_name": displayName, + "email": email, + "role": role, + }, 200, &resp)) + require.True(t, resp.Success) + } +} + +func forceRetryableState(t *testing.T, dbPath string, batchID int, itemIDs []int) { + t.Helper() + + db, err := helpers.OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, helpers.CloseSQLite(db)) + }() + + require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID). + Update("status", model.UserMigrationBatchStatusFailed).Error) + require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("id IN ?", itemIDs). + Updates(map[string]any{ + "status": model.UserMigrationItemStatusFailed, + "error_message": "forced retry for e2e", + }).Error) +} + +func setBatchStatus(t *testing.T, dbPath string, batchID int, status string) { + t.Helper() + + db, err := helpers.OpenSQLite(dbPath) + require.NoError(t, err) + defer func() { + require.NoError(t, helpers.CloseSQLite(db)) + }() + + require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID).Update("status", status).Error) +} + +func getBatchPage(t *testing.T, client *helpers.APIClient, batchID, page, pageSize int) *batchPageResponse { + t.Helper() + + var resp batchPageResponse + require.NoError(t, client.GetJSON( + fmt.Sprintf("/api/user-migrations/batches/%d?page=%d&page_size=%d", batchID, page, pageSize), + 200, + &resp, + )) + require.True(t, resp.Success) + return &resp +} + +func convertRemoteUserToSynced(t *testing.T, ovBaseURL string, sourceUserID, remoteUserID, syncedQuota int) { + t.Helper() + + client, err := helpers.NewAPIClient(ovBaseURL) + require.NoError(t, err) + + var resp simpleResponse + require.NoError(t, client.DoJSONWithHeaders( + "POST", + fmt.Sprintf("/api/internal/migration/users/%d/convert-to-synced", sourceUserID), + map[string]any{ + "remote_user_id": remoteUserID, + "synced_quota": syncedQuota, + }, + 200, + map[string]string{ + "X-Sync-API-Key": helpers.DefaultSyncAPIKey, + "X-Sync-Node": "e2e-test", + }, + &resp, + )) + require.True(t, resp.Success) +} + +func indexItemsBySourceUserID(items []model.UserMigrationItem) map[int]*model.UserMigrationItem { + indexed := make(map[int]*model.UserMigrationItem, len(items)) + for i := range items { + indexed[items[i].SourceUserId] = &items[i] + } + return indexed +} + +func decodeFlags(t *testing.T, flagsJSON string) []string { + t.Helper() + + var flags []string + require.NoError(t, common.UnmarshalJsonStr(flagsJSON, &flags)) + return flags +} diff --git a/web/e2e/fixtures/cluster.ts b/web/e2e/fixtures/cluster.ts new file mode 100644 index 0000000..d382766 --- /dev/null +++ b/web/e2e/fixtures/cluster.ts @@ -0,0 +1,60 @@ +import { expect, test as base } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +export const webRoot = path.resolve(currentDir, '..', '..'); +export const repoRoot = path.resolve(webRoot, '..'); +export const artifactRoot = path.join( + repoRoot, + 'test-artifacts', + 'user-migration-e2e', +); +export const playwrightArtifactRoot = path.join(artifactRoot, 'playwright'); +export const authDir = path.join(playwrightArtifactRoot, 'auth'); +export const metadataPath = path.join(playwrightArtifactRoot, 'metadata.json'); +export const readyMarkerPath = path.join( + playwrightArtifactRoot, + 'server-ready.json', +); +export const serverLogPath = path.join(playwrightArtifactRoot, 'ui-server.log'); +export const serverBinaryDir = path.join(playwrightArtifactRoot, 'bin'); +export const serverBinaryPath = path.join( + serverBinaryDir, + process.platform === 'win32' + ? 'user-migration-ui-server.exe' + : 'user-migration-ui-server', +); +export const rootStorageStatePath = path.join(authDir, 'root-storage.json'); +export const userStorageStatePath = path.join(authDir, 'user-storage.json'); +export const adminStorageStatePath = userStorageStatePath; +export const clusterRunDir = path.join(artifactRoot, 'runs', 'playwright-ui'); +export const cnBaseURL = 'http://127.0.0.1:3100'; +export const ovBaseURL = 'http://127.0.0.1:3101'; +export const rootUsername = 'root'; +export const rootPassword = 'Password123!'; +export const adminUsername = 'playwright-admin'; +export const adminPassword = 'Password123!'; + +export type ClusterMetadata = { + serverPid: number; + serverBinaryPath: string; + serverLogPath: string; + runDir: string; + baseURL: string; + ovBaseURL: string; +}; + +export async function readClusterMetadata(): Promise { + const raw = await readFile(metadataPath, 'utf8'); + return JSON.parse(raw) as ClusterMetadata; +} + +export const test = base.extend<{ cluster: ClusterMetadata }>({ + cluster: async ({}, use) => { + await use(await readClusterMetadata()); + }, +}); + +export { expect }; diff --git a/web/e2e/global-setup.ts b/web/e2e/global-setup.ts new file mode 100644 index 0000000..6a95a8f --- /dev/null +++ b/web/e2e/global-setup.ts @@ -0,0 +1,306 @@ +import { request } from '@playwright/test'; +import { spawn } from 'node:child_process'; +import { access, open, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; +import { + adminPassword, + adminStorageStatePath, + adminUsername, + authDir, + clusterRunDir, + cnBaseURL, + metadataPath, + ovBaseURL, + playwrightArtifactRoot, + readyMarkerPath, + repoRoot, + rootPassword, + rootStorageStatePath, + rootUsername, + serverBinaryDir, + serverBinaryPath, + serverLogPath, + webRoot, +} from './fixtures/cluster'; + +type LoginResponse = { + success: boolean; + message?: string; + data?: { + id: number; + username: string; + display_name: string; + role: number; + status: number; + group: string; + }; +}; + +type SimpleResponse = { + success: boolean; + message?: string; + error?: string; +}; + +async function runCommand(command: string, args: string[], cwd: string) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${command} ${args.join(' ')} failed with code ${code}\n${stdout}${stderr}`, + ), + ); + }); + }); +} + +async function killPreviousServer() { + try { + const raw = await readFile(metadataPath, 'utf8'); + const metadata = JSON.parse(raw) as { serverPid?: number }; + if (!metadata.serverPid) { + return; + } + await killProcessTree(metadata.serverPid); + } catch { + // ignore stale or missing metadata + } +} + +async function killProcessTree(pid: number) { + if (process.platform === 'win32') { + await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot); + return; + } + process.kill(pid, 'SIGTERM'); +} + +async function cleanupListenerPorts() { + if (process.platform !== 'win32') { + return; + } + + await runCommand( + 'powershell', + [ + '-NoProfile', + '-Command', + "$ports = 3100,3101; " + + "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | " + + "Where-Object { $ports -contains $_.LocalPort } | " + + 'Select-Object -ExpandProperty OwningProcess -Unique | ' + + 'ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }', + ], + repoRoot, + ); +} + +async function waitForServerReady() { + const deadline = Date.now() + 180_000; + + while (Date.now() < deadline) { + try { + await access(readyMarkerPath); + const response = await fetch(`${cnBaseURL}/api/status`, { + headers: { 'Cache-Control': 'no-store' }, + }); + if (response.ok) { + return; + } + } catch { + // retry + } + await delay(500); + } + + throw new Error( + `user migration UI server did not become ready in time, see ${serverLogPath}`, + ); +} + +async function login(username: string, password: string) { + const api = await request.newContext({ + baseURL: cnBaseURL, + extraHTTPHeaders: { + 'Cache-Control': 'no-store', + }, + }); + + const response = await api.post('/api/user/login', { + data: { + username, + password, + }, + }); + if (!response.ok()) { + throw new Error(`login request for ${username} failed: ${response.status()}`); + } + + const payload = (await response.json()) as LoginResponse; + if (!payload.success || !payload.data) { + throw new Error(`login failed for ${username}: ${payload.message ?? 'unknown error'}`); + } + + return { api, user: payload.data }; +} + +async function loginWithRetry( + username: string, + password: string, + timeoutMs = 60_000, +) { + const deadline = Date.now() + timeoutMs; + let lastError = `login failed for ${username}`; + + while (Date.now() < deadline) { + try { + return await login(username, password); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + await delay(500); + } + } + + throw new Error(lastError); +} + +async function writeStorageState( + api: Awaited>, + user: LoginResponse['data'], + outputPath: string, +) { + const state = await api.storageState(); + await writeFile( + outputPath, + JSON.stringify( + { + cookies: state.cookies, + origins: [ + { + origin: cnBaseURL, + localStorage: [ + { + name: 'user', + value: JSON.stringify(user), + }, + ], + }, + ], + }, + null, + 2, + ), + 'utf8', + ); +} + +export default async function globalSetup() { + await killPreviousServer(); + await cleanupListenerPorts(); + await rm(playwrightArtifactRoot, { recursive: true, force: true }); + await mkdir(authDir, { recursive: true }); + await mkdir(serverBinaryDir, { recursive: true }); + + await runCommand('bun', ['run', 'build'], webRoot); + await runCommand( + 'go', + ['build', '-o', serverBinaryPath, './test/e2e/cmd/user_migration_ui_server'], + repoRoot, + ); + + const logFile = await open(serverLogPath, 'a'); + const child = spawn(serverBinaryPath, [], { + cwd: repoRoot, + stdio: ['ignore', logFile.fd, logFile.fd], + }); + await logFile.close(); + + await writeFile( + metadataPath, + JSON.stringify( + { + serverPid: child.pid, + serverBinaryPath, + serverLogPath, + runDir: clusterRunDir, + baseURL: cnBaseURL, + ovBaseURL, + }, + null, + 2, + ), + 'utf8', + ); + + await waitForServerReady(); + + const rootSession = await loginWithRetry(rootUsername, rootPassword); + try { + const createAdminResponse = await rootSession.api.post('/api/user/', { + headers: { + 'New-API-User': String(rootSession.user.id), + }, + data: { + username: adminUsername, + password: adminPassword, + display_name: 'Playwright Admin', + role: 10, + }, + }); + if (!createAdminResponse.ok()) { + throw new Error( + `create admin request failed: ${createAdminResponse.status()} ${createAdminResponse.statusText()}`, + ); + } + + const createAdminPayload = (await createAdminResponse.json()) as SimpleResponse; + const createAdminMessage = + createAdminPayload.message ?? createAdminPayload.error ?? 'unknown error'; + const adminAlreadyExists = + createAdminMessage.includes('UNIQUE constraint failed: users.username') || + createAdminMessage.includes('Duplicate entry'); + if (!createAdminPayload.success && !adminAlreadyExists) { + throw new Error( + `create admin failed: ${createAdminMessage}`, + ); + } + + await writeStorageState( + rootSession.api, + rootSession.user, + rootStorageStatePath, + ); + } finally { + await rootSession.api.dispose(); + } + + const adminSession = await loginWithRetry(adminUsername, adminPassword, 20_000); + try { + await writeStorageState( + adminSession.api, + adminSession.user, + adminStorageStatePath, + ); + } finally { + await adminSession.api.dispose(); + } +} diff --git a/web/e2e/global-teardown.ts b/web/e2e/global-teardown.ts new file mode 100644 index 0000000..14b360d --- /dev/null +++ b/web/e2e/global-teardown.ts @@ -0,0 +1,44 @@ +import { rm } from 'node:fs/promises'; +import { metadataPath, playwrightArtifactRoot, repoRoot } from './fixtures/cluster'; +import { readClusterMetadata } from './fixtures/cluster'; +import { spawn } from 'node:child_process'; + +async function runCommand(command: string, args: string[], cwd: string) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: 'ignore', + shell: false, + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(' ')} failed with code ${code}`)); + }); + }); +} + +async function killProcessTree(pid: number) { + if (process.platform === 'win32') { + await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot); + return; + } + process.kill(pid, 'SIGTERM'); +} + +export default async function globalTeardown() { + try { + const metadata = await readClusterMetadata(); + await killProcessTree(metadata.serverPid); + } catch { + // ignore missing metadata or already-exited processes + } + + await rm(metadataPath, { force: true }); + await rm(playwrightArtifactRoot, { recursive: false, force: false }).catch(() => { + // keep artifacts when directory is not empty + }); +} diff --git a/web/e2e/user-migration.spec.ts b/web/e2e/user-migration.spec.ts new file mode 100644 index 0000000..6ba4d23 --- /dev/null +++ b/web/e2e/user-migration.spec.ts @@ -0,0 +1,141 @@ +import type { Page } from '@playwright/test'; +import { + adminStorageStatePath, + expect, + rootStorageStatePath, + test, +} from './fixtures/cluster'; + +function nextBatchName(prefix: string) { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; +} + +async function gotoUserPage(page: Page) { + await page.goto('/console/user'); + await expect(page.getByTestId('user-page')).toBeVisible(); +} + +async function openMigrationTab(page: Page) { + const migrationTab = page.getByTestId('user-migration-tab'); + await expect(migrationTab).toBeVisible(); + await migrationTab.click(); + await expect(page.getByTestId('user-migration-center')).toBeVisible(); +} + +async function createBatch(page: Page, batchName: string) { + await page.getByTestId('migration-create-batch-button').click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + const nameField = dialog.getByRole('textbox').first(); + await expect(nameField).toBeVisible(); + await nameField.fill(batchName); + + await dialog + .getByRole('button', { name: /confirm|确定/i }) + .click(); + await expect(page.getByTestId('migration-batch-table')).toContainText(batchName); + await page.getByTestId('migration-batch-table').getByText(batchName).click(); + await expect(page.getByTestId('migration-execution-panel')).toBeVisible(); + await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); +} + +test.describe('User Migration Access', () => { + test.use({ storageState: adminStorageStatePath }); + + test('non-root admin cannot see migration tab', async ({ page }) => { + await gotoUserPage(page); + await expect(page.getByTestId('user-migration-tab')).toHaveCount(0); + }); +}); + +test.describe.serial('User Migration Smoke', () => { + test.use({ storageState: rootStorageStatePath }); + + test('root can open migration center and create a batch', async ({ page }) => { + const batchName = nextBatchName('ui-create'); + + await gotoUserPage(page); + await openMigrationTab(page); + await createBatch(page, batchName); + + await expect(page.getByTestId('migration-batch-status')).toHaveText('draft'); + }); + + test('root can scan, resolve conflicts, execute, and verify a batch', async ({ + page, + }) => { + const batchName = nextBatchName('ui-flow'); + + await gotoUserPage(page); + await openMigrationTab(page); + await createBatch(page, batchName); + + await page.getByTestId('migration-scan-button').click(); + await expect(page.getByTestId('migration-batch-status')).toHaveText( + /scanning|scanned/, + ); + await expect(page.getByTestId('migration-batch-status')).toHaveText( + 'scanned', + { + timeout: 25_000, + }, + ); + + await expect(page.getByTestId('migration-conflict-table')).toBeVisible(); + await page.getByTestId('migration-resolve-skip-10000003').click(); + await page.getByTestId('migration-resolve-skip-10000004').click(); + await expect(page.getByTestId('migration-batch-status')).toHaveText('ready'); + + await page.getByTestId('migration-execute-button').click(); + await expect(page.getByTestId('migration-execute-confirm')).toBeVisible(); + await page.getByTestId('migration-execute-confirm').click(); + + await expect(page.getByTestId('migration-batch-status')).toHaveText( + /running|completed/, + ); + await expect(page.getByTestId('migration-batch-status')).toHaveText( + 'completed', + { + timeout: 25_000, + }, + ); + + await page.getByTestId('migration-verify-button').click(); + await expect(page.getByTestId('migration-verify-results-table')).toBeVisible(); + await expect(page.getByTestId('migration-verify-results-table')).toContainText( + '10000001', + ); + await expect(page.getByTestId('migration-batch-detail-table')).toContainText( + 'migrated', + ); + }); + + test('page reload keeps batch list and detail loading available', async ({ + page, + }) => { + const batchName = nextBatchName('ui-refresh'); + + await gotoUserPage(page); + await openMigrationTab(page); + await createBatch(page, batchName); + + await page.getByTestId('migration-scan-button').click(); + await expect(page.getByTestId('migration-batch-status')).toHaveText( + 'scanned', + { + timeout: 25_000, + }, + ); + + await page.reload(); + await gotoUserPage(page); + await openMigrationTab(page); + + await expect(page.getByTestId('migration-batch-table')).toContainText(batchName); + await page.getByTestId('migration-batch-table').getByText(batchName).click(); + await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); + await expect(page.getByTestId('migration-batch-status')).toHaveText('scanned'); + }); +}); diff --git a/web/package.json b/web/package.json index 145448d..28694fb 100644 --- a/web/package.json +++ b/web/package.json @@ -76,6 +76,7 @@ }, "devDependencies": { "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", + "@playwright/test": "^1.60.0", "@so1ve/prettier-config": "^3.1.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.21", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..68df1f0 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from '@playwright/test'; +import path from 'node:path'; +import { cnBaseURL, playwrightArtifactRoot } from './e2e/fixtures/cluster'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 15_000, + }, + outputDir: path.join(playwrightArtifactRoot, 'test-output'), + reporter: [ + ['list'], + [ + 'html', + { + outputFolder: path.join(playwrightArtifactRoot, 'html-report'), + open: 'never', + }, + ], + ], + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + use: { + baseURL: cnBaseURL, + channel: 'msedge', + headless: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, +}); diff --git a/web/src/components/table/users/migration/BatchDetail.jsx b/web/src/components/table/users/migration/BatchDetail.jsx index 3dc5774..c4584dd 100644 --- a/web/src/components/table/users/migration/BatchDetail.jsx +++ b/web/src/components/table/users/migration/BatchDetail.jsx @@ -42,13 +42,21 @@ export default function BatchDetail({ dataIndex: 'status', render: (value) => {value}, }, - { title: t('目标国内账号'), dataIndex: 'target_user_id', render: (value) => value || '-' }, + { + title: t('目标国内账号'), + dataIndex: 'target_user_id', + render: (value) => value || '-', + }, { title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' }, ]; return ( -
- +
+ {t('批次详情')} #{selectedBatch.id} fetchItems(selectedBatch.id, page - 1, itemsPageSize), + onPageChange: (page) => + fetchItems(selectedBatch.id, page - 1, itemsPageSize), onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size), }} /> diff --git a/web/src/components/table/users/migration/BatchList.jsx b/web/src/components/table/users/migration/BatchList.jsx index 9b3fb9a..778a50f 100644 --- a/web/src/components/table/users/migration/BatchList.jsx +++ b/web/src/components/table/users/migration/BatchList.jsx @@ -61,15 +61,27 @@ export default function BatchList({ }; return ( -
+
- + {t('迁移批次')} - - @@ -79,11 +91,15 @@ export default function BatchList({ dataSource={batches} pagination={false} size='small' + data-testid='migration-batch-table' rowSelection={{ type: 'radio', selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], onChange: (_, rows) => rows[0] && selectBatch(rows[0]), }} + onRow={(record) => ({ + onClick: () => selectBatch(record), + })} empty={t('暂无数据')} /> @@ -92,6 +108,9 @@ export default function BatchList({ title={t('创建批次')} onCancel={() => setVisible(false)} onOk={handleCreate} + okButtonProps={{ 'data-testid': 'migration-create-batch-submit' }} + cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }} + data-testid='migration-create-batch-modal' >
{ @@ -103,16 +122,19 @@ export default function BatchList({ field='name' label={t('批次名称')} rules={[{ required: true, message: t('请输入批次名称') }]} + data-testid='migration-batch-name-field' /> diff --git a/web/src/components/table/users/migration/ConflictResolver.jsx b/web/src/components/table/users/migration/ConflictResolver.jsx index abcf3fb..c0966ec 100644 --- a/web/src/components/table/users/migration/ConflictResolver.jsx +++ b/web/src/components/table/users/migration/ConflictResolver.jsx @@ -39,6 +39,7 @@ export default function ConflictResolver({ items, resolveItem }) { setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); }} placeholder={t('请输入目标国内用户 ID')} + data-testid={`migration-resolve-target-${record.source_user_id}`} /> ), }, @@ -46,16 +47,32 @@ export default function ConflictResolver({ items, resolveItem }) { title: t('操作'), render: (_, record) => ( - - @@ -64,7 +81,7 @@ export default function ConflictResolver({ items, resolveItem }) { ]; return ( -
+
{t('冲突处理')} @@ -75,6 +92,7 @@ export default function ConflictResolver({ items, resolveItem }) { pagination={false} size='small' empty={t('暂无数据')} + data-testid='migration-conflict-table' />
); diff --git a/web/src/components/table/users/migration/ExecutionPanel.jsx b/web/src/components/table/users/migration/ExecutionPanel.jsx index f5d84de..1b5faf6 100644 --- a/web/src/components/table/users/migration/ExecutionPanel.jsx +++ b/web/src/components/table/users/migration/ExecutionPanel.jsx @@ -30,7 +30,9 @@ export default function ExecutionPanel({ { title: t('状态'), render: (_, record) => ( - {record.error ? t('失败数') : t('验证')} + + {record.error ? t('失败数') : t('验证')} + ), }, { @@ -40,25 +42,49 @@ export default function ExecutionPanel({ ]; return ( -
+
{t('执行与验证')} - + {selectedBatch.status} - + executeBatch(selectedBatch.id)} + okButtonProps={{ 'data-testid': 'migration-execute-confirm' }} + cancelButtonProps={{ 'data-testid': 'migration-execute-cancel' }} > - - - + + {verifyResults.length > 0 && (
)} diff --git a/web/src/components/table/users/migration/index.jsx b/web/src/components/table/users/migration/index.jsx index e4639b9..75b53b1 100644 --- a/web/src/components/table/users/migration/index.jsx +++ b/web/src/components/table/users/migration/index.jsx @@ -11,7 +11,13 @@ export default function UserMigrationCenter() { return ( - + {data.selectedBatch && ( <> diff --git a/web/src/pages/User/index.jsx b/web/src/pages/User/index.jsx index 718c1fc..b11a3c2 100644 --- a/web/src/pages/User/index.jsx +++ b/web/src/pages/User/index.jsx @@ -28,8 +28,8 @@ import { isRoot } from '../../helpers'; const User = () => { const { t } = useTranslation(); return ( -
- +
+ @@ -37,7 +37,10 @@ const User = () => { {isRoot() && ( - + {t('用户迁移')}} + itemKey='user-migration' + > )}