fix(profile): stabilize identity binding management
This commit is contained in:
@@ -29,18 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
|
|||||||
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
||||||
AvatarSource: "remote_url",
|
AvatarSource: "remote_url",
|
||||||
},
|
},
|
||||||
identities: []service.UserAuthIdentityRecord{
|
identities: []service.UserAuthIdentityRecord{
|
||||||
{
|
{
|
||||||
ProviderType: "linuxdo",
|
ProviderType: "linuxdo",
|
||||||
ProviderKey: "linuxdo",
|
ProviderKey: "linuxdo",
|
||||||
ProviderSubject: "linuxdo-subject-31",
|
ProviderSubject: "linuxdo-subject-31",
|
||||||
VerifiedAt: &verifiedAt,
|
VerifiedAt: &verifiedAt,
|
||||||
Metadata: map[string]any{
|
Metadata: map[string]any{
|
||||||
"username": "linuxdo-handle",
|
"username": "linuxdo-handle",
|
||||||
|
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
|
||||||
handler := &AuthHandler{
|
handler := &AuthHandler{
|
||||||
userService: service.NewUserService(repo, nil, nil, nil),
|
userService: service.NewUserService(repo, nil, nil, nil),
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) {
|
|||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.authService != nil {
|
||||||
|
if err := h.authService.RevokeAllUserSessions(c.Request.Context(), subject.UserID); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
|
profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -504,8 +510,12 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
|
|||||||
|
|
||||||
thirdParty := thirdPartyIdentityProviders(identities)
|
thirdParty := thirdPartyIdentityProviders(identities)
|
||||||
var avatarSource *userProfileSourceContext
|
var avatarSource *userProfileSourceContext
|
||||||
if strings.TrimSpace(user.AvatarURL) != "" && len(thirdParty) == 1 {
|
avatarValue := strings.TrimSpace(user.AvatarURL)
|
||||||
avatarSource = buildUserProfileSourceContext(thirdParty[0].Provider)
|
for _, summary := range thirdParty {
|
||||||
|
if avatarValue != "" && avatarValue == strings.TrimSpace(summary.DisplayName) {
|
||||||
|
avatarSource = buildUserProfileSourceContext(summary.Provider)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
usernameValue := strings.TrimSpace(user.Username)
|
usernameValue := strings.TrimSpace(user.Username)
|
||||||
@@ -516,9 +526,6 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if usernameSource == nil && usernameValue != "" && len(thirdParty) == 1 {
|
|
||||||
usernameSource = buildUserProfileSourceContext(thirdParty[0].Provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
profileSources := map[string]*userProfileSourceContext{}
|
profileSources := map[string]*userProfileSourceContext{}
|
||||||
if avatarSource != nil {
|
if avatarSource != nil {
|
||||||
|
|||||||
@@ -270,18 +270,19 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
|||||||
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
||||||
AvatarSource: "remote_url",
|
AvatarSource: "remote_url",
|
||||||
},
|
},
|
||||||
identities: []service.UserAuthIdentityRecord{
|
identities: []service.UserAuthIdentityRecord{
|
||||||
{
|
{
|
||||||
ProviderType: "linuxdo",
|
ProviderType: "linuxdo",
|
||||||
ProviderKey: "linuxdo",
|
ProviderKey: "linuxdo",
|
||||||
ProviderSubject: "linuxdo-subject-21",
|
ProviderSubject: "linuxdo-subject-21",
|
||||||
VerifiedAt: &verifiedAt,
|
VerifiedAt: &verifiedAt,
|
||||||
Metadata: map[string]any{
|
Metadata: map[string]any{
|
||||||
"username": "linuxdo-handle",
|
"username": "linuxdo-handle",
|
||||||
|
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil)
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil)
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -331,10 +332,102 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
|||||||
require.Equal(t, "linuxdo", usernameSource["source"])
|
require.Equal(t, "linuxdo", usernameSource["source"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerGetProfileDoesNotInferEditedProfileSourcesWithoutMatchingIdentityMetadata(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
repo := &userHandlerRepoStub{
|
||||||
|
user: &service.User{
|
||||||
|
ID: 22,
|
||||||
|
Email: "edited-profile@example.com",
|
||||||
|
Username: "custom-name",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
AvatarURL: "https://cdn.example.com/custom.png",
|
||||||
|
AvatarSource: "remote_url",
|
||||||
|
},
|
||||||
|
identities: []service.UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "linuxdo",
|
||||||
|
ProviderKey: "linuxdo",
|
||||||
|
ProviderSubject: "linuxdo-subject-22",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"username": "linuxdo-handle",
|
||||||
|
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 22})
|
||||||
|
|
||||||
|
handler.GetProfile(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.NotContains(t, resp.Data, "avatar_source")
|
||||||
|
require.NotContains(t, resp.Data, "username_source")
|
||||||
|
require.NotContains(t, resp.Data, "profile_sources")
|
||||||
|
}
|
||||||
|
|
||||||
type userHandlerEmailCacheStub struct {
|
type userHandlerEmailCacheStub struct {
|
||||||
data *service.VerificationCodeData
|
data *service.VerificationCodeData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userHandlerRefreshTokenCacheStub struct {
|
||||||
|
revokedUserIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) StoreRefreshToken(context.Context, string, *service.RefreshTokenData, time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) GetRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
|
||||||
|
return nil, service.ErrRefreshTokenNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) DeleteUserRefreshTokens(_ context.Context, userID int64) error {
|
||||||
|
s.revokedUserIDs = append(s.revokedUserIDs, userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userHandlerRefreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *userHandlerEmailCacheStub) GetVerificationCode(context.Context, string) (*service.VerificationCodeData, error) {
|
func (s *userHandlerEmailCacheStub) GetVerificationCode(context.Context, string) (*service.VerificationCodeData, error) {
|
||||||
return s.data, nil
|
return s.data, nil
|
||||||
}
|
}
|
||||||
@@ -495,6 +588,52 @@ func TestUserHandlerUnbindIdentityReturnsUpdatedProfile(t *testing.T) {
|
|||||||
require.Equal(t, false, linuxdoBinding["bound"])
|
require.Equal(t, false, linuxdoBinding["bound"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigured(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
repo := &userHandlerRepoStub{
|
||||||
|
user: &service.User{
|
||||||
|
ID: 23,
|
||||||
|
Email: "identity@example.com",
|
||||||
|
Username: "identity-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
identities: []service.UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "email",
|
||||||
|
ProviderKey: "email",
|
||||||
|
ProviderSubject: "identity@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ProviderType: "linuxdo",
|
||||||
|
ProviderKey: "linuxdo",
|
||||||
|
ProviderSubject: "linuxdo-subject-23",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refreshTokenCache := &userHandlerRefreshTokenCacheStub{}
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWT: config.JWTConfig{
|
||||||
|
Secret: "test-secret",
|
||||||
|
ExpireHour: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil)
|
||||||
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/user/account-bindings/linuxdo", nil)
|
||||||
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 23})
|
||||||
|
c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}}
|
||||||
|
|
||||||
|
handler.UnbindIdentity(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
|
func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -301,17 +301,18 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
|
|||||||
client := clientFromContext(txCtx, r.client)
|
client := clientFromContext(txCtx, r.client)
|
||||||
canonical := input.Canonical
|
canonical := input.Canonical
|
||||||
|
|
||||||
identity, err := client.AuthIdentity.Query().
|
identityRecords, err := client.AuthIdentity.Query().
|
||||||
Where(
|
Where(
|
||||||
authidentity.ProviderTypeEQ(strings.TrimSpace(canonical.ProviderType)),
|
authidentity.ProviderTypeEQ(strings.TrimSpace(canonical.ProviderType)),
|
||||||
authidentity.ProviderKeyEQ(strings.TrimSpace(canonical.ProviderKey)),
|
authidentity.ProviderKeyIn(compatibleIdentityProviderKeys(canonical.ProviderType, canonical.ProviderKey)...),
|
||||||
authidentity.ProviderSubjectEQ(strings.TrimSpace(canonical.ProviderSubject)),
|
authidentity.ProviderSubjectEQ(strings.TrimSpace(canonical.ProviderSubject)),
|
||||||
).
|
).
|
||||||
Only(txCtx)
|
All(txCtx)
|
||||||
if err != nil && !dbent.IsNotFound(err) {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if identity != nil && identity.UserID != input.UserID {
|
identity := selectOwnedCompatibleIdentity(identityRecords, input.UserID)
|
||||||
|
if identity == nil && hasCompatibleIdentityConflict(identityRecords, input.UserID) {
|
||||||
return ErrAuthIdentityOwnershipConflict
|
return ErrAuthIdentityOwnershipConflict
|
||||||
}
|
}
|
||||||
if identity == nil {
|
if identity == nil {
|
||||||
@@ -346,20 +347,21 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
|
|||||||
|
|
||||||
var channel *dbent.AuthIdentityChannel
|
var channel *dbent.AuthIdentityChannel
|
||||||
if input.Channel != nil {
|
if input.Channel != nil {
|
||||||
channel, err = client.AuthIdentityChannel.Query().
|
channelRecords, err := client.AuthIdentityChannel.Query().
|
||||||
Where(
|
Where(
|
||||||
authidentitychannel.ProviderTypeEQ(strings.TrimSpace(input.Channel.ProviderType)),
|
authidentitychannel.ProviderTypeEQ(strings.TrimSpace(input.Channel.ProviderType)),
|
||||||
authidentitychannel.ProviderKeyEQ(strings.TrimSpace(input.Channel.ProviderKey)),
|
authidentitychannel.ProviderKeyIn(compatibleIdentityProviderKeys(input.Channel.ProviderType, input.Channel.ProviderKey)...),
|
||||||
authidentitychannel.ChannelEQ(strings.TrimSpace(input.Channel.Channel)),
|
authidentitychannel.ChannelEQ(strings.TrimSpace(input.Channel.Channel)),
|
||||||
authidentitychannel.ChannelAppIDEQ(strings.TrimSpace(input.Channel.ChannelAppID)),
|
authidentitychannel.ChannelAppIDEQ(strings.TrimSpace(input.Channel.ChannelAppID)),
|
||||||
authidentitychannel.ChannelSubjectEQ(strings.TrimSpace(input.Channel.ChannelSubject)),
|
authidentitychannel.ChannelSubjectEQ(strings.TrimSpace(input.Channel.ChannelSubject)),
|
||||||
).
|
).
|
||||||
WithIdentity().
|
WithIdentity().
|
||||||
Only(txCtx)
|
All(txCtx)
|
||||||
if err != nil && !dbent.IsNotFound(err) {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if channel != nil && channel.Edges.Identity != nil && channel.Edges.Identity.UserID != input.UserID {
|
channel = selectOwnedCompatibleChannel(channelRecords, input.UserID)
|
||||||
|
if channel == nil && hasCompatibleChannelConflict(channelRecords, input.UserID) {
|
||||||
return ErrAuthIdentityChannelOwnershipConflict
|
return ErrAuthIdentityChannelOwnershipConflict
|
||||||
}
|
}
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
@@ -397,6 +399,61 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compatibleIdentityProviderKeys(providerType, providerKey string) []string {
|
||||||
|
providerType = strings.TrimSpace(strings.ToLower(providerType))
|
||||||
|
providerKey = strings.TrimSpace(providerKey)
|
||||||
|
if providerKey == "" {
|
||||||
|
return []string{providerKey}
|
||||||
|
}
|
||||||
|
if providerType != "wechat" {
|
||||||
|
return []string{providerKey}
|
||||||
|
}
|
||||||
|
keys := []string{providerKey}
|
||||||
|
if !strings.EqualFold(providerKey, "wechat-main") {
|
||||||
|
keys = append(keys, "wechat-main")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(providerKey, "wechat") {
|
||||||
|
keys = append(keys, "wechat")
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectOwnedCompatibleIdentity(records []*dbent.AuthIdentity, userID int64) *dbent.AuthIdentity {
|
||||||
|
for _, record := range records {
|
||||||
|
if record.UserID == userID {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCompatibleIdentityConflict(records []*dbent.AuthIdentity, userID int64) bool {
|
||||||
|
for _, record := range records {
|
||||||
|
if record.UserID != userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectOwnedCompatibleChannel(records []*dbent.AuthIdentityChannel, userID int64) *dbent.AuthIdentityChannel {
|
||||||
|
for _, record := range records {
|
||||||
|
if record.Edges.Identity != nil && record.Edges.Identity.UserID == userID {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCompatibleChannelConflict(records []*dbent.AuthIdentityChannel, userID int64) bool {
|
||||||
|
for _, record := range records {
|
||||||
|
if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userRepository) RecordProviderGrant(ctx context.Context, input ProviderGrantRecordInput) (bool, error) {
|
func (r *userRepository) RecordProviderGrant(ctx context.Context, input ProviderGrantRecordInput) (bool, error) {
|
||||||
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
||||||
if exec == nil {
|
if exec == nil {
|
||||||
|
|||||||
@@ -186,6 +186,79 @@ func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAn
|
|||||||
s.Require().ErrorIs(err, ErrAuthIdentityChannelOwnershipConflict)
|
s.Require().ErrorIs(err, ErrAuthIdentityChannelOwnershipConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_ReusesLegacyWeChatAliasRecords() {
|
||||||
|
user := s.mustCreateUser("wechat-legacy-alias")
|
||||||
|
|
||||||
|
legacyIdentity, err := s.client.AuthIdentity.Create().
|
||||||
|
SetUserID(user.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey("wechat").
|
||||||
|
SetProviderSubject("union-legacy-123").
|
||||||
|
SetMetadata(map[string]any{"source": "legacy-alias"}).
|
||||||
|
Save(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
|
||||||
|
legacyChannel, err := s.client.AuthIdentityChannel.Create().
|
||||||
|
SetIdentityID(legacyIdentity.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey("wechat").
|
||||||
|
SetChannel("oa").
|
||||||
|
SetChannelAppID("wx-app-legacy").
|
||||||
|
SetChannelSubject("openid-legacy-123").
|
||||||
|
SetMetadata(map[string]any{"scene": "legacy-alias"}).
|
||||||
|
Save(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
|
||||||
|
bound, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
Canonical: AuthIdentityKey{
|
||||||
|
ProviderType: "wechat",
|
||||||
|
ProviderKey: "wechat-main",
|
||||||
|
ProviderSubject: "union-legacy-123",
|
||||||
|
},
|
||||||
|
Channel: &AuthIdentityChannelKey{
|
||||||
|
ProviderType: "wechat",
|
||||||
|
ProviderKey: "wechat-main",
|
||||||
|
Channel: "oa",
|
||||||
|
ChannelAppID: "wx-app-legacy",
|
||||||
|
ChannelSubject: "openid-legacy-123",
|
||||||
|
},
|
||||||
|
Metadata: map[string]any{"source": "canonical-bind"},
|
||||||
|
ChannelMetadata: map[string]any{"scene": "canonical-bind"},
|
||||||
|
})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().NotNil(bound)
|
||||||
|
s.Require().NotNil(bound.Identity)
|
||||||
|
s.Require().NotNil(bound.Channel)
|
||||||
|
s.Require().Equal(legacyIdentity.ID, bound.Identity.ID)
|
||||||
|
s.Require().Equal(legacyChannel.ID, bound.Channel.ID)
|
||||||
|
s.Require().Equal("wechat-main", bound.Identity.ProviderKey)
|
||||||
|
s.Require().Equal("wechat-main", bound.Channel.ProviderKey)
|
||||||
|
s.Require().Equal("canonical-bind", bound.Identity.Metadata["source"])
|
||||||
|
s.Require().Equal("canonical-bind", bound.Channel.Metadata["scene"])
|
||||||
|
|
||||||
|
identityCount, err := s.client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.UserIDEQ(user.ID),
|
||||||
|
authidentity.ProviderTypeEQ("wechat"),
|
||||||
|
authidentity.ProviderSubjectEQ("union-legacy-123"),
|
||||||
|
).
|
||||||
|
Count(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Equal(1, identityCount)
|
||||||
|
|
||||||
|
channelCount, err := s.client.AuthIdentityChannel.Query().
|
||||||
|
Where(
|
||||||
|
authidentitychannel.ProviderTypeEQ("wechat"),
|
||||||
|
authidentitychannel.ChannelEQ("oa"),
|
||||||
|
authidentitychannel.ChannelAppIDEQ("wx-app-legacy"),
|
||||||
|
authidentitychannel.ChannelSubjectEQ("openid-legacy-123"),
|
||||||
|
).
|
||||||
|
Count(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Equal(1, channelCount)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserProfileIdentityRepoSuite) TestCreateAuthIdentity_RejectsChannelProviderMismatch() {
|
func (s *UserProfileIdentityRepoSuite) TestCreateAuthIdentity_RejectsChannelProviderMismatch() {
|
||||||
user := s.mustCreateUser("provider-mismatch-create")
|
user := s.mustCreateUser("provider-mismatch-create")
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
|
|||||||
if userIn == nil {
|
if userIn == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := r.ensureNormalizedEmailAvailable(ctx, 0, userIn.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 统一使用 ent 的事务:保证用户与允许分组的更新原子化,
|
// 统一使用 ent 的事务:保证用户与允许分组的更新原子化,
|
||||||
// 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。
|
// 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。
|
||||||
@@ -146,6 +149,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
|||||||
if userIn == nil {
|
if userIn == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := r.ensureNormalizedEmailAvailable(ctx, userIn.ID, userIn.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
|
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
|
||||||
tx, err := r.client.Tx(ctx)
|
tx, err := r.client.Tx(ctx)
|
||||||
@@ -704,6 +710,21 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
|
|||||||
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
|
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) ensureNormalizedEmailAvailable(ctx context.Context, userID int64, email string) error {
|
||||||
|
matches, err := r.client.User.Query().
|
||||||
|
Where(userEmailLookupPredicate(email)).
|
||||||
|
All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.ID != userID {
|
||||||
|
return service.ErrEmailExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func userEmailLookupPredicate(email string) predicate.User {
|
func userEmailLookupPredicate(email string) predicate.User {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
|
|||||||
@@ -67,3 +67,80 @@ func TestUserRepositoryExistsByEmailNormalizesLegacySpacingAndCase(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, exists)
|
require.True(t, exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserRepositoryCreateRejectsNormalizedEmailDuplicate(t *testing.T) {
|
||||||
|
repo, _ := newUserEntRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := repo.Create(ctx, &service.User{
|
||||||
|
Email: " Existing@Example.com ",
|
||||||
|
Username: "existing-user",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.Create(ctx, &service.User{
|
||||||
|
Email: "existing@example.com",
|
||||||
|
Username: "duplicate-user",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, service.ErrEmailExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRepositoryUpdateRejectsNormalizedEmailDuplicate(t *testing.T) {
|
||||||
|
repo, _ := newUserEntRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first := &service.User{
|
||||||
|
Email: " Existing@Example.com ",
|
||||||
|
Username: "existing-user",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
}
|
||||||
|
require.NoError(t, repo.Create(ctx, first))
|
||||||
|
|
||||||
|
second := &service.User{
|
||||||
|
Email: "second@example.com",
|
||||||
|
Username: "second-user",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
}
|
||||||
|
require.NoError(t, repo.Create(ctx, second))
|
||||||
|
|
||||||
|
second.Email = " existing@example.com "
|
||||||
|
err := repo.Update(ctx, second)
|
||||||
|
require.ErrorIs(t, err, service.ErrEmailExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRepositoryGetByEmailReportsNormalizedEmailConflict(t *testing.T) {
|
||||||
|
repo, client := newUserEntRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := client.User.Create().
|
||||||
|
SetEmail("Conflict@Example.com").
|
||||||
|
SetUsername("conflict-user-1").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.User.Create().
|
||||||
|
SetEmail(" conflict@example.com ").
|
||||||
|
SetUsername("conflict-user-2").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = repo.GetByEmail(ctx, "conflict@example.com")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorContains(t, err, "normalized email lookup matched multiple users")
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,6 +188,93 @@ func TestAdminServiceBindUserAuthIdentityIsIdempotentForSameUser(t *testing.T) {
|
|||||||
require.Equal(t, "second", identities[0].Metadata["source"])
|
require.Equal(t, "second", identities[0].Metadata["source"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminServiceBindUserAuthIdentityReusesLegacyWeChatAliasRecords(t *testing.T) {
|
||||||
|
client := newAdminServiceAuthIdentityBindingTestClient(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := client.User.Create().
|
||||||
|
SetEmail("wechat-alias@example.com").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(RoleUser).
|
||||||
|
SetStatus(StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
legacyIdentity, err := client.AuthIdentity.Create().
|
||||||
|
SetUserID(user.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey("wechat").
|
||||||
|
SetProviderSubject("union-legacy-123").
|
||||||
|
SetMetadata(map[string]any{"source": "legacy"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
legacyChannel, err := client.AuthIdentityChannel.Create().
|
||||||
|
SetIdentityID(legacyIdentity.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey("wechat").
|
||||||
|
SetChannel("open").
|
||||||
|
SetChannelAppID("wx-open").
|
||||||
|
SetChannelSubject("openid-legacy-123").
|
||||||
|
SetMetadata(map[string]any{"scene": "legacy"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc := &adminServiceImpl{
|
||||||
|
userRepo: &userRepoStub{user: &User{ID: user.ID, Email: user.Email, Status: StatusActive}},
|
||||||
|
entClient: client,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.BindUserAuthIdentity(ctx, user.ID, AdminBindAuthIdentityInput{
|
||||||
|
ProviderType: "wechat",
|
||||||
|
ProviderKey: "wechat-main",
|
||||||
|
ProviderSubject: "union-legacy-123",
|
||||||
|
Metadata: map[string]any{"source": "admin-repair"},
|
||||||
|
Channel: &AdminBindAuthIdentityChannelInput{
|
||||||
|
Channel: "open",
|
||||||
|
ChannelAppID: "wx-open",
|
||||||
|
ChannelSubject: "openid-legacy-123",
|
||||||
|
Metadata: map[string]any{"scene": "admin-repair"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, "wechat-main", result.ProviderKey)
|
||||||
|
require.NotNil(t, result.Channel)
|
||||||
|
require.Equal(t, "open", result.Channel.Channel)
|
||||||
|
|
||||||
|
identity, err := client.AuthIdentity.Get(ctx, legacyIdentity.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "wechat-main", identity.ProviderKey)
|
||||||
|
require.Equal(t, "admin-repair", identity.Metadata["source"])
|
||||||
|
|
||||||
|
channel, err := client.AuthIdentityChannel.Get(ctx, legacyChannel.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "wechat-main", channel.ProviderKey)
|
||||||
|
require.Equal(t, legacyIdentity.ID, channel.IdentityID)
|
||||||
|
require.Equal(t, "admin-repair", channel.Metadata["scene"])
|
||||||
|
|
||||||
|
identityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ("wechat"),
|
||||||
|
authidentity.ProviderSubjectEQ("union-legacy-123"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, identityCount)
|
||||||
|
|
||||||
|
channelCount, err := client.AuthIdentityChannel.Query().
|
||||||
|
Where(
|
||||||
|
authidentitychannel.ProviderTypeEQ("wechat"),
|
||||||
|
authidentitychannel.ChannelEQ("open"),
|
||||||
|
authidentitychannel.ChannelAppIDEQ("wx-open"),
|
||||||
|
authidentitychannel.ChannelSubjectEQ("openid-legacy-123"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, channelCount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminServiceBindUserAuthIdentityRejectsInvalidProviderType(t *testing.T) {
|
func TestAdminServiceBindUserAuthIdentityRejectsInvalidProviderType(t *testing.T) {
|
||||||
client := newAdminServiceAuthIdentityBindingTestClient(t)
|
client := newAdminServiceAuthIdentityBindingTestClient(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -406,13 +406,15 @@ func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testin
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewUserService(repo, nil, nil, nil)
|
invalidator := &mockAuthCacheInvalidator{}
|
||||||
|
svc := NewUserService(repo, nil, invalidator, nil)
|
||||||
|
|
||||||
user, err := svc.UnbindUserAuthProvider(context.Background(), 12, "linuxdo")
|
user, err := svc.UnbindUserAuthProvider(context.Background(), 12, "linuxdo")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"linuxdo"}, repo.unboundProviders)
|
require.Equal(t, []string{"linuxdo"}, repo.unboundProviders)
|
||||||
require.Equal(t, int64(12), user.ID)
|
require.Equal(t, int64(12), user.ID)
|
||||||
|
require.Equal(t, []int64{12}, invalidator.invalidatedUserIDs)
|
||||||
|
|
||||||
summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 12, user)
|
summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 12, user)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -444,7 +444,14 @@ function providerIconClass(provider: UserAuthProvider): string {
|
|||||||
|
|
||||||
function providerSummary(provider: UserAuthProvider): string {
|
function providerSummary(provider: UserAuthProvider): string {
|
||||||
if (provider === 'email') {
|
if (provider === 'email') {
|
||||||
return currentUser.value?.email || ''
|
const email = currentUser.value?.email?.trim() || ''
|
||||||
|
if (!email) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (currentUser.value?.email_bound === false && email.endsWith('.invalid')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return email
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="truncate text-sm text-gray-600 dark:text-gray-300">
|
<p class="truncate text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ user?.email }}
|
{{ primaryEmailDisplay }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="sourceHints.length"
|
v-if="sourceHints.length"
|
||||||
@@ -208,6 +208,16 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
|
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
|
||||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
|
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
|
||||||
|
const primaryEmailDisplay = computed(() => {
|
||||||
|
const email = props.user?.email?.trim() || ''
|
||||||
|
if (!email) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (props.user?.email_bound === false && email.endsWith('.invalid')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return email
|
||||||
|
})
|
||||||
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
|
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
|
||||||
const memberSinceLabel = computed(() => {
|
const memberSinceLabel = computed(() => {
|
||||||
const raw = props.user?.created_at?.trim()
|
const raw = props.user?.created_at?.trim()
|
||||||
@@ -229,7 +239,7 @@ const memberSinceLabel = computed(() => {
|
|||||||
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
||||||
email: t('profile.authBindings.providers.email'),
|
email: t('profile.authBindings.providers.email'),
|
||||||
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
||||||
oidc: t('profile.authBindings.providers.oidc', { providerName: 'OIDC' }),
|
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||||
wechat: t('profile.authBindings.providers.wechat')
|
wechat: t('profile.authBindings.providers.wechat')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,29 @@ describe('ProfileIdentityBindingsSection', () => {
|
|||||||
expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true)
|
expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not show a synthetic oauth-only email as the bound email summary', () => {
|
||||||
|
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
user: createUser({
|
||||||
|
email: 'legacy-user@linuxdo-connect.invalid',
|
||||||
|
email_bound: false,
|
||||||
|
auth_bindings: {
|
||||||
|
email: { bound: false },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
linuxdoEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
wechatEnabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('legacy-user@linuxdo-connect.invalid')
|
||||||
|
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the email form available for replacing a bound primary email', async () => {
|
it('keeps the email form available for replacing a bound primary email', async () => {
|
||||||
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
|
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
|
||||||
userApiMocks.bindEmailIdentity.mockResolvedValue(
|
userApiMocks.bindEmailIdentity.mockResolvedValue(
|
||||||
|
|||||||
@@ -111,6 +111,47 @@ describe('ProfileInfoCard', () => {
|
|||||||
expect(wrapper.text()).toContain('Username synced from LinuxDo')
|
expect(wrapper.text()).toContain('Username synced from LinuxDo')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the configured OIDC provider name in source hints', () => {
|
||||||
|
const wrapper = mount(ProfileInfoCard, {
|
||||||
|
props: {
|
||||||
|
user: createUser({
|
||||||
|
profile_sources: {
|
||||||
|
username: { provider: 'oidc', source: 'oidc' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
oidcProviderName: 'ExampleID'
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Icon: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Username synced from ExampleID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not display synthetic oauth-only emails as a real bound email', () => {
|
||||||
|
const wrapper = mount(ProfileInfoCard, {
|
||||||
|
props: {
|
||||||
|
user: createUser({
|
||||||
|
email: 'legacy-user@oidc-connect.invalid',
|
||||||
|
email_bound: false,
|
||||||
|
auth_bindings: {
|
||||||
|
email: { bound: false }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Icon: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the approved overview hero and two-column content shell', () => {
|
it('renders the approved overview hero and two-column content shell', () => {
|
||||||
const wrapper = mount(ProfileInfoCard, {
|
const wrapper = mount(ProfileInfoCard, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
Reference in New Issue
Block a user