Add auth identity legacy backfill and email sync
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/apikey"
|
"github.com/Wei-Shaw/sub2api/ent/apikey"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
|
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
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 {
|
if err := r.syncUserAllowedGroupsWithClient(ctx, txClient, created.ID, userIn.AllowedGroups); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ensureEmailAuthIdentityWithClient(ctx, txClient, created.ID, created.Email, "user_repo_create"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
@@ -150,6 +154,11 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
|||||||
// 已处于外部事务中(ErrTxStarted),复用当前 client 并由调用方负责提交/回滚。
|
// 已处于外部事务中(ErrTxStarted),复用当前 client 并由调用方负责提交/回滚。
|
||||||
txClient = r.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).
|
updateOp := txClient.User.UpdateOneID(userIn.ID).
|
||||||
SetEmail(userIn.Email).
|
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 {
|
if err := r.syncUserAllowedGroupsWithClient(ctx, txClient, updated.ID, userIn.AllowedGroups); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := replaceEmailAuthIdentityWithClient(ctx, txClient, updated.ID, oldEmail, updated.Email, "user_repo_update"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
@@ -196,6 +208,96 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
|||||||
return nil
|
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 {
|
func (r *userRepository) Delete(ctx context.Context, id int64) error {
|
||||||
affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
|
affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ func (s *UserRepoSuite) SetupTest() {
|
|||||||
s.repo = newUserRepositoryWithSQL(s.client, integrationDB)
|
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_subscriptions")
|
||||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups")
|
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups")
|
||||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users")
|
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users")
|
||||||
|
|||||||
@@ -630,6 +630,9 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
|
|||||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
return nil, err
|
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)
|
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
@@ -665,6 +668,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
oldConcurrency := user.Concurrency
|
oldConcurrency := user.Concurrency
|
||||||
oldStatus := user.Status
|
oldStatus := user.Status
|
||||||
oldRole := user.Role
|
oldRole := user.Role
|
||||||
|
oldEmail := user.Email
|
||||||
|
|
||||||
if input.Email != "" {
|
if input.Email != "" {
|
||||||
user.Email = 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 {
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
return nil, err
|
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 {
|
if input.GroupRates != nil && s.userGroupRateRepo != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user