fix(profile): stabilize binding compatibility and frontend checks
This commit is contained in:
22
.github/workflows/backend-ci.yml
vendored
22
.github/workflows/backend-ci.yml
vendored
@ -28,6 +28,26 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: make test-integration
|
run: make test-integration
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Frontend typecheck and critical vitest
|
||||||
|
run: make test-frontend
|
||||||
|
|
||||||
golangci-lint:
|
golangci-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -46,4 +66,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: v2.9
|
version: v2.9
|
||||||
args: --timeout=30m
|
args: --timeout=30m
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|||||||
14
Makefile
14
Makefile
@ -1,4 +1,12 @@
|
|||||||
.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-datamanagementd secret-scan
|
.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan
|
||||||
|
|
||||||
|
FRONTEND_CRITICAL_VITEST := \
|
||||||
|
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
|
||||||
|
src/views/auth/__tests__/WechatCallbackView.spec.ts \
|
||||||
|
src/views/user/__tests__/PaymentView.spec.ts \
|
||||||
|
src/views/user/__tests__/PaymentResultView.spec.ts \
|
||||||
|
src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \
|
||||||
|
src/views/admin/__tests__/SettingsView.spec.ts
|
||||||
|
|
||||||
# 一键编译前后端
|
# 一键编译前后端
|
||||||
build: build-backend build-frontend
|
build: build-backend build-frontend
|
||||||
@ -24,6 +32,10 @@ test-backend:
|
|||||||
test-frontend:
|
test-frontend:
|
||||||
@pnpm --dir frontend run lint:check
|
@pnpm --dir frontend run lint:check
|
||||||
@pnpm --dir frontend run typecheck
|
@pnpm --dir frontend run typecheck
|
||||||
|
@$(MAKE) test-frontend-critical
|
||||||
|
|
||||||
|
test-frontend-critical:
|
||||||
|
@pnpm --dir frontend exec vitest run $(FRONTEND_CRITICAL_VITEST)
|
||||||
|
|
||||||
test-datamanagementd:
|
test-datamanagementd:
|
||||||
@cd datamanagement && go test ./...
|
@cd datamanagement && go test ./...
|
||||||
|
|||||||
12
README.md
12
README.md
@ -42,10 +42,18 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
|||||||
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
||||||
- **Concurrency Control** - Per-user and per-account concurrency limits
|
- **Concurrency Control** - Per-user and per-account concurrency limits
|
||||||
- **Rate Limiting** - Configurable request and token rate limits
|
- **Rate Limiting** - Configurable request and token rate limits
|
||||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
|
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Payment Setup](#payment))
|
||||||
- **Admin Dashboard** - Web interface for monitoring and management
|
- **Admin Dashboard** - Web interface for monitoring and management
|
||||||
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
||||||
|
|
||||||
|
## Payment
|
||||||
|
|
||||||
|
Sub2API includes the payment system in the main service. No standalone payment service or separate payment guide is required.
|
||||||
|
|
||||||
|
- Supported providers: EasyPay, Alipay, WeChat Pay, Stripe
|
||||||
|
- The frontend keeps user-facing methods unified; admins choose the backing source in `Admin -> Settings -> Payment`
|
||||||
|
- Callback URLs are generated from the site domain when configuring providers
|
||||||
|
|
||||||
## ❤️ Sponsors
|
## ❤️ Sponsors
|
||||||
|
|
||||||
> [Want to appear here?](mailto:support@pincc.ai)
|
> [Want to appear here?](mailto:support@pincc.ai)
|
||||||
@ -109,7 +117,7 @@ Community projects that extend or integrate with Sub2API:
|
|||||||
|
|
||||||
| Project | Description | Features |
|
| Project | Description | Features |
|
||||||
|---------|-------------|----------|
|
|---------|-------------|----------|
|
||||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Configuration Guide](docs/PAYMENT.md) |
|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Setup](#payment) |
|
||||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | Mobile admin console | Cross-platform app (iOS/Android/Web) for user management, account management, monitoring dashboard, and multi-backend switching; built with Expo + React Native |
|
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | Mobile admin console | Cross-platform app (iOS/Android/Web) for user management, account management, monitoring dashboard, and multi-backend switching; built with Expo + React Native |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|||||||
12
README_CN.md
12
README_CN.md
@ -41,10 +41,18 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
|
|||||||
- **智能调度** - 智能账号选择,支持粘性会话
|
- **智能调度** - 智能账号选择,支持粘性会话
|
||||||
- **并发控制** - 用户级和账号级并发限制
|
- **并发控制** - 用户级和账号级并发限制
|
||||||
- **速率限制** - 可配置的请求和 Token 速率限制
|
- **速率限制** - 可配置的请求和 Token 速率限制
|
||||||
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md))
|
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([支付说明](#支付))
|
||||||
- **管理后台** - Web 界面进行监控和管理
|
- **管理后台** - Web 界面进行监控和管理
|
||||||
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
|
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
|
||||||
|
|
||||||
|
## 支付
|
||||||
|
|
||||||
|
Sub2API 已将支付系统集成到主服务中,无需独立支付服务,也不再依赖单独的支付配置文档。
|
||||||
|
|
||||||
|
- 支持服务商:EasyPay 易支付、支付宝官方、微信官方、Stripe
|
||||||
|
- 前台统一展示用户可见支付方式,管理员在 `管理后台 -> 设置 -> 支付` 里选择对应来源
|
||||||
|
- 添加服务商时会基于站点域名生成回调地址
|
||||||
|
|
||||||
## ❤️ 赞助商
|
## ❤️ 赞助商
|
||||||
|
|
||||||
> [想出现在这里?](mailto:support@pincc.ai)
|
> [想出现在这里?](mailto:support@pincc.ai)
|
||||||
@ -108,7 +116,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
|
|||||||
|
|
||||||
| 项目 | 说明 | 功能 |
|
| 项目 | 说明 | 功能 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付说明](#支付) |
|
||||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
|
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
12
README_JA.md
12
README_JA.md
@ -42,10 +42,18 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
|||||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
|
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([決済案内](#決済))
|
||||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||||
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||||
|
|
||||||
|
## 決済
|
||||||
|
|
||||||
|
Sub2API の決済機能は本体に統合されています。独立した決済サービスや別個の決済ガイドは不要です。
|
||||||
|
|
||||||
|
- 対応プロバイダー: EasyPay、Alipay、WeChat Pay、Stripe
|
||||||
|
- フロントエンドではユーザー向け決済方法を統一表示し、管理者は `管理画面 -> 設定 -> 決済` で実際の接続先を選択します
|
||||||
|
- プロバイダー設定時のコールバック URL はサイトドメインから自動生成されます
|
||||||
|
|
||||||
## ❤️ スポンサー
|
## ❤️ スポンサー
|
||||||
|
|
||||||
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
|
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
|
||||||
@ -108,7 +116,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
|
|||||||
|
|
||||||
| プロジェクト | 説明 | 機能 |
|
| プロジェクト | 説明 | 機能 |
|
||||||
|---------|-------------|----------|
|
|---------|-------------|----------|
|
||||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済設定ガイド](docs/PAYMENT.md)をご参照ください |
|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済案内](#決済)をご参照ください |
|
||||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリ(iOS/Android/Web)。Expo + React Native で構築 |
|
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリ(iOS/Android/Web)。Expo + React Native で構築 |
|
||||||
|
|
||||||
## 技術スタック
|
## 技術スタック
|
||||||
|
|||||||
@ -249,7 +249,7 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedUser, err := h.userService.UnbindUserAuthProvider(
|
updatedUser, unbound, err := h.userService.UnbindUserAuthProviderWithResult(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
subject.UserID,
|
subject.UserID,
|
||||||
c.Param("provider"),
|
c.Param("provider"),
|
||||||
@ -258,7 +258,7 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) {
|
|||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.authService != nil {
|
if unbound && h.authService != nil {
|
||||||
if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil {
|
if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@ -512,7 +512,7 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
|
|||||||
var avatarSource *userProfileSourceContext
|
var avatarSource *userProfileSourceContext
|
||||||
avatarValue := strings.TrimSpace(user.AvatarURL)
|
avatarValue := strings.TrimSpace(user.AvatarURL)
|
||||||
for _, summary := range thirdParty {
|
for _, summary := range thirdParty {
|
||||||
if avatarValue != "" && avatarValue == strings.TrimSpace(summary.DisplayName) {
|
if avatarValue != "" && avatarValue == strings.TrimSpace(summary.AvatarURL) {
|
||||||
avatarSource = buildUserProfileSourceContext(summary.Provider)
|
avatarSource = buildUserProfileSourceContext(summary.Provider)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -636,6 +636,50 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure
|
|||||||
require.Equal(t, int64(5), repo.user.TokenVersion)
|
require.Equal(t, int64(5), repo.user.TokenVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerUnbindIdentityDoesNotRevokeSessionsWhenNothingWasUnbound(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
repo := &userHandlerRepoStub{
|
||||||
|
user: &service.User{
|
||||||
|
ID: 24,
|
||||||
|
Email: "identity@example.com",
|
||||||
|
Username: "identity-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
TokenVersion: 4,
|
||||||
|
},
|
||||||
|
identities: []service.UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "email",
|
||||||
|
ProviderKey: "email",
|
||||||
|
ProviderSubject: "identity@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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: 24})
|
||||||
|
c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}}
|
||||||
|
|
||||||
|
handler.UnbindIdentity(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
require.Empty(t, repo.unbound)
|
||||||
|
require.Empty(t, refreshTokenCache.revokedUserIDs)
|
||||||
|
require.Equal(t, int64(4), repo.user.TokenVersion)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
|
func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
@ -728,7 +772,7 @@ func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
|||||||
require.Equal(t, "wechat", resp.Data.Provider)
|
require.Equal(t, "wechat", resp.Data.Provider)
|
||||||
require.Equal(t, "GET", resp.Data.Method)
|
require.Equal(t, "GET", resp.Data.Method)
|
||||||
require.True(t, resp.Data.UseBrowserRedirect)
|
require.True(t, resp.Data.UseBrowserRedirect)
|
||||||
require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/start")
|
require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/bind/start")
|
||||||
require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user")
|
require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user")
|
||||||
require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile")
|
require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,7 +85,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
},
|
},
|
||||||
"oidc": {
|
"oidc": {
|
||||||
"provider": "oidc",
|
"provider": "oidc",
|
||||||
@ -93,7 +93,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/oidc/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
},
|
},
|
||||||
"wechat": {
|
"wechat": {
|
||||||
"provider": "wechat",
|
"provider": "wechat",
|
||||||
@ -101,7 +101,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/wechat/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identity_bindings": {
|
"identity_bindings": {
|
||||||
@ -122,7 +122,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
},
|
},
|
||||||
"oidc": {
|
"oidc": {
|
||||||
"provider": "oidc",
|
"provider": "oidc",
|
||||||
@ -130,7 +130,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/oidc/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
},
|
},
|
||||||
"wechat": {
|
"wechat": {
|
||||||
"provider": "wechat",
|
"provider": "wechat",
|
||||||
@ -138,7 +138,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/wechat/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_bindings": {
|
"auth_bindings": {
|
||||||
@ -159,7 +159,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
},
|
},
|
||||||
"oidc": {
|
"oidc": {
|
||||||
"provider": "oidc",
|
"provider": "oidc",
|
||||||
@ -167,7 +167,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/oidc/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
},
|
},
|
||||||
"wechat": {
|
"wechat": {
|
||||||
"provider": "wechat",
|
"provider": "wechat",
|
||||||
@ -175,7 +175,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"bound_count": 0,
|
"bound_count": 0,
|
||||||
"can_bind": true,
|
"can_bind": true,
|
||||||
"can_unbind": false,
|
"can_unbind": false,
|
||||||
"bind_start_path": "/api/v1/auth/oauth/wechat/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"run_mode": "standard"
|
"run_mode": "standard"
|
||||||
|
|||||||
@ -63,8 +63,20 @@ func RegisterAuthRoutes(
|
|||||||
FailureMode: middleware.RateLimitFailClose,
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
}), h.Auth.ResetPassword)
|
}), h.Auth.ResetPassword)
|
||||||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||||
|
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
c.Request.URL.RawQuery = query.Encode()
|
||||||
|
h.Auth.LinuxDoOAuthStart(c)
|
||||||
|
})
|
||||||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||||||
|
auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
c.Request.URL.RawQuery = query.Encode()
|
||||||
|
h.Auth.WeChatOAuthStart(c)
|
||||||
|
})
|
||||||
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
||||||
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
|
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
|
||||||
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
|
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
|
||||||
@ -129,6 +141,12 @@ func RegisterAuthRoutes(
|
|||||||
h.Auth.CreateWeChatOAuthAccount,
|
h.Auth.CreateWeChatOAuthAccount,
|
||||||
)
|
)
|
||||||
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
||||||
|
auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
query.Set("intent", "bind_current_user")
|
||||||
|
c.Request.URL.RawQuery = query.Encode()
|
||||||
|
h.Auth.OIDCOAuthStart(c)
|
||||||
|
})
|
||||||
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
|
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
|
||||||
auth.POST("/oauth/oidc/complete-registration",
|
auth.POST("/oauth/oidc/complete-registration",
|
||||||
rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{
|
rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||||
@ -165,23 +183,5 @@ func RegisterAuthRoutes(
|
|||||||
// 撤销所有会话(需要认证)
|
// 撤销所有会话(需要认证)
|
||||||
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
||||||
authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie)
|
authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie)
|
||||||
authenticated.GET("/auth/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
|
||||||
query := c.Request.URL.Query()
|
|
||||||
query.Set("intent", "bind_current_user")
|
|
||||||
c.Request.URL.RawQuery = query.Encode()
|
|
||||||
h.Auth.LinuxDoOAuthStart(c)
|
|
||||||
})
|
|
||||||
authenticated.GET("/auth/oauth/oidc/bind/start", func(c *gin.Context) {
|
|
||||||
query := c.Request.URL.Query()
|
|
||||||
query.Set("intent", "bind_current_user")
|
|
||||||
c.Request.URL.RawQuery = query.Encode()
|
|
||||||
h.Auth.OIDCOAuthStart(c)
|
|
||||||
})
|
|
||||||
authenticated.GET("/auth/oauth/wechat/bind/start", func(c *gin.Context) {
|
|
||||||
query := c.Request.URL.Query()
|
|
||||||
query.Set("intent", "bind_current_user")
|
|
||||||
c.Request.URL.RawQuery = query.Encode()
|
|
||||||
h.Auth.WeChatOAuthStart(c)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BindEmailIdentity verifies and binds a local email/password identity to the
|
// BindEmailIdentity verifies and binds a local email/password identity to the
|
||||||
@ -69,6 +70,7 @@ func (s *AuthService) BindEmailIdentity(
|
|||||||
if err := s.updateBoundEmailIdentityTx(ctx, currentUser, normalizedEmail, hashedPassword, firstRealEmailBind); err != nil {
|
if err := s.updateBoundEmailIdentityTx(ctx, currentUser, normalizedEmail, hashedPassword, firstRealEmailBind); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.revokeEmailIdentitySessions(ctx, userID)
|
||||||
return currentUser, nil
|
return currentUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +89,7 @@ func (s *AuthService) BindEmailIdentity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.revokeEmailIdentitySessions(ctx, userID)
|
||||||
return currentUser, nil
|
return currentUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +222,12 @@ func (s *AuthService) updateBoundEmailIdentityWithClient(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) revokeEmailIdentitySessions(ctx context.Context, userID int64) {
|
||||||
|
if err := s.RevokeAllUserSessions(ctx, userID); err != nil {
|
||||||
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after email identity bind for user %d: %v", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func replaceBoundEmailAuthIdentityWithClient(
|
func replaceBoundEmailAuthIdentityWithClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *dbent.Client,
|
client *dbent.Client,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -54,6 +56,16 @@ func newAuthServiceForEmailBind(
|
|||||||
settings map[string]string,
|
settings map[string]string,
|
||||||
emailCache service.EmailCache,
|
emailCache service.EmailCache,
|
||||||
defaultSubAssigner service.DefaultSubscriptionAssigner,
|
defaultSubAssigner service.DefaultSubscriptionAssigner,
|
||||||
|
) (*service.AuthService, service.UserRepository, *dbent.Client) {
|
||||||
|
return newAuthServiceForEmailBindWithRefreshCache(t, settings, emailCache, defaultSubAssigner, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthServiceForEmailBindWithRefreshCache(
|
||||||
|
t *testing.T,
|
||||||
|
settings map[string]string,
|
||||||
|
emailCache service.EmailCache,
|
||||||
|
defaultSubAssigner service.DefaultSubscriptionAssigner,
|
||||||
|
refreshTokenCache service.RefreshTokenCache,
|
||||||
) (*service.AuthService, service.UserRepository, *dbent.Client) {
|
) (*service.AuthService, service.UserRepository, *dbent.Client) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@ -98,7 +110,7 @@ CREATE TABLE IF NOT EXISTS user_provider_default_grants (
|
|||||||
emailSvc = service.NewEmailService(settingRepo, emailCache)
|
emailSvc = service.NewEmailService(settingRepo, emailCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := service.NewAuthService(client, repo, nil, nil, cfg, settingSvc, emailSvc, nil, nil, nil, defaultSubAssigner)
|
svc := service.NewAuthService(client, repo, nil, refreshTokenCache, cfg, settingSvc, emailSvc, nil, nil, nil, defaultSubAssigner)
|
||||||
return svc, repo, client
|
return svc, repo, client
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,6 +439,61 @@ func TestAuthServiceBindEmailIdentity_RejectsWrongCurrentPasswordForBoundEmail(t
|
|||||||
require.Equal(t, 0, newIdentityCount)
|
require.Equal(t, 0, newIdentityCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceBindEmailIdentity_RevokesExistingAccessAndRefreshTokens(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cache := &emailBindCacheStub{
|
||||||
|
data: &service.VerificationCodeData{
|
||||||
|
Code: "123456",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refreshTokenCache := newEmailBindRefreshTokenCacheStub()
|
||||||
|
userRepo := newEmailBindUserRepoStub(&service.User{
|
||||||
|
ID: 41,
|
||||||
|
Email: "legacy-user" + service.OIDCConnectSyntheticEmailDomain,
|
||||||
|
Username: "legacy-user",
|
||||||
|
PasswordHash: "old-hash",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
TokenVersion: 4,
|
||||||
|
})
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWT: config.JWTConfig{
|
||||||
|
Secret: "test-bind-email-secret",
|
||||||
|
ExpireHour: 1,
|
||||||
|
AccessTokenExpireMinutes: 60,
|
||||||
|
RefreshTokenExpireDays: 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
emailService := service.NewEmailService(nil, cache)
|
||||||
|
svc := service.NewAuthService(nil, userRepo, nil, refreshTokenCache, cfg, nil, emailService, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
oldTokenPair, err := svc.GenerateTokenPair(ctx, &service.User{
|
||||||
|
ID: 41,
|
||||||
|
Email: "legacy-user" + service.OIDCConnectSyntheticEmailDomain,
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
TokenVersion: 4,
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedUser, err := svc.BindEmailIdentity(ctx, 41, "new@example.com", "123456", "new-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, updatedUser)
|
||||||
|
|
||||||
|
storedUser, err := userRepo.GetByID(ctx, 41)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "new@example.com", storedUser.Email)
|
||||||
|
require.True(t, svc.CheckPassword("new-password", storedUser.PasswordHash))
|
||||||
|
|
||||||
|
_, err = svc.RefreshToken(ctx, oldTokenPair.AccessToken)
|
||||||
|
require.ErrorIs(t, err, service.ErrTokenRevoked)
|
||||||
|
|
||||||
|
_, err = svc.RefreshTokenPair(ctx, oldTokenPair.RefreshToken)
|
||||||
|
require.True(t, errors.Is(err, service.ErrTokenRevoked) || errors.Is(err, service.ErrRefreshTokenInvalid))
|
||||||
|
}
|
||||||
|
|
||||||
type emailBindSettingRepoStub struct {
|
type emailBindSettingRepoStub struct {
|
||||||
values map[string]string
|
values map[string]string
|
||||||
}
|
}
|
||||||
@ -527,3 +594,260 @@ func (s *emailBindCacheStub) GetNotifyCodeUserRate(context.Context, int64) (int6
|
|||||||
func (s *emailBindCacheStub) IncrNotifyCodeUserRate(context.Context, int64, time.Duration) (int64, error) {
|
func (s *emailBindCacheStub) IncrNotifyCodeUserRate(context.Context, int64, time.Duration) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type emailBindRefreshTokenCacheStub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens map[string]*service.RefreshTokenData
|
||||||
|
userSets map[int64]map[string]struct{}
|
||||||
|
families map[string]map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEmailBindRefreshTokenCacheStub() *emailBindRefreshTokenCacheStub {
|
||||||
|
return &emailBindRefreshTokenCacheStub{
|
||||||
|
tokens: make(map[string]*service.RefreshTokenData),
|
||||||
|
userSets: make(map[int64]map[string]struct{}),
|
||||||
|
families: make(map[string]map[string]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) StoreRefreshToken(_ context.Context, tokenHash string, data *service.RefreshTokenData, _ time.Duration) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
cloned := *data
|
||||||
|
s.tokens[tokenHash] = &cloned
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) GetRefreshToken(_ context.Context, tokenHash string) (*service.RefreshTokenData, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
data, ok := s.tokens[tokenHash]
|
||||||
|
if !ok {
|
||||||
|
return nil, service.ErrRefreshTokenNotFound
|
||||||
|
}
|
||||||
|
cloned := *data
|
||||||
|
return &cloned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) DeleteRefreshToken(_ context.Context, tokenHash string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.tokens, tokenHash)
|
||||||
|
for _, tokenSet := range s.userSets {
|
||||||
|
delete(tokenSet, tokenHash)
|
||||||
|
}
|
||||||
|
for _, tokenSet := range s.families {
|
||||||
|
delete(tokenSet, tokenHash)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) DeleteUserRefreshTokens(_ context.Context, userID int64) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for tokenHash := range s.userSets[userID] {
|
||||||
|
delete(s.tokens, tokenHash)
|
||||||
|
for _, tokenSet := range s.families {
|
||||||
|
delete(tokenSet, tokenHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(s.userSets, userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) DeleteTokenFamily(_ context.Context, familyID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for tokenHash := range s.families[familyID] {
|
||||||
|
delete(s.tokens, tokenHash)
|
||||||
|
for _, tokenSet := range s.userSets {
|
||||||
|
delete(tokenSet, tokenHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(s.families, familyID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) AddToUserTokenSet(_ context.Context, userID int64, tokenHash string, _ time.Duration) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.userSets[userID] == nil {
|
||||||
|
s.userSets[userID] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
s.userSets[userID][tokenHash] = struct{}{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) AddToFamilyTokenSet(_ context.Context, familyID string, tokenHash string, _ time.Duration) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.families[familyID] == nil {
|
||||||
|
s.families[familyID] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
s.families[familyID][tokenHash] = struct{}{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) GetUserTokenHashes(_ context.Context, userID int64) ([]string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
tokenSet := s.userSets[userID]
|
||||||
|
out := make([]string, 0, len(tokenSet))
|
||||||
|
for tokenHash := range tokenSet {
|
||||||
|
out = append(out, tokenHash)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) GetFamilyTokenHashes(_ context.Context, familyID string) ([]string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
tokenSet := s.families[familyID]
|
||||||
|
out := make([]string, 0, len(tokenSet))
|
||||||
|
for tokenHash := range tokenSet {
|
||||||
|
out = append(out, tokenHash)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindRefreshTokenCacheStub) IsTokenInFamily(_ context.Context, familyID string, tokenHash string) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, ok := s.families[familyID][tokenHash]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type emailBindUserRepoStub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
usersByID map[int64]*service.User
|
||||||
|
usersByEmail map[string]*service.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEmailBindUserRepoStub(user *service.User) *emailBindUserRepoStub {
|
||||||
|
cloned := cloneEmailBindUser(user)
|
||||||
|
return &emailBindUserRepoStub{
|
||||||
|
usersByID: map[int64]*service.User{
|
||||||
|
cloned.ID: cloned,
|
||||||
|
},
|
||||||
|
usersByEmail: map[string]*service.User{
|
||||||
|
cloned.Email: cloned,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) Create(context.Context, *service.User) error { return nil }
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) GetByID(_ context.Context, id int64) (*service.User, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
user, ok := s.usersByID[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return cloneEmailBindUser(user), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) GetByEmail(_ context.Context, email string) (*service.User, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
user, ok := s.usersByEmail[email]
|
||||||
|
if !ok {
|
||||||
|
return nil, service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return cloneEmailBindUser(user), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) GetFirstAdmin(context.Context) (*service.User, error) {
|
||||||
|
panic("unexpected GetFirstAdmin call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) Update(_ context.Context, user *service.User) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
existing, ok := s.usersByID[user.ID]
|
||||||
|
if !ok {
|
||||||
|
return service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
delete(s.usersByEmail, existing.Email)
|
||||||
|
cloned := cloneEmailBindUser(user)
|
||||||
|
s.usersByID[user.ID] = cloned
|
||||||
|
s.usersByEmail[cloned.Email] = cloned
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) Delete(context.Context, int64) error { return nil }
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) UpsertUserAvatar(context.Context, int64, service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||||
|
panic("unexpected UpsertUserAvatar call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) DeleteUserAvatar(context.Context, int64) error {
|
||||||
|
panic("unexpected DeleteUserAvatar call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
||||||
|
panic("unexpected List call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
|
||||||
|
panic("unexpected ListWithFilters call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
||||||
|
return map[int64]*time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) UpdateUserLastActiveAt(context.Context, int64, time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
|
||||||
|
func (s *emailBindUserRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
|
||||||
|
func (s *emailBindUserRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, ok := s.usersByEmail[email]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) UnbindUserAuthProvider(context.Context, int64, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
||||||
|
func (s *emailBindUserRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
||||||
|
func (s *emailBindUserRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
||||||
|
|
||||||
|
func cloneEmailBindUser(user *service.User) *service.User {
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *user
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|||||||
@ -127,6 +127,7 @@ type UserIdentitySummary struct {
|
|||||||
Bound bool `json:"bound"`
|
Bound bool `json:"bound"`
|
||||||
BoundCount int `json:"bound_count"`
|
BoundCount int `json:"bound_count"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
AvatarURL string `json:"-"`
|
||||||
SubjectHint string `json:"subject_hint,omitempty"`
|
SubjectHint string `json:"subject_hint,omitempty"`
|
||||||
ProviderKey string `json:"provider_key,omitempty"`
|
ProviderKey string `json:"provider_key,omitempty"`
|
||||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||||
@ -228,6 +229,7 @@ func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
return nil, fmt.Errorf("get user: %w", err)
|
||||||
}
|
}
|
||||||
|
normalizeLoadedUserTokenVersion(user)
|
||||||
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
||||||
return nil, fmt.Errorf("get user avatar: %w", err)
|
return nil, fmt.Errorf("get user avatar: %w", err)
|
||||||
}
|
}
|
||||||
@ -323,29 +325,34 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) (*User, error) {
|
func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) (*User, error) {
|
||||||
|
user, _, err := s.UnbindUserAuthProviderWithResult(ctx, userID, provider)
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UnbindUserAuthProviderWithResult(ctx context.Context, userID int64, provider string) (*User, bool, error) {
|
||||||
provider = normalizeUserIdentityProvider(provider)
|
provider = normalizeUserIdentityProvider(provider)
|
||||||
if provider == "" || provider == "email" {
|
if provider == "" || provider == "email" {
|
||||||
return nil, ErrIdentityProviderInvalid
|
return nil, false, ErrIdentityProviderInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
return nil, false, fmt.Errorf("get user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
records, err := s.listUserAuthIdentities(ctx, userID)
|
records, err := s.listUserAuthIdentities(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
if len(filterUserAuthIdentities(records, provider)) == 0 {
|
if len(filterUserAuthIdentities(records, provider)) == 0 {
|
||||||
return user, nil
|
return user, false, nil
|
||||||
}
|
}
|
||||||
if !s.canUnbindProvider(provider, user, records) {
|
if !s.canUnbindProvider(provider, user, records) {
|
||||||
return nil, ErrIdentityUnbindLastMethod
|
return nil, false, ErrIdentityUnbindLastMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userRepo.UnbindUserAuthProvider(ctx, userID, provider); err != nil {
|
if err := s.userRepo.UnbindUserAuthProvider(ctx, userID, provider); err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
if s.authCacheInvalidator != nil {
|
if s.authCacheInvalidator != nil {
|
||||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||||
@ -353,9 +360,9 @@ func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64,
|
|||||||
|
|
||||||
updatedUser, err := s.GetProfile(ctx, userID)
|
updatedUser, err := s.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
return updatedUser, nil
|
return updatedUser, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile 更新用户资料
|
// UpdateProfile 更新用户资料
|
||||||
@ -655,6 +662,7 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User,
|
|||||||
summary.Bound = true
|
summary.Bound = true
|
||||||
summary.BoundCount = len(filtered)
|
summary.BoundCount = len(filtered)
|
||||||
summary.DisplayName = userAuthIdentityDisplayName(primary)
|
summary.DisplayName = userAuthIdentityDisplayName(primary)
|
||||||
|
summary.AvatarURL = strings.TrimSpace(firstStringIdentityValue(primary.Metadata, "avatar_url", "suggested_avatar_url", "headimgurl"))
|
||||||
summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject)
|
summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject)
|
||||||
summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
|
summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
|
||||||
summary.VerifiedAt = primary.VerifiedAt
|
summary.VerifiedAt = primary.VerifiedAt
|
||||||
@ -672,7 +680,7 @@ func (s *UserService) canUnbindProvider(provider string, user *User, records []U
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.buildEmailIdentitySummary(user, records).Bound {
|
if s.canUseEmailAsSignInMethod(user, records) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -688,6 +696,44 @@ func (s *UserService) canUnbindProvider(provider string, user *User, records []U
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) canUseEmailAsSignInMethod(user *User, records []UserAuthIdentityRecord) bool {
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.ToLower(strings.TrimSpace(user.Email))
|
||||||
|
if email == "" || isReservedEmail(email) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if emailSignupSourceAllowsLogin(user.SignupSource) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range filterUserAuthIdentities(records, "email") {
|
||||||
|
if emailIdentitySupportsSignIn(record) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailSignupSourceAllowsLogin(signupSource string) bool {
|
||||||
|
signupSource = strings.ToLower(strings.TrimSpace(signupSource))
|
||||||
|
return signupSource == "" || signupSource == "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailIdentitySupportsSignIn(record UserAuthIdentityRecord) bool {
|
||||||
|
source := strings.TrimSpace(firstStringIdentityValue(record.Metadata, "source"))
|
||||||
|
switch source {
|
||||||
|
case "auth_service_email_bind", "auth_service_login_backfill", "auth_service_dual_write":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserService) listUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
|
func (s *UserService) listUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
|
||||||
if userID <= 0 || s == nil || s.userRepo == nil {
|
if userID <= 0 || s == nil || s.userRepo == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -709,11 +755,11 @@ func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, err
|
|||||||
path := ""
|
path := ""
|
||||||
switch provider {
|
switch provider {
|
||||||
case "linuxdo":
|
case "linuxdo":
|
||||||
path = "/api/v1/auth/oauth/linuxdo/start"
|
path = "/api/v1/auth/oauth/linuxdo/bind/start"
|
||||||
case "oidc":
|
case "oidc":
|
||||||
path = "/api/v1/auth/oauth/oidc/start"
|
path = "/api/v1/auth/oauth/oidc/bind/start"
|
||||||
case "wechat":
|
case "wechat":
|
||||||
path = "/api/v1/auth/oauth/wechat/start"
|
path = "/api/v1/auth/oauth/wechat/bind/start"
|
||||||
default:
|
default:
|
||||||
return "", ErrIdentityProviderInvalid
|
return "", ErrIdentityProviderInvalid
|
||||||
}
|
}
|
||||||
@ -889,12 +935,20 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
return nil, fmt.Errorf("get user: %w", err)
|
||||||
}
|
}
|
||||||
|
normalizeLoadedUserTokenVersion(user)
|
||||||
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
||||||
return nil, fmt.Errorf("get user avatar: %w", err)
|
return nil, fmt.Errorf("get user avatar: %w", err)
|
||||||
}
|
}
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLoadedUserTokenVersion(user *User) {
|
||||||
|
if user == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.TokenVersion = resolvedTokenVersion(user)
|
||||||
|
}
|
||||||
|
|
||||||
// TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。
|
// TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。
|
||||||
// 该操作为尽力而为,不应中断正常请求。
|
// 该操作为尽力而为,不应中断正常请求。
|
||||||
func (s *UserService) TouchLastActive(ctx context.Context, userID int64) {
|
func (s *UserService) TouchLastActive(ctx context.Context, userID int64) {
|
||||||
|
|||||||
@ -387,6 +387,70 @@ func TestUnbindUserAuthProviderRejectsLastRemainingLoginMethod(t *testing.T) {
|
|||||||
require.Empty(t, repo.unboundProviders)
|
require.Empty(t, repo.unboundProviders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProfileIdentitySummaries_DoesNotTreatOAuthOnlyCompatEmailAsAlternativeLoginMethod(t *testing.T) {
|
||||||
|
repo := &mockUserRepo{
|
||||||
|
getByIDUser: &User{
|
||||||
|
ID: 10,
|
||||||
|
Email: "oauth-only@example.com",
|
||||||
|
SignupSource: "oidc",
|
||||||
|
},
|
||||||
|
identities: []UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: "https://issuer.example.com",
|
||||||
|
ProviderSubject: "oidc-only-subject",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewUserService(repo, nil, nil, nil)
|
||||||
|
|
||||||
|
summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 10, repo.getByIDUser)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, summaries.OIDC.CanUnbind)
|
||||||
|
|
||||||
|
_, err = svc.UnbindUserAuthProvider(context.Background(), 10, "oidc")
|
||||||
|
require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
|
||||||
|
require.Empty(t, repo.unboundProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProfileIdentitySummaries_DoesNotTreatCompatBackfilledEmailIdentityAsAlternativeLoginMethod(t *testing.T) {
|
||||||
|
repo := &mockUserRepo{
|
||||||
|
getByIDUser: &User{
|
||||||
|
ID: 11,
|
||||||
|
Email: "oauth-only@example.com",
|
||||||
|
SignupSource: "wechat",
|
||||||
|
},
|
||||||
|
identities: []UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "email",
|
||||||
|
ProviderKey: "email",
|
||||||
|
ProviderSubject: "oauth-only@example.com",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"backfill_source": "users.email",
|
||||||
|
"migration": "109_auth_identity_compat_backfill",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ProviderType: "wechat",
|
||||||
|
ProviderKey: "wechat",
|
||||||
|
ProviderSubject: "wechat-only-subject",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewUserService(repo, nil, nil, nil)
|
||||||
|
|
||||||
|
summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 11, repo.getByIDUser)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, summaries.Email.Bound)
|
||||||
|
require.False(t, summaries.WeChat.CanUnbind)
|
||||||
|
|
||||||
|
_, err = svc.UnbindUserAuthProvider(context.Background(), 11, "wechat")
|
||||||
|
require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
|
||||||
|
require.Empty(t, repo.unboundProviders)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testing.T) {
|
func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testing.T) {
|
||||||
repo := &mockUserRepo{
|
repo := &mockUserRepo{
|
||||||
getByIDUser: &User{
|
getByIDUser: &User{
|
||||||
@ -451,6 +515,42 @@ func TestGetProfileIdentitySummaries_HidesBindActionWhenProviderExplicitlyDisabl
|
|||||||
require.Empty(t, summaries.LinuxDo.BindStartPath)
|
require.Empty(t, summaries.LinuxDo.BindStartPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProfileIdentitySummaries_UsesBindStartRoute(t *testing.T) {
|
||||||
|
repo := &mockUserRepo{
|
||||||
|
getByIDUser: &User{
|
||||||
|
ID: 16,
|
||||||
|
Email: "alice@example.com",
|
||||||
|
},
|
||||||
|
identities: []UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "email",
|
||||||
|
ProviderKey: "email",
|
||||||
|
ProviderSubject: "alice@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewUserService(repo, nil, nil, nil)
|
||||||
|
|
||||||
|
summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 16, repo.getByIDUser)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
"/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
|
||||||
|
summaries.LinuxDo.BindStartPath,
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
"/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
|
||||||
|
summaries.OIDC.BindStartPath,
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
"/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
|
||||||
|
summaries.WeChat.BindStartPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
|
svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
|
||||||
|
|||||||
117
frontend/src/api/__tests__/admin.users.spec.ts
Normal file
117
frontend/src/api/__tests__/admin.users.spec.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -91,6 +91,22 @@ describe('API Client', () => {
|
|||||||
const config = adapter.mock.calls[0][0]
|
const config = adapter.mock.calls[0][0]
|
||||||
expect(config.params?.timezone).toBeUndefined()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- 响应拦截器 ---
|
// --- 响应拦截器 ---
|
||||||
|
|||||||
32
frontend/src/api/__tests__/user.spec.ts
Normal file
32
frontend/src/api/__tests__/user.spec.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,26 +8,40 @@ import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/
|
|||||||
|
|
||||||
export interface AdminBindAuthIdentityChannelRequest {
|
export interface AdminBindAuthIdentityChannelRequest {
|
||||||
channel: string
|
channel: string
|
||||||
channel_app_id?: string
|
channel_app_id: string
|
||||||
channel_subject: string
|
channel_subject: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminBindAuthIdentityRequest {
|
export interface AdminBindAuthIdentityRequest {
|
||||||
provider_type: string
|
provider_type: string
|
||||||
provider_key: string
|
provider_key: string
|
||||||
provider_subject: string
|
provider_subject: string
|
||||||
issuer?: string
|
issuer?: string | null
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown> | null
|
||||||
channel?: AdminBindAuthIdentityChannelRequest
|
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 {
|
export interface AdminBoundAuthIdentity {
|
||||||
identity_id: number
|
user_id: number
|
||||||
provider_type: string
|
provider_type: string
|
||||||
provider_key: string
|
provider_key: string
|
||||||
provider_subject: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -194,6 +194,7 @@ export interface OAuthTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
||||||
|
auth_result?: string
|
||||||
redirect?: string
|
redirect?: string
|
||||||
error?: string
|
error?: string
|
||||||
requires_2fa?: boolean
|
requires_2fa?: boolean
|
||||||
@ -206,7 +207,9 @@ export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenRespons
|
|||||||
|
|
||||||
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
|
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
|
||||||
|
|
||||||
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {}
|
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
|
||||||
|
auth_result?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
|
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
|
||||||
auth_result?: string
|
auth_result?: string
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
|||||||
|
|
||||||
export const apiClient: AxiosInstance = axios.create({
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
withCredentials: true,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|||||||
@ -150,7 +150,7 @@ export function buildOAuthBindingStartURL(
|
|||||||
params.set('mode', mode)
|
params.set('mode', mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
return `${normalized}/auth/oauth/${provider}/bind/start?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOAuthBinding(
|
export async function startOAuthBinding(
|
||||||
|
|||||||
@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() =>
|
|||||||
: t('profile.authBindings.confirmEmailBindAction')
|
: t('profile.authBindings.confirmEmailBindAction')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
wechat_oauth_open_enabled: settings.wechat_oauth_enabled,
|
||||||
|
wechat_oauth_mp_enabled: settings.wechat_oauth_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
|
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
|
||||||
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) {
|
const cachedSettings = resolveLegacyCompatibleWeChatSettings(appStore.cachedPublicSettings)
|
||||||
return appStore.cachedPublicSettings
|
if (cachedSettings) {
|
||||||
|
return cachedSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof props.wechatOpenEnabled === 'boolean' && typeof props.wechatMpEnabled === 'boolean') {
|
return resolveLegacyCompatibleWeChatSettings({
|
||||||
return {
|
wechat_oauth_enabled: props.wechatEnabled,
|
||||||
wechat_oauth_enabled: props.wechatEnabled,
|
wechat_oauth_open_enabled: props.wechatOpenEnabled,
|
||||||
wechat_oauth_open_enabled: props.wechatOpenEnabled,
|
wechat_oauth_mp_enabled: props.wechatMpEnabled,
|
||||||
wechat_oauth_mp_enabled: props.wechatMpEnabled,
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value))
|
const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value))
|
||||||
@ -362,6 +384,17 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
|
|||||||
return binding
|
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 {
|
function isProviderEnabledForBinding(provider: BindableProvider): boolean {
|
||||||
if (provider === 'linuxdo') {
|
if (provider === 'linuxdo') {
|
||||||
return props.linuxdoEnabled
|
return props.linuxdoEnabled
|
||||||
@ -444,14 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string {
|
|||||||
|
|
||||||
function providerSummary(provider: UserAuthProvider): string {
|
function providerSummary(provider: UserAuthProvider): string {
|
||||||
if (provider === 'email') {
|
if (provider === 'email') {
|
||||||
const email = currentUser.value?.email?.trim() || ''
|
return getDisplayableEmail(currentUser.value)
|
||||||
if (!email) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (currentUser.value?.email_bound === false && email.endsWith('.invalid')) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return email
|
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue'
|
import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue'
|
||||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||||
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.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<{
|
const props = withDefaults(defineProps<{
|
||||||
user: User | null
|
user: User | null
|
||||||
@ -206,6 +206,29 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
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 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 primaryEmailDisplay = computed(() => {
|
||||||
@ -213,7 +236,7 @@ const primaryEmailDisplay = computed(() => {
|
|||||||
if (!email) {
|
if (!email) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
if (props.user?.email_bound === false && email.endsWith('.invalid')) {
|
if (email.endsWith('.invalid') && !isEmailBound(props.user)) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return email
|
return email
|
||||||
|
|||||||
@ -188,7 +188,7 @@ 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(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the WeChat bind action when only the legacy aggregate setting is present', () => {
|
it('keeps the WeChat bind action visible when only the legacy aggregate setting is present', () => {
|
||||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [pinia],
|
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', () => {
|
it('uses explicit cached WeChat capabilities and ignores legacy prop fallbacks', () => {
|
||||||
@ -358,6 +379,28 @@ describe('ProfileIdentityBindingsSection', () => {
|
|||||||
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
|
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 () => {
|
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(
|
||||||
|
|||||||
@ -152,6 +152,26 @@ describe('ProfileInfoCard', () => {
|
|||||||
expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid')
|
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', () => {
|
it('renders the approved overview hero and two-column content shell', () => {
|
||||||
const wrapper = mount(ProfileInfoCard, {
|
const wrapper = mount(ProfileInfoCard, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@ -3763,11 +3763,7 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{{ t("admin.settings.payment.description") }}
|
{{ t("admin.settings.payment.description") }}
|
||||||
<a
|
<a
|
||||||
:href="
|
:href="paymentGuideHref"
|
||||||
locale === 'zh'
|
|
||||||
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md'
|
|
||||||
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md'
|
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
class="ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
@ -4140,11 +4136,7 @@
|
|||||||
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
|
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
|
||||||
<a
|
<a
|
||||||
:href="
|
:href="paymentGuideHref"
|
||||||
locale === 'zh'
|
|
||||||
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F'
|
|
||||||
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'
|
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
|
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string {
|
|||||||
return locale.value.startsWith("zh") ? zh : en;
|
return locale.value.startsWith("zh") ? zh : en;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paymentGuideHref = computed(() =>
|
||||||
|
locale.value.startsWith("zh")
|
||||||
|
? "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98"
|
||||||
|
: "https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment",
|
||||||
|
);
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
| "general"
|
| "general"
|
||||||
| "security"
|
| "security"
|
||||||
|
|||||||
@ -46,6 +46,8 @@ const {
|
|||||||
showSuccess: vi.fn(),
|
showSuccess: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const localeRef = vi.hoisted(() => ({ value: "zh-CN" }));
|
||||||
|
|
||||||
vi.mock("@/api", () => ({
|
vi.mock("@/api", () => ({
|
||||||
adminAPI: {
|
adminAPI: {
|
||||||
settings: {
|
settings: {
|
||||||
@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
|
|||||||
"admin.settings.paymentVisibleMethods.sourceLabel": "支付来源",
|
"admin.settings.paymentVisibleMethods.sourceLabel": "支付来源",
|
||||||
"admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。",
|
"admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。",
|
||||||
"admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。",
|
"admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。",
|
||||||
|
"admin.settings.payment.configGuide": "查看支付配置说明",
|
||||||
|
"admin.settings.payment.findProvider": "查看支持的支付方式",
|
||||||
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
|
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
|
||||||
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
||||||
};
|
};
|
||||||
@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
|
|||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
t: (key: string, params?: Record<string, string>) =>
|
t: (key: string, params?: Record<string, string>) =>
|
||||||
(translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`),
|
(translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`),
|
||||||
locale: ref("zh-CN"),
|
locale: localeRef,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
adminSettingsFetch.mockReset();
|
adminSettingsFetch.mockReset();
|
||||||
showError.mockReset();
|
showError.mockReset();
|
||||||
showSuccess.mockReset();
|
showSuccess.mockReset();
|
||||||
|
localeRef.value = "zh-CN";
|
||||||
|
|
||||||
getSettings.mockResolvedValue({ ...baseSettingsResponse });
|
getSettings.mockResolvedValue({ ...baseSettingsResponse });
|
||||||
updateSettings.mockImplementation(async (payload) => ({
|
updateSettings.mockImplementation(async (payload) => ({
|
||||||
@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
expect(wrapper.text()).not.toContain("支付来源");
|
expect(wrapper.text()).not.toContain("支付来源");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links payment guidance to README sections instead of removed payment docs", async () => {
|
||||||
|
const wrapper = mountView();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await openPaymentTab(wrapper);
|
||||||
|
|
||||||
|
const paymentLinks = wrapper
|
||||||
|
.findAll("a")
|
||||||
|
.filter((node) =>
|
||||||
|
["查看支付配置说明", "查看支持的支付方式"].includes(node.text()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(paymentLinks).toHaveLength(2);
|
||||||
|
expect(paymentLinks[0]?.attributes("href")).toBe(
|
||||||
|
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
|
||||||
|
);
|
||||||
|
expect(paymentLinks[1]?.attributes("href")).toBe(
|
||||||
|
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
|
||||||
|
);
|
||||||
|
for (const link of paymentLinks) {
|
||||||
|
expect(link.attributes("href")).not.toContain("docs/PAYMENT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not submit legacy visible payment method settings", async () => {
|
it("does not submit legacy visible payment method settings", async () => {
|
||||||
const wrapper = mountView();
|
const wrapper = mountView();
|
||||||
|
|
||||||
|
|||||||
@ -456,7 +456,14 @@ function resolvePendingAccountAction(
|
|||||||
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||||
return 'create_account'
|
return 'create_account'
|
||||||
}
|
}
|
||||||
if (raw === 'bind_login_required' || raw === 'bind_login') {
|
if (
|
||||||
|
raw === 'bind_login_required' ||
|
||||||
|
raw === 'bind_login' ||
|
||||||
|
raw === 'existing_account' ||
|
||||||
|
raw === 'existing_account_required' ||
|
||||||
|
raw === 'existing_account_binding_required' ||
|
||||||
|
raw === 'adopt_existing_user_by_email'
|
||||||
|
) {
|
||||||
return 'bind_login'
|
return 'bind_login'
|
||||||
}
|
}
|
||||||
return 'none'
|
return 'none'
|
||||||
|
|||||||
@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await prepareOAuthBindAccessTokenCookie()
|
try {
|
||||||
window.location.href = startURL
|
await prepareOAuthBindAccessTokenCookie()
|
||||||
|
window.location.href = startURL
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExistingAccountBinding() {
|
async function handleExistingAccountBinding() {
|
||||||
|
|||||||
@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps rendering bind-login UI for legacy pending bind responses instead of treating them as success', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'adopt_existing_user_by_email',
|
||||||
|
redirect: '/profile/security',
|
||||||
|
email: 'existing@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(LinuxDoCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(showSuccess).not.toHaveBeenCalled()
|
||||||
|
expect(replace).not.toHaveBeenCalled()
|
||||||
|
expect((wrapper.get('[data-testid="linuxdo-bind-login-email"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'existing@example.com'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('persists a pending auth session when the oauth flow still needs account creation', async () => {
|
it('persists a pending auth session when the oauth flow still needs account creation', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'email_required',
|
error: 'email_required',
|
||||||
|
|||||||
@ -621,6 +621,34 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(locationState.current.href).toContain('mode=open')
|
expect(locationState.current.href).toContain('mode=open')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows an error and stays on the page when preparing bind-token for the current account fails', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'invitation_required',
|
||||||
|
redirect: '/usage',
|
||||||
|
})
|
||||||
|
getAuthTokenMock.mockReturnValue('current-auth-token')
|
||||||
|
prepareOAuthBindAccessTokenCookieMock.mockRejectedValue(new Error('bind token failed'))
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="existing-account-submit"]').trigger('click').catch(() => undefined)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(showErrorMock).toHaveBeenCalledWith('bind token failed')
|
||||||
|
expect(locationState.current.href).toBe('http://localhost/auth/wechat/callback')
|
||||||
|
})
|
||||||
|
|
||||||
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
getPublicSettingsMock.mockResolvedValue({
|
getPublicSettingsMock.mockResolvedValue({
|
||||||
invitation_code_enabled: true,
|
invitation_code_enabled: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user