Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
ebe75244
Commit
ebe75244
authored
Apr 21, 2026
by
IanShaw027
Browse files
fix profile activity and migration remediation
parent
a27a7add
Changes
12
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/admin_service_stub_test.go
View file @
ebe75244
...
...
@@ -45,6 +45,14 @@ type stubAdminService struct {
sortOrder
string
calls
int
}
lastListUsers
struct
{
page
int
pageSize
int
filters
service
.
UserListFilters
sortBy
string
sortOrder
string
calls
int
}
lastListProxies
struct
{
protocol
string
status
string
...
...
@@ -139,6 +147,12 @@ func newStubAdminService() *stubAdminService {
}
func
(
s
*
stubAdminService
)
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
filters
service
.
UserListFilters
,
sortBy
,
sortOrder
string
)
([]
service
.
User
,
int64
,
error
)
{
s
.
lastListUsers
.
page
=
page
s
.
lastListUsers
.
pageSize
=
pageSize
s
.
lastListUsers
.
filters
=
filters
s
.
lastListUsers
.
sortBy
=
sortBy
s
.
lastListUsers
.
sortOrder
=
sortOrder
s
.
lastListUsers
.
calls
++
return
s
.
users
,
int64
(
len
(
s
.
users
)),
nil
}
...
...
backend/internal/handler/admin/user_handler_activity_test.go
0 → 100644
View file @
ebe75244
//go:build unit
package
admin
import
(
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestUserHandlerListIncludesActivityFieldsAndSortParams
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
lastLoginAt
:=
time
.
Date
(
2026
,
4
,
20
,
8
,
0
,
0
,
0
,
time
.
UTC
)
lastActiveAt
:=
lastLoginAt
.
Add
(
30
*
time
.
Minute
)
lastUsedAt
:=
lastLoginAt
.
Add
(
90
*
time
.
Minute
)
adminSvc
:=
newStubAdminService
()
adminSvc
.
users
=
[]
service
.
User
{
{
ID
:
7
,
Email
:
"activity@example.com"
,
Username
:
"activity-user"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
LastLoginAt
:
&
lastLoginAt
,
LastActiveAt
:
&
lastActiveAt
,
LastUsedAt
:
&
lastUsedAt
,
CreatedAt
:
lastLoginAt
.
Add
(
-
24
*
time
.
Hour
),
UpdatedAt
:
lastLoginAt
,
},
}
handler
:=
NewUserHandler
(
adminSvc
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users?sort_by=last_used_at&sort_order=asc&search=activity"
,
nil
,
)
handler
.
List
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
require
.
Equal
(
t
,
"last_used_at"
,
adminSvc
.
lastListUsers
.
sortBy
)
require
.
Equal
(
t
,
"asc"
,
adminSvc
.
lastListUsers
.
sortOrder
)
require
.
Equal
(
t
,
"activity"
,
adminSvc
.
lastListUsers
.
filters
.
Search
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
Items
[]
struct
{
LastLoginAt
*
time
.
Time
`json:"last_login_at"`
LastActiveAt
*
time
.
Time
`json:"last_active_at"`
LastUsedAt
*
time
.
Time
`json:"last_used_at"`
}
`json:"items"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Items
,
1
)
require
.
WithinDuration
(
t
,
lastLoginAt
,
*
resp
.
Data
.
Items
[
0
]
.
LastLoginAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastActiveAt
,
*
resp
.
Data
.
Items
[
0
]
.
LastActiveAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastUsedAt
,
*
resp
.
Data
.
Items
[
0
]
.
LastUsedAt
,
time
.
Second
)
}
func
TestUserHandlerGetByIDIncludesActivityFields
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
lastLoginAt
:=
time
.
Date
(
2026
,
4
,
20
,
8
,
0
,
0
,
0
,
time
.
UTC
)
lastActiveAt
:=
lastLoginAt
.
Add
(
30
*
time
.
Minute
)
lastUsedAt
:=
lastLoginAt
.
Add
(
90
*
time
.
Minute
)
adminSvc
:=
newStubAdminService
()
adminSvc
.
users
=
[]
service
.
User
{
{
ID
:
8
,
Email
:
"detail@example.com"
,
Username
:
"detail-user"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
LastLoginAt
:
&
lastLoginAt
,
LastActiveAt
:
&
lastActiveAt
,
LastUsedAt
:
&
lastUsedAt
,
CreatedAt
:
lastLoginAt
.
Add
(
-
24
*
time
.
Hour
),
UpdatedAt
:
lastLoginAt
,
},
}
handler
:=
NewUserHandler
(
adminSvc
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Params
=
gin
.
Params
{{
Key
:
"id"
,
Value
:
"8"
}}
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users/8"
,
nil
)
handler
.
GetByID
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
LastLoginAt
*
time
.
Time
`json:"last_login_at"`
LastActiveAt
*
time
.
Time
`json:"last_active_at"`
LastUsedAt
*
time
.
Time
`json:"last_used_at"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
WithinDuration
(
t
,
lastLoginAt
,
*
resp
.
Data
.
LastLoginAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastActiveAt
,
*
resp
.
Data
.
LastActiveAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastUsedAt
,
*
resp
.
Data
.
LastUsedAt
,
time
.
Second
)
}
backend/internal/handler/auth_current_user_test.go
View file @
ebe75244
...
...
@@ -71,8 +71,15 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
true
,
linuxdoBinding
[
"bound"
])
_
,
hasAvatarSource
:=
resp
.
Data
[
"avatar_source"
]
require
.
False
(
t
,
hasAvatarSource
)
_
,
hasProfileSources
:=
resp
.
Data
[
"profile_sources"
]
require
.
False
(
t
,
hasProfileSources
)
avatarSource
,
ok
:=
resp
.
Data
[
"avatar_source"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"provider"
])
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"source"
])
profileSources
,
ok
:=
resp
.
Data
[
"profile_sources"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
usernameSource
,
ok
:=
profileSources
[
"username"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"provider"
])
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"source"
])
}
backend/internal/handler/user_handler.go
View file @
ebe75244
...
...
@@ -2,6 +2,7 @@ package handler
import
(
"context"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
...
...
@@ -352,16 +353,22 @@ func userProfileResponseFromService(user *service.User, identities service.UserI
return
userProfileResponse
{}
}
bindings
:=
userProfileBindingMap
(
identities
)
profileSources
,
avatarSource
,
usernameSource
:=
inferUserProfileSources
(
user
,
identities
)
return
userProfileResponse
{
User
:
*
base
,
AvatarURL
:
user
.
AvatarURL
,
Identities
:
identities
,
AuthBindings
:
bindings
,
IdentityBindings
:
bindings
,
EmailBound
:
identities
.
Email
.
Bound
,
LinuxDoBound
:
identities
.
LinuxDo
.
Bound
,
OIDCBound
:
identities
.
OIDC
.
Bound
,
WeChatBound
:
identities
.
WeChat
.
Bound
,
User
:
*
base
,
AvatarURL
:
user
.
AvatarURL
,
AvatarSource
:
avatarSource
,
UsernameSource
:
usernameSource
,
DisplayNameSource
:
usernameSource
,
NicknameSource
:
usernameSource
,
ProfileSources
:
profileSources
,
Identities
:
identities
,
AuthBindings
:
bindings
,
IdentityBindings
:
bindings
,
EmailBound
:
identities
.
Email
.
Bound
,
LinuxDoBound
:
identities
.
LinuxDo
.
Bound
,
OIDCBound
:
identities
.
OIDC
.
Bound
,
WeChatBound
:
identities
.
WeChat
.
Bound
,
}
}
...
...
@@ -373,3 +380,66 @@ func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string
"wechat"
:
identities
.
WeChat
,
}
}
func
inferUserProfileSources
(
user
*
service
.
User
,
identities
service
.
UserIdentitySummarySet
)
(
map
[
string
]
*
userProfileSourceContext
,
*
userProfileSourceContext
,
*
userProfileSourceContext
,
)
{
if
user
==
nil
{
return
nil
,
nil
,
nil
}
thirdParty
:=
thirdPartyIdentityProviders
(
identities
)
var
avatarSource
*
userProfileSourceContext
if
strings
.
TrimSpace
(
user
.
AvatarURL
)
!=
""
&&
len
(
thirdParty
)
==
1
{
avatarSource
=
buildUserProfileSourceContext
(
thirdParty
[
0
]
.
Provider
)
}
usernameValue
:=
strings
.
TrimSpace
(
user
.
Username
)
var
usernameSource
*
userProfileSourceContext
for
_
,
summary
:=
range
thirdParty
{
if
usernameValue
!=
""
&&
usernameValue
==
strings
.
TrimSpace
(
summary
.
DisplayName
)
{
usernameSource
=
buildUserProfileSourceContext
(
summary
.
Provider
)
break
}
}
if
usernameSource
==
nil
&&
usernameValue
!=
""
&&
len
(
thirdParty
)
==
1
{
usernameSource
=
buildUserProfileSourceContext
(
thirdParty
[
0
]
.
Provider
)
}
profileSources
:=
map
[
string
]
*
userProfileSourceContext
{}
if
avatarSource
!=
nil
{
profileSources
[
"avatar"
]
=
avatarSource
}
if
usernameSource
!=
nil
{
profileSources
[
"username"
]
=
usernameSource
profileSources
[
"display_name"
]
=
usernameSource
profileSources
[
"nickname"
]
=
usernameSource
}
if
len
(
profileSources
)
==
0
{
return
nil
,
avatarSource
,
usernameSource
}
return
profileSources
,
avatarSource
,
usernameSource
}
func
thirdPartyIdentityProviders
(
identities
service
.
UserIdentitySummarySet
)
[]
service
.
UserIdentitySummary
{
out
:=
make
([]
service
.
UserIdentitySummary
,
0
,
3
)
for
_
,
summary
:=
range
[]
service
.
UserIdentitySummary
{
identities
.
LinuxDo
,
identities
.
OIDC
,
identities
.
WeChat
}
{
if
summary
.
Bound
{
out
=
append
(
out
,
summary
)
}
}
return
out
}
func
buildUserProfileSourceContext
(
provider
string
)
*
userProfileSourceContext
{
provider
=
strings
.
TrimSpace
(
provider
)
if
provider
==
""
{
return
nil
}
return
&
userProfileSourceContext
{
Provider
:
provider
,
Source
:
provider
,
}
}
backend/internal/handler/user_handler_test.go
View file @
ebe75244
...
...
@@ -285,6 +285,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
require
.
Equal
(
t
,
false
,
resp
.
Data
[
"wechat_bound"
])
require
.
Equal
(
t
,
"https://cdn.example.com/linuxdo.png"
,
resp
.
Data
[
"avatar_url"
])
avatarSource
,
ok
:=
resp
.
Data
[
"avatar_source"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"provider"
])
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"source"
])
authBindings
,
ok
:=
resp
.
Data
[
"auth_bindings"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
linuxdoBinding
,
ok
:=
authBindings
[
"linuxdo"
]
.
(
map
[
string
]
any
)
...
...
@@ -298,10 +303,12 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
true
,
emailBinding
[
"bound"
])
_
,
hasAvatarSource
:=
resp
.
Data
[
"avatar_source"
]
require
.
False
(
t
,
hasAvatarSource
)
_
,
hasProfileSources
:=
resp
.
Data
[
"profile_sources"
]
require
.
False
(
t
,
hasProfileSources
)
profileSources
,
ok
:=
resp
.
Data
[
"profile_sources"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
usernameSource
,
ok
:=
profileSources
[
"username"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"provider"
])
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"source"
])
}
func
TestUserHandlerStartIdentityBindingReturnsAuthorizeURL
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/user_service.go
View file @
ebe75244
...
...
@@ -161,6 +161,10 @@ type userAuthIdentityReader interface {
ListUserAuthIdentities
(
ctx
context
.
Context
,
userID
int64
)
([]
UserAuthIdentityRecord
,
error
)
}
type
userProfileIdentityTxRunner
interface
{
WithUserProfileIdentityTx
(
ctx
context
.
Context
,
fn
func
(
txCtx
context
.
Context
)
error
)
error
}
// ChangePasswordRequest 修改密码请求
type
ChangePasswordRequest
struct
{
CurrentPassword
string
`json:"current_password"`
...
...
@@ -249,9 +253,38 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs
// UpdateProfile 更新用户资料
func
(
s
*
UserService
)
UpdateProfile
(
ctx
context
.
Context
,
userID
int64
,
req
UpdateProfileRequest
)
(
*
User
,
error
)
{
if
txRunner
,
ok
:=
s
.
userRepo
.
(
userProfileIdentityTxRunner
);
ok
{
var
(
updated
*
User
oldConcurrency
int
)
if
err
:=
txRunner
.
WithUserProfileIdentityTx
(
ctx
,
func
(
txCtx
context
.
Context
)
error
{
var
err
error
updated
,
oldConcurrency
,
err
=
s
.
updateProfile
(
txCtx
,
userID
,
req
)
return
err
});
err
!=
nil
{
return
nil
,
err
}
if
s
.
authCacheInvalidator
!=
nil
&&
updated
!=
nil
&&
updated
.
Concurrency
!=
oldConcurrency
{
s
.
authCacheInvalidator
.
InvalidateAuthCacheByUserID
(
ctx
,
userID
)
}
return
updated
,
nil
}
updated
,
oldConcurrency
,
err
:=
s
.
updateProfile
(
ctx
,
userID
,
req
)
if
err
!=
nil
{
return
nil
,
err
}
if
s
.
authCacheInvalidator
!=
nil
&&
updated
.
Concurrency
!=
oldConcurrency
{
s
.
authCacheInvalidator
.
InvalidateAuthCacheByUserID
(
ctx
,
userID
)
}
return
updated
,
nil
}
func
(
s
*
UserService
)
updateProfile
(
ctx
context
.
Context
,
userID
int64
,
req
UpdateProfileRequest
)
(
*
User
,
int
,
error
)
{
user
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get user: %w"
,
err
)
return
nil
,
0
,
fmt
.
Errorf
(
"get user: %w"
,
err
)
}
oldConcurrency
:=
user
.
Concurrency
...
...
@@ -260,10 +293,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
// 检查新邮箱是否已被使用
exists
,
err
:=
s
.
userRepo
.
ExistsByEmail
(
ctx
,
*
req
.
Email
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check email exists: %w"
,
err
)
return
nil
,
oldConcurrency
,
fmt
.
Errorf
(
"check email exists: %w"
,
err
)
}
if
exists
&&
*
req
.
Email
!=
user
.
Email
{
return
nil
,
ErrEmailExists
return
nil
,
oldConcurrency
,
ErrEmailExists
}
user
.
Email
=
*
req
.
Email
}
...
...
@@ -275,7 +308,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
if
req
.
AvatarURL
!=
nil
{
avatar
,
err
:=
s
.
SetAvatar
(
ctx
,
userID
,
*
req
.
AvatarURL
)
if
err
!=
nil
{
return
nil
,
err
return
nil
,
oldConcurrency
,
err
}
applyUserAvatar
(
user
,
avatar
)
}
...
...
@@ -296,13 +329,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
}
if
err
:=
s
.
userRepo
.
Update
(
ctx
,
user
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"update user: %w"
,
err
)
}
if
s
.
authCacheInvalidator
!=
nil
&&
user
.
Concurrency
!=
oldConcurrency
{
s
.
authCacheInvalidator
.
InvalidateAuthCacheByUserID
(
ctx
,
userID
)
return
nil
,
oldConcurrency
,
fmt
.
Errorf
(
"update user: %w"
,
err
)
}
return
user
,
nil
return
user
,
oldConcurrency
,
nil
}
func
(
s
*
UserService
)
SetAvatar
(
ctx
context
.
Context
,
userID
int64
,
raw
string
)
(
*
UserAvatar
,
error
)
{
...
...
backend/internal/service/user_service_test.go
View file @
ebe75244
...
...
@@ -31,13 +31,26 @@ type mockUserRepo struct {
deleteAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
)
error
deleteAvatarIDs
[]
int64
getAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
)
(
*
UserAvatar
,
error
)
txCalls
int
}
type
mockUserRepoTxKey
struct
{}
type
mockUserRepoTxState
struct
{
getByIDUser
*
User
upsertAvatarArgs
[]
UpsertUserAvatarInput
deleteAvatarIDs
[]
int64
}
func
(
m
*
mockUserRepo
)
Create
(
context
.
Context
,
*
User
)
error
{
return
nil
}
func
(
m
*
mockUserRepo
)
GetByID
(
context
.
Context
,
int64
)
(
*
User
,
error
)
{
func
(
m
*
mockUserRepo
)
GetByID
(
ctx
context
.
Context
,
_
int64
)
(
*
User
,
error
)
{
if
m
.
getByIDErr
!=
nil
{
return
nil
,
m
.
getByIDErr
}
if
txState
,
_
:=
ctx
.
Value
(
mockUserRepoTxKey
{})
.
(
*
mockUserRepoTxState
);
txState
!=
nil
&&
txState
.
getByIDUser
!=
nil
{
cloned
:=
*
txState
.
getByIDUser
return
&
cloned
,
nil
}
if
m
.
getByIDUser
!=
nil
{
cloned
:=
*
m
.
getByIDUser
return
&
cloned
,
nil
...
...
@@ -61,6 +74,27 @@ func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAv
return
nil
,
nil
}
func
(
m
*
mockUserRepo
)
UpsertUserAvatar
(
ctx
context
.
Context
,
userID
int64
,
input
UpsertUserAvatarInput
)
(
*
UserAvatar
,
error
)
{
if
txState
,
_
:=
ctx
.
Value
(
mockUserRepoTxKey
{})
.
(
*
mockUserRepoTxState
);
txState
!=
nil
{
txState
.
upsertAvatarArgs
=
append
(
txState
.
upsertAvatarArgs
,
input
)
if
txState
.
getByIDUser
!=
nil
{
txState
.
getByIDUser
.
AvatarURL
=
input
.
URL
txState
.
getByIDUser
.
AvatarSource
=
input
.
StorageProvider
txState
.
getByIDUser
.
AvatarMIME
=
input
.
ContentType
txState
.
getByIDUser
.
AvatarByteSize
=
input
.
ByteSize
txState
.
getByIDUser
.
AvatarSHA256
=
input
.
SHA256
}
if
m
.
upsertAvatarFn
!=
nil
{
return
m
.
upsertAvatarFn
(
ctx
,
userID
,
input
)
}
return
&
UserAvatar
{
StorageProvider
:
input
.
StorageProvider
,
StorageKey
:
input
.
StorageKey
,
URL
:
input
.
URL
,
ContentType
:
input
.
ContentType
,
ByteSize
:
input
.
ByteSize
,
SHA256
:
input
.
SHA256
,
},
nil
}
m
.
upsertAvatarArgs
=
append
(
m
.
upsertAvatarArgs
,
input
)
if
m
.
upsertAvatarFn
!=
nil
{
return
m
.
upsertAvatarFn
(
ctx
,
userID
,
input
)
...
...
@@ -75,6 +109,20 @@ func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input
},
nil
}
func
(
m
*
mockUserRepo
)
DeleteUserAvatar
(
ctx
context
.
Context
,
userID
int64
)
error
{
if
txState
,
_
:=
ctx
.
Value
(
mockUserRepoTxKey
{})
.
(
*
mockUserRepoTxState
);
txState
!=
nil
{
txState
.
deleteAvatarIDs
=
append
(
txState
.
deleteAvatarIDs
,
userID
)
if
txState
.
getByIDUser
!=
nil
{
txState
.
getByIDUser
.
AvatarURL
=
""
txState
.
getByIDUser
.
AvatarSource
=
""
txState
.
getByIDUser
.
AvatarMIME
=
""
txState
.
getByIDUser
.
AvatarByteSize
=
0
txState
.
getByIDUser
.
AvatarSHA256
=
""
}
if
m
.
deleteAvatarFn
!=
nil
{
return
m
.
deleteAvatarFn
(
ctx
,
userID
)
}
return
nil
}
m
.
deleteAvatarIDs
=
append
(
m
.
deleteAvatarIDs
,
userID
)
if
m
.
deleteAvatarFn
!=
nil
{
return
m
.
deleteAvatarFn
(
ctx
,
userID
)
...
...
@@ -116,6 +164,26 @@ func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64,
return
nil
}
func
(
m
*
mockUserRepo
)
WithUserProfileIdentityTx
(
ctx
context
.
Context
,
fn
func
(
txCtx
context
.
Context
)
error
)
error
{
m
.
txCalls
++
txState
:=
&
mockUserRepoTxState
{
upsertAvatarArgs
:
append
([]
UpsertUserAvatarInput
(
nil
),
m
.
upsertAvatarArgs
...
),
deleteAvatarIDs
:
append
([]
int64
(
nil
),
m
.
deleteAvatarIDs
...
),
}
if
m
.
getByIDUser
!=
nil
{
userCopy
:=
*
m
.
getByIDUser
txState
.
getByIDUser
=
&
userCopy
}
err
:=
fn
(
context
.
WithValue
(
ctx
,
mockUserRepoTxKey
{},
txState
))
if
err
!=
nil
{
return
err
}
m
.
getByIDUser
=
txState
.
getByIDUser
m
.
upsertAvatarArgs
=
txState
.
upsertAvatarArgs
m
.
deleteAvatarIDs
=
txState
.
deleteAvatarIDs
return
nil
}
// --- mock: APIKeyAuthCacheInvalidator ---
type
mockAuthCacheInvalidator
struct
{
...
...
@@ -360,6 +428,33 @@ func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
require
.
Empty
(
t
,
updated
.
AvatarSource
)
}
func
TestUpdateProfile_RollsBackAvatarMutationWhenUserUpdateFails
(
t
*
testing
.
T
)
{
repo
:=
&
mockUserRepo
{
getByIDUser
:
&
User
{
ID
:
11
,
Email
:
"rollback@example.com"
,
AvatarURL
:
"https://cdn.example.com/original.png"
,
AvatarSource
:
"remote_url"
,
},
updateFn
:
func
(
context
.
Context
,
*
User
)
error
{
return
errors
.
New
(
"write user failed"
)
},
}
svc
:=
NewUserService
(
repo
,
nil
,
nil
,
nil
)
remoteURL
:=
"https://cdn.example.com/new.png"
_
,
err
:=
svc
.
UpdateProfile
(
context
.
Background
(),
11
,
UpdateProfileRequest
{
AvatarURL
:
&
remoteURL
,
})
require
.
EqualError
(
t
,
err
,
"update user: write user failed"
)
require
.
Equal
(
t
,
1
,
repo
.
txCalls
)
require
.
Empty
(
t
,
repo
.
upsertAvatarArgs
)
require
.
Empty
(
t
,
repo
.
deleteAvatarIDs
)
require
.
Equal
(
t
,
"https://cdn.example.com/original.png"
,
repo
.
getByIDUser
.
AvatarURL
)
require
.
Equal
(
t
,
"remote_url"
,
repo
.
getByIDUser
.
AvatarSource
)
}
func
TestGetProfile_HydratesAvatarFromRepository
(
t
*
testing
.
T
)
{
repo
:=
&
mockUserRepo
{
getByIDUser
:
&
User
{
...
...
frontend/src/api/__tests__/users.migrationReports.spec.ts
View file @
ebe75244
...
...
@@ -13,6 +13,7 @@ vi.mock('@/api/client', () => ({
}))
import
{
bindUserAuthIdentity
,
getAuthIdentityMigrationReportSummary
,
listAuthIdentityMigrationReports
,
resolveAuthIdentityMigrationReport
,
...
...
@@ -81,4 +82,31 @@ describe('admin users auth identity migration reports API', () => {
})
expect
(
result
).
toBe
(
response
)
})
it
(
'
binds a canonical auth identity to a user for remediation
'
,
async
()
=>
{
const
response
=
{
identity_id
:
11
,
provider_type
:
'
oidc
'
,
provider_key
:
'
https://issuer.example
'
,
provider_subject
:
'
subject-123
'
,
}
post
.
mockResolvedValue
({
data
:
response
})
const
result
=
await
bindUserAuthIdentity
(
42
,
{
provider_type
:
'
oidc
'
,
provider_key
:
'
https://issuer.example
'
,
provider_subject
:
'
subject-123
'
,
issuer
:
'
https://issuer.example
'
,
metadata
:
{
source
:
'
migration-report
'
},
})
expect
(
post
).
toHaveBeenCalledWith
(
'
/admin/users/42/auth-identities
'
,
{
provider_type
:
'
oidc
'
,
provider_key
:
'
https://issuer.example
'
,
provider_subject
:
'
subject-123
'
,
issuer
:
'
https://issuer.example
'
,
metadata
:
{
source
:
'
migration-report
'
},
})
expect
(
result
).
toBe
(
response
)
})
})
frontend/src/api/admin/users.ts
View file @
ebe75244
...
...
@@ -24,6 +24,30 @@ export interface AuthIdentityMigrationReportSummary {
by_type
:
Record
<
string
,
number
>
}
export
interface
AdminBindAuthIdentityChannelRequest
{
channel
:
string
channel_app_id
?:
string
channel_subject
:
string
metadata
?:
Record
<
string
,
unknown
>
}
export
interface
AdminBindAuthIdentityRequest
{
provider_type
:
string
provider_key
:
string
provider_subject
:
string
issuer
?:
string
metadata
?:
Record
<
string
,
unknown
>
channel
?:
AdminBindAuthIdentityChannelRequest
}
export
interface
AdminBoundAuthIdentity
{
identity_id
:
number
provider_type
:
string
provider_key
:
string
provider_subject
:
string
channel_id
?:
number
|
null
}
export
interface
ListAuthIdentityMigrationReportsParams
{
page
?:
number
pageSize
?:
number
...
...
@@ -308,6 +332,17 @@ export async function resolveAuthIdentityMigrationReport(
return
data
}
export
async
function
bindUserAuthIdentity
(
userId
:
number
,
input
:
AdminBindAuthIdentityRequest
):
Promise
<
AdminBoundAuthIdentity
>
{
const
{
data
}
=
await
apiClient
.
post
<
AdminBoundAuthIdentity
>
(
`/admin/users/
${
userId
}
/auth-identities`
,
input
)
return
data
}
export
const
usersAPI
=
{
list
,
getById
,
...
...
@@ -321,6 +356,7 @@ export const usersAPI = {
getUserUsageStats
,
getUserBalanceHistory
,
replaceGroup
,
bindUserAuthIdentity
,
getAuthIdentityMigrationReportSummary
,
listAuthIdentityMigrationReports
,
resolveAuthIdentityMigrationReport
...
...
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
ebe75244
...
...
@@ -62,6 +62,8 @@ vi.mock('vue-i18n', async (importOriginal) => {
if
(
key
===
'
profile.authBindings.providers.linuxdo
'
)
return
'
LinuxDo
'
if
(
key
===
'
profile.authBindings.providers.wechat
'
)
return
'
WeChat
'
if
(
key
===
'
profile.authBindings.providers.oidc
'
)
return
params
?.
providerName
||
'
OIDC
'
if
(
key
===
'
profile.authBindings.source.avatar
'
)
return
`Avatar synced from
${
params
?.
providerName
||
'
provider
'
}
`
if
(
key
===
'
profile.authBindings.source.username
'
)
return
`Username synced from
${
params
?.
providerName
||
'
provider
'
}
`
if
(
key
===
'
common.save
'
)
return
'
Save
'
if
(
key
===
'
common.delete
'
)
return
'
Delete
'
return
key
...
...
@@ -169,4 +171,29 @@ describe('ProfileInfoCard', () => {
expect
(
authStoreState
.
user
?.
avatar_url
).
toBeNull
()
expect
(
showSuccessMock
).
toHaveBeenCalledWith
(
'
Avatar removed
'
)
})
it
(
'
renders third-party source hints from profile_sources
'
,
()
=>
{
authStoreState
.
user
=
createUser
({
avatar_url
:
'
https://cdn.example.com/linuxdo.png
'
,
profile_sources
:
{
avatar
:
{
provider
:
'
linuxdo
'
,
source
:
'
linuxdo
'
},
username
:
{
provider
:
'
linuxdo
'
,
source
:
'
linuxdo
'
}
}
})
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
expect
(
wrapper
.
text
()).
toContain
(
'
Avatar synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Username synced from LinuxDo
'
)
})
})
frontend/src/views/admin/AuthIdentityMigrationReportsView.vue
View file @
ebe75244
...
...
@@ -238,6 +238,83 @@
{{ resolving ? copy.resolving : copy.resolveAction }}
</button>
</div>
<div
class=
"mt-8 border-t border-gray-200 pt-6 dark:border-dark-700"
>
<h3
class=
"text-base font-semibold text-gray-900 dark:text-gray-100"
>
{{ copy.remediationTitle }}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
{{ copy.remediationSubtitle }}
</p>
<div
class=
"mt-6 space-y-4"
>
<div>
<label
class=
"input-label"
for=
"remediation-user-id"
>
{{ copy.remediationUserID }}
</label>
<input
id=
"remediation-user-id"
v-model=
"remediation.userID"
data-test=
"remediation-user-id"
class=
"input"
:disabled=
"!selectedReport || Boolean(selectedReport.resolved_at) || binding"
inputmode=
"numeric"
/>
</div>
<div>
<label
class=
"input-label"
for=
"remediation-provider-type"
>
{{ copy.remediationProviderType }}
</label>
<input
id=
"remediation-provider-type"
v-model=
"remediation.providerType"
data-test=
"remediation-provider-type"
class=
"input"
:disabled=
"!selectedReport || Boolean(selectedReport.resolved_at) || binding"
/>
</div>
<div>
<label
class=
"input-label"
for=
"remediation-provider-key"
>
{{ copy.remediationProviderKey }}
</label>
<input
id=
"remediation-provider-key"
v-model=
"remediation.providerKey"
data-test=
"remediation-provider-key"
class=
"input"
:disabled=
"!selectedReport || Boolean(selectedReport.resolved_at) || binding"
/>
</div>
<div>
<label
class=
"input-label"
for=
"remediation-provider-subject"
>
{{ copy.remediationProviderSubject }}
</label>
<input
id=
"remediation-provider-subject"
v-model=
"remediation.providerSubject"
data-test=
"remediation-provider-subject"
class=
"input"
:disabled=
"!selectedReport || Boolean(selectedReport.resolved_at) || binding"
/>
</div>
<div>
<label
class=
"input-label"
for=
"remediation-issuer"
>
{{ copy.remediationIssuer }}
</label>
<input
id=
"remediation-issuer"
v-model=
"remediation.issuer"
data-test=
"remediation-issuer"
class=
"input"
:disabled=
"!selectedReport || Boolean(selectedReport.resolved_at) || binding"
/>
</div>
<button
type=
"button"
class=
"btn btn-secondary w-full"
data-test=
"remediation-submit"
:disabled=
"!canBindRemediation"
@
click=
"submitRemediationBinding"
>
{{ binding ? copy.remediationSubmitting : copy.remediationAction }}
</button>
</div>
</div>
</div>
</section>
</div>
...
...
@@ -249,6 +326,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
AdminBindAuthIdentityRequest
,
AuthIdentityMigrationReport
,
AuthIdentityMigrationReportSummary
,
}
from
'
@/api/admin/users
'
...
...
@@ -294,6 +372,15 @@ const copy = computed(() => ({
resolvePlaceholder
:
text
(
'
填写本次处理动作、用户沟通结果或后续追踪信息。
'
,
'
Describe the action taken, user communication, or follow-up context.
'
),
resolveAction
:
text
(
'
提交 Resolve
'
,
'
Submit resolve
'
),
resolving
:
text
(
'
提交中...
'
,
'
Submitting...
'
),
remediationTitle
:
text
(
'
修复绑定
'
,
'
Remediation binding
'
),
remediationSubtitle
:
text
(
'
可直接把迁移报告中的身份信息绑定到指定用户;已识别字段会自动预填。
'
,
'
Bind the migrated identity directly to a user. Recognized fields are prefilled automatically.
'
),
remediationUserID
:
text
(
'
目标用户 ID
'
,
'
Target user ID
'
),
remediationProviderType
:
text
(
'
Provider Type
'
,
'
Provider type
'
),
remediationProviderKey
:
text
(
'
Provider Key
'
,
'
Provider key
'
),
remediationProviderSubject
:
text
(
'
Provider Subject
'
,
'
Provider subject
'
),
remediationIssuer
:
text
(
'
Issuer
'
,
'
Issuer
'
),
remediationAction
:
text
(
'
提交绑定修复
'
,
'
Submit remediation binding
'
),
remediationSubmitting
:
text
(
'
提交中...
'
,
'
Submitting...
'
),
}))
const
summary
=
ref
<
AuthIdentityMigrationReportSummary
>
({
...
...
@@ -308,6 +395,7 @@ const resolutionNote = ref('')
const
loading
=
ref
(
false
)
const
summaryLoading
=
ref
(
false
)
const
resolving
=
ref
(
false
)
const
binding
=
ref
(
false
)
const
filters
=
reactive
({
reportType
:
''
,
...
...
@@ -319,6 +407,13 @@ const pagination = reactive({
total
:
0
,
})
const
knownReportTypes
=
ref
<
string
[]
>
([])
const
remediation
=
reactive
({
userID
:
''
,
providerType
:
''
,
providerKey
:
''
,
providerSubject
:
''
,
issuer
:
''
,
})
const
columns
:
Column
[]
=
[
{
key
:
'
status
'
,
label
:
text
(
'
状态
'
,
'
Status
'
)
},
...
...
@@ -352,6 +447,18 @@ const canResolve = computed(() =>
)
)
const
canBindRemediation
=
computed
(()
=>
Boolean
(
selectedReport
.
value
&&
!
selectedReport
.
value
.
resolved_at
&&
remediation
.
userID
.
trim
()
&&
remediation
.
providerType
.
trim
()
&&
remediation
.
providerKey
.
trim
()
&&
remediation
.
providerSubject
.
trim
()
&&
!
binding
.
value
)
)
const
mergeKnownReportTypes
=
(...
values
:
Array
<
string
|
null
|
undefined
>
)
=>
{
const
merged
=
new
Set
(
knownReportTypes
.
value
)
for
(
const
value
of
values
)
{
...
...
@@ -392,6 +499,7 @@ const loadReports = async () => {
if
(
selectedReport
.
value
)
{
const
refreshed
=
response
.
items
.
find
((
report
)
=>
report
.
id
===
selectedReport
.
value
?.
id
)
??
null
selectedReport
.
value
=
refreshed
applyRemediationDefaults
(
refreshed
)
resolutionNote
.
value
=
refreshed
?.
resolved_at
?
refreshed
.
resolution_note
??
''
:
resolutionNote
.
value
...
...
@@ -427,6 +535,7 @@ const handlePageSizeChange = async (pageSize: number) => {
const
selectReport
=
(
report
:
AuthIdentityMigrationReport
)
=>
{
selectedReport
.
value
=
report
resolutionNote
.
value
=
report
.
resolution_note
??
''
applyRemediationDefaults
(
report
)
}
const
formatDetailsJson
=
(
details
:
Record
<
string
,
unknown
>
)
=>
JSON
.
stringify
(
details
??
{},
null
,
2
)
...
...
@@ -458,6 +567,63 @@ const getDetailHighlights = (details: Record<string, unknown>) => {
.
map
(([
key
,
value
])
=>
({
key
,
value
:
String
(
value
)
}))
}
const
stringDetailValue
=
(
details
:
Record
<
string
,
unknown
>
,
key
:
string
)
=>
{
const
value
=
details
[
key
]
return
typeof
value
===
'
string
'
?
value
.
trim
()
:
''
}
const
numericDetailValue
=
(
details
:
Record
<
string
,
unknown
>
,
key
:
string
)
=>
{
const
value
=
details
[
key
]
if
(
typeof
value
===
'
number
'
&&
Number
.
isFinite
(
value
))
{
return
String
(
Math
.
trunc
(
value
))
}
if
(
typeof
value
===
'
string
'
&&
value
.
trim
())
{
return
value
.
trim
()
}
return
''
}
const
inferProviderTypeFromReport
=
(
report
:
AuthIdentityMigrationReport
)
=>
{
const
explicit
=
stringDetailValue
(
report
.
details
,
'
provider_type
'
)
if
(
explicit
)
{
return
explicit
}
if
(
report
.
report_type
.
includes
(
'
oidc
'
))
{
return
'
oidc
'
}
if
(
report
.
report_type
.
includes
(
'
wechat
'
))
{
return
'
wechat
'
}
if
(
report
.
report_type
.
includes
(
'
linuxdo
'
))
{
return
'
linuxdo
'
}
return
''
}
const
inferProviderKeyFromReport
=
(
report
:
AuthIdentityMigrationReport
,
providerType
:
string
)
=>
{
const
explicit
=
stringDetailValue
(
report
.
details
,
'
provider_key
'
)
if
(
explicit
)
{
return
explicit
}
if
(
providerType
===
'
wechat
'
)
{
return
'
wechat-main
'
}
return
''
}
const
inferProviderSubjectFromReport
=
(
report
:
AuthIdentityMigrationReport
)
=>
stringDetailValue
(
report
.
details
,
'
provider_subject
'
)
||
stringDetailValue
(
report
.
details
,
'
subject
'
)
||
stringDetailValue
(
report
.
details
,
'
unionid
'
)
const
applyRemediationDefaults
=
(
report
:
AuthIdentityMigrationReport
|
null
)
=>
{
remediation
.
userID
=
report
?
numericDetailValue
(
report
.
details
,
'
user_id
'
)
:
''
remediation
.
providerType
=
report
?
inferProviderTypeFromReport
(
report
)
:
''
remediation
.
providerKey
=
report
?
inferProviderKeyFromReport
(
report
,
remediation
.
providerType
)
:
''
remediation
.
providerSubject
=
report
?
inferProviderSubjectFromReport
(
report
)
:
''
remediation
.
issuer
=
report
?
stringDetailValue
(
report
.
details
,
'
issuer
'
)
:
''
}
const
submitResolve
=
async
()
=>
{
if
(
!
selectedReport
.
value
)
{
appStore
.
showError
(
text
(
'
请先选择一条报告
'
,
'
Select a report first
'
))
...
...
@@ -485,6 +651,38 @@ const submitResolve = async () => {
}
}
const
submitRemediationBinding
=
async
()
=>
{
if
(
!
selectedReport
.
value
)
{
appStore
.
showError
(
text
(
'
请先选择一条报告
'
,
'
Select a report first
'
))
return
}
const
userID
=
Number
.
parseInt
(
remediation
.
userID
.
trim
(),
10
)
if
(
!
Number
.
isFinite
(
userID
)
||
userID
<=
0
)
{
appStore
.
showError
(
text
(
'
请输入有效的目标用户 ID
'
,
'
Enter a valid target user ID
'
))
return
}
const
payload
:
AdminBindAuthIdentityRequest
=
{
provider_type
:
remediation
.
providerType
.
trim
(),
provider_key
:
remediation
.
providerKey
.
trim
(),
provider_subject
:
remediation
.
providerSubject
.
trim
(),
issuer
:
remediation
.
issuer
.
trim
()
||
undefined
,
metadata
:
{},
}
binding
.
value
=
true
try
{
await
adminAPI
.
users
.
bindUserAuthIdentity
(
userID
,
payload
)
appStore
.
showSuccess
(
text
(
'
修复绑定已提交
'
,
'
Remediation binding submitted
'
))
}
catch
(
error
)
{
console
.
error
(
'
Failed to submit auth identity remediation binding:
'
,
error
)
appStore
.
showError
(
text
(
'
提交修复绑定失败
'
,
'
Failed to submit remediation binding
'
))
}
finally
{
binding
.
value
=
false
}
}
onMounted
(
async
()
=>
{
await
refreshAll
()
})
...
...
frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts
View file @
ebe75244
...
...
@@ -4,7 +4,13 @@ import { defineComponent, h } from 'vue'
import
AuthIdentityMigrationReportsView
from
'
../AuthIdentityMigrationReportsView.vue
'
const
{
getAuthIdentityMigrationReportSummary
,
listAuthIdentityMigrationReports
,
resolveAuthIdentityMigrationReport
}
=
vi
.
hoisted
(()
=>
({
const
{
bindUserAuthIdentity
,
getAuthIdentityMigrationReportSummary
,
listAuthIdentityMigrationReports
,
resolveAuthIdentityMigrationReport
,
}
=
vi
.
hoisted
(()
=>
({
bindUserAuthIdentity
:
vi
.
fn
(),
getAuthIdentityMigrationReportSummary
:
vi
.
fn
(),
listAuthIdentityMigrationReports
:
vi
.
fn
(),
resolveAuthIdentityMigrationReport
:
vi
.
fn
(),
...
...
@@ -18,6 +24,7 @@ const { showError, showSuccess } = vi.hoisted(() => ({
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
users
:
{
bindUserAuthIdentity
,
getAuthIdentityMigrationReportSummary
,
listAuthIdentityMigrationReports
,
resolveAuthIdentityMigrationReport
,
...
...
@@ -156,6 +163,7 @@ describe('AuthIdentityMigrationReportsView', () => {
getAuthIdentityMigrationReportSummary
.
mockReset
()
listAuthIdentityMigrationReports
.
mockReset
()
resolveAuthIdentityMigrationReport
.
mockReset
()
bindUserAuthIdentity
.
mockReset
()
showError
.
mockReset
()
showSuccess
.
mockReset
()
...
...
@@ -167,6 +175,12 @@ describe('AuthIdentityMigrationReportsView', () => {
resolved_by_user_id
:
100
,
resolution_note
:
'
resolved by admin
'
,
})
bindUserAuthIdentity
.
mockResolvedValue
({
identity_id
:
77
,
provider_type
:
'
oidc
'
,
provider_key
:
'
https://issuer.example
'
,
provider_subject
:
'
subject-123
'
,
})
})
const
mountView
=
()
=>
...
...
@@ -241,6 +255,35 @@ describe('AuthIdentityMigrationReportsView', () => {
})
})
it
(
'
pre-fills and submits remediation binding for the selected report
'
,
async
()
=>
{
const
wrapper
=
mountView
()
await
flushPromises
()
await
wrapper
.
get
(
'
[data-test="select-report-1"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
((
wrapper
.
get
(
'
[data-test="remediation-user-id"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
42
'
)
expect
((
wrapper
.
get
(
'
[data-test="remediation-provider-type"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
oidc
'
)
expect
((
wrapper
.
get
(
'
[data-test="remediation-provider-key"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
https://issuer.example
'
)
expect
((
wrapper
.
get
(
'
[data-test="remediation-provider-subject"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
subject-123
'
)
await
wrapper
.
get
(
'
[data-test="remediation-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
bindUserAuthIdentity
).
toHaveBeenCalledWith
(
42
,
{
provider_type
:
'
oidc
'
,
provider_key
:
'
https://issuer.example
'
,
provider_subject
:
'
subject-123
'
,
issuer
:
undefined
,
metadata
:
{},
})
expect
(
showSuccess
).
toHaveBeenCalled
()
})
it
(
'
keeps report type filter options available from list data when summary fails
'
,
async
()
=>
{
getAuthIdentityMigrationReportSummary
.
mockRejectedValueOnce
(
new
Error
(
'
summary failed
'
))
listAuthIdentityMigrationReports
.
mockResolvedValueOnce
(
listResponse
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment