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
ddf80f5e
Unverified
Commit
ddf80f5e
authored
Apr 22, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 22, 2026
Browse files
Merge pull request #1799 from IanShaw027/rebuild/auth-identity-foundation
fix(auth,payment,profile): 修复认证身份和支付系统的后续问题
parents
4d0483f5
c048ca80
Changes
140
Show whitespace changes
Inline
Side-by-side
backend/migrations/124_backfill_legacy_oidc_security_flags.sql
0 → 100644
View file @
ddf80f5e
-- Preserve legacy OIDC behavior for upgraded installs that predate the
-- introduction of secure PKCE/id_token defaults. Fresh installs continue to
-- inherit runtime defaults when these rows are absent.
WITH
legacy_oidc_install
AS
(
SELECT
1
FROM
settings
WHERE
key
IN
(
'oidc_connect_enabled'
,
'oidc_connect_client_id'
,
'oidc_connect_authorize_url'
,
'oidc_connect_token_url'
,
'oidc_connect_issuer_url'
,
'oidc_connect_userinfo_url'
,
'oidc_connect_frontend_redirect_url'
)
LIMIT
1
)
INSERT
INTO
settings
(
key
,
value
)
SELECT
defaults
.
key
,
'false'
FROM
legacy_oidc_install
CROSS
JOIN
(
VALUES
(
'oidc_connect_use_pkce'
),
(
'oidc_connect_validate_id_token'
)
)
AS
defaults
(
key
)
WHERE
NOT
EXISTS
(
SELECT
1
FROM
settings
existing
WHERE
existing
.
key
=
defaults
.
key
)
ON
CONFLICT
(
key
)
DO
NOTHING
;
backend/migrations/auth_identity_payment_migrations_regression_test.go
0 → 100644
View file @
ddf80f5e
package
migrations
import
(
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func
TestMigration112UsesIdempotentAddColumn
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"112_add_payment_order_provider_key_snapshot.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"ADD COLUMN IF NOT EXISTS provider_key VARCHAR(30)"
)
require
.
NotContains
(
t
,
sql
,
"ADD COLUMN provider_key VARCHAR(30);"
)
}
func
TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"118_wechat_dual_mode_and_auth_source_defaults.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
NotContains
(
t
,
sql
,
"UPDATE settings"
)
require
.
NotContains
(
t
,
sql
,
"SET value = 'false'"
)
require
.
True
(
t
,
strings
.
Contains
(
sql
,
"ON CONFLICT (key) DO NOTHING"
))
require
.
Contains
(
t
,
sql
,
"THEN ''"
)
}
func
TestAuthIdentityReportTypeWideningRunsBeforeLongReportWritersAndStillReconcilesAt121
(
t
*
testing
.
T
)
{
preflightContent
,
err
:=
FS
.
ReadFile
(
"108a_widen_auth_identity_migration_report_type.sql"
)
require
.
NoError
(
t
,
err
)
preflightSQL
:=
string
(
preflightContent
)
require
.
Contains
(
t
,
preflightSQL
,
"ALTER TABLE auth_identity_migration_reports"
)
require
.
Contains
(
t
,
preflightSQL
,
"ALTER COLUMN report_type TYPE VARCHAR(80)"
)
content
,
err
:=
FS
.
ReadFile
(
"109_auth_identity_compat_backfill.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
NotContains
(
t
,
sql
,
"ALTER TABLE auth_identity_migration_reports"
)
followupContent
,
err
:=
FS
.
ReadFile
(
"121_auth_identity_migration_report_type_widen.sql"
)
require
.
NoError
(
t
,
err
)
followupSQL
:=
string
(
followupContent
)
require
.
Contains
(
t
,
followupSQL
,
"ALTER TABLE auth_identity_migration_reports"
)
require
.
Contains
(
t
,
followupSQL
,
"ALTER COLUMN report_type TYPE VARCHAR(80)"
)
}
func
TestMigration119DefersPaymentIndexRolloutToOnlineFollowup
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"119_enforce_payment_orders_out_trade_no_unique.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"120_enforce_payment_orders_out_trade_no_unique_notx.sql"
)
require
.
Contains
(
t
,
sql
,
"NULL;"
)
require
.
NotContains
(
t
,
sql
,
"CREATE UNIQUE INDEX"
)
require
.
NotContains
(
t
,
sql
,
"DROP INDEX"
)
followupContent
,
err
:=
FS
.
ReadFile
(
"120_enforce_payment_orders_out_trade_no_unique_notx.sql"
)
require
.
NoError
(
t
,
err
)
followupSQL
:=
string
(
followupContent
)
require
.
Contains
(
t
,
followupSQL
,
"explicit duplicate out_trade_no precheck"
)
require
.
Contains
(
t
,
followupSQL
,
"stale invalid paymentorder_out_trade_no_unique index"
)
require
.
Contains
(
t
,
followupSQL
,
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique"
)
require
.
NotContains
(
t
,
followupSQL
,
"DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique"
)
require
.
Contains
(
t
,
followupSQL
,
"DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no"
)
require
.
Contains
(
t
,
followupSQL
,
"WHERE out_trade_no <> ''"
)
alignmentContent
,
err
:=
FS
.
ReadFile
(
"120a_align_payment_orders_out_trade_no_index_name.sql"
)
require
.
NoError
(
t
,
err
)
alignmentSQL
:=
string
(
alignmentContent
)
require
.
Contains
(
t
,
alignmentSQL
,
"paymentorder_out_trade_no_unique"
)
require
.
Contains
(
t
,
alignmentSQL
,
"RENAME TO paymentorder_out_trade_no"
)
}
func
TestMigration110SeedsAuthSourceSignupGrantsDisabledByDefault
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"110_pending_auth_and_provider_default_grants.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_email_grant_on_signup', 'false')"
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_linuxdo_grant_on_signup', 'false')"
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_oidc_grant_on_signup', 'false')"
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_wechat_grant_on_signup', 'false')"
)
require
.
NotContains
(
t
,
sql
,
"('auth_source_default_email_grant_on_signup', 'true')"
)
}
func
TestMigration122ScrubsPendingOAuthCompletionTokensAtRest
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"122_pending_auth_completion_token_cleanup.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"UPDATE pending_auth_sessions"
)
require
.
Contains
(
t
,
sql
,
"completion_response"
)
require
.
Contains
(
t
,
sql
,
"access_token"
)
require
.
Contains
(
t
,
sql
,
"refresh_token"
)
require
.
Contains
(
t
,
sql
,
"expires_in"
)
require
.
Contains
(
t
,
sql
,
"token_type"
)
}
func
TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"123_fix_legacy_auth_source_grant_on_signup_defaults.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"110_pending_auth_and_provider_default_grants.sql"
)
require
.
Contains
(
t
,
sql
,
"schema_migrations"
)
require
.
Contains
(
t
,
sql
,
"updated_at"
)
require
.
Contains
(
t
,
sql
,
"'_grant_on_signup'"
)
require
.
Contains
(
t
,
sql
,
"value = 'false'"
)
require
.
Contains
(
t
,
sql
,
"auth_identity_migration_reports"
)
}
func
TestMigration124BackfillsLegacyOIDCSecurityFlagsSafely
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"124_backfill_legacy_oidc_security_flags.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"oidc_connect_use_pkce"
)
require
.
Contains
(
t
,
sql
,
"oidc_connect_validate_id_token"
)
require
.
Contains
(
t
,
sql
,
"ON CONFLICT (key) DO NOTHING"
)
require
.
Contains
(
t
,
sql
,
"oidc_connect_enabled"
)
require
.
Contains
(
t
,
sql
,
"'false'"
)
}
deploy/config.example.yaml
View file @
ddf80f5e
...
...
@@ -841,7 +841,7 @@ linuxdo_connect:
frontend_redirect_url
:
"
/auth/linuxdo/callback"
token_auth_method
:
"
client_secret_post"
# client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce
:
fals
e
use_pkce
:
tru
e
userinfo_email_path
:
"
"
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
...
...
frontend/src/api/__tests__/admin.users.spec.ts
0 → 100644
View file @
ddf80f5e
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
const
{
post
}
=
vi
.
hoisted
(()
=>
({
post
:
vi
.
fn
(),
}))
vi
.
mock
(
'
@/api/client
'
,
()
=>
({
apiClient
:
{
post
,
},
}))
import
{
bindUserAuthIdentity
,
type
AdminBindAuthIdentityRequest
,
type
AdminBoundAuthIdentity
,
}
from
'
@/api/admin/users
'
type
Assert
<
T
extends
true
>
=
T
type
IsExact
<
T
,
U
>
=
(
(
<
G
>
()
=>
G
extends
T
?
1
:
2
)
extends
(
<
G
>
()
=>
G
extends
U
?
1
:
2
)
?
((
<
G
>
()
=>
G
extends
U
?
1
:
2
)
extends
(
<
G
>
()
=>
G
extends
T
?
1
:
2
)
?
true
:
false
)
:
false
)
type
ExpectedAdminBindAuthIdentityRequest
=
{
provider_type
:
string
provider_key
:
string
provider_subject
:
string
issuer
?:
string
metadata
?:
Record
<
string
,
unknown
>
channel
?:
{
channel
:
string
channel_app_id
:
string
channel_subject
:
string
metadata
?:
Record
<
string
,
unknown
>
}
}
type
ExpectedAdminBoundAuthIdentity
=
{
user_id
:
number
provider_type
:
string
provider_key
:
string
provider_subject
:
string
verified_at
?:
string
|
null
issuer
?:
string
|
null
metadata
:
Record
<
string
,
unknown
>
|
null
created_at
:
string
updated_at
:
string
channel
?:
{
channel
:
string
channel_app_id
:
string
channel_subject
:
string
metadata
:
Record
<
string
,
unknown
>
|
null
created_at
:
string
updated_at
:
string
}
|
null
}
const
requestContractExact
:
Assert
<
IsExact
<
AdminBindAuthIdentityRequest
,
ExpectedAdminBindAuthIdentityRequest
>
>
=
true
const
responseContractExact
:
Assert
<
IsExact
<
AdminBoundAuthIdentity
,
ExpectedAdminBoundAuthIdentity
>
>
=
true
describe
(
'
admin users api auth identity binding
'
,
()
=>
{
beforeEach
(()
=>
{
post
.
mockReset
()
})
it
(
'
posts the backend-compatible auth identity bind payload and returns the backend response shape
'
,
async
()
=>
{
const
payload
:
AdminBindAuthIdentityRequest
=
{
provider_type
:
'
wechat
'
,
provider_key
:
'
wechat-main
'
,
provider_subject
:
'
union-123
'
,
metadata
:
{
source
:
'
admin-repair
'
},
channel
:
{
channel
:
'
open
'
,
channel_app_id
:
'
wx-open
'
,
channel_subject
:
'
openid-123
'
,
metadata
:
{
scene
:
'
migration
'
},
},
}
const
response
:
AdminBoundAuthIdentity
=
{
user_id
:
9
,
provider_type
:
'
wechat
'
,
provider_key
:
'
wechat-main
'
,
provider_subject
:
'
union-123
'
,
verified_at
:
'
2026-04-22T00:00:00Z
'
,
issuer
:
null
,
metadata
:
{
source
:
'
admin-repair
'
},
created_at
:
'
2026-04-22T00:00:00Z
'
,
updated_at
:
'
2026-04-22T00:00:00Z
'
,
channel
:
{
channel
:
'
open
'
,
channel_app_id
:
'
wx-open
'
,
channel_subject
:
'
openid-123
'
,
metadata
:
{
scene
:
'
migration
'
},
created_at
:
'
2026-04-22T00:00:00Z
'
,
updated_at
:
'
2026-04-22T00:00:00Z
'
,
},
}
post
.
mockResolvedValue
({
data
:
response
})
const
result
=
await
bindUserAuthIdentity
(
9
,
payload
)
expect
(
post
).
toHaveBeenCalledWith
(
'
/admin/users/9/auth-identities
'
,
payload
)
expect
(
result
).
toEqual
(
response
)
})
it
(
'
keeps bind auth identity request and response types aligned with the backend contract
'
,
()
=>
{
expect
(
requestContractExact
).
toBe
(
true
)
expect
(
responseContractExact
).
toBe
(
true
)
})
})
frontend/src/api/__tests__/auth-oauth-adoption.spec.ts
View file @
ddf80f5e
...
...
@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => {
expect
(
hasPendingOAuthSuggestedProfile
({})).
toBe
(
false
)
})
it
(
'
p
re
pares an oauth bind access token
cookie before redirect binding
'
,
async
()
=>
{
it
(
'
re
quests an HttpOnly oauth bind
cookie before redirect binding
'
,
async
()
=>
{
localStorage
.
setItem
(
'
auth_token
'
,
'
access-token-value
'
)
const
setCookie
=
vi
.
fn
()
Object
.
defineProperty
(
document
,
'
cookie
'
,
{
configurable
:
true
,
get
:
()
=>
''
,
set
:
setCookie
})
const
{
prepareOAuthBindAccessTokenCookie
}
=
await
import
(
'
@/api/auth
'
)
prepareOAuthBindAccessTokenCookie
()
await
prepareOAuthBindAccessTokenCookie
()
expect
(
setCookie
).
toHaveBeenCalledTimes
(
1
)
expect
(
setCookie
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
oauth_bind_access_token=access-token-value
'
)
expect
(
post
).
toHaveBeenCalledWith
(
'
/auth/oauth/bind-token
'
)
})
})
frontend/src/api/__tests__/client.spec.ts
View file @
ddf80f5e
...
...
@@ -91,6 +91,22 @@ describe('API Client', () => {
const
config
=
adapter
.
mock
.
calls
[
0
][
0
]
expect
(
config
.
params
?.
timezone
).
toBeUndefined
()
})
it
(
'
请求默认带 withCredentials 以支持跨域 cookie
'
,
async
()
=>
{
const
adapter
=
vi
.
fn
().
mockResolvedValue
({
status
:
200
,
data
:
{
code
:
0
,
data
:
{}
},
headers
:
{},
config
:
{},
statusText
:
'
OK
'
,
})
apiClient
.
defaults
.
adapter
=
adapter
await
apiClient
.
post
(
'
/auth/oauth/bind-token
'
)
const
config
=
adapter
.
mock
.
calls
[
0
][
0
]
expect
(
config
.
withCredentials
).
toBe
(
true
)
})
})
// --- 响应拦截器 ---
...
...
frontend/src/api/__tests__/payment.spec.ts
View file @
ddf80f5e
...
...
@@ -22,8 +22,12 @@ describe('payment api', () => {
post
.
mockResolvedValue
({
data
:
{}
})
})
it
(
'
does not expose anonymous public out_trade_no verification
'
,
()
=>
{
expect
(
Object
.
prototype
.
hasOwnProperty
.
call
(
paymentAPI
,
'
verifyOrderPublic
'
)).
toBe
(
false
)
it
(
'
keeps legacy public out_trade_no verification for upgrade compatibility
'
,
async
()
=>
{
await
paymentAPI
.
verifyOrderPublic
(
'
legacy-order-no
'
)
expect
(
post
).
toHaveBeenCalledWith
(
'
/payment/public/orders/verify
'
,
{
out_trade_no
:
'
legacy-order-no
'
,
})
})
it
(
'
keeps signed public resume-token resolve endpoint
'
,
async
()
=>
{
...
...
frontend/src/api/__tests__/user.spec.ts
0 → 100644
View file @
ddf80f5e
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
describe
(
'
user api oauth binding urls
'
,
()
=>
{
beforeEach
(()
=>
{
vi
.
resetModules
()
vi
.
stubEnv
(
'
VITE_API_BASE_URL
'
,
'
https://api.example.com/api/v1
'
)
})
afterEach
(()
=>
{
vi
.
unstubAllEnvs
()
})
it
(
'
builds third-party bind urls against the bind start endpoint
'
,
async
()
=>
{
const
{
buildOAuthBindingStartURL
}
=
await
import
(
'
@/api/user
'
)
expect
(
buildOAuthBindingStartURL
(
'
linuxdo
'
,
{
redirectTo
:
'
/settings/profile
'
})).
toBe
(
'
https://api.example.com/api/v1/auth/oauth/linuxdo/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user
'
)
expect
(
buildOAuthBindingStartURL
(
'
wechat
'
,
{
redirectTo
:
'
/settings/profile
'
,
wechatOAuthSettings
:
{
wechat_oauth_open_enabled
:
true
,
wechat_oauth_mp_enabled
:
false
,
wechat_oauth_mobile_enabled
:
false
}
})
).
toBe
(
'
https://api.example.com/api/v1/auth/oauth/wechat/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user&mode=open
'
)
})
})
frontend/src/api/admin/users.ts
View file @
ddf80f5e
...
...
@@ -8,26 +8,40 @@ import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/
export
interface
AdminBindAuthIdentityChannelRequest
{
channel
:
string
channel_app_id
?
:
string
channel_app_id
:
string
channel_subject
:
string
metadata
?:
Record
<
string
,
unknown
>
metadata
?:
Record
<
string
,
unknown
>
|
null
}
export
interface
AdminBindAuthIdentityRequest
{
provider_type
:
string
provider_key
:
string
provider_subject
:
string
issuer
?:
string
metadata
?:
Record
<
string
,
unknown
>
issuer
?:
string
|
null
metadata
?:
Record
<
string
,
unknown
>
|
null
channel
?:
AdminBindAuthIdentityChannelRequest
}
export
interface
AdminBoundAuthIdentityChannel
{
channel
:
string
channel_app_id
:
string
channel_subject
:
string
metadata
:
Record
<
string
,
unknown
>
|
null
created_at
:
string
updated_at
:
string
}
export
interface
AdminBoundAuthIdentity
{
identity
_id
:
number
user
_id
:
number
provider_type
:
string
provider_key
:
string
provider_subject
:
string
channel_id
?:
number
|
null
verified_at
?:
string
|
null
issuer
?:
string
|
null
metadata
:
Record
<
string
,
unknown
>
|
null
created_at
:
string
updated_at
:
string
channel
?:
AdminBoundAuthIdentityChannel
|
null
}
/**
...
...
frontend/src/api/auth.ts
View file @
ddf80f5e
...
...
@@ -194,6 +194,7 @@ export interface OAuthTokenResponse {
}
export
interface
PendingOAuthBindLoginResponse
extends
Partial
<
OAuthTokenResponse
>
{
auth_result
?:
string
redirect
?:
string
error
?:
string
requires_2fa
?:
boolean
...
...
@@ -206,7 +207,9 @@ export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenRespons
export
type
PendingOAuthExchangeResponse
=
PendingOAuthBindLoginResponse
export
interface
PendingOAuthCreateAccountResponse
extends
OAuthTokenResponse
{}
export
interface
PendingOAuthCreateAccountResponse
extends
OAuthTokenResponse
{
auth_result
?:
string
}
export
interface
PendingOAuthSendVerifyCodeResponse
extends
SendVerifyCodeResponse
{
auth_result
?:
string
...
...
@@ -278,33 +281,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v
}
}
export
function
prepareOAuthBindAccessTokenCookie
():
void
{
if
(
typeof
document
===
'
undefined
'
||
typeof
window
===
'
undefined
'
)
{
export
async
function
prepareOAuthBindAccessTokenCookie
():
Promise
<
void
>
{
if
(
!
getAuthToken
()
)
{
return
}
const
token
=
getAuthToken
()
if
(
!
token
)
{
return
}
const
secure
=
window
.
location
.
protocol
===
'
https:
'
?
'
; Secure
'
:
''
const
path
=
resolveOAuthBindCookiePath
()
document
.
cookie
=
`oauth_bind_access_token=
${
encodeURIComponent
(
token
)}
; Path=
${
path
}
/auth/oauth; Max-Age=600; SameSite=Lax
${
secure
}
`
}
function
resolveOAuthBindCookiePath
():
string
{
const
apiBase
=
((
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
).
replace
(
/
\/
$/
,
''
)
try
{
return
new
URL
(
apiBase
,
window
.
location
.
origin
).
pathname
.
replace
(
/
\/
$/
,
''
)
||
'
/api/v1
'
}
catch
{
if
(
apiBase
.
startsWith
(
'
/
'
))
{
return
apiBase
}
return
'
/api/v1
'
}
await
apiClient
.
post
(
'
/auth/oauth/bind-token
'
)
}
/**
...
...
frontend/src/api/client.ts
View file @
ddf80f5e
...
...
@@ -13,6 +13,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
export
const
apiClient
:
AxiosInstance
=
axios
.
create
({
baseURL
:
API_BASE_URL
,
withCredentials
:
true
,
timeout
:
30000
,
headers
:
{
'
Content-Type
'
:
'
application/json
'
...
...
frontend/src/api/payment.ts
View file @
ddf80f5e
...
...
@@ -67,6 +67,11 @@ export const paymentAPI = {
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/orders/verify
'
,
{
out_trade_no
:
outTradeNo
})
},
/** Legacy-compatible public order lookup by out_trade_no */
verifyOrderPublic
(
outTradeNo
:
string
)
{
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/public/orders/verify
'
,
{
out_trade_no
:
outTradeNo
})
},
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken
(
resumeToken
:
string
)
{
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/public/orders/resolve
'
,
{
resume_token
:
resumeToken
})
...
...
frontend/src/api/user.ts
View file @
ddf80f5e
...
...
@@ -150,13 +150,13 @@ export function buildOAuthBindingStartURL(
params
.
set
(
'
mode
'
,
mode
)
}
return
`
${
normalized
}
/auth/oauth/
${
provider
}
/start?
${
params
.
toString
()}
`
return
`
${
normalized
}
/auth/oauth/
${
provider
}
/
bind/
start?
${
params
.
toString
()}
`
}
export
function
startOAuthBinding
(
export
async
function
startOAuthBinding
(
provider
:
BindableOAuthProvider
,
options
:
BuildOAuthBindingStartURLOptions
=
{}
):
void
{
):
Promise
<
void
>
{
if
(
typeof
window
===
'
undefined
'
)
{
return
}
...
...
@@ -164,7 +164,7 @@ export function startOAuthBinding(
if
(
!
startURL
)
{
return
}
prepareOAuthBindAccessTokenCookie
()
await
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
}
...
...
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
View file @
ddf80f5e
...
...
@@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => {
describe
(
'
AppSidebar header styles
'
,
()
=>
{
it
(
'
does not clip the version badge dropdown
'
,
()
=>
{
const
sidebarHeaderBlockMatch
=
styleSource
.
match
(
/
\.
sidebar-header
\s
*
\{[\s\S]
*
?\n
\}
/
)
const
sidebarHeaderBlockMatch
=
styleSource
.
match
(
/
\.
sidebar-header
\s
*
\{[\s\S]
*
?\n
{2}
\}
/
)
const
sidebarBrandBlockMatch
=
componentSource
.
match
(
/
\.
sidebar-brand
\s
*
\{[\s\S]
*
?\n\}
/
)
expect
(
sidebarHeaderBlockMatch
).
not
.
toBeNull
()
...
...
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
View file @
ddf80f5e
...
...
@@ -73,6 +73,7 @@ describe('decidePaymentLaunch', () => {
expect
(
decision
.
paymentState
.
paymentType
).
toBe
(
'
alipay
'
)
expect
(
decision
.
stripeMethod
).
toBe
(
'
alipay
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-1
'
)
expect
(
decision
.
recovery
.
outTradeNo
).
toBe
(
''
)
})
it
(
'
uses Stripe route flow for mobile WeChat client secret
'
,
()
=>
{
...
...
@@ -94,6 +95,7 @@ describe('decidePaymentLaunch', () => {
pay_url
:
'
https://pay.example.com/session/abc
'
,
payment_mode
:
'
popup
'
,
resume_token
:
'
resume-2
'
,
out_trade_no
:
'
sub2_abc
'
,
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
balance
'
,
...
...
@@ -103,6 +105,7 @@ describe('decidePaymentLaunch', () => {
expect
(
decision
.
kind
).
toBe
(
'
redirect_waiting
'
)
expect
(
decision
.
paymentState
.
payUrl
).
toBe
(
'
https://pay.example.com/session/abc
'
)
expect
(
decision
.
recovery
.
paymentMode
).
toBe
(
'
popup
'
)
expect
(
decision
.
recovery
.
outTradeNo
).
toBe
(
'
sub2_abc
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-2
'
)
})
...
...
@@ -225,6 +228,7 @@ describe('readPaymentRecoverySnapshot', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/33
'
,
outTradeNo
:
'
sub2_33
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
...
...
@@ -249,6 +253,7 @@ describe('readPaymentRecoverySnapshot', () => {
expiresAt
:
'
2024-01-01T00:10:00.000Z
'
,
paymentType
:
'
wxpay
'
,
payUrl
:
'
https://pay.example.com/session/55
'
,
outTradeNo
:
'
sub2_55
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
...
...
@@ -264,10 +269,34 @@ describe('readPaymentRecoverySnapshot', () => {
expect
(
readPaymentRecoverySnapshot
(
JSON
.
stringify
({
...
expiredSnapshot
,
outTradeNo
:
'
sub2_55
'
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
}),
{
now
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
1
,
0
),
resumeToken
:
'
other-token
'
,
})).
toBeNull
()
})
it
(
'
keeps backward compatibility with snapshots written before outTradeNo existed
'
,
()
=>
{
const
restored
=
readPaymentRecoverySnapshot
(
JSON
.
stringify
({
orderId
:
44
,
amount
:
18
,
qrCode
:
''
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/44
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
'
resume-44
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}),
{
now
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
1
,
0
),
resumeToken
:
'
resume-44
'
,
})
expect
(
restored
?.
orderId
).
toBe
(
44
)
expect
(
restored
?.
outTradeNo
).
toBe
(
''
)
})
})
frontend/src/components/payment/paymentFlow.ts
View file @
ddf80f5e
...
...
@@ -34,6 +34,7 @@ export interface PaymentRecoverySnapshot {
expiresAt
:
string
paymentType
:
string
payUrl
:
string
outTradeNo
:
string
clientSecret
:
string
payAmount
:
number
orderType
:
OrderType
|
''
...
...
@@ -132,6 +133,7 @@ export function decidePaymentLaunch(
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
visibleMethod
,
payUrl
:
result
.
pay_url
||
''
,
outTradeNo
:
result
.
out_trade_no
||
''
,
clientSecret
:
result
.
client_secret
||
''
,
payAmount
:
result
.
pay_amount
,
orderType
:
context
.
orderType
,
...
...
@@ -227,6 +229,7 @@ export function readPaymentRecoverySnapshot(
||
typeof
parsed
.
expiresAt
!==
'
string
'
||
typeof
parsed
.
paymentType
!==
'
string
'
||
typeof
parsed
.
payUrl
!==
'
string
'
||
(
parsed
.
outTradeNo
!=
null
&&
typeof
parsed
.
outTradeNo
!==
'
string
'
)
||
typeof
parsed
.
clientSecret
!==
'
string
'
||
typeof
parsed
.
payAmount
!==
'
number
'
||
typeof
parsed
.
paymentMode
!==
'
string
'
...
...
@@ -241,7 +244,7 @@ export function readPaymentRecoverySnapshot(
if
(
Number
.
isFinite
(
expiresAt
)
&&
expiresAt
<=
now
)
{
return
null
}
if
(
options
.
resumeToken
&&
parsed
.
resumeToken
&&
parsed
.
resumeToken
!==
options
.
resumeToken
)
{
if
(
options
.
resumeToken
&&
parsed
.
resumeToken
!==
options
.
resumeToken
)
{
return
null
}
...
...
@@ -252,6 +255,7 @@ export function readPaymentRecoverySnapshot(
expiresAt
:
parsed
.
expiresAt
,
paymentType
:
parsed
.
paymentType
,
payUrl
:
parsed
.
payUrl
,
outTradeNo
:
parsed
.
outTradeNo
||
''
,
clientSecret
:
parsed
.
clientSecret
,
payAmount
:
parsed
.
payAmount
,
orderType
:
parsed
.
orderType
===
'
subscription
'
?
'
subscription
'
:
'
balance
'
,
...
...
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
ddf80f5e
...
...
@@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() =>
:
t
(
'
profile.authBindings.confirmEmailBindAction
'
)
)
const
wechatOAuthSettings
=
computed
<
WeChatOAuthPublicSettings
|
null
>
(()
=>
{
if
(
hasExplicitWeChatOAuthCapabilities
(
appStore
.
cachedPublicSettings
))
{
return
appStore
.
cachedPublicSettings
function
resolveLegacyCompatibleWeChatSettings
(
settings
:
WeChatOAuthPublicSettings
|
null
|
undefined
):
(
WeChatOAuthPublicSettings
&
{
wechat_oauth_open_enabled
:
boolean
wechat_oauth_mp_enabled
:
boolean
}
)
|
null
{
if
(
!
settings
)
{
return
null
}
if
(
hasExplicitWeChatOAuthCapabilities
(
settings
))
{
return
settings
}
if
(
typeof
settings
.
wechat_oauth_enabled
!==
'
boolean
'
)
{
return
null
}
if
(
typeof
props
.
wechatOpenEnabled
===
'
boolean
'
&&
typeof
props
.
wechatMpEnabled
===
'
boolean
'
)
{
return
{
wechat_oauth_enabled
:
props
.
wechatEnabled
,
wechat_oauth_open_enabled
:
prop
s
.
wechat
OpenE
nabled
,
wechat_oauth_mp_enabled
:
prop
s
.
wechat
MpE
nabled
,
...
settings
,
wechat_oauth_open_enabled
:
setting
s
.
wechat
_oauth_e
nabled
,
wechat_oauth_mp_enabled
:
setting
s
.
wechat
_oauth_e
nabled
,
}
}
const
wechatOAuthSettings
=
computed
<
WeChatOAuthPublicSettings
|
null
>
(()
=>
{
const
cachedSettings
=
resolveLegacyCompatibleWeChatSettings
(
appStore
.
cachedPublicSettings
)
if
(
cachedSettings
)
{
return
cachedSettings
}
return
null
return
resolveLegacyCompatibleWeChatSettings
({
wechat_oauth_enabled
:
props
.
wechatEnabled
,
wechat_oauth_open_enabled
:
props
.
wechatOpenEnabled
,
wechat_oauth_mp_enabled
:
props
.
wechatMpEnabled
,
}
)
}
)
const
resolvedWeChatBinding
=
computed
(()
=>
resolveWeChatOAuthStartStrict
(
wechatOAuthSettings
.
value
))
...
...
@@ -362,6 +384,27 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
return
binding
}
function
getDisplayableEmail
(
user
:
User
|
null
|
undefined
):
string
{
const
email
=
user
?.
email
?.
trim
()
||
''
if
(
!
email
)
{
return
''
}
if
(
email
.
endsWith
(
'
.invalid
'
)
&&
!
getBindingStatusForUser
(
user
,
'
email
'
))
{
return
''
}
return
email
}
function
isProviderEnabledForBinding
(
provider
:
BindableProvider
):
boolean
{
if
(
provider
===
'
linuxdo
'
)
{
return
props
.
linuxdoEnabled
}
if
(
provider
===
'
oidc
'
)
{
return
props
.
oidcEnabled
}
return
resolvedWeChatBinding
.
value
.
mode
!==
null
}
const
providerItems
=
computed
(()
=>
[
{
provider
:
'
email
'
as
const
,
...
...
@@ -375,7 +418,10 @@ const providerItems = computed(() => [
provider
:
'
linuxdo
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.linuxdo
'
),
bound
:
getBindingStatus
(
'
linuxdo
'
),
canBind
:
getBindingDetails
(
'
linuxdo
'
)?.
can_bind
??
(
props
.
linuxdoEnabled
&&
!
getBindingStatus
(
'
linuxdo
'
)),
canBind
:
!
getBindingStatus
(
'
linuxdo
'
)
&&
isProviderEnabledForBinding
(
'
linuxdo
'
)
&&
(
getBindingDetails
(
'
linuxdo
'
)?.
can_bind
??
true
),
canUnbind
:
Boolean
(
getBindingStatus
(
'
linuxdo
'
)
&&
getBindingDetails
(
'
linuxdo
'
)?.
can_unbind
),
details
:
getBindingDetails
(
'
linuxdo
'
),
}
,
...
...
@@ -383,7 +429,10 @@ const providerItems = computed(() => [
provider
:
'
oidc
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.oidc
'
,
{
providerName
:
props
.
oidcProviderName
}
),
bound
:
getBindingStatus
(
'
oidc
'
),
canBind
:
getBindingDetails
(
'
oidc
'
)?.
can_bind
??
(
props
.
oidcEnabled
&&
!
getBindingStatus
(
'
oidc
'
)),
canBind
:
!
getBindingStatus
(
'
oidc
'
)
&&
isProviderEnabledForBinding
(
'
oidc
'
)
&&
(
getBindingDetails
(
'
oidc
'
)?.
can_bind
??
true
),
canUnbind
:
Boolean
(
getBindingStatus
(
'
oidc
'
)
&&
getBindingDetails
(
'
oidc
'
)?.
can_unbind
),
details
:
getBindingDetails
(
'
oidc
'
),
}
,
...
...
@@ -391,7 +440,10 @@ const providerItems = computed(() => [
provider
:
'
wechat
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.wechat
'
),
bound
:
getBindingStatus
(
'
wechat
'
),
canBind
:
getBindingDetails
(
'
wechat
'
)?.
can_bind
??
(
resolvedWeChatBinding
.
value
.
mode
!==
null
&&
!
getBindingStatus
(
'
wechat
'
)),
canBind
:
!
getBindingStatus
(
'
wechat
'
)
&&
isProviderEnabledForBinding
(
'
wechat
'
)
&&
(
getBindingDetails
(
'
wechat
'
)?.
can_bind
??
true
),
canUnbind
:
Boolean
(
getBindingStatus
(
'
wechat
'
)
&&
getBindingDetails
(
'
wechat
'
)?.
can_unbind
),
details
:
getBindingDetails
(
'
wechat
'
),
}
,
...
...
@@ -425,7 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string {
function
providerSummary
(
provider
:
UserAuthProvider
):
string
{
if
(
provider
===
'
email
'
)
{
return
currentUser
.
value
?.
email
||
''
return
getDisplayableEmail
(
currentUser
.
value
)
}
return
''
}
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
ddf80f5e
...
...
@@ -40,7 +40,7 @@
<div
class=
"space-y-1"
>
<p
class=
"truncate text-sm text-gray-600 dark:text-gray-300"
>
{{
user
?.
email
}}
{{
primaryEmailDisplay
}}
</p>
<div
v-if=
"sourceHints.length"
...
...
@@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
import
type
{
User
,
UserAuthBindingStatus
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
const
props
=
withDefaults
(
defineProps
<
{
user
:
User
|
null
...
...
@@ -206,8 +206,41 @@ const props = withDefaults(defineProps<{
const
{
t
}
=
useI18n
()
function
normalizeBindingStatus
(
binding
:
boolean
|
UserAuthBindingStatus
|
undefined
):
boolean
|
null
{
if
(
typeof
binding
===
'
boolean
'
)
{
return
binding
}
if
(
!
binding
)
{
return
null
}
if
(
typeof
binding
.
bound
===
'
boolean
'
)
{
return
binding
.
bound
}
return
Boolean
(
binding
.
provider_subject
||
binding
.
issuer
||
binding
.
provider_key
)
}
function
isEmailBound
(
user
:
User
|
null
|
undefined
):
boolean
{
if
(
typeof
user
?.
email_bound
===
'
boolean
'
)
{
return
user
.
email_bound
}
const
nested
=
user
?.
auth_bindings
?.
email
??
user
?.
identity_bindings
?.
email
const
normalized
=
normalizeBindingStatus
(
nested
)
return
normalized
??
false
}
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
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
(
email
.
endsWith
(
'
.invalid
'
)
&&
!
isEmailBound
(
props
.
user
))
{
return
''
}
return
email
})
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
const
memberSinceLabel
=
computed
(()
=>
{
const
raw
=
props
.
user
?.
created_at
?.
trim
()
...
...
@@ -229,7 +262,7 @@ const memberSinceLabel = computed(() => {
const
providerLabels
=
computed
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
email
:
t
(
'
profile.authBindings.providers.email
'
),
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
'
)
}))
...
...
frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
View file @
ddf80f5e
...
...
@@ -188,7 +188,7 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-wechat-action"]
'
).
exists
()).
toBe
(
false
)
})
it
(
'
hide
s the WeChat bind action when only the legacy aggregate setting is present
'
,
()
=>
{
it
(
'
keep
s the WeChat bind action
visible
when only the legacy aggregate setting is present
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
...
...
@@ -201,7 +201,28 @@ describe('ProfileIdentityBindingsSection', () => {
},
})
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-wechat-action"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-wechat-action"]
'
).
exists
()).
toBe
(
true
)
})
it
(
'
starts the WeChat bind flow when only the legacy aggregate setting is present
'
,
async
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
(),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
true
,
},
})
await
wrapper
.
get
(
'
[data-testid="profile-binding-wechat-action"]
'
).
trigger
(
'
click
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
/api/v1/auth/oauth/wechat/start?
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
})
it
(
'
uses explicit cached WeChat capabilities and ignores legacy prop fallbacks
'
,
()
=>
{
...
...
@@ -335,6 +356,51 @@ describe('ProfileIdentityBindingsSection', () => {
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
(
'
does not show a synthetic oauth-only email when only fallback auth bindings mark email as unbound
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
({
email
:
'
legacy-user@wechat-connect.invalid
'
,
auth_bindings
:
{
email
:
{
bound
:
false
},
},
}),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
false
,
},
})
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@wechat-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
()
=>
{
userApiMocks
.
sendEmailBindingCode
.
mockResolvedValue
(
undefined
)
userApiMocks
.
bindEmailIdentity
.
mockResolvedValue
(
...
...
@@ -474,4 +540,26 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
userApiMocks
.
unbindAuthIdentity
).
toHaveBeenCalledWith
(
'
linuxdo
'
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-linuxdo-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
})
it
(
'
hides bind actions when provider details say bindable but the provider is disabled
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
({
auth_bindings
:
{
linuxdo
:
{
bound
:
false
,
can_bind
:
true
},
oidc
:
{
bound
:
false
,
can_bind
:
true
},
},
}),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
false
,
},
})
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-linuxdo-action"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-oidc-action"]
'
).
exists
()).
toBe
(
false
)
})
})
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
ddf80f5e
...
...
@@ -111,6 +111,67 @@ describe('ProfileInfoCard', () => {
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
(
'
does not display synthetic oauth-only emails when only legacy identity bindings mark email as unbound
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
createUser
({
email
:
'
legacy-user@wechat-connect.invalid
'
,
identity_bindings
:
{
email
:
{
bound
:
false
}
}
})
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@wechat-connect.invalid
'
)
})
it
(
'
renders the approved overview hero and two-column content shell
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
...
...
Prev
1
2
3
4
5
6
7
Next
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