diff --git a/backend/internal/repository/auth_identity_legacy_migration_integration_test.go b/backend/internal/repository/auth_identity_legacy_migration_integration_test.go new file mode 100644 index 00000000..6a6312d4 --- /dev/null +++ b/backend/internal/repository/auth_identity_legacy_migration_integration_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package repository + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAuthIdentityLegacyExternalBackfillMigration(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + migrationPath := filepath.Join("..", "..", "migrations", "115_auth_identity_legacy_external_backfill.sql") + migrationSQL, err := os.ReadFile(migrationPath) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS user_external_identities ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + provider_union_id TEXT NULL, + provider_username TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + profile_url TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + TRUNCATE TABLE + auth_identity_channels, + auth_identities, + auth_identity_migration_reports, + user_external_identities, + users + RESTART IDENTITY; +`) + require.NoError(t, err) + + var linuxDoUserID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO users (email, password_hash, role, status, balance, concurrency) +VALUES ('legacy-linuxdo@example.com', 'hash', 'user', 'active', 0, 1) +RETURNING id`).Scan(&linuxDoUserID)) + + var wechatUnionUserID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO users (email, password_hash, role, status, balance, concurrency) +VALUES ('legacy-wechat-union@example.com', 'hash', 'user', 'active', 0, 1) +RETURNING id`).Scan(&wechatUnionUserID)) + + var wechatOpenIDOnlyUserID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO users (email, password_hash, role, status, balance, concurrency) +VALUES ('legacy-wechat-openid@example.com', 'hash', 'user', 'active', 0, 1) +RETURNING id`).Scan(&wechatOpenIDOnlyUserID)) + + var syntheticAuthIdentityID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO auth_identities (user_id, provider_type, provider_key, provider_subject, metadata) +VALUES ($1, 'wechat', 'wechat-main', 'openid-synthetic', '{"backfill_source":"synthetic_email"}'::jsonb) +RETURNING id`, wechatOpenIDOnlyUserID).Scan(&syntheticAuthIdentityID)) + + var linuxDoLegacyID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO user_external_identities ( + user_id, + provider, + provider_user_id, + provider_union_id, + provider_username, + display_name, + metadata +) VALUES ($1, 'linuxdo', 'linuxdo-user-1', NULL, 'linux-user', 'Linux User', '{"source":"legacy"}') +RETURNING id +`, linuxDoUserID).Scan(&linuxDoLegacyID)) + + var wechatUnionLegacyID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO user_external_identities ( + user_id, + provider, + provider_user_id, + provider_union_id, + provider_username, + display_name, + metadata +) VALUES ($1, 'wechat', 'openid-union-1', 'union-1', 'wechat-union-user', 'WeChat Union User', '{"channel":"oa","appid":"wx-app-1"}') +RETURNING id +`, wechatUnionUserID).Scan(&wechatUnionLegacyID)) + + var wechatOpenIDLegacyID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO user_external_identities ( + user_id, + provider, + provider_user_id, + provider_union_id, + provider_username, + display_name, + metadata +) VALUES ($1, 'wechat', 'openid-only-1', NULL, 'wechat-openid-user', 'WeChat OpenID User', '{"channel":"oa","appid":"wx-app-2"}') +RETURNING id +`, wechatOpenIDOnlyUserID).Scan(&wechatOpenIDLegacyID)) + + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + var linuxDoCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identities +WHERE user_id = $1 + AND provider_type = 'linuxdo' + AND provider_key = 'linuxdo' + AND provider_subject = 'linuxdo-user-1' +`, linuxDoUserID).Scan(&linuxDoCount)) + require.Equal(t, 1, linuxDoCount) + + var wechatSubject string + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT provider_subject +FROM auth_identities +WHERE user_id = $1 + AND provider_type = 'wechat' + AND provider_key = 'wechat-main' + AND provider_subject = 'union-1' +`, wechatUnionUserID).Scan(&wechatSubject)) + require.Equal(t, "union-1", wechatSubject) + + var wechatChannelCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_channels channel +JOIN auth_identities ai ON ai.id = channel.identity_id +WHERE ai.user_id = $1 + AND channel.provider_type = 'wechat' + AND channel.provider_key = 'wechat-main' + AND channel.channel = 'oa' + AND channel.channel_app_id = 'wx-app-1' + AND channel.channel_subject = 'openid-union-1' +`, wechatUnionUserID).Scan(&wechatChannelCount)) + require.Equal(t, 1, wechatChannelCount) + + var legacyOpenIDOnlyReportCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_migration_reports +WHERE report_type = 'wechat_openid_only_requires_remediation' + AND report_key = $1 +`, "legacy_external_identity:"+strconv.FormatInt(wechatOpenIDLegacyID, 10)).Scan(&legacyOpenIDOnlyReportCount)) + require.Equal(t, 1, legacyOpenIDOnlyReportCount) + + var syntheticReviewCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_migration_reports +WHERE report_type = 'wechat_openid_only_requires_remediation' + AND report_key = $1 +`, "synthetic_auth_identity:"+strconv.FormatInt(syntheticAuthIdentityID, 10)).Scan(&syntheticReviewCount)) + require.Equal(t, 1, syntheticReviewCount) + + var unionLegacyReportCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_migration_reports +WHERE report_type = 'wechat_openid_only_requires_remediation' + AND report_key = $1 +`, "legacy_external_identity:"+strconv.FormatInt(wechatUnionLegacyID, 10)).Scan(&unionLegacyReportCount)) + require.Zero(t, unionLegacyReportCount) + require.NotZero(t, linuxDoLegacyID) +} + +func TestAuthIdentityLegacyExternalBackfillMigration_IsSafeWhenLegacyTableMissing(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + migrationPath := filepath.Join("..", "..", "migrations", "115_auth_identity_legacy_external_backfill.sql") + migrationSQL, err := os.ReadFile(migrationPath) + require.NoError(t, err) + + var beforeCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_migration_reports +`).Scan(&beforeCount)) + + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + var afterCount int + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM auth_identity_migration_reports +`).Scan(&afterCount)) + require.Equal(t, beforeCount, afterCount) +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index b5efd19d..b2190b68 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -11,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/authidentity" dbgroup "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" dbuser "github.com/Wei-Shaw/sub2api/ent/user" @@ -76,6 +77,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error if err := r.syncUserAllowedGroupsWithClient(ctx, txClient, created.ID, userIn.AllowedGroups); err != nil { return err } + if err := ensureEmailAuthIdentityWithClient(ctx, txClient, created.ID, created.Email, "user_repo_create"); err != nil { + return err + } if tx != nil { if err := tx.Commit(); err != nil { @@ -150,6 +154,11 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error // 已处于外部事务中(ErrTxStarted),复用当前 client 并由调用方负责提交/回滚。 txClient = r.client } + existing, err := clientFromContext(ctx, txClient).User.Get(ctx, userIn.ID) + if err != nil { + return translatePersistenceError(err, service.ErrUserNotFound, nil) + } + oldEmail := existing.Email updateOp := txClient.User.UpdateOneID(userIn.ID). SetEmail(userIn.Email). @@ -185,6 +194,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error if err := r.syncUserAllowedGroupsWithClient(ctx, txClient, updated.ID, userIn.AllowedGroups); err != nil { return err } + if err := replaceEmailAuthIdentityWithClient(ctx, txClient, updated.ID, oldEmail, updated.Email, "user_repo_update"); err != nil { + return err + } if tx != nil { if err := tx.Commit(); err != nil { @@ -196,6 +208,96 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error return nil } +func (r *userRepository) EnsureEmailAuthIdentity(ctx context.Context, userID int64, email string) error { + return ensureEmailAuthIdentityWithClient(ctx, r.client, userID, email, "service_dual_write") +} + +func (r *userRepository) ReplaceEmailAuthIdentity(ctx context.Context, userID int64, oldEmail, newEmail string) error { + return replaceEmailAuthIdentityWithClient(ctx, r.client, userID, oldEmail, newEmail, "service_dual_write") +} + +func ensureEmailAuthIdentityWithClient(ctx context.Context, client *dbent.Client, userID int64, email string, source string) error { + client = clientFromContext(ctx, client) + if client == nil || userID <= 0 { + return nil + } + + subject := normalizeEmailAuthIdentitySubject(email) + if subject == "" { + return nil + } + + if err := client.AuthIdentity.Create(). + SetUserID(userID). + SetProviderType("email"). + SetProviderKey("email"). + SetProviderSubject(subject). + SetVerifiedAt(time.Now().UTC()). + SetMetadata(map[string]any{"source": source}). + OnConflictColumns( + authidentity.FieldProviderType, + authidentity.FieldProviderKey, + authidentity.FieldProviderSubject, + ). + DoNothing(). + Exec(ctx); err != nil { + return err + } + + identity, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("email"), + authidentity.ProviderKeyEQ("email"), + authidentity.ProviderSubjectEQ(subject), + ). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil + } + return err + } + if identity.UserID != userID { + return ErrAuthIdentityOwnershipConflict + } + return nil +} + +func replaceEmailAuthIdentityWithClient(ctx context.Context, client *dbent.Client, userID int64, oldEmail, newEmail string, source string) error { + newSubject := normalizeEmailAuthIdentitySubject(newEmail) + if err := ensureEmailAuthIdentityWithClient(ctx, client, userID, newEmail, source); err != nil { + return err + } + + oldSubject := normalizeEmailAuthIdentitySubject(oldEmail) + if oldSubject == "" || oldSubject == newSubject { + return nil + } + + _, err := clientFromContext(ctx, client).AuthIdentity.Delete(). + Where( + authidentity.UserIDEQ(userID), + authidentity.ProviderTypeEQ("email"), + authidentity.ProviderKeyEQ("email"), + authidentity.ProviderSubjectEQ(oldSubject), + ). + Exec(ctx) + return err +} + +func normalizeEmailAuthIdentitySubject(email string) string { + normalized := strings.ToLower(strings.TrimSpace(email)) + if normalized == "" { + return "" + } + if strings.HasSuffix(normalized, service.LinuxDoConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, service.OIDCConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, service.WeChatConnectSyntheticEmailDomain) { + return "" + } + return normalized +} + func (r *userRepository) Delete(ctx context.Context, id int64) error { affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx) if err != nil { diff --git a/backend/internal/repository/user_repo_email_identity_integration_test.go b/backend/internal/repository/user_repo_email_identity_integration_test.go new file mode 100644 index 00000000..fddd82c5 --- /dev/null +++ b/backend/internal/repository/user_repo_email_identity_integration_test.go @@ -0,0 +1,86 @@ +//go:build integration + +package repository + +import ( + "context" + + "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (s *UserRepoSuite) TestCreate_CreatesEmailAuthIdentityForNormalEmail() { + user := &service.User{ + Email: "repo-create@example.com", + PasswordHash: "test-password-hash", + Role: service.RoleUser, + Status: service.StatusActive, + Concurrency: 2, + } + + s.Require().NoError(s.repo.Create(s.ctx, user)) + + identity, err := s.client.AuthIdentity.Query(). + Where( + authidentity.UserIDEQ(user.ID), + authidentity.ProviderTypeEQ("email"), + authidentity.ProviderKeyEQ("email"), + authidentity.ProviderSubjectEQ("repo-create@example.com"), + ). + Only(s.ctx) + s.Require().NoError(err) + s.Require().Equal(user.ID, identity.UserID) +} + +func (s *UserRepoSuite) TestCreate_SkipsEmailAuthIdentityForSyntheticLinuxDoEmail() { + user := &service.User{ + Email: "linuxdo-legacy-user@linuxdo-connect.invalid", + PasswordHash: "test-password-hash", + Role: service.RoleUser, + Status: service.StatusActive, + Concurrency: 2, + } + + s.Require().NoError(s.repo.Create(s.ctx, user)) + + count, err := s.client.AuthIdentity.Query(). + Where( + authidentity.UserIDEQ(user.ID), + authidentity.ProviderTypeEQ("email"), + authidentity.ProviderKeyEQ("email"), + ). + Count(s.ctx) + s.Require().NoError(err) + s.Require().Zero(count) +} + +func (s *UserRepoSuite) TestUpdate_ReplacesEmailAuthIdentityWhenEmailChanges() { + user := s.mustCreateUser(&service.User{ + Email: "before-update@example.com", + }) + + user.Email = "after-update@example.com" + s.Require().NoError(s.repo.Update(s.ctx, user)) + + newIdentity, err := s.client.AuthIdentity.Query(). + Where( + authidentity.UserIDEQ(user.ID), + authidentity.ProviderTypeEQ("email"), + authidentity.ProviderKeyEQ("email"), + authidentity.ProviderSubjectEQ("after-update@example.com"), + ). + Only(s.ctx) + s.Require().NoError(err) + s.Require().Equal(user.ID, newIdentity.UserID) + + oldCount, err := s.client.AuthIdentity.Query(). + Where( + authidentity.UserIDEQ(user.ID), + authidentity.ProviderTypeEQ("email"), + authidentity.ProviderKeyEQ("email"), + authidentity.ProviderSubjectEQ("before-update@example.com"), + ). + Count(context.Background()) + s.Require().NoError(err) + s.Require().Zero(oldCount) +} diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index f5d0f9ff..07fb0598 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -26,6 +26,8 @@ func (s *UserRepoSuite) SetupTest() { s.repo = newUserRepositoryWithSQL(s.client, integrationDB) // 清理测试数据,确保每个测试从干净状态开始 + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM auth_identity_channels") + _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM auth_identities") _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_subscriptions") _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups") _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users") diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 3490374e..79840e5b 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -630,6 +630,9 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu if err := s.userRepo.Create(ctx, user); err != nil { return nil, err } + if err := ensureEmailAuthIdentitySync(ctx, s.userRepo, user.ID, user.Email); err != nil { + return nil, fmt.Errorf("sync email auth identity: %w", err) + } s.assignDefaultSubscriptions(ctx, user.ID) return user, nil } @@ -665,6 +668,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda oldConcurrency := user.Concurrency oldStatus := user.Status oldRole := user.Role + oldEmail := user.Email if input.Email != "" { user.Email = input.Email @@ -697,6 +701,9 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda if err := s.userRepo.Update(ctx, user); err != nil { return nil, err } + if err := replaceEmailAuthIdentitySync(ctx, s.userRepo, user.ID, oldEmail, user.Email); err != nil { + return nil, fmt.Errorf("sync email auth identity: %w", err) + } // 同步用户专属分组倍率 if input.GroupRates != nil && s.userGroupRateRepo != nil { diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go new file mode 100644 index 00000000..3f3d867c --- /dev/null +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -0,0 +1,95 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type ensureEmailCall struct { + userID int64 + email string +} + +type replaceEmailCall struct { + userID int64 + oldEmail string + newEmail string +} + +type emailSyncUserRepoStub struct { + *userRepoStub + ensureCalls []ensureEmailCall + replaceCalls []replaceEmailCall +} + +func (s *emailSyncUserRepoStub) EnsureEmailAuthIdentity(_ context.Context, userID int64, email string) error { + s.ensureCalls = append(s.ensureCalls, ensureEmailCall{userID: userID, email: email}) + return nil +} + +func (s *emailSyncUserRepoStub) ReplaceEmailAuthIdentity(_ context.Context, userID int64, oldEmail, newEmail string) error { + s.replaceCalls = append(s.replaceCalls, replaceEmailCall{ + userID: userID, + oldEmail: oldEmail, + newEmail: newEmail, + }) + return nil +} + +func (s *emailSyncUserRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { + return map[int64]*time.Time{}, nil +} + +func (s *emailSyncUserRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) { + return nil, nil +} + +func TestAdminService_CreateUser_EnsuresEmailAuthIdentity(t *testing.T) { + repo := &emailSyncUserRepoStub{userRepoStub: &userRepoStub{nextID: 55}} + svc := &adminServiceImpl{userRepo: repo} + + user, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "admin-created@example.com", + Password: "strong-pass", + }) + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, []ensureEmailCall{{ + userID: 55, + email: "admin-created@example.com", + }}, repo.ensureCalls) + require.Empty(t, repo.replaceCalls) +} + +func TestAdminService_UpdateUser_ReplacesEmailAuthIdentity(t *testing.T) { + repo := &emailSyncUserRepoStub{ + userRepoStub: &userRepoStub{ + user: &User{ + ID: 91, + Email: "before@example.com", + Role: RoleUser, + Status: StatusActive, + Concurrency: 3, + }, + }, + } + svc := &adminServiceImpl{userRepo: repo} + + updated, err := svc.UpdateUser(context.Background(), 91, &UpdateUserInput{ + Email: "after@example.com", + }) + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, "after@example.com", updated.Email) + require.Equal(t, []replaceEmailCall{{ + userID: 91, + oldEmail: "before@example.com", + newEmail: "after@example.com", + }}, repo.replaceCalls) + require.Empty(t, repo.ensureCalls) +} diff --git a/backend/internal/service/user_service_email_identity_sync_test.go b/backend/internal/service/user_service_email_identity_sync_test.go new file mode 100644 index 00000000..3950df8b --- /dev/null +++ b/backend/internal/service/user_service_email_identity_sync_test.go @@ -0,0 +1,68 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type emailSyncMockUserRepo struct { + *mockUserRepo + ensureCalls []ensureEmailCall + replaceCalls []replaceEmailCall +} + +func (m *emailSyncMockUserRepo) EnsureEmailAuthIdentity(_ context.Context, userID int64, email string) error { + m.ensureCalls = append(m.ensureCalls, ensureEmailCall{userID: userID, email: email}) + return nil +} + +func (m *emailSyncMockUserRepo) ReplaceEmailAuthIdentity(_ context.Context, userID int64, oldEmail, newEmail string) error { + m.replaceCalls = append(m.replaceCalls, replaceEmailCall{ + userID: userID, + oldEmail: oldEmail, + newEmail: newEmail, + }) + return nil +} + +func (m *emailSyncMockUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { + return map[int64]*time.Time{}, nil +} + +func (m *emailSyncMockUserRepo) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) { + return nil, nil +} + +func TestUpdateProfile_ReplacesEmailAuthIdentityWhenEmailChanges(t *testing.T) { + repo := &emailSyncMockUserRepo{ + mockUserRepo: &mockUserRepo{ + getByIDUser: &User{ + ID: 19, + Email: "profile-before@example.com", + Username: "tester", + Concurrency: 2, + }, + }, + } + svc := NewUserService(repo, nil, nil, nil) + + newEmail := "profile-after@example.com" + updated, err := svc.UpdateProfile(context.Background(), 19, UpdateProfileRequest{ + Email: &newEmail, + }) + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, newEmail, updated.Email) + require.Equal(t, 1, repo.updateCalls) + require.Equal(t, []replaceEmailCall{{ + userID: 19, + oldEmail: "profile-before@example.com", + newEmail: "profile-after@example.com", + }}, repo.replaceCalls) + require.Empty(t, repo.ensureCalls) +} diff --git a/backend/migrations/115_auth_identity_legacy_external_backfill.sql b/backend/migrations/115_auth_identity_legacy_external_backfill.sql new file mode 100644 index 00000000..f4a13c36 --- /dev/null +++ b/backend/migrations/115_auth_identity_legacy_external_backfill.sql @@ -0,0 +1,187 @@ +DO $$ +BEGIN + IF to_regclass('public.user_external_identities') IS NULL THEN + RETURN; + END IF; + + EXECUTE $sql$ +INSERT INTO auth_identities ( + user_id, + provider_type, + provider_key, + provider_subject, + verified_at, + metadata +) +SELECT + legacy.user_id, + 'linuxdo', + 'linuxdo', + legacy.provider_user_id, + COALESCE(legacy.updated_at, legacy.created_at, NOW()), + legacy.metadata_json || jsonb_build_object( + 'legacy_identity_id', legacy.id, + 'provider_user_id', legacy.provider_user_id, + 'provider_username', legacy.provider_username, + 'display_name', legacy.display_name, + 'migration', '115_auth_identity_legacy_external_backfill' + ) +FROM ( + SELECT + uei.id, + uei.user_id, + BTRIM(uei.provider_user_id) AS provider_user_id, + BTRIM(uei.provider_username) AS provider_username, + BTRIM(uei.display_name) AS display_name, + COALESCE(NULLIF(BTRIM(COALESCE(uei.metadata, '')), '')::jsonb, '{}'::jsonb) AS metadata_json, + uei.created_at, + uei.updated_at + FROM user_external_identities AS uei + JOIN users AS u ON u.id = uei.user_id + WHERE u.deleted_at IS NULL + AND LOWER(BTRIM(COALESCE(uei.provider, ''))) = 'linuxdo' + AND BTRIM(COALESCE(uei.provider_user_id, '')) <> '' +) AS legacy +ON CONFLICT (provider_type, provider_key, provider_subject) DO NOTHING; +$sql$; + + EXECUTE $sql$ +INSERT INTO auth_identities ( + user_id, + provider_type, + provider_key, + provider_subject, + verified_at, + metadata +) +SELECT + legacy.user_id, + 'wechat', + 'wechat-main', + legacy.provider_union_id, + COALESCE(legacy.updated_at, legacy.created_at, NOW()), + legacy.metadata_json || jsonb_build_object( + 'legacy_identity_id', legacy.id, + 'openid', legacy.provider_user_id, + 'unionid', legacy.provider_union_id, + 'provider_user_id', legacy.provider_user_id, + 'provider_union_id', legacy.provider_union_id, + 'provider_username', legacy.provider_username, + 'display_name', legacy.display_name, + 'migration', '115_auth_identity_legacy_external_backfill' + ) +FROM ( + SELECT + uei.id, + uei.user_id, + BTRIM(uei.provider_user_id) AS provider_user_id, + BTRIM(uei.provider_union_id) AS provider_union_id, + BTRIM(uei.provider_username) AS provider_username, + BTRIM(uei.display_name) AS display_name, + COALESCE(NULLIF(BTRIM(COALESCE(uei.metadata, '')), '')::jsonb, '{}'::jsonb) AS metadata_json, + uei.created_at, + uei.updated_at + FROM user_external_identities AS uei + JOIN users AS u ON u.id = uei.user_id + WHERE u.deleted_at IS NULL + AND LOWER(BTRIM(COALESCE(uei.provider, ''))) = 'wechat' + AND BTRIM(COALESCE(uei.provider_union_id, '')) <> '' +) AS legacy +ON CONFLICT (provider_type, provider_key, provider_subject) DO NOTHING; +$sql$; + + EXECUTE $sql$ +INSERT INTO auth_identity_channels ( + identity_id, + provider_type, + provider_key, + channel, + channel_app_id, + channel_subject, + metadata +) +SELECT + ai.id, + 'wechat', + 'wechat-main', + legacy.channel, + legacy.channel_app_id, + legacy.provider_user_id, + legacy.metadata_json || jsonb_build_object( + 'openid', legacy.provider_user_id, + 'unionid', legacy.provider_union_id, + 'migration', '115_auth_identity_legacy_external_backfill' + ) +FROM ( + SELECT + uei.user_id, + BTRIM(uei.provider_user_id) AS provider_user_id, + BTRIM(uei.provider_union_id) AS provider_union_id, + BTRIM(COALESCE(meta.metadata_json ->> 'channel', '')) AS channel, + BTRIM(COALESCE(meta.metadata_json ->> 'channel_app_id', meta.metadata_json ->> 'appid', meta.metadata_json ->> 'app_id', '')) AS channel_app_id, + meta.metadata_json + FROM user_external_identities AS uei + JOIN users AS u ON u.id = uei.user_id + CROSS JOIN LATERAL ( + SELECT COALESCE(NULLIF(BTRIM(COALESCE(uei.metadata, '')), '')::jsonb, '{}'::jsonb) AS metadata_json + ) AS meta + WHERE u.deleted_at IS NULL + AND LOWER(BTRIM(COALESCE(uei.provider, ''))) = 'wechat' + AND BTRIM(COALESCE(uei.provider_union_id, '')) <> '' +) AS legacy +JOIN auth_identities AS ai + ON ai.user_id = legacy.user_id + AND ai.provider_type = 'wechat' + AND ai.provider_key = 'wechat-main' + AND ai.provider_subject = legacy.provider_union_id +WHERE legacy.channel <> '' + AND legacy.channel_app_id <> '' + AND legacy.provider_user_id <> '' +ON CONFLICT DO NOTHING; +$sql$; + + EXECUTE $sql$ +INSERT INTO auth_identity_migration_reports (report_type, report_key, details) +SELECT + 'wechat_openid_only_requires_remediation', + 'legacy_external_identity:' || legacy.id::text, + legacy.metadata_json || jsonb_build_object( + 'legacy_identity_id', legacy.id, + 'user_id', legacy.user_id, + 'openid', legacy.provider_user_id, + 'reason', 'legacy user_external_identities row only has openid and cannot be canonicalized offline', + 'migration', '115_auth_identity_legacy_external_backfill' + ) +FROM ( + SELECT + uei.id, + uei.user_id, + BTRIM(uei.provider_user_id) AS provider_user_id, + COALESCE(NULLIF(BTRIM(COALESCE(uei.metadata, '')), '')::jsonb, '{}'::jsonb) AS metadata_json + FROM user_external_identities AS uei + JOIN users AS u ON u.id = uei.user_id + WHERE u.deleted_at IS NULL + AND LOWER(BTRIM(COALESCE(uei.provider, ''))) = 'wechat' + AND BTRIM(COALESCE(uei.provider_user_id, '')) <> '' + AND BTRIM(COALESCE(uei.provider_union_id, '')) = '' +) AS legacy +ON CONFLICT (report_type, report_key) DO NOTHING; +$sql$; +END $$; + +INSERT INTO auth_identity_migration_reports (report_type, report_key, details) +SELECT + 'wechat_openid_only_requires_remediation', + 'synthetic_auth_identity:' || ai.id::text, + COALESCE(ai.metadata, '{}'::jsonb) || jsonb_build_object( + 'auth_identity_id', ai.id, + 'user_id', ai.user_id, + 'provider_subject', ai.provider_subject, + 'reason', 'synthetic wechat auth identity still lacks unionid metadata and needs remediation', + 'migration', '115_auth_identity_legacy_external_backfill' + ) +FROM auth_identities AS ai +WHERE ai.provider_type = 'wechat' + AND COALESCE(ai.metadata ->> 'backfill_source', '') = 'synthetic_email' + AND BTRIM(COALESCE(ai.metadata ->> 'unionid', '')) = '' +ON CONFLICT (report_type, report_key) DO NOTHING;