feat: rebuild auth identity foundation flow
This commit is contained in:
@@ -1608,6 +1608,9 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
|
||||
}
|
||||
if c.LinuxDo.Enabled {
|
||||
if !c.LinuxDo.UsePKCE {
|
||||
return fmt.Errorf("linuxdo_connect.use_pkce must be true when linuxdo_connect.enabled=true")
|
||||
}
|
||||
if strings.TrimSpace(c.LinuxDo.ClientID) == "" {
|
||||
return fmt.Errorf("linuxdo_connect.client_id is required when linuxdo_connect.enabled=true")
|
||||
}
|
||||
@@ -1629,9 +1632,6 @@ func (c *Config) Validate() error {
|
||||
default:
|
||||
return fmt.Errorf("linuxdo_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
|
||||
}
|
||||
if method == "none" && !c.LinuxDo.UsePKCE {
|
||||
return fmt.Errorf("linuxdo_connect.use_pkce must be true when linuxdo_connect.token_auth_method=none")
|
||||
}
|
||||
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") &&
|
||||
strings.TrimSpace(c.LinuxDo.ClientSecret) == "" {
|
||||
return fmt.Errorf("linuxdo_connect.client_secret is required when linuxdo_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic")
|
||||
@@ -1663,6 +1663,12 @@ func (c *Config) Validate() error {
|
||||
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
|
||||
}
|
||||
if c.OIDC.Enabled {
|
||||
if !c.OIDC.UsePKCE {
|
||||
return fmt.Errorf("oidc_connect.use_pkce must be true when oidc_connect.enabled=true")
|
||||
}
|
||||
if !c.OIDC.ValidateIDToken {
|
||||
return fmt.Errorf("oidc_connect.validate_id_token must be true when oidc_connect.enabled=true")
|
||||
}
|
||||
if strings.TrimSpace(c.OIDC.ClientID) == "" {
|
||||
return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true")
|
||||
}
|
||||
@@ -1685,9 +1691,6 @@ func (c *Config) Validate() error {
|
||||
default:
|
||||
return fmt.Errorf("oidc_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
|
||||
}
|
||||
if method == "none" && !c.OIDC.UsePKCE {
|
||||
return fmt.Errorf("oidc_connect.use_pkce must be true when oidc_connect.token_auth_method=none")
|
||||
}
|
||||
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") &&
|
||||
strings.TrimSpace(c.OIDC.ClientSecret) == "" {
|
||||
return fmt.Errorf("oidc_connect.client_secret is required when oidc_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic")
|
||||
|
||||
@@ -73,6 +73,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
authSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if ops monitoring is enabled (respects config.ops.enabled)
|
||||
opsEnabled := h.opsService != nil && h.opsService.IsMonitoringEnabled(c.Request.Context())
|
||||
@@ -93,7 +98,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
paymentCfg = &service.PaymentConfig{}
|
||||
}
|
||||
|
||||
response.Success(c, dto.SystemSettings{
|
||||
payload := dto.SystemSettings{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||
@@ -200,7 +205,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow,
|
||||
PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit,
|
||||
PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode,
|
||||
})
|
||||
}
|
||||
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
|
||||
}
|
||||
|
||||
// UpdateSettingsRequest 更新设置请求
|
||||
@@ -276,9 +282,30 @@ type UpdateSettingsRequest struct {
|
||||
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
|
||||
|
||||
// 默认配置
|
||||
DefaultConcurrency int `json:"default_concurrency"`
|
||||
DefaultBalance float64 `json:"default_balance"`
|
||||
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||
DefaultConcurrency int `json:"default_concurrency"`
|
||||
DefaultBalance float64 `json:"default_balance"`
|
||||
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
|
||||
AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"`
|
||||
AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"`
|
||||
AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"`
|
||||
AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"`
|
||||
AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"`
|
||||
AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"`
|
||||
AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"`
|
||||
AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"`
|
||||
AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"`
|
||||
AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"`
|
||||
AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"`
|
||||
AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"`
|
||||
AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"`
|
||||
AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"`
|
||||
AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"`
|
||||
AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"`
|
||||
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
|
||||
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
|
||||
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
|
||||
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
|
||||
|
||||
// Model fallback configuration
|
||||
EnableModelFallback bool `json:"enable_model_fallback"`
|
||||
@@ -357,6 +384,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
previousAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if req.DefaultConcurrency < 1 {
|
||||
@@ -381,6 +413,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
req.SMTPPort = 587
|
||||
}
|
||||
req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions)
|
||||
req.AuthSourceDefaultEmailSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultEmailSubscriptions)
|
||||
req.AuthSourceDefaultLinuxDoSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultLinuxDoSubscriptions)
|
||||
req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions)
|
||||
req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions)
|
||||
|
||||
// SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置
|
||||
// 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置
|
||||
@@ -538,25 +574,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.BadRequest(c, "OIDC scopes must contain openid")
|
||||
return
|
||||
}
|
||||
if !req.OIDCConnectUsePKCE {
|
||||
response.BadRequest(c, "OIDC PKCE must be enabled")
|
||||
return
|
||||
}
|
||||
if !req.OIDCConnectValidateIDToken {
|
||||
response.BadRequest(c, "OIDC ID Token validation must be enabled")
|
||||
return
|
||||
}
|
||||
switch req.OIDCConnectTokenAuthMethod {
|
||||
case "", "client_secret_post", "client_secret_basic", "none":
|
||||
default:
|
||||
response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectTokenAuthMethod == "none" && !req.OIDCConnectUsePKCE {
|
||||
response.BadRequest(c, "OIDC PKCE must be enabled when token_auth_method=none")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 {
|
||||
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectValidateIDToken {
|
||||
if req.OIDCConnectAllowedSigningAlgs == "" {
|
||||
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectAllowedSigningAlgs == "" {
|
||||
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectJWKSURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil {
|
||||
@@ -933,6 +971,41 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
||||
Email: service.ProviderDefaultGrantSettings{
|
||||
Balance: float64ValueOrDefault(req.AuthSourceDefaultEmailBalance, previousAuthSourceDefaults.Email.Balance),
|
||||
Concurrency: intValueOrDefault(req.AuthSourceDefaultEmailConcurrency, previousAuthSourceDefaults.Email.Concurrency),
|
||||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultEmailSubscriptions, previousAuthSourceDefaults.Email.Subscriptions),
|
||||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnSignup, previousAuthSourceDefaults.Email.GrantOnSignup),
|
||||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnFirstBind, previousAuthSourceDefaults.Email.GrantOnFirstBind),
|
||||
},
|
||||
LinuxDo: service.ProviderDefaultGrantSettings{
|
||||
Balance: float64ValueOrDefault(req.AuthSourceDefaultLinuxDoBalance, previousAuthSourceDefaults.LinuxDo.Balance),
|
||||
Concurrency: intValueOrDefault(req.AuthSourceDefaultLinuxDoConcurrency, previousAuthSourceDefaults.LinuxDo.Concurrency),
|
||||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultLinuxDoSubscriptions, previousAuthSourceDefaults.LinuxDo.Subscriptions),
|
||||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnSignup, previousAuthSourceDefaults.LinuxDo.GrantOnSignup),
|
||||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnFirstBind, previousAuthSourceDefaults.LinuxDo.GrantOnFirstBind),
|
||||
},
|
||||
OIDC: service.ProviderDefaultGrantSettings{
|
||||
Balance: float64ValueOrDefault(req.AuthSourceDefaultOIDCBalance, previousAuthSourceDefaults.OIDC.Balance),
|
||||
Concurrency: intValueOrDefault(req.AuthSourceDefaultOIDCConcurrency, previousAuthSourceDefaults.OIDC.Concurrency),
|
||||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultOIDCSubscriptions, previousAuthSourceDefaults.OIDC.Subscriptions),
|
||||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnSignup, previousAuthSourceDefaults.OIDC.GrantOnSignup),
|
||||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnFirstBind, previousAuthSourceDefaults.OIDC.GrantOnFirstBind),
|
||||
},
|
||||
WeChat: service.ProviderDefaultGrantSettings{
|
||||
Balance: float64ValueOrDefault(req.AuthSourceDefaultWeChatBalance, previousAuthSourceDefaults.WeChat.Balance),
|
||||
Concurrency: intValueOrDefault(req.AuthSourceDefaultWeChatConcurrency, previousAuthSourceDefaults.WeChat.Concurrency),
|
||||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultWeChatSubscriptions, previousAuthSourceDefaults.WeChat.Subscriptions),
|
||||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
|
||||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind),
|
||||
},
|
||||
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
|
||||
}
|
||||
if err := h.settingService.UpdateAuthSourceDefaultSettings(c.Request.Context(), authSourceDefaults); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update payment configuration (integrated into system settings).
|
||||
// Skip if no payment fields were provided (prevents accidental wipe).
|
||||
@@ -977,6 +1050,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
updatedAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
updatedDefaultSubscriptions := make([]dto.DefaultSubscriptionSetting, 0, len(updatedSettings.DefaultSubscriptions))
|
||||
for _, sub := range updatedSettings.DefaultSubscriptions {
|
||||
updatedDefaultSubscriptions = append(updatedDefaultSubscriptions, dto.DefaultSubscriptionSetting{
|
||||
@@ -994,7 +1072,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
updatedPaymentCfg = &service.PaymentConfig{}
|
||||
}
|
||||
|
||||
response.Success(c, dto.SystemSettings{
|
||||
payload := dto.SystemSettings{
|
||||
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
|
||||
@@ -1100,7 +1178,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow,
|
||||
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
|
||||
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
|
||||
})
|
||||
}
|
||||
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
|
||||
}
|
||||
|
||||
// hasPaymentFields returns true if any payment-related field was explicitly provided.
|
||||
@@ -1412,6 +1491,84 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeOptionalDefaultSubscriptions(input *[]dto.DefaultSubscriptionSetting) *[]dto.DefaultSubscriptionSetting {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
normalized := normalizeDefaultSubscriptions(*input)
|
||||
return &normalized
|
||||
}
|
||||
|
||||
func float64ValueOrDefault(value *float64, fallback float64) float64 {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func intValueOrDefault(value *int, fallback int) int {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func boolValueOrDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func defaultSubscriptionsValueOrDefault(input *[]dto.DefaultSubscriptionSetting, fallback []service.DefaultSubscriptionSetting) []service.DefaultSubscriptionSetting {
|
||||
if input == nil {
|
||||
return fallback
|
||||
}
|
||||
result := make([]service.DefaultSubscriptionSetting, 0, len(*input))
|
||||
for _, item := range *input {
|
||||
result = append(result, service.DefaultSubscriptionSetting{
|
||||
GroupID: item.GroupID,
|
||||
ValidityDays: item.ValidityDays,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults *service.AuthSourceDefaultSettings) map[string]any {
|
||||
data := make(map[string]any)
|
||||
raw, err := json.Marshal(settings)
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(raw, &data)
|
||||
}
|
||||
if authSourceDefaults == nil {
|
||||
authSourceDefaults = &service.AuthSourceDefaultSettings{}
|
||||
}
|
||||
|
||||
data["auth_source_default_email_balance"] = authSourceDefaults.Email.Balance
|
||||
data["auth_source_default_email_concurrency"] = authSourceDefaults.Email.Concurrency
|
||||
data["auth_source_default_email_subscriptions"] = authSourceDefaults.Email.Subscriptions
|
||||
data["auth_source_default_email_grant_on_signup"] = authSourceDefaults.Email.GrantOnSignup
|
||||
data["auth_source_default_email_grant_on_first_bind"] = authSourceDefaults.Email.GrantOnFirstBind
|
||||
data["auth_source_default_linuxdo_balance"] = authSourceDefaults.LinuxDo.Balance
|
||||
data["auth_source_default_linuxdo_concurrency"] = authSourceDefaults.LinuxDo.Concurrency
|
||||
data["auth_source_default_linuxdo_subscriptions"] = authSourceDefaults.LinuxDo.Subscriptions
|
||||
data["auth_source_default_linuxdo_grant_on_signup"] = authSourceDefaults.LinuxDo.GrantOnSignup
|
||||
data["auth_source_default_linuxdo_grant_on_first_bind"] = authSourceDefaults.LinuxDo.GrantOnFirstBind
|
||||
data["auth_source_default_oidc_balance"] = authSourceDefaults.OIDC.Balance
|
||||
data["auth_source_default_oidc_concurrency"] = authSourceDefaults.OIDC.Concurrency
|
||||
data["auth_source_default_oidc_subscriptions"] = authSourceDefaults.OIDC.Subscriptions
|
||||
data["auth_source_default_oidc_grant_on_signup"] = authSourceDefaults.OIDC.GrantOnSignup
|
||||
data["auth_source_default_oidc_grant_on_first_bind"] = authSourceDefaults.OIDC.GrantOnFirstBind
|
||||
data["auth_source_default_wechat_balance"] = authSourceDefaults.WeChat.Balance
|
||||
data["auth_source_default_wechat_concurrency"] = authSourceDefaults.WeChat.Concurrency
|
||||
data["auth_source_default_wechat_subscriptions"] = authSourceDefaults.WeChat.Subscriptions
|
||||
data["auth_source_default_wechat_grant_on_signup"] = authSourceDefaults.WeChat.GrantOnSignup
|
||||
data["auth_source_default_wechat_grant_on_first_bind"] = authSourceDefaults.WeChat.GrantOnFirstBind
|
||||
data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type settingHandlerRepoStub struct {
|
||||
values map[string]string
|
||||
lastUpdates map[string]string
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) Get(ctx context.Context, key string) (*service.Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
panic("unexpected GetValue call")
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := s.values[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
s.lastUpdates = make(map[string]string, len(settings))
|
||||
for key, value := range settings {
|
||||
s.lastUpdates[key] = value
|
||||
if s.values == nil {
|
||||
s.values = map[string]string{}
|
||||
}
|
||||
s.values[key] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(s.values))
|
||||
for key, value := range s.values {
|
||||
out[key] = value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *settingHandlerRepoStub) Delete(ctx context.Context, key string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func TestSettingHandler_GetSettings_InjectsAuthSourceDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
service.SettingKeyPromoCodeEnabled: "true",
|
||||
service.SettingKeyAuthSourceDefaultEmailBalance: "9.5",
|
||||
service.SettingKeyAuthSourceDefaultEmailConcurrency: "8",
|
||||
service.SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":31,"validity_days":15}]`,
|
||||
service.SettingKeyForceEmailOnThirdPartySignup: "true",
|
||||
},
|
||||
}
|
||||
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
|
||||
|
||||
handler.GetSettings(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp response.Response
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
data, ok := resp.Data.(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 9.5, data["auth_source_default_email_balance"])
|
||||
require.Equal(t, float64(8), data["auth_source_default_email_concurrency"])
|
||||
require.Equal(t, true, data["force_email_on_third_party_signup"])
|
||||
|
||||
subscriptions, ok := data["auth_source_default_email_subscriptions"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, subscriptions, 1)
|
||||
}
|
||||
|
||||
func TestSettingHandler_UpdateSettings_PreservesOmittedAuthSourceDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "false",
|
||||
service.SettingKeyPromoCodeEnabled: "true",
|
||||
service.SettingKeyAuthSourceDefaultEmailBalance: "9.5",
|
||||
service.SettingKeyAuthSourceDefaultEmailConcurrency: "8",
|
||||
service.SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":31,"validity_days":15}]`,
|
||||
service.SettingKeyAuthSourceDefaultEmailGrantOnSignup: "true",
|
||||
service.SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
|
||||
service.SettingKeyForceEmailOnThirdPartySignup: "true",
|
||||
},
|
||||
}
|
||||
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
|
||||
|
||||
body := map[string]any{
|
||||
"registration_enabled": true,
|
||||
"promo_code_enabled": true,
|
||||
"auth_source_default_email_balance": 12.75,
|
||||
}
|
||||
rawBody, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "12.75000000", repo.values[service.SettingKeyAuthSourceDefaultEmailBalance])
|
||||
require.Equal(t, "8", repo.values[service.SettingKeyAuthSourceDefaultEmailConcurrency])
|
||||
require.Equal(t, `[{"group_id":31,"validity_days":15}]`, repo.values[service.SettingKeyAuthSourceDefaultEmailSubscriptions])
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyForceEmailOnThirdPartySignup])
|
||||
|
||||
var resp response.Response
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
data, ok := resp.Data.(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 12.75, data["auth_source_default_email_balance"])
|
||||
require.Equal(t, float64(8), data["auth_source_default_email_concurrency"])
|
||||
require.Equal(t, true, data["force_email_on_third_party_signup"])
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||
@@ -262,6 +262,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: subject,
|
||||
},
|
||||
TargetUserID: &user.ID,
|
||||
ResolvedEmail: email,
|
||||
RedirectTo: redirectTo,
|
||||
BrowserSessionKey: browserSessionKey,
|
||||
@@ -287,7 +288,9 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
type completeLinuxDoOAuthRequest struct {
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
}
|
||||
|
||||
// CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
|
||||
@@ -335,11 +338,23 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
||||
AdoptDisplayName: req.AdoptDisplayName,
|
||||
AdoptAvatar: req.AdoptAvatar,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), session, decision, &user.ID); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
||||
return
|
||||
}
|
||||
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -110,3 +121,79 @@ func TestSingleLineStripsWhitespace(t *testing.T) {
|
||||
require.Equal(t, "hello world", singleLine("hello\r\nworld"))
|
||||
require.Equal(t, "", singleLine("\n\t\r"))
|
||||
}
|
||||
|
||||
func TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("linuxdo-complete-session").
|
||||
SetIntent("login").
|
||||
SetProviderType("linuxdo").
|
||||
SetProviderKey("linuxdo").
|
||||
SetProviderSubject("linuxdo-subject-1").
|
||||
SetResolvedEmail("linuxdo-subject-1@linuxdo-connect.invalid").
|
||||
SetBrowserSessionKey("linuxdo-browser").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "linuxdo_user",
|
||||
"suggested_display_name": "LinuxDo Display",
|
||||
"suggested_avatar_url": "https://cdn.example/linuxdo.png",
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
AdoptAvatar: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-browser")})
|
||||
c.Request = req
|
||||
|
||||
handler.CompleteLinuxDoOAuthRegistration(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
responseData := decodeJSONBody(t, recorder)
|
||||
require.NotEmpty(t, responseData["access_token"])
|
||||
|
||||
userEntity, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ(session.ResolvedEmail)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "LinuxDo Display", userEntity.Username)
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("linuxdo"),
|
||||
authidentity.ProviderKeyEQ("linuxdo"),
|
||||
authidentity.ProviderSubjectEQ("linuxdo-subject-1"),
|
||||
).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userEntity.ID, identity.UserID)
|
||||
require.Equal(t, "LinuxDo Display", identity.Metadata["display_name"])
|
||||
require.Equal(t, "https://cdn.example/linuxdo.png", identity.Metadata["avatar_url"])
|
||||
|
||||
decision, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision.IdentityID)
|
||||
require.Equal(t, identity.ID, *decision.IdentityID)
|
||||
require.True(t, decision.AdoptDisplayName)
|
||||
require.True(t, decision.AdoptAvatar)
|
||||
|
||||
consumed, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.IDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, consumed.ConsumedAt)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -26,6 +33,7 @@ const (
|
||||
type oauthPendingSessionPayload struct {
|
||||
Intent string
|
||||
Identity service.PendingAuthIdentityKey
|
||||
TargetUserID *int64
|
||||
ResolvedEmail string
|
||||
RedirectTo string
|
||||
BrowserSessionKey string
|
||||
@@ -33,6 +41,11 @@ type oauthPendingSessionPayload struct {
|
||||
CompletionResponse map[string]any
|
||||
}
|
||||
|
||||
type oauthAdoptionDecisionRequest struct {
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) pendingIdentityService() (*service.AuthPendingIdentityService, error) {
|
||||
if h == nil || h.authService == nil || h.authService.EntClient() == nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||
@@ -125,6 +138,7 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
|
||||
session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{
|
||||
Intent: strings.TrimSpace(payload.Intent),
|
||||
Identity: payload.Identity,
|
||||
TargetUserID: payload.TargetUserID,
|
||||
ResolvedEmail: strings.TrimSpace(payload.ResolvedEmail),
|
||||
RedirectTo: strings.TrimSpace(payload.RedirectTo),
|
||||
BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey),
|
||||
@@ -175,6 +189,291 @@ func pendingSessionWantsInvitation(payload map[string]any) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "error")), "invitation_required")
|
||||
}
|
||||
|
||||
func (r oauthAdoptionDecisionRequest) hasDecision() bool {
|
||||
return r.AdoptDisplayName != nil || r.AdoptAvatar != nil
|
||||
}
|
||||
|
||||
func (r oauthAdoptionDecisionRequest) toServiceInput(sessionID int64) service.PendingIdentityAdoptionDecisionInput {
|
||||
input := service.PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: sessionID,
|
||||
}
|
||||
if r.AdoptDisplayName != nil {
|
||||
input.AdoptDisplayName = *r.AdoptDisplayName
|
||||
}
|
||||
if r.AdoptAvatar != nil {
|
||||
input.AdoptAvatar = *r.AdoptAvatar
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func bindOptionalOAuthAdoptionDecision(c *gin.Context) (oauthAdoptionDecisionRequest, error) {
|
||||
var req oauthAdoptionDecisionRequest
|
||||
if c == nil || c.Request == nil || c.Request.Body == nil {
|
||||
return req, nil
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return req, nil
|
||||
}
|
||||
return req, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func persistPendingOAuthAdoptionDecision(
|
||||
c *gin.Context,
|
||||
svc *service.AuthPendingIdentityService,
|
||||
sessionID int64,
|
||||
req oauthAdoptionDecisionRequest,
|
||||
) error {
|
||||
if !req.hasDecision() {
|
||||
return nil
|
||||
}
|
||||
if svc == nil {
|
||||
return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||
}
|
||||
if _, err := svc.UpsertAdoptionDecision(c.Request.Context(), req.toServiceInput(sessionID)); err != nil {
|
||||
return infraerrors.InternalServer("PENDING_AUTH_ADOPTION_SAVE_FAILED", "failed to save oauth profile adoption decision").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneOAuthMetadata(values map[string]any) map[string]any {
|
||||
if len(values) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
cloned := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeAdoptedOAuthDisplayName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len([]rune(value)) > 100 {
|
||||
value = string([]rune(value)[:100])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (h *AuthHandler) entClient() *dbent.Client {
|
||||
if h == nil || h.authService == nil {
|
||||
return nil
|
||||
}
|
||||
return h.authService.EntClient()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) upsertPendingOAuthAdoptionDecision(
|
||||
c *gin.Context,
|
||||
sessionID int64,
|
||||
req oauthAdoptionDecisionRequest,
|
||||
) (*dbent.IdentityAdoptionDecision, error) {
|
||||
client := h.entClient()
|
||||
if client == nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||
}
|
||||
|
||||
existing, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(sessionID)).
|
||||
Only(c.Request.Context())
|
||||
if err != nil && !dbent.IsNotFound(err) {
|
||||
return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_LOAD_FAILED", "failed to load oauth profile adoption decision").WithCause(err)
|
||||
}
|
||||
if existing != nil && !req.hasDecision() {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == nil && !req.hasDecision() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
input := service.PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: sessionID,
|
||||
}
|
||||
if existing != nil {
|
||||
input.AdoptDisplayName = existing.AdoptDisplayName
|
||||
input.AdoptAvatar = existing.AdoptAvatar
|
||||
input.IdentityID = existing.IdentityID
|
||||
}
|
||||
if req.AdoptDisplayName != nil {
|
||||
input.AdoptDisplayName = *req.AdoptDisplayName
|
||||
}
|
||||
if req.AdoptAvatar != nil {
|
||||
input.AdoptAvatar = *req.AdoptAvatar
|
||||
}
|
||||
|
||||
svc, err := h.pendingIdentityService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decision, err := svc.UpsertAdoptionDecision(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_SAVE_FAILED", "failed to save oauth profile adoption decision").WithCause(err)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func resolvePendingOAuthTargetUserID(ctx context.Context, client *dbent.Client, session *dbent.PendingAuthSession) (int64, error) {
|
||||
if session == nil {
|
||||
return 0, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth session is invalid")
|
||||
}
|
||||
if session.TargetUserID != nil && *session.TargetUserID > 0 {
|
||||
return *session.TargetUserID, nil
|
||||
}
|
||||
email := strings.TrimSpace(session.ResolvedEmail)
|
||||
if email == "" {
|
||||
return 0, infraerrors.BadRequest("PENDING_AUTH_TARGET_USER_MISSING", "pending auth target user is missing")
|
||||
}
|
||||
|
||||
userEntity, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ(email)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return 0, infraerrors.InternalServer("PENDING_AUTH_TARGET_USER_NOT_FOUND", "pending auth target user was not found")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return userEntity.ID, nil
|
||||
}
|
||||
|
||||
func oauthIdentityIssuer(session *dbent.PendingAuthSession) *string {
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
switch strings.TrimSpace(session.ProviderType) {
|
||||
case "oidc":
|
||||
issuer := strings.TrimSpace(session.ProviderKey)
|
||||
if issuer == "" {
|
||||
issuer = pendingSessionStringValue(session.UpstreamIdentityClaims, "issuer")
|
||||
}
|
||||
if issuer == "" {
|
||||
return nil
|
||||
}
|
||||
return &issuer
|
||||
default:
|
||||
issuer := pendingSessionStringValue(session.UpstreamIdentityClaims, "issuer")
|
||||
if issuer == "" {
|
||||
return nil
|
||||
}
|
||||
return &issuer
|
||||
}
|
||||
}
|
||||
|
||||
func ensurePendingOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx, session *dbent.PendingAuthSession, userID int64) (*dbent.AuthIdentity, error) {
|
||||
client := tx.Client()
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ(strings.TrimSpace(session.ProviderType)),
|
||||
authidentity.ProviderKeyEQ(strings.TrimSpace(session.ProviderKey)),
|
||||
authidentity.ProviderSubjectEQ(strings.TrimSpace(session.ProviderSubject)),
|
||||
).
|
||||
Only(ctx)
|
||||
if err != nil && !dbent.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if identity != nil {
|
||||
if identity.UserID != userID {
|
||||
return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
create := client.AuthIdentity.Create().
|
||||
SetUserID(userID).
|
||||
SetProviderType(strings.TrimSpace(session.ProviderType)).
|
||||
SetProviderKey(strings.TrimSpace(session.ProviderKey)).
|
||||
SetProviderSubject(strings.TrimSpace(session.ProviderSubject)).
|
||||
SetMetadata(cloneOAuthMetadata(session.UpstreamIdentityClaims))
|
||||
if issuer := oauthIdentityIssuer(session); issuer != nil {
|
||||
create = create.SetIssuer(strings.TrimSpace(*issuer))
|
||||
}
|
||||
return create.Save(ctx)
|
||||
}
|
||||
|
||||
func applyPendingOAuthAdoption(
|
||||
ctx context.Context,
|
||||
client *dbent.Client,
|
||||
session *dbent.PendingAuthSession,
|
||||
decision *dbent.IdentityAdoptionDecision,
|
||||
overrideUserID *int64,
|
||||
) error {
|
||||
if client == nil || session == nil || decision == nil {
|
||||
return nil
|
||||
}
|
||||
if !decision.AdoptDisplayName && !decision.AdoptAvatar {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetUserID := int64(0)
|
||||
if overrideUserID != nil && *overrideUserID > 0 {
|
||||
targetUserID = *overrideUserID
|
||||
} else {
|
||||
resolvedUserID, err := resolvePendingOAuthTargetUserID(ctx, client, session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetUserID = resolvedUserID
|
||||
}
|
||||
|
||||
adoptedDisplayName := ""
|
||||
if decision.AdoptDisplayName {
|
||||
adoptedDisplayName = normalizeAdoptedOAuthDisplayName(pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_display_name"))
|
||||
}
|
||||
adoptedAvatarURL := ""
|
||||
if decision.AdoptAvatar {
|
||||
adoptedAvatarURL = pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_avatar_url")
|
||||
}
|
||||
|
||||
tx, err := client.Tx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if decision.AdoptDisplayName && adoptedDisplayName != "" {
|
||||
if err := tx.Client().User.UpdateOneID(targetUserID).
|
||||
SetUsername(adoptedDisplayName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
identity, err := ensurePendingOAuthIdentityForUser(ctx, tx, session, targetUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadata := cloneOAuthMetadata(identity.Metadata)
|
||||
for key, value := range session.UpstreamIdentityClaims {
|
||||
metadata[key] = value
|
||||
}
|
||||
if decision.AdoptDisplayName && adoptedDisplayName != "" {
|
||||
metadata["display_name"] = adoptedDisplayName
|
||||
}
|
||||
if decision.AdoptAvatar && adoptedAvatarURL != "" {
|
||||
metadata["avatar_url"] = adoptedAvatarURL
|
||||
}
|
||||
|
||||
updateIdentity := tx.Client().AuthIdentity.UpdateOneID(identity.ID).SetMetadata(metadata)
|
||||
if issuer := oauthIdentityIssuer(session); issuer != nil {
|
||||
updateIdentity = updateIdentity.SetIssuer(strings.TrimSpace(*issuer))
|
||||
}
|
||||
if _, err := updateIdentity.Save(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if decision.IdentityID == nil || *decision.IdentityID != identity.ID {
|
||||
if _, err := tx.Client().IdentityAdoptionDecision.UpdateOneID(decision.ID).
|
||||
SetIdentityID(identity.ID).
|
||||
Save(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func applySuggestedProfileToCompletionResponse(payload map[string]any, upstream map[string]any) {
|
||||
if len(payload) == 0 || len(upstream) == 0 {
|
||||
return
|
||||
@@ -206,6 +505,11 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
}
|
||||
adoptionDecision, err := bindOptionalOAuthAdoptionDecision(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken, err := readOAuthPendingSessionCookie(c)
|
||||
if err != nil || strings.TrimSpace(sessionToken) == "" {
|
||||
@@ -248,9 +552,30 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
||||
applySuggestedProfileToCompletionResponse(payload, session.UpstreamIdentityClaims)
|
||||
|
||||
if pendingSessionWantsInvitation(payload) {
|
||||
if adoptionDecision.hasDecision() {
|
||||
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, adoptionDecision)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
_ = decision
|
||||
}
|
||||
response.Success(c, payload)
|
||||
return
|
||||
}
|
||||
if !adoptionDecision.hasDecision() {
|
||||
response.Success(c, payload)
|
||||
return
|
||||
}
|
||||
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, adoptionDecision)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), session, decision, session.TargetUserID); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := svc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
||||
clearCookies()
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestApplySuggestedProfileToCompletionResponse(t *testing.T) {
|
||||
@@ -38,3 +59,439 @@ func TestApplySuggestedProfileToCompletionResponseKeepsExistingPayloadValues(t *
|
||||
require.Equal(t, "https://cdn.example/avatar.png", payload["suggested_avatar_url"])
|
||||
require.Equal(t, true, payload["adoption_required"])
|
||||
}
|
||||
|
||||
func TestExchangePendingOAuthCompletionPreviewThenFinalizeAppliesAdoptionDecision(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
userEntity, err := client.User.Create().
|
||||
SetEmail("linuxdo-123@linuxdo-connect.invalid").
|
||||
SetUsername("legacy-name").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("pending-session-token").
|
||||
SetIntent("login").
|
||||
SetProviderType("linuxdo").
|
||||
SetProviderKey("linuxdo").
|
||||
SetProviderSubject("123").
|
||||
SetTargetUserID(userEntity.ID).
|
||||
SetResolvedEmail(userEntity.Email).
|
||||
SetBrowserSessionKey("browser-session-key").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "linuxdo_user",
|
||||
"suggested_display_name": "Alice Example",
|
||||
"suggested_avatar_url": "https://cdn.example/alice.png",
|
||||
}).
|
||||
SetLocalFlowState(map[string]any{
|
||||
oauthCompletionResponseKey: map[string]any{
|
||||
"access_token": "access-token",
|
||||
"redirect": "/dashboard",
|
||||
},
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
previewRecorder := httptest.NewRecorder()
|
||||
previewCtx, _ := gin.CreateTestContext(previewRecorder)
|
||||
previewReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
||||
previewReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
previewReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-session-key")})
|
||||
previewCtx.Request = previewReq
|
||||
|
||||
handler.ExchangePendingOAuthCompletion(previewCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, previewRecorder.Code)
|
||||
previewData := decodeJSONResponseData(t, previewRecorder)
|
||||
require.Equal(t, "Alice Example", previewData["suggested_display_name"])
|
||||
require.Equal(t, "https://cdn.example/alice.png", previewData["suggested_avatar_url"])
|
||||
require.Equal(t, true, previewData["adoption_required"])
|
||||
|
||||
storedUser, err := client.User.Get(ctx, userEntity.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "legacy-name", storedUser.Username)
|
||||
|
||||
previewSession, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.IDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, previewSession.ConsumedAt)
|
||||
|
||||
body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`)
|
||||
finalizeRecorder := httptest.NewRecorder()
|
||||
finalizeCtx, _ := gin.CreateTestContext(finalizeRecorder)
|
||||
finalizeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||
finalizeReq.Header.Set("Content-Type", "application/json")
|
||||
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-session-key")})
|
||||
finalizeCtx.Request = finalizeReq
|
||||
|
||||
handler.ExchangePendingOAuthCompletion(finalizeCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, finalizeRecorder.Code)
|
||||
|
||||
storedUser, err = client.User.Get(ctx, userEntity.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Alice Example", storedUser.Username)
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("linuxdo"),
|
||||
authidentity.ProviderKeyEQ("linuxdo"),
|
||||
authidentity.ProviderSubjectEQ("123"),
|
||||
).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userEntity.ID, identity.UserID)
|
||||
require.Equal(t, "Alice Example", identity.Metadata["display_name"])
|
||||
require.Equal(t, "https://cdn.example/alice.png", identity.Metadata["avatar_url"])
|
||||
|
||||
decision, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision.IdentityID)
|
||||
require.Equal(t, identity.ID, *decision.IdentityID)
|
||||
require.True(t, decision.AdoptDisplayName)
|
||||
require.True(t, decision.AdoptAvatar)
|
||||
|
||||
consumed, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.IDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, consumed.ConsumedAt)
|
||||
}
|
||||
|
||||
func newOAuthPendingFlowTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:auth_oauth_pending_flow_handler?mode=memory&cache=shared")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
||||
require.NoError(t, err)
|
||||
|
||||
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpireHour: 1,
|
||||
AccessTokenExpireMinutes: 60,
|
||||
RefreshTokenExpireDays: 7,
|
||||
},
|
||||
Default: config.DefaultConfig{
|
||||
UserBalance: 0,
|
||||
UserConcurrency: 1,
|
||||
},
|
||||
}
|
||||
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled),
|
||||
},
|
||||
}, cfg)
|
||||
authSvc := service.NewAuthService(
|
||||
client,
|
||||
&oauthPendingFlowUserRepo{client: client},
|
||||
nil,
|
||||
&oauthPendingFlowRefreshTokenCacheStub{},
|
||||
cfg,
|
||||
settingSvc,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
return &AuthHandler{
|
||||
authService: authSvc,
|
||||
settingSvc: settingSvc,
|
||||
}, client
|
||||
}
|
||||
|
||||
func boolSettingValue(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
type oauthPendingFlowSettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
value, ok := s.values[key]
|
||||
if !ok {
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) Set(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := s.values[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
result := make(map[string]string, len(s.values))
|
||||
for key, value := range s.values {
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowSettingRepoStub) Delete(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type oauthPendingFlowRefreshTokenCacheStub struct{}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) StoreRefreshToken(context.Context, string, *service.RefreshTokenData, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) GetRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
|
||||
return nil, service.ErrRefreshTokenNotFound
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteUserRefreshTokens(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowRefreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func decodeJSONResponseData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
type oauthPendingFlowUserRepo struct {
|
||||
client *dbent.Client
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) Create(ctx context.Context, user *service.User) error {
|
||||
entity, err := r.client.User.Create().
|
||||
SetEmail(user.Email).
|
||||
SetUsername(user.Username).
|
||||
SetNotes(user.Notes).
|
||||
SetPasswordHash(user.PasswordHash).
|
||||
SetRole(user.Role).
|
||||
SetBalance(user.Balance).
|
||||
SetConcurrency(user.Concurrency).
|
||||
SetStatus(user.Status).
|
||||
SetSignupSource(user.SignupSource).
|
||||
SetNillableLastLoginAt(user.LastLoginAt).
|
||||
SetNillableLastActiveAt(user.LastActiveAt).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.ID = entity.ID
|
||||
user.CreatedAt = entity.CreatedAt
|
||||
user.UpdatedAt = entity.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
|
||||
entity, err := r.client.User.Get(ctx, id)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, service.ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return oauthPendingFlowServiceUser(entity), nil
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetByEmail(ctx context.Context, email string) (*service.User, error) {
|
||||
entity, err := r.client.User.Query().Where(dbuser.EmailEQ(email)).Only(ctx)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, service.ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return oauthPendingFlowServiceUser(entity), nil
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetFirstAdmin(context.Context) (*service.User, error) {
|
||||
panic("unexpected GetFirstAdmin call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User) error {
|
||||
entity, err := r.client.User.UpdateOneID(user.ID).
|
||||
SetEmail(user.Email).
|
||||
SetUsername(user.Username).
|
||||
SetNotes(user.Notes).
|
||||
SetPasswordHash(user.PasswordHash).
|
||||
SetRole(user.Role).
|
||||
SetBalance(user.Balance).
|
||||
SetConcurrency(user.Concurrency).
|
||||
SetStatus(user.Status).
|
||||
SetSignupSource(user.SignupSource).
|
||||
SetNillableLastLoginAt(user.LastLoginAt).
|
||||
SetNillableLastActiveAt(user.LastActiveAt).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.UpdatedAt = entity.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) Delete(ctx context.Context, id int64) error {
|
||||
return r.client.User.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
|
||||
return nil, service.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) UpsertUserAvatar(context.Context, int64, service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||
panic("unexpected UpsertUserAvatar call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) DeleteUserAvatar(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) UpdateBalance(context.Context, int64, float64) error {
|
||||
panic("unexpected UpdateBalance call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64) error {
|
||||
panic("unexpected DeductBalance call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int) error {
|
||||
panic("unexpected UpdateConcurrency call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
count, err := r.client.User.Query().Where(dbuser.EmailEQ(email)).Count(ctx)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
panic("unexpected RemoveGroupFromAllowedGroups call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error {
|
||||
panic("unexpected AddGroupToAllowedGroups call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
||||
panic("unexpected RemoveGroupFromUserAllowedGroups call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) UpdateTotpSecret(context.Context, int64, *string) error {
|
||||
panic("unexpected UpdateTotpSecret call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) EnableTotp(context.Context, int64) error {
|
||||
panic("unexpected EnableTotp call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) DisableTotp(context.Context, int64) error {
|
||||
panic("unexpected DisableTotp call")
|
||||
}
|
||||
|
||||
func oauthPendingFlowServiceUser(entity *dbent.User) *service.User {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
return &service.User{
|
||||
ID: entity.ID,
|
||||
Email: entity.Email,
|
||||
Username: entity.Username,
|
||||
Notes: entity.Notes,
|
||||
PasswordHash: entity.PasswordHash,
|
||||
Role: entity.Role,
|
||||
Balance: entity.Balance,
|
||||
Concurrency: entity.Concurrency,
|
||||
Status: entity.Status,
|
||||
SignupSource: entity.SignupSource,
|
||||
LastLoginAt: entity.LastLoginAt,
|
||||
LastActiveAt: entity.LastActiveAt,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
)
|
||||
|
||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||
@@ -371,6 +371,7 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
ProviderKey: issuer,
|
||||
ProviderSubject: subject,
|
||||
},
|
||||
TargetUserID: &user.ID,
|
||||
ResolvedEmail: email,
|
||||
RedirectTo: redirectTo,
|
||||
BrowserSessionKey: browserSessionKey,
|
||||
@@ -399,7 +400,9 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
type completeOIDCOAuthRequest struct {
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
}
|
||||
|
||||
// CompleteOIDCOAuthRegistration completes a pending OAuth registration by validating
|
||||
@@ -447,11 +450,23 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
||||
AdoptDisplayName: req.AdoptDisplayName,
|
||||
AdoptAvatar: req.AdoptAvatar,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), session, decision, &user.ID); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
||||
return
|
||||
}
|
||||
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -12,7 +13,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -123,3 +130,80 @@ func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK {
|
||||
E: e,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("oidc-complete-session").
|
||||
SetIntent("login").
|
||||
SetProviderType("oidc").
|
||||
SetProviderKey("https://issuer.example.com").
|
||||
SetProviderSubject("oidc-subject-1").
|
||||
SetResolvedEmail("93a310f4c1944c5bbd2e246df1f76485@oidc-connect.invalid").
|
||||
SetBrowserSessionKey("oidc-browser").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "oidc_user",
|
||||
"issuer": "https://issuer.example.com",
|
||||
"suggested_display_name": "OIDC Display",
|
||||
"suggested_avatar_url": "https://cdn.example/oidc.png",
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
AdoptAvatar: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-browser")})
|
||||
c.Request = req
|
||||
|
||||
handler.CompleteOIDCOAuthRegistration(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
responseData := decodeJSONBody(t, recorder)
|
||||
require.NotEmpty(t, responseData["access_token"])
|
||||
|
||||
userEntity, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ(session.ResolvedEmail)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "OIDC Display", userEntity.Username)
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("oidc"),
|
||||
authidentity.ProviderKeyEQ("https://issuer.example.com"),
|
||||
authidentity.ProviderSubjectEQ("oidc-subject-1"),
|
||||
).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userEntity.ID, identity.UserID)
|
||||
require.Equal(t, "OIDC Display", identity.Metadata["display_name"])
|
||||
require.Equal(t, "https://cdn.example/oidc.png", identity.Metadata["avatar_url"])
|
||||
|
||||
decision, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision.IdentityID)
|
||||
require.Equal(t, identity.ID, *decision.IdentityID)
|
||||
require.True(t, decision.AdoptDisplayName)
|
||||
require.True(t, decision.AdoptAvatar)
|
||||
|
||||
consumed, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.IDEQ(session.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, consumed.ConsumedAt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
wechatOAuthCookiePath = "/api/v1/auth/oauth/wechat"
|
||||
wechatOAuthCookieMaxAgeSec = 10 * 60
|
||||
wechatOAuthStateCookieName = "wechat_oauth_state"
|
||||
wechatOAuthRedirectCookieName = "wechat_oauth_redirect"
|
||||
wechatOAuthIntentCookieName = "wechat_oauth_intent"
|
||||
wechatOAuthModeCookieName = "wechat_oauth_mode"
|
||||
wechatOAuthDefaultRedirectTo = "/dashboard"
|
||||
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
|
||||
wechatOAuthProviderKey = "wechat-main"
|
||||
|
||||
wechatOAuthIntentLogin = "login"
|
||||
wechatOAuthIntentBind = "bind_current_user"
|
||||
wechatOAuthIntentAdoptEmail = "adopt_existing_user_by_email"
|
||||
)
|
||||
|
||||
var (
|
||||
wechatOAuthAccessTokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
wechatOAuthUserInfoURL = "https://api.weixin.qq.com/sns/userinfo"
|
||||
)
|
||||
|
||||
type wechatOAuthConfig struct {
|
||||
mode string
|
||||
appID string
|
||||
appSecret string
|
||||
authorizeURL string
|
||||
scope string
|
||||
redirectURI string
|
||||
frontendCallback string
|
||||
}
|
||||
|
||||
type wechatOAuthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
OpenID string `json:"openid"`
|
||||
Scope string `json:"scope"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int64 `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
type wechatOAuthUserInfoResponse struct {
|
||||
OpenID string `json:"openid"`
|
||||
Nickname string `json:"nickname"`
|
||||
HeadImgURL string `json:"headimgurl"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int64 `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
|
||||
// browser cookies required by the rebuild pending-auth bridge.
|
||||
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := oauth.GenerateState()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
|
||||
if redirectTo == "" {
|
||||
redirectTo = wechatOAuthDefaultRedirectTo
|
||||
}
|
||||
|
||||
browserSessionKey, err := generateOAuthPendingBrowserSession()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
intent := normalizeWeChatOAuthIntent(c.Query("intent"))
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
wechatSetCookie(c, wechatOAuthStateCookieName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
|
||||
authURL, err := buildWeChatAuthorizeURL(cfg, state)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
|
||||
// and stores the result in the unified pending-auth flow.
|
||||
func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
||||
frontendCallback := wechatOAuthFrontendCallback()
|
||||
|
||||
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
if code == "" || state == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
|
||||
return
|
||||
}
|
||||
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
defer func() {
|
||||
wechatClearCookie(c, wechatOAuthStateCookieName, secureCookie)
|
||||
wechatClearCookie(c, wechatOAuthRedirectCookieName, secureCookie)
|
||||
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
|
||||
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
|
||||
}()
|
||||
|
||||
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
|
||||
if err != nil || expectedState == "" || state != expectedState {
|
||||
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo, _ := readCookieDecoded(c, wechatOAuthRedirectCookieName)
|
||||
redirectTo = sanitizeFrontendRedirectPath(redirectTo)
|
||||
if redirectTo == "" {
|
||||
redirectTo = wechatOAuthDefaultRedirectTo
|
||||
}
|
||||
browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
|
||||
if strings.TrimSpace(browserSessionKey) == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
|
||||
return
|
||||
}
|
||||
|
||||
intent, _ := readCookieDecoded(c, wechatOAuthIntentCookieName)
|
||||
mode, err := readCookieDecoded(c, wechatOAuthModeCookieName)
|
||||
if err != nil || strings.TrimSpace(mode) == "" {
|
||||
redirectOAuthError(c, frontendCallback, "invalid_state", "missing oauth mode", "")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), mode, c)
|
||||
if err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
|
||||
tokenResp, userInfo, err := fetchWeChatOAuthIdentity(c.Request.Context(), cfg, code)
|
||||
if err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_identity_fetch_failed", singleLine(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID))
|
||||
openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID))
|
||||
providerSubject := firstNonEmpty(unionid, openid)
|
||||
if providerSubject == "" {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_subject", "")
|
||||
return
|
||||
}
|
||||
|
||||
username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject))
|
||||
email := wechatSyntheticEmail(providerSubject)
|
||||
upstreamClaims := map[string]any{
|
||||
"email": email,
|
||||
"username": username,
|
||||
"subject": providerSubject,
|
||||
"openid": openid,
|
||||
"unionid": unionid,
|
||||
"mode": cfg.mode,
|
||||
"suggested_display_name": strings.TrimSpace(userInfo.Nickname),
|
||||
"suggested_avatar_url": strings.TrimSpace(userInfo.HeadImgURL),
|
||||
}
|
||||
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||
if err != nil {
|
||||
if err := h.createWeChatPendingSession(c, normalizeWeChatOAuthIntent(intent), providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err); err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||
return
|
||||
}
|
||||
redirectToFrontendCallback(c, frontendCallback)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.createWeChatPendingSession(c, normalizeWeChatOAuthIntent(intent), providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil); err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||
return
|
||||
}
|
||||
redirectToFrontendCallback(c, frontendCallback)
|
||||
}
|
||||
|
||||
type completeWeChatOAuthRequest struct {
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
}
|
||||
|
||||
// CompleteWeChatOAuthRegistration completes a pending WeChat OAuth registration by
|
||||
// validating the invitation code and consuming the current pending browser session.
|
||||
// POST /api/v1/auth/oauth/wechat/complete-registration
|
||||
func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
|
||||
var req completeWeChatOAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
sessionToken, err := readOAuthPendingSessionCookie(c)
|
||||
if err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
|
||||
return
|
||||
}
|
||||
browserSessionKey, err := readOAuthPendingBrowserCookie(c)
|
||||
if err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
|
||||
return
|
||||
}
|
||||
pendingSvc, err := h.pendingIdentityService()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
|
||||
if err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(session.ResolvedEmail)
|
||||
username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
|
||||
if email == "" || username == "" {
|
||||
response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
||||
AdoptDisplayName: req.AdoptDisplayName,
|
||||
AdoptAvatar: req.AdoptAvatar,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), session, decision, &user.ID); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
||||
return
|
||||
}
|
||||
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": tokenPair.AccessToken,
|
||||
"refresh_token": tokenPair.RefreshToken,
|
||||
"expires_in": tokenPair.ExpiresIn,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) createWeChatPendingSession(
|
||||
c *gin.Context,
|
||||
intent string,
|
||||
providerSubject string,
|
||||
email string,
|
||||
redirectTo string,
|
||||
browserSessionKey string,
|
||||
upstreamClaims map[string]any,
|
||||
tokenPair *service.TokenPair,
|
||||
authErr error,
|
||||
) error {
|
||||
completionResponse := map[string]any{
|
||||
"redirect": redirectTo,
|
||||
}
|
||||
if authErr != nil {
|
||||
if errors.Is(authErr, service.ErrOAuthInvitationRequired) {
|
||||
completionResponse["error"] = "invitation_required"
|
||||
} else {
|
||||
return authErr
|
||||
}
|
||||
} else if tokenPair != nil {
|
||||
completionResponse["access_token"] = tokenPair.AccessToken
|
||||
completionResponse["refresh_token"] = tokenPair.RefreshToken
|
||||
completionResponse["expires_in"] = tokenPair.ExpiresIn
|
||||
completionResponse["token_type"] = "Bearer"
|
||||
}
|
||||
|
||||
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||
Intent: intent,
|
||||
Identity: service.PendingAuthIdentityKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: wechatOAuthProviderKey,
|
||||
ProviderSubject: providerSubject,
|
||||
},
|
||||
ResolvedEmail: email,
|
||||
RedirectTo: redirectTo,
|
||||
BrowserSessionKey: browserSessionKey,
|
||||
UpstreamIdentityClaims: upstreamClaims,
|
||||
CompletionResponse: completionResponse,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, c *gin.Context) (wechatOAuthConfig, error) {
|
||||
mode, err := resolveWeChatOAuthMode(rawMode, c)
|
||||
if err != nil {
|
||||
return wechatOAuthConfig{}, err
|
||||
}
|
||||
|
||||
apiBaseURL := ""
|
||||
if h != nil && h.settingSvc != nil {
|
||||
settings, err := h.settingSvc.GetAllSettings(ctx)
|
||||
if err == nil && settings != nil {
|
||||
apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := wechatOAuthConfig{
|
||||
mode: mode,
|
||||
redirectURI: resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback"),
|
||||
frontendCallback: wechatOAuthFrontendCallback(),
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "mp":
|
||||
cfg.appID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
|
||||
cfg.appSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
|
||||
cfg.authorizeURL = "https://open.weixin.qq.com/connect/oauth2/authorize"
|
||||
cfg.scope = "snsapi_userinfo"
|
||||
default:
|
||||
cfg.appID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID"))
|
||||
cfg.appSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET"))
|
||||
cfg.authorizeURL = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
cfg.scope = "snsapi_login"
|
||||
}
|
||||
|
||||
if cfg.appID == "" || cfg.appSecret == "" {
|
||||
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
|
||||
}
|
||||
if strings.TrimSpace(cfg.redirectURI) == "" {
|
||||
return wechatOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func wechatOAuthFrontendCallback() string {
|
||||
return firstNonEmpty(strings.TrimSpace(os.Getenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL")), wechatOAuthDefaultFrontendCB)
|
||||
}
|
||||
|
||||
func resolveWeChatOAuthMode(rawMode string, c *gin.Context) (string, error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(rawMode))
|
||||
if mode == "" {
|
||||
if isWeChatBrowserRequest(c) {
|
||||
return "mp", nil
|
||||
}
|
||||
return "open", nil
|
||||
}
|
||||
if mode != "open" && mode != "mp" {
|
||||
return "", infraerrors.BadRequest("INVALID_MODE", "wechat oauth mode must be open or mp")
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func isWeChatBrowserRequest(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent"))), "micromessenger")
|
||||
}
|
||||
|
||||
func normalizeWeChatOAuthIntent(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "login":
|
||||
return wechatOAuthIntentLogin
|
||||
case "bind", "bind_current_user":
|
||||
return wechatOAuthIntentBind
|
||||
case "adopt", "adopt_existing_user_by_email":
|
||||
return wechatOAuthIntentAdoptEmail
|
||||
default:
|
||||
return wechatOAuthIntentLogin
|
||||
}
|
||||
}
|
||||
|
||||
func buildWeChatAuthorizeURL(cfg wechatOAuthConfig, state string) (string, error) {
|
||||
u, err := url.Parse(cfg.authorizeURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse authorize url: %w", err)
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set("appid", cfg.appID)
|
||||
query.Set("redirect_uri", cfg.redirectURI)
|
||||
query.Set("response_type", "code")
|
||||
query.Set("scope", cfg.scope)
|
||||
query.Set("state", state)
|
||||
u.RawQuery = query.Encode()
|
||||
u.Fragment = "wechat_redirect"
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func resolveWeChatOAuthAbsoluteURL(apiBaseURL string, c *gin.Context, callbackPath string) string {
|
||||
callbackPath = strings.TrimSpace(callbackPath)
|
||||
if callbackPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if raw := strings.TrimSpace(apiBaseURL); raw != "" {
|
||||
if parsed, err := url.Parse(raw); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||
basePath := strings.TrimRight(parsed.EscapedPath(), "/")
|
||||
targetPath := callbackPath
|
||||
if basePath != "" && strings.HasSuffix(basePath, "/api/v1") && strings.HasPrefix(callbackPath, "/api/v1") {
|
||||
targetPath = basePath + strings.TrimPrefix(callbackPath, "/api/v1")
|
||||
} else if basePath != "" {
|
||||
targetPath = basePath + callbackPath
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host + targetPath
|
||||
}
|
||||
}
|
||||
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
scheme := "http"
|
||||
if isRequestHTTPS(c) {
|
||||
scheme = "https"
|
||||
}
|
||||
host := strings.TrimSpace(c.Request.Host)
|
||||
if forwardedHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return scheme + "://" + host + callbackPath
|
||||
}
|
||||
|
||||
func fetchWeChatOAuthIdentity(ctx context.Context, cfg wechatOAuthConfig, code string) (*wechatOAuthTokenResponse, *wechatOAuthUserInfoResponse, error) {
|
||||
tokenResp, err := exchangeWeChatOAuthCode(ctx, cfg, code)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
userInfo, err := fetchWeChatUserInfo(ctx, tokenResp)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return tokenResp, userInfo, nil
|
||||
}
|
||||
|
||||
func exchangeWeChatOAuthCode(ctx context.Context, cfg wechatOAuthConfig, code string) (*wechatOAuthTokenResponse, error) {
|
||||
endpoint, err := url.Parse(wechatOAuthAccessTokenURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse wechat access token url: %w", err)
|
||||
}
|
||||
|
||||
query := endpoint.Query()
|
||||
query.Set("appid", cfg.appID)
|
||||
query.Set("secret", cfg.appSecret)
|
||||
query.Set("code", strings.TrimSpace(code))
|
||||
query.Set("grant_type", "authorization_code")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build wechat access token request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request wechat access token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read wechat access token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("wechat access token status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var tokenResp wechatOAuthTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("decode wechat access token response: %w", err)
|
||||
}
|
||||
if tokenResp.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("wechat access token error=%d %s", tokenResp.ErrCode, strings.TrimSpace(tokenResp.ErrMsg))
|
||||
}
|
||||
if strings.TrimSpace(tokenResp.AccessToken) == "" {
|
||||
return nil, fmt.Errorf("wechat access token missing access_token")
|
||||
}
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func fetchWeChatUserInfo(ctx context.Context, tokenResp *wechatOAuthTokenResponse) (*wechatOAuthUserInfoResponse, error) {
|
||||
if tokenResp == nil {
|
||||
return nil, fmt.Errorf("wechat token response is nil")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(wechatOAuthUserInfoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse wechat userinfo url: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("access_token", strings.TrimSpace(tokenResp.AccessToken))
|
||||
query.Set("openid", strings.TrimSpace(tokenResp.OpenID))
|
||||
query.Set("lang", "zh_CN")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build wechat userinfo request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request wechat userinfo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read wechat userinfo response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("wechat userinfo status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var userInfo wechatOAuthUserInfoResponse
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return nil, fmt.Errorf("decode wechat userinfo response: %w", err)
|
||||
}
|
||||
if userInfo.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("wechat userinfo error=%d %s", userInfo.ErrCode, strings.TrimSpace(userInfo.ErrMsg))
|
||||
}
|
||||
return &userInfo, nil
|
||||
}
|
||||
|
||||
func wechatSyntheticEmail(subject string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
return ""
|
||||
}
|
||||
return "wechat-" + subject + service.WeChatConnectSyntheticEmailDomain
|
||||
}
|
||||
|
||||
func wechatFallbackUsername(subject string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
return "wechat_user"
|
||||
}
|
||||
return "wechat_" + truncateFragmentValue(subject)
|
||||
}
|
||||
|
||||
func wechatSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: wechatOAuthCookiePath,
|
||||
MaxAge: maxAgeSec,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func wechatClearCookie(c *gin.Context, name string, secure bool) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: wechatOAuthCookiePath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) {
|
||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/start?mode=open&redirect=/billing", nil)
|
||||
c.Request.Host = "api.example.com"
|
||||
|
||||
handler := &AuthHandler{}
|
||||
handler.WeChatOAuthStart(c)
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
require.NotEmpty(t, location)
|
||||
require.Contains(t, location, "open.weixin.qq.com")
|
||||
require.Contains(t, location, "appid=wx-open-app")
|
||||
require.Contains(t, location, "scope=snsapi_login")
|
||||
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.NotEmpty(t, findCookie(cookies, wechatOAuthStateCookieName))
|
||||
require.NotEmpty(t, findCookie(cookies, wechatOAuthRedirectCookieName))
|
||||
require.NotEmpty(t, findCookie(cookies, wechatOAuthModeCookieName))
|
||||
require.NotEmpty(t, findCookie(cookies, oauthPendingBrowserCookieName))
|
||||
}
|
||||
|
||||
func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
|
||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
||||
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
|
||||
|
||||
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||
t.Cleanup(func() {
|
||||
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||
wechatOAuthUserInfoURL = originalUserInfoURL
|
||||
})
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/sns/oauth2/access_token"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-123","unionid":"union-456","scope":"snsapi_login"}`))
|
||||
case strings.Contains(r.URL.Path, "/sns/userinfo"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"openid":"openid-123","unionid":"union-456","nickname":"WeChat Nick","headimgurl":"https://cdn.example/avatar.png"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
|
||||
|
||||
handler, client := newWeChatOAuthTestHandler(t, false)
|
||||
defer client.Close()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/callback?code=wechat-code&state=state-123", nil)
|
||||
req.Host = "api.example.com"
|
||||
req.AddCookie(encodedCookie(wechatOAuthStateCookieName, "state-123"))
|
||||
req.AddCookie(encodedCookie(wechatOAuthRedirectCookieName, "/dashboard"))
|
||||
req.AddCookie(encodedCookie(wechatOAuthModeCookieName, "open"))
|
||||
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||
c.Request = req
|
||||
|
||||
handler.WeChatOAuthCallback(c)
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
require.Equal(t, "/auth/wechat/callback", recorder.Header().Get("Location"))
|
||||
|
||||
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||
require.NotNil(t, sessionCookie)
|
||||
|
||||
ctx := context.Background()
|
||||
session, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "wechat", session.ProviderType)
|
||||
require.Equal(t, "wechat-main", session.ProviderKey)
|
||||
require.Equal(t, "union-456", session.ProviderSubject)
|
||||
require.Equal(t, "wechat-union-456@wechat-connect.invalid", session.ResolvedEmail)
|
||||
require.Equal(t, "WeChat Nick", session.UpstreamIdentityClaims["suggested_display_name"])
|
||||
require.Equal(t, "https://cdn.example/avatar.png", session.UpstreamIdentityClaims["suggested_avatar_url"])
|
||||
require.Equal(t, "union-456", session.UpstreamIdentityClaims["unionid"])
|
||||
require.Equal(t, "openid-123", session.UpstreamIdentityClaims["openid"])
|
||||
}
|
||||
|
||||
func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing.T) {
|
||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
|
||||
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
|
||||
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
|
||||
|
||||
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||
t.Cleanup(func() {
|
||||
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||
wechatOAuthUserInfoURL = originalUserInfoURL
|
||||
})
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/sns/oauth2/access_token"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-123","unionid":"union-456","scope":"snsapi_login"}`))
|
||||
case strings.Contains(r.URL.Path, "/sns/userinfo"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"openid":"openid-123","unionid":"union-456","nickname":"WeChat Display","headimgurl":"https://cdn.example/wechat.png"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
|
||||
|
||||
handler, client := newWeChatOAuthTestHandler(t, true)
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redeemRepo := repository.NewRedeemCodeRepository(client)
|
||||
require.NoError(t, redeemRepo.Create(ctx, &service.RedeemCode{
|
||||
Code: "invite-1",
|
||||
Type: service.RedeemTypeInvitation,
|
||||
Status: service.StatusUnused,
|
||||
}))
|
||||
|
||||
callbackRecorder := httptest.NewRecorder()
|
||||
callbackCtx, _ := gin.CreateTestContext(callbackRecorder)
|
||||
callbackReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/callback?code=wechat-code&state=state-123", nil)
|
||||
callbackReq.Host = "api.example.com"
|
||||
callbackReq.AddCookie(encodedCookie(wechatOAuthStateCookieName, "state-123"))
|
||||
callbackReq.AddCookie(encodedCookie(wechatOAuthRedirectCookieName, "/dashboard"))
|
||||
callbackReq.AddCookie(encodedCookie(wechatOAuthModeCookieName, "open"))
|
||||
callbackReq.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||
callbackCtx.Request = callbackReq
|
||||
|
||||
handler.WeChatOAuthCallback(callbackCtx)
|
||||
|
||||
require.Equal(t, http.StatusFound, callbackRecorder.Code)
|
||||
require.Equal(t, "/auth/wechat/callback", callbackRecorder.Header().Get("Location"))
|
||||
|
||||
sessionCookie := findCookie(callbackRecorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||
require.NotNil(t, sessionCookie)
|
||||
sessionToken := decodeCookieValueForTest(t, sessionCookie.Value)
|
||||
|
||||
pendingSession, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.SessionTokenEQ(sessionToken)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "invitation_required", pendingSession.LocalFlowState[oauthCompletionResponseKey].(map[string]any)["error"])
|
||||
|
||||
body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true,"adopt_avatar":true}`)
|
||||
completeRecorder := httptest.NewRecorder()
|
||||
completeCtx, _ := gin.CreateTestContext(completeRecorder)
|
||||
completeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/wechat/complete-registration", body)
|
||||
completeReq.Header.Set("Content-Type", "application/json")
|
||||
completeReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(sessionToken)})
|
||||
completeReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-123")})
|
||||
completeCtx.Request = completeReq
|
||||
|
||||
handler.CompleteWeChatOAuthRegistration(completeCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, completeRecorder.Code)
|
||||
responseData := decodeJSONBody(t, completeRecorder)
|
||||
require.NotEmpty(t, responseData["access_token"])
|
||||
|
||||
userEntity, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ("wechat-union-456@wechat-connect.invalid")).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "WeChat Display", userEntity.Username)
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("wechat"),
|
||||
authidentity.ProviderKeyEQ("wechat-main"),
|
||||
authidentity.ProviderSubjectEQ("union-456"),
|
||||
).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userEntity.ID, identity.UserID)
|
||||
require.Equal(t, "WeChat Display", identity.Metadata["display_name"])
|
||||
require.Equal(t, "https://cdn.example/wechat.png", identity.Metadata["avatar_url"])
|
||||
|
||||
decision, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(pendingSession.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision.IdentityID)
|
||||
require.Equal(t, identity.ID, *decision.IdentityID)
|
||||
require.True(t, decision.AdoptDisplayName)
|
||||
require.True(t, decision.AdoptAvatar)
|
||||
|
||||
consumed, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.IDEQ(pendingSession.ID)).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, consumed.ConsumedAt)
|
||||
}
|
||||
|
||||
func newWeChatOAuthTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:auth_wechat_oauth?mode=memory&cache=shared")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
||||
require.NoError(t, err)
|
||||
|
||||
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||
|
||||
userRepo := &oauthPendingFlowUserRepo{client: client}
|
||||
redeemRepo := repository.NewRedeemCodeRepository(client)
|
||||
settingSvc := service.NewSettingService(&wechatOAuthSettingRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled),
|
||||
},
|
||||
}, &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpireHour: 1,
|
||||
AccessTokenExpireMinutes: 60,
|
||||
RefreshTokenExpireDays: 7,
|
||||
},
|
||||
Default: config.DefaultConfig{
|
||||
UserBalance: 0,
|
||||
UserConcurrency: 1,
|
||||
},
|
||||
})
|
||||
|
||||
authSvc := service.NewAuthService(
|
||||
client,
|
||||
userRepo,
|
||||
redeemRepo,
|
||||
&wechatOAuthRefreshTokenCacheStub{},
|
||||
&config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpireHour: 1,
|
||||
AccessTokenExpireMinutes: 60,
|
||||
RefreshTokenExpireDays: 7,
|
||||
},
|
||||
Default: config.DefaultConfig{
|
||||
UserBalance: 0,
|
||||
UserConcurrency: 1,
|
||||
},
|
||||
},
|
||||
settingSvc,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
return &AuthHandler{
|
||||
authService: authSvc,
|
||||
settingSvc: settingSvc,
|
||||
}, client
|
||||
}
|
||||
|
||||
func encodedCookie(name, value string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: encodeCookieValue(value),
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeCookieValueForTest(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
raw, err := base64.RawURLEncoding.DecodeString(value)
|
||||
require.NoError(t, err)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
type wechatOAuthSettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
value, ok := s.values[key]
|
||||
if !ok {
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) Set(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := s.values[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
result := make(map[string]string, len(s.values))
|
||||
for key, value := range s.values {
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthSettingRepoStub) Delete(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type wechatOAuthRefreshTokenCacheStub struct{}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) StoreRefreshToken(context.Context, string, *service.RefreshTokenData, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) GetRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
|
||||
return nil, service.ErrRefreshTokenNotFound
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) DeleteUserRefreshTokens(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *wechatOAuthRefreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -189,6 +189,7 @@ type PublicSettings struct {
|
||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
|
||||
@@ -120,7 +120,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
|
||||
// This allows looking up the correct provider instance before verification.
|
||||
func extractOutTradeNo(rawBody, providerKey string) string {
|
||||
switch providerKey {
|
||||
case payment.TypeEasyPay:
|
||||
case payment.TypeEasyPay, payment.TypeAlipay:
|
||||
values, err := url.ParseQuery(rawBody)
|
||||
if err == nil {
|
||||
return values.Get("out_trade_no")
|
||||
|
||||
@@ -97,3 +97,37 @@ func TestWebhookConstants(t *testing.T) {
|
||||
assert.Equal(t, 200, webhookLogTruncateLen)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractOutTradeNo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
providerKey string
|
||||
rawBody string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "easypay query payload",
|
||||
providerKey: "easypay",
|
||||
rawBody: "out_trade_no=sub2_123&trade_status=TRADE_SUCCESS",
|
||||
want: "sub2_123",
|
||||
},
|
||||
{
|
||||
name: "alipay query payload",
|
||||
providerKey: "alipay",
|
||||
rawBody: "notify_time=2026-04-20+12%3A00%3A00&out_trade_no=sub2_456",
|
||||
want: "sub2_456",
|
||||
},
|
||||
{
|
||||
name: "unknown provider",
|
||||
providerKey: "wxpay",
|
||||
rawBody: "{}",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, extractOutTradeNo(tt.rawBody, tt.providerKey))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
|
||||
@@ -34,10 +34,16 @@ type ChangePasswordRequest struct {
|
||||
// UpdateProfileRequest represents the update profile request payload
|
||||
type UpdateProfileRequest struct {
|
||||
Username *string `json:"username"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
}
|
||||
|
||||
type userProfileResponse struct {
|
||||
dto.User
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// GetProfile handles getting user profile
|
||||
// GET /api/v1/users/me
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
@@ -47,13 +53,13 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userData, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
|
||||
userData, err := h.userService.GetProfile(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserFromService(userData))
|
||||
response.Success(c, userProfileResponseFromService(userData))
|
||||
}
|
||||
|
||||
// ChangePassword handles changing user password
|
||||
@@ -101,6 +107,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
svcReq := service.UpdateProfileRequest{
|
||||
Username: req.Username,
|
||||
AvatarURL: req.AvatarURL,
|
||||
BalanceNotifyEnabled: req.BalanceNotifyEnabled,
|
||||
BalanceNotifyThreshold: req.BalanceNotifyThreshold,
|
||||
}
|
||||
@@ -110,7 +117,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserFromService(updatedUser))
|
||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
||||
}
|
||||
|
||||
// SendNotifyEmailCodeRequest represents the request to send notify email verification code
|
||||
@@ -176,7 +183,7 @@ func (h *UserHandler) VerifyNotifyEmail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserFromService(updatedUser))
|
||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
||||
}
|
||||
|
||||
// RemoveNotifyEmailRequest represents the request to remove a notify email
|
||||
@@ -212,7 +219,7 @@ func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserFromService(updatedUser))
|
||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
||||
}
|
||||
|
||||
// ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state
|
||||
@@ -248,5 +255,16 @@ func (h *UserHandler) ToggleNotifyEmail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserFromService(updatedUser))
|
||||
response.Success(c, userProfileResponseFromService(updatedUser))
|
||||
}
|
||||
|
||||
func userProfileResponseFromService(user *service.User) userProfileResponse {
|
||||
base := dto.UserFromService(user)
|
||||
if base == nil {
|
||||
return userProfileResponse{}
|
||||
}
|
||||
return userProfileResponse{
|
||||
User: *base,
|
||||
AvatarURL: user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type userHandlerRepoStub struct {
|
||||
user *service.User
|
||||
}
|
||||
|
||||
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
|
||||
func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) {
|
||||
cloned := *s.user
|
||||
return &cloned, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) GetByEmail(context.Context, string) (*service.User, error) {
|
||||
cloned := *s.user
|
||||
return &cloned, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) GetFirstAdmin(context.Context) (*service.User, error) {
|
||||
cloned := *s.user
|
||||
return &cloned, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User) error {
|
||||
cloned := *user
|
||||
s.user = &cloned
|
||||
return nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) Delete(context.Context, int64) error { return nil }
|
||||
func (s *userHandlerRepoStub) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
|
||||
if s.user == nil || s.user.AvatarURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &service.UserAvatar{
|
||||
StorageProvider: s.user.AvatarSource,
|
||||
URL: s.user.AvatarURL,
|
||||
ContentType: s.user.AvatarMIME,
|
||||
ByteSize: s.user.AvatarByteSize,
|
||||
SHA256: s.user.AvatarSHA256,
|
||||
}, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) UpsertUserAvatar(_ context.Context, _ int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||
s.user.AvatarURL = input.URL
|
||||
s.user.AvatarSource = input.StorageProvider
|
||||
s.user.AvatarMIME = input.ContentType
|
||||
s.user.AvatarByteSize = input.ByteSize
|
||||
s.user.AvatarSHA256 = input.SHA256
|
||||
return &service.UserAvatar{
|
||||
StorageProvider: input.StorageProvider,
|
||||
URL: input.URL,
|
||||
ContentType: input.ContentType,
|
||||
ByteSize: input.ByteSize,
|
||||
SHA256: input.SHA256,
|
||||
}, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) DeleteUserAvatar(context.Context, int64) error {
|
||||
s.user.AvatarURL = ""
|
||||
s.user.AvatarSource = ""
|
||||
s.user.AvatarMIME = ""
|
||||
s.user.AvatarByteSize = 0
|
||||
s.user.AvatarSHA256 = ""
|
||||
return nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
|
||||
func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
|
||||
func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
|
||||
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
|
||||
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
||||
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
||||
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
||||
|
||||
func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &userHandlerRepoStub{
|
||||
user: &service.User{
|
||||
ID: 11,
|
||||
Email: "handler-avatar@example.com",
|
||||
Username: "handler-avatar",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil)
|
||||
|
||||
body := []byte(`{"avatar_url":"https://cdn.example.com/avatar.png"}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/user", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
||||
|
||||
handler.UpdateProfile(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Username string `json:"username"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||
require.Equal(t, 0, resp.Code)
|
||||
require.Equal(t, "https://cdn.example.com/avatar.png", resp.Data.AvatarURL)
|
||||
require.Equal(t, "handler-avatar", resp.Data.Username)
|
||||
}
|
||||
@@ -149,6 +149,9 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
user.FieldBalanceNotifyThreshold,
|
||||
user.FieldBalanceNotifyExtraEmails,
|
||||
user.FieldTotalRecharged,
|
||||
user.FieldSignupSource,
|
||||
user.FieldLastLoginAt,
|
||||
user.FieldLastActiveAt,
|
||||
)
|
||||
}).
|
||||
WithGroup(func(q *dbent.GroupQuery) {
|
||||
@@ -656,6 +659,9 @@ func userEntityToService(u *dbent.User) *service.User {
|
||||
Balance: u.Balance,
|
||||
Concurrency: u.Concurrency,
|
||||
Status: u.Status,
|
||||
SignupSource: u.SignupSource,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
LastActiveAt: u.LastActiveAt,
|
||||
TotpSecretEncrypted: u.TotpSecretEncrypted,
|
||||
TotpEnabled: u.TotpEnabled,
|
||||
TotpEnabledAt: u.TotpEnabledAt,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthIdentityMigrationReport struct {
|
||||
ID int64
|
||||
ReportType string
|
||||
ReportKey string
|
||||
Details map[string]any
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AuthIdentityMigrationReportQuery struct {
|
||||
ReportType string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type AuthIdentityMigrationReportSummary struct {
|
||||
Total int64
|
||||
ByType map[string]int64
|
||||
}
|
||||
|
||||
func (r *userRepository) ListAuthIdentityMigrationReports(ctx context.Context, query AuthIdentityMigrationReportQuery) ([]AuthIdentityMigrationReport, error) {
|
||||
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
||||
if exec == nil {
|
||||
return nil, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := exec.QueryContext(ctx, `
|
||||
SELECT id, report_type, report_key, details, created_at
|
||||
FROM auth_identity_migration_reports
|
||||
WHERE ($1 = '' OR report_type = $1)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
strings.TrimSpace(query.ReportType),
|
||||
limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
reports := make([]AuthIdentityMigrationReport, 0)
|
||||
for rows.Next() {
|
||||
report, scanErr := scanAuthIdentityMigrationReport(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
reports = append(reports, report)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetAuthIdentityMigrationReport(ctx context.Context, reportType, reportKey string) (*AuthIdentityMigrationReport, error) {
|
||||
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
||||
if exec == nil {
|
||||
return nil, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
|
||||
rows, err := exec.QueryContext(ctx, `
|
||||
SELECT id, report_type, report_key, details, created_at
|
||||
FROM auth_identity_migration_reports
|
||||
WHERE report_type = $1 AND report_key = $2
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(reportType),
|
||||
strings.TrimSpace(reportKey),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
report, err := scanAuthIdentityMigrationReport(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &report, rows.Err()
|
||||
}
|
||||
|
||||
func (r *userRepository) SummarizeAuthIdentityMigrationReports(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) {
|
||||
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
||||
if exec == nil {
|
||||
return nil, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
|
||||
rows, err := exec.QueryContext(ctx, `
|
||||
SELECT report_type, COUNT(*)
|
||||
FROM auth_identity_migration_reports
|
||||
GROUP BY report_type
|
||||
ORDER BY report_type ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
summary := &AuthIdentityMigrationReportSummary{
|
||||
ByType: make(map[string]int64),
|
||||
}
|
||||
for rows.Next() {
|
||||
var reportType string
|
||||
var count int64
|
||||
if err := rows.Scan(&reportType, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.ByType[reportType] = count
|
||||
summary.Total += count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) {
|
||||
var (
|
||||
report AuthIdentityMigrationReport
|
||||
details []byte
|
||||
)
|
||||
if err := scanner.Scan(&report.ID, &report.ReportType, &report.ReportKey, &details, &report.CreatedAt); err != nil {
|
||||
return AuthIdentityMigrationReport{}, err
|
||||
}
|
||||
report.Details = map[string]any{}
|
||||
if len(details) > 0 {
|
||||
if err := json.Unmarshal(details, &report.Details); err != nil {
|
||||
return AuthIdentityMigrationReport{}, err
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthIdentityOwnershipConflict = infraerrors.Conflict(
|
||||
"AUTH_IDENTITY_OWNERSHIP_CONFLICT",
|
||||
"auth identity already belongs to another user",
|
||||
)
|
||||
ErrAuthIdentityChannelOwnershipConflict = infraerrors.Conflict(
|
||||
"AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT",
|
||||
"auth identity channel already belongs to another user",
|
||||
)
|
||||
)
|
||||
|
||||
type ProviderGrantReason string
|
||||
|
||||
const (
|
||||
ProviderGrantReasonSignup ProviderGrantReason = "signup"
|
||||
ProviderGrantReasonFirstBind ProviderGrantReason = "first_bind"
|
||||
)
|
||||
|
||||
type AuthIdentityKey struct {
|
||||
ProviderType string
|
||||
ProviderKey string
|
||||
ProviderSubject string
|
||||
}
|
||||
|
||||
type AuthIdentityChannelKey struct {
|
||||
ProviderType string
|
||||
ProviderKey string
|
||||
Channel string
|
||||
ChannelAppID string
|
||||
ChannelSubject string
|
||||
}
|
||||
|
||||
type CreateAuthIdentityInput struct {
|
||||
UserID int64
|
||||
Canonical AuthIdentityKey
|
||||
Channel *AuthIdentityChannelKey
|
||||
Issuer *string
|
||||
VerifiedAt *time.Time
|
||||
Metadata map[string]any
|
||||
ChannelMetadata map[string]any
|
||||
}
|
||||
|
||||
type BindAuthIdentityInput = CreateAuthIdentityInput
|
||||
|
||||
type CreateAuthIdentityResult struct {
|
||||
Identity *dbent.AuthIdentity
|
||||
Channel *dbent.AuthIdentityChannel
|
||||
}
|
||||
|
||||
func (r *CreateAuthIdentityResult) IdentityRef() AuthIdentityKey {
|
||||
if r == nil || r.Identity == nil {
|
||||
return AuthIdentityKey{}
|
||||
}
|
||||
return AuthIdentityKey{
|
||||
ProviderType: r.Identity.ProviderType,
|
||||
ProviderKey: r.Identity.ProviderKey,
|
||||
ProviderSubject: r.Identity.ProviderSubject,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CreateAuthIdentityResult) ChannelRef() *AuthIdentityChannelKey {
|
||||
if r == nil || r.Channel == nil {
|
||||
return nil
|
||||
}
|
||||
return &AuthIdentityChannelKey{
|
||||
ProviderType: r.Channel.ProviderType,
|
||||
ProviderKey: r.Channel.ProviderKey,
|
||||
Channel: r.Channel.Channel,
|
||||
ChannelAppID: r.Channel.ChannelAppID,
|
||||
ChannelSubject: r.Channel.ChannelSubject,
|
||||
}
|
||||
}
|
||||
|
||||
type UserAuthIdentityLookup struct {
|
||||
User *dbent.User
|
||||
Identity *dbent.AuthIdentity
|
||||
Channel *dbent.AuthIdentityChannel
|
||||
}
|
||||
|
||||
type ProviderGrantRecordInput struct {
|
||||
UserID int64
|
||||
ProviderType string
|
||||
GrantReason ProviderGrantReason
|
||||
}
|
||||
|
||||
type IdentityAdoptionDecisionInput struct {
|
||||
PendingAuthSessionID int64
|
||||
IdentityID *int64
|
||||
AdoptDisplayName bool
|
||||
AdoptAvatar bool
|
||||
}
|
||||
|
||||
type sqlQueryExecutor interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
func (r *userRepository) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
|
||||
if dbent.TxFromContext(ctx) != nil {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
tx, err := r.client.Tx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
txCtx := dbent.NewTxContext(ctx, tx)
|
||||
if err := fn(txCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *userRepository) CreateAuthIdentity(ctx context.Context, input CreateAuthIdentityInput) (*CreateAuthIdentityResult, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
|
||||
create := client.AuthIdentity.Create().
|
||||
SetUserID(input.UserID).
|
||||
SetProviderType(strings.TrimSpace(input.Canonical.ProviderType)).
|
||||
SetProviderKey(strings.TrimSpace(input.Canonical.ProviderKey)).
|
||||
SetProviderSubject(strings.TrimSpace(input.Canonical.ProviderSubject)).
|
||||
SetMetadata(copyMetadata(input.Metadata)).
|
||||
SetNillableIssuer(input.Issuer).
|
||||
SetNillableVerifiedAt(input.VerifiedAt)
|
||||
|
||||
identity, err := create.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var channel *dbent.AuthIdentityChannel
|
||||
if input.Channel != nil {
|
||||
channel, err = client.AuthIdentityChannel.Create().
|
||||
SetIdentityID(identity.ID).
|
||||
SetProviderType(strings.TrimSpace(input.Channel.ProviderType)).
|
||||
SetProviderKey(strings.TrimSpace(input.Channel.ProviderKey)).
|
||||
SetChannel(strings.TrimSpace(input.Channel.Channel)).
|
||||
SetChannelAppID(strings.TrimSpace(input.Channel.ChannelAppID)).
|
||||
SetChannelSubject(strings.TrimSpace(input.Channel.ChannelSubject)).
|
||||
SetMetadata(copyMetadata(input.ChannelMetadata)).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &CreateAuthIdentityResult{Identity: identity, Channel: channel}, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetUserByCanonicalIdentity(ctx context.Context, key AuthIdentityKey) (*UserAuthIdentityLookup, error) {
|
||||
identity, err := clientFromContext(ctx, r.client).AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ(strings.TrimSpace(key.ProviderType)),
|
||||
authidentity.ProviderKeyEQ(strings.TrimSpace(key.ProviderKey)),
|
||||
authidentity.ProviderSubjectEQ(strings.TrimSpace(key.ProviderSubject)),
|
||||
).
|
||||
WithUser().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserAuthIdentityLookup{
|
||||
User: identity.Edges.User,
|
||||
Identity: identity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetUserByChannelIdentity(ctx context.Context, key AuthIdentityChannelKey) (*UserAuthIdentityLookup, error) {
|
||||
channel, err := clientFromContext(ctx, r.client).AuthIdentityChannel.Query().
|
||||
Where(
|
||||
authidentitychannel.ProviderTypeEQ(strings.TrimSpace(key.ProviderType)),
|
||||
authidentitychannel.ProviderKeyEQ(strings.TrimSpace(key.ProviderKey)),
|
||||
authidentitychannel.ChannelEQ(strings.TrimSpace(key.Channel)),
|
||||
authidentitychannel.ChannelAppIDEQ(strings.TrimSpace(key.ChannelAppID)),
|
||||
authidentitychannel.ChannelSubjectEQ(strings.TrimSpace(key.ChannelSubject)),
|
||||
).
|
||||
WithIdentity(func(q *dbent.AuthIdentityQuery) {
|
||||
q.WithUser()
|
||||
}).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserAuthIdentityLookup{
|
||||
User: channel.Edges.Identity.Edges.User,
|
||||
Identity: channel.Edges.Identity,
|
||||
Channel: channel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindAuthIdentityInput) (*CreateAuthIdentityResult, error) {
|
||||
var result *CreateAuthIdentityResult
|
||||
err := r.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
|
||||
client := clientFromContext(txCtx, r.client)
|
||||
canonical := input.Canonical
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ(strings.TrimSpace(canonical.ProviderType)),
|
||||
authidentity.ProviderKeyEQ(strings.TrimSpace(canonical.ProviderKey)),
|
||||
authidentity.ProviderSubjectEQ(strings.TrimSpace(canonical.ProviderSubject)),
|
||||
).
|
||||
Only(txCtx)
|
||||
if err != nil && !dbent.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if identity != nil && identity.UserID != input.UserID {
|
||||
return ErrAuthIdentityOwnershipConflict
|
||||
}
|
||||
if identity == nil {
|
||||
identity, err = client.AuthIdentity.Create().
|
||||
SetUserID(input.UserID).
|
||||
SetProviderType(strings.TrimSpace(canonical.ProviderType)).
|
||||
SetProviderKey(strings.TrimSpace(canonical.ProviderKey)).
|
||||
SetProviderSubject(strings.TrimSpace(canonical.ProviderSubject)).
|
||||
SetMetadata(copyMetadata(input.Metadata)).
|
||||
SetNillableIssuer(input.Issuer).
|
||||
SetNillableVerifiedAt(input.VerifiedAt).
|
||||
Save(txCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
update := client.AuthIdentity.UpdateOneID(identity.ID)
|
||||
if input.Metadata != nil {
|
||||
update = update.SetMetadata(copyMetadata(input.Metadata))
|
||||
}
|
||||
if input.Issuer != nil {
|
||||
update = update.SetIssuer(strings.TrimSpace(*input.Issuer))
|
||||
}
|
||||
if input.VerifiedAt != nil {
|
||||
update = update.SetVerifiedAt(*input.VerifiedAt)
|
||||
}
|
||||
identity, err = update.Save(txCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var channel *dbent.AuthIdentityChannel
|
||||
if input.Channel != nil {
|
||||
channel, err = client.AuthIdentityChannel.Query().
|
||||
Where(
|
||||
authidentitychannel.ProviderTypeEQ(strings.TrimSpace(input.Channel.ProviderType)),
|
||||
authidentitychannel.ProviderKeyEQ(strings.TrimSpace(input.Channel.ProviderKey)),
|
||||
authidentitychannel.ChannelEQ(strings.TrimSpace(input.Channel.Channel)),
|
||||
authidentitychannel.ChannelAppIDEQ(strings.TrimSpace(input.Channel.ChannelAppID)),
|
||||
authidentitychannel.ChannelSubjectEQ(strings.TrimSpace(input.Channel.ChannelSubject)),
|
||||
).
|
||||
WithIdentity().
|
||||
Only(txCtx)
|
||||
if err != nil && !dbent.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if channel != nil && channel.Edges.Identity != nil && channel.Edges.Identity.UserID != input.UserID {
|
||||
return ErrAuthIdentityChannelOwnershipConflict
|
||||
}
|
||||
if channel == nil {
|
||||
channel, err = client.AuthIdentityChannel.Create().
|
||||
SetIdentityID(identity.ID).
|
||||
SetProviderType(strings.TrimSpace(input.Channel.ProviderType)).
|
||||
SetProviderKey(strings.TrimSpace(input.Channel.ProviderKey)).
|
||||
SetChannel(strings.TrimSpace(input.Channel.Channel)).
|
||||
SetChannelAppID(strings.TrimSpace(input.Channel.ChannelAppID)).
|
||||
SetChannelSubject(strings.TrimSpace(input.Channel.ChannelSubject)).
|
||||
SetMetadata(copyMetadata(input.ChannelMetadata)).
|
||||
Save(txCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
update := client.AuthIdentityChannel.UpdateOneID(channel.ID).
|
||||
SetIdentityID(identity.ID)
|
||||
if input.ChannelMetadata != nil {
|
||||
update = update.SetMetadata(copyMetadata(input.ChannelMetadata))
|
||||
}
|
||||
channel, err = update.Save(txCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = &CreateAuthIdentityResult{Identity: identity, Channel: channel}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) RecordProviderGrant(ctx context.Context, input ProviderGrantRecordInput) (bool, error) {
|
||||
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
||||
if exec == nil {
|
||||
return false, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
|
||||
result, err := exec.ExecContext(ctx, `
|
||||
INSERT INTO user_provider_default_grants (user_id, provider_type, grant_reason)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, provider_type, grant_reason) DO NOTHING`,
|
||||
input.UserID,
|
||||
strings.TrimSpace(input.ProviderType),
|
||||
string(input.GrantReason),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) UpsertIdentityAdoptionDecision(ctx context.Context, input IdentityAdoptionDecisionInput) (*dbent.IdentityAdoptionDecision, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
current, err := client.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(input.PendingAuthSessionID)).
|
||||
Only(ctx)
|
||||
if err != nil && !dbent.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if current == nil {
|
||||
create := client.IdentityAdoptionDecision.Create().
|
||||
SetPendingAuthSessionID(input.PendingAuthSessionID).
|
||||
SetAdoptDisplayName(input.AdoptDisplayName).
|
||||
SetAdoptAvatar(input.AdoptAvatar).
|
||||
SetDecidedAt(now)
|
||||
if input.IdentityID != nil {
|
||||
create = create.SetIdentityID(*input.IdentityID)
|
||||
}
|
||||
return create.Save(ctx)
|
||||
}
|
||||
|
||||
update := client.IdentityAdoptionDecision.UpdateOneID(current.ID).
|
||||
SetAdoptDisplayName(input.AdoptDisplayName).
|
||||
SetAdoptAvatar(input.AdoptAvatar)
|
||||
if input.IdentityID != nil {
|
||||
update = update.SetIdentityID(*input.IdentityID)
|
||||
}
|
||||
return update.Save(ctx)
|
||||
}
|
||||
|
||||
func (r *userRepository) GetIdentityAdoptionDecisionByPendingAuthSessionID(ctx context.Context, pendingAuthSessionID int64) (*dbent.IdentityAdoptionDecision, error) {
|
||||
return clientFromContext(ctx, r.client).IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(pendingAuthSessionID)).
|
||||
Only(ctx)
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateUserLastLoginAt(ctx context.Context, userID int64, loginAt time.Time) error {
|
||||
_, err := clientFromContext(ctx, r.client).User.UpdateOneID(userID).
|
||||
SetLastLoginAt(loginAt).
|
||||
Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
|
||||
_, err := clientFromContext(ctx, r.client).User.UpdateOneID(userID).
|
||||
SetLastActiveAt(activeAt).
|
||||
Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) GetUserAvatar(ctx context.Context, userID int64) (*service.UserAvatar, error) {
|
||||
exec, err := r.userProfileIdentitySQL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := exec.QueryContext(ctx, `
|
||||
SELECT storage_provider, storage_key, url, content_type, byte_size, sha256
|
||||
FROM user_avatars
|
||||
WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
var avatar service.UserAvatar
|
||||
if err := rows.Scan(
|
||||
&avatar.StorageProvider,
|
||||
&avatar.StorageKey,
|
||||
&avatar.URL,
|
||||
&avatar.ContentType,
|
||||
&avatar.ByteSize,
|
||||
&avatar.SHA256,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &avatar, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) UpsertUserAvatar(ctx context.Context, userID int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||
exec, err := r.userProfileIdentitySQL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = exec.ExecContext(ctx, `
|
||||
INSERT INTO user_avatars (user_id, storage_provider, storage_key, url, content_type, byte_size, sha256, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
storage_provider = EXCLUDED.storage_provider,
|
||||
storage_key = EXCLUDED.storage_key,
|
||||
url = EXCLUDED.url,
|
||||
content_type = EXCLUDED.content_type,
|
||||
byte_size = EXCLUDED.byte_size,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
updated_at = NOW()`,
|
||||
userID,
|
||||
strings.TrimSpace(input.StorageProvider),
|
||||
strings.TrimSpace(input.StorageKey),
|
||||
strings.TrimSpace(input.URL),
|
||||
strings.TrimSpace(input.ContentType),
|
||||
input.ByteSize,
|
||||
strings.TrimSpace(input.SHA256),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &service.UserAvatar{
|
||||
StorageProvider: strings.TrimSpace(input.StorageProvider),
|
||||
StorageKey: strings.TrimSpace(input.StorageKey),
|
||||
URL: strings.TrimSpace(input.URL),
|
||||
ContentType: strings.TrimSpace(input.ContentType),
|
||||
ByteSize: input.ByteSize,
|
||||
SHA256: strings.TrimSpace(input.SHA256),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||
exec, err := r.userProfileIdentitySQL(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = exec.ExecContext(ctx, `DELETE FROM user_avatars WHERE user_id = $1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) attachUserAvatar(ctx context.Context, user *service.User) error {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
avatar, err := r.GetUserAvatar(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if avatar == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
user.AvatarURL = avatar.URL
|
||||
user.AvatarSource = avatar.StorageProvider
|
||||
user.AvatarMIME = avatar.ContentType
|
||||
user.AvatarByteSize = avatar.ByteSize
|
||||
user.AvatarSHA256 = avatar.SHA256
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMetadata(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func txAwareSQLExecutor(ctx context.Context, fallback sqlExecutor, client *dbent.Client) sqlQueryExecutor {
|
||||
if tx := dbent.TxFromContext(ctx); tx != nil {
|
||||
if exec := sqlExecutorFromEntClient(tx.Client()); exec != nil {
|
||||
return exec
|
||||
}
|
||||
}
|
||||
if fallback != nil {
|
||||
return fallback
|
||||
}
|
||||
return sqlExecutorFromEntClient(client)
|
||||
}
|
||||
|
||||
func (r *userRepository) userProfileIdentitySQL(ctx context.Context) (sqlQueryExecutor, error) {
|
||||
exec := txAwareSQLExecutor(ctx, r.sql, r.client)
|
||||
if exec == nil {
|
||||
return nil, fmt.Errorf("sql executor is not configured")
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
func sqlExecutorFromEntClient(client *dbent.Client) sqlQueryExecutor {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clientValue := reflect.ValueOf(client).Elem()
|
||||
configValue := clientValue.FieldByName("config")
|
||||
driverValue := configValue.FieldByName("driver")
|
||||
if !driverValue.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
driver := reflect.NewAt(driverValue.Type(), unsafe.Pointer(driverValue.UnsafeAddr())).Elem().Interface()
|
||||
exec, ok := driver.(sqlQueryExecutor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return exec
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type UserProfileIdentityRepoSuite struct {
|
||||
suite.Suite
|
||||
ctx context.Context
|
||||
client *dbent.Client
|
||||
repo *userRepository
|
||||
}
|
||||
|
||||
func TestUserProfileIdentityRepoSuite(t *testing.T) {
|
||||
suite.Run(t, new(UserProfileIdentityRepoSuite))
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) SetupTest() {
|
||||
s.ctx = context.Background()
|
||||
s.client = testEntClient(s.T())
|
||||
s.repo = newUserRepositoryWithSQL(s.client, integrationDB)
|
||||
|
||||
_, err := integrationDB.ExecContext(s.ctx, `
|
||||
TRUNCATE TABLE
|
||||
identity_adoption_decisions,
|
||||
auth_identity_channels,
|
||||
auth_identities,
|
||||
pending_auth_sessions,
|
||||
auth_identity_migration_reports,
|
||||
user_provider_default_grants,
|
||||
user_avatars
|
||||
RESTART IDENTITY`)
|
||||
s.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) mustCreateUser(label string) *dbent.User {
|
||||
s.T().Helper()
|
||||
|
||||
user, err := s.client.User.Create().
|
||||
SetEmail(fmt.Sprintf("%s-%d@example.com", label, time.Now().UnixNano())).
|
||||
SetPasswordHash("test-password-hash").
|
||||
SetRole("user").
|
||||
SetStatus("active").
|
||||
Save(s.ctx)
|
||||
s.Require().NoError(err)
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) mustCreatePendingAuthSession(key AuthIdentityKey) *dbent.PendingAuthSession {
|
||||
s.T().Helper()
|
||||
|
||||
session, err := s.client.PendingAuthSession.Create().
|
||||
SetSessionToken(fmt.Sprintf("pending-%d", time.Now().UnixNano())).
|
||||
SetIntent("bind_current_user").
|
||||
SetProviderType(key.ProviderType).
|
||||
SetProviderKey(key.ProviderKey).
|
||||
SetProviderSubject(key.ProviderSubject).
|
||||
SetExpiresAt(time.Now().UTC().Add(15 * time.Minute)).
|
||||
SetUpstreamIdentityClaims(map[string]any{"provider_subject": key.ProviderSubject}).
|
||||
SetLocalFlowState(map[string]any{"step": "pending"}).
|
||||
Save(s.ctx)
|
||||
s.Require().NoError(err)
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestCreateAndLookupCanonicalAndChannelIdentity() {
|
||||
user := s.mustCreateUser("canonical-channel")
|
||||
|
||||
verifiedAt := time.Now().UTC().Truncate(time.Second)
|
||||
created, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
|
||||
UserID: user.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: "wechat-open",
|
||||
ProviderSubject: "union-123",
|
||||
},
|
||||
Channel: &AuthIdentityChannelKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: "wechat-open",
|
||||
Channel: "mp",
|
||||
ChannelAppID: "wx-app",
|
||||
ChannelSubject: "openid-123",
|
||||
},
|
||||
Issuer: stringPtr("https://issuer.example"),
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{"unionid": "union-123"},
|
||||
ChannelMetadata: map[string]any{"openid": "openid-123"},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(created.Identity)
|
||||
s.Require().NotNil(created.Channel)
|
||||
|
||||
canonical, err := s.repo.GetUserByCanonicalIdentity(s.ctx, created.IdentityRef())
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(user.ID, canonical.User.ID)
|
||||
s.Require().Equal(created.Identity.ID, canonical.Identity.ID)
|
||||
s.Require().Equal("union-123", canonical.Identity.ProviderSubject)
|
||||
|
||||
channel, err := s.repo.GetUserByChannelIdentity(s.ctx, *created.ChannelRef())
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(user.ID, channel.User.ID)
|
||||
s.Require().Equal(created.Identity.ID, channel.Identity.ID)
|
||||
s.Require().Equal(created.Channel.ID, channel.Channel.ID)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAndRejectsOtherOwners() {
|
||||
owner := s.mustCreateUser("owner")
|
||||
other := s.mustCreateUser("other")
|
||||
|
||||
first, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
|
||||
UserID: owner.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
ProviderSubject: "subject-1",
|
||||
},
|
||||
Channel: &AuthIdentityChannelKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
Channel: "oauth",
|
||||
ChannelAppID: "linuxdo-web",
|
||||
ChannelSubject: "subject-1",
|
||||
},
|
||||
Metadata: map[string]any{"username": "first"},
|
||||
ChannelMetadata: map[string]any{"scope": "read"},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
|
||||
second, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
|
||||
UserID: owner.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
ProviderSubject: "subject-1",
|
||||
},
|
||||
Channel: &AuthIdentityChannelKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
Channel: "oauth",
|
||||
ChannelAppID: "linuxdo-web",
|
||||
ChannelSubject: "subject-1",
|
||||
},
|
||||
Metadata: map[string]any{"username": "second"},
|
||||
ChannelMetadata: map[string]any{"scope": "write"},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(first.Identity.ID, second.Identity.ID)
|
||||
s.Require().Equal(first.Channel.ID, second.Channel.ID)
|
||||
s.Require().Equal("second", second.Identity.Metadata["username"])
|
||||
s.Require().Equal("write", second.Channel.Metadata["scope"])
|
||||
|
||||
_, err = s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
|
||||
UserID: other.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
ProviderSubject: "subject-1",
|
||||
},
|
||||
})
|
||||
s.Require().ErrorIs(err, ErrAuthIdentityOwnershipConflict)
|
||||
|
||||
_, err = s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
|
||||
UserID: other.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
ProviderSubject: "subject-2",
|
||||
},
|
||||
Channel: &AuthIdentityChannelKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
Channel: "oauth",
|
||||
ChannelAppID: "linuxdo-web",
|
||||
ChannelSubject: "subject-1",
|
||||
},
|
||||
})
|
||||
s.Require().ErrorIs(err, ErrAuthIdentityChannelOwnershipConflict)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestWithUserProfileIdentityTx_RollsBackIdentityAndGrantOnError() {
|
||||
user := s.mustCreateUser("tx-rollback")
|
||||
expectedErr := errors.New("rollback")
|
||||
|
||||
err := s.repo.WithUserProfileIdentityTx(s.ctx, func(txCtx context.Context) error {
|
||||
_, err := s.repo.CreateAuthIdentity(txCtx, CreateAuthIdentityInput{
|
||||
UserID: user.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "oidc",
|
||||
ProviderKey: "https://issuer.example",
|
||||
ProviderSubject: "subject-rollback",
|
||||
},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
|
||||
inserted, err := s.repo.RecordProviderGrant(txCtx, ProviderGrantRecordInput{
|
||||
UserID: user.ID,
|
||||
ProviderType: "oidc",
|
||||
GrantReason: ProviderGrantReasonFirstBind,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().True(inserted)
|
||||
return expectedErr
|
||||
})
|
||||
s.Require().ErrorIs(err, expectedErr)
|
||||
|
||||
_, err = s.repo.GetUserByCanonicalIdentity(s.ctx, AuthIdentityKey{
|
||||
ProviderType: "oidc",
|
||||
ProviderKey: "https://issuer.example",
|
||||
ProviderSubject: "subject-rollback",
|
||||
})
|
||||
s.Require().True(dbent.IsNotFound(err))
|
||||
|
||||
var count int
|
||||
s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM user_provider_default_grants
|
||||
WHERE user_id = $1 AND provider_type = $2 AND grant_reason = $3`,
|
||||
user.ID,
|
||||
"oidc",
|
||||
string(ProviderGrantReasonFirstBind),
|
||||
).Scan(&count))
|
||||
s.Require().Zero(count)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestRecordProviderGrant_IsIdempotentPerReason() {
|
||||
user := s.mustCreateUser("grant")
|
||||
|
||||
inserted, err := s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
|
||||
UserID: user.ID,
|
||||
ProviderType: "wechat",
|
||||
GrantReason: ProviderGrantReasonFirstBind,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().True(inserted)
|
||||
|
||||
inserted, err = s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
|
||||
UserID: user.ID,
|
||||
ProviderType: "wechat",
|
||||
GrantReason: ProviderGrantReasonFirstBind,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().False(inserted)
|
||||
|
||||
inserted, err = s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
|
||||
UserID: user.ID,
|
||||
ProviderType: "wechat",
|
||||
GrantReason: ProviderGrantReasonSignup,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().True(inserted)
|
||||
|
||||
var count int
|
||||
s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM user_provider_default_grants
|
||||
WHERE user_id = $1 AND provider_type = $2`,
|
||||
user.ID,
|
||||
"wechat",
|
||||
).Scan(&count))
|
||||
s.Require().Equal(2, count)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestUpsertIdentityAdoptionDecision_PersistsAndLinksIdentity() {
|
||||
user := s.mustCreateUser("adoption")
|
||||
identity, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
|
||||
UserID: user.ID,
|
||||
Canonical: AuthIdentityKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: "wechat-open",
|
||||
ProviderSubject: "union-adoption",
|
||||
},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
|
||||
session := s.mustCreatePendingAuthSession(identity.IdentityRef())
|
||||
|
||||
first, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
AdoptDisplayName: true,
|
||||
AdoptAvatar: false,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().True(first.AdoptDisplayName)
|
||||
s.Require().False(first.AdoptAvatar)
|
||||
s.Require().Nil(first.IdentityID)
|
||||
|
||||
second, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
IdentityID: &identity.Identity.ID,
|
||||
AdoptDisplayName: true,
|
||||
AdoptAvatar: true,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(first.ID, second.ID)
|
||||
s.Require().NotNil(second.IdentityID)
|
||||
s.Require().Equal(identity.Identity.ID, *second.IdentityID)
|
||||
s.Require().True(second.AdoptAvatar)
|
||||
|
||||
loaded, err := s.repo.GetIdentityAdoptionDecisionByPendingAuthSessionID(s.ctx, session.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(second.ID, loaded.ID)
|
||||
s.Require().Equal(identity.Identity.ID, *loaded.IdentityID)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestUserAvatarCRUDAndUserLookup() {
|
||||
user := s.mustCreateUser("avatar")
|
||||
|
||||
inlineAvatar, err := s.repo.UpsertUserAvatar(s.ctx, user.ID, service.UpsertUserAvatarInput{
|
||||
StorageProvider: "inline",
|
||||
URL: "data:image/png;base64,QUJD",
|
||||
ContentType: "image/png",
|
||||
ByteSize: 3,
|
||||
SHA256: "902fbdd2b1df0c4f70b4a5d23525e932",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("inline", inlineAvatar.StorageProvider)
|
||||
s.Require().Equal("data:image/png;base64,QUJD", inlineAvatar.URL)
|
||||
|
||||
loadedAvatar, err := s.repo.GetUserAvatar(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(loadedAvatar)
|
||||
s.Require().Equal("image/png", loadedAvatar.ContentType)
|
||||
s.Require().Equal(3, loadedAvatar.ByteSize)
|
||||
|
||||
_, err = s.repo.UpsertUserAvatar(s.ctx, user.ID, service.UpsertUserAvatarInput{
|
||||
StorageProvider: "remote_url",
|
||||
URL: "https://cdn.example.com/avatar.png",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
|
||||
loadedAvatar, err = s.repo.GetUserAvatar(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(loadedAvatar)
|
||||
s.Require().Equal("remote_url", loadedAvatar.StorageProvider)
|
||||
s.Require().Equal("https://cdn.example.com/avatar.png", loadedAvatar.URL)
|
||||
s.Require().Zero(loadedAvatar.ByteSize)
|
||||
|
||||
s.Require().NoError(s.repo.DeleteUserAvatar(s.ctx, user.ID))
|
||||
loadedAvatar, err = s.repo.GetUserAvatar(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(loadedAvatar)
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestAuthIdentityMigrationReportHelpers_ListAndSummarize() {
|
||||
_, err := integrationDB.ExecContext(s.ctx, `
|
||||
INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at)
|
||||
VALUES
|
||||
('wechat_openid_only_requires_remediation', 'u-1', '{"user_id":1}'::jsonb, '2026-04-20T10:00:00Z'),
|
||||
('wechat_openid_only_requires_remediation', 'u-2', '{"user_id":2}'::jsonb, '2026-04-20T11:00:00Z'),
|
||||
('oidc_synthetic_email_requires_manual_recovery', 'u-3', '{"user_id":3}'::jsonb, '2026-04-20T12:00:00Z')`)
|
||||
s.Require().NoError(err)
|
||||
|
||||
summary, err := s.repo.SummarizeAuthIdentityMigrationReports(s.ctx)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(int64(3), summary.Total)
|
||||
s.Require().Equal(int64(2), summary.ByType["wechat_openid_only_requires_remediation"])
|
||||
s.Require().Equal(int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"])
|
||||
|
||||
reports, err := s.repo.ListAuthIdentityMigrationReports(s.ctx, AuthIdentityMigrationReportQuery{
|
||||
ReportType: "wechat_openid_only_requires_remediation",
|
||||
Limit: 10,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(reports, 2)
|
||||
s.Require().Equal("u-2", reports[0].ReportKey)
|
||||
s.Require().Equal(float64(2), reports[0].Details["user_id"])
|
||||
|
||||
report, err := s.repo.GetAuthIdentityMigrationReport(s.ctx, "oidc_synthetic_email_requires_manual_recovery", "u-3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("u-3", report.ReportKey)
|
||||
s.Require().Equal(float64(3), report.Details["user_id"])
|
||||
}
|
||||
|
||||
func (s *UserProfileIdentityRepoSuite) TestUpdateUserLastLoginAndActiveAt_UsesDedicatedColumns() {
|
||||
user := s.mustCreateUser("activity")
|
||||
loginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
|
||||
activeAt := loginAt.Add(5 * time.Minute)
|
||||
|
||||
s.Require().NoError(s.repo.UpdateUserLastLoginAt(s.ctx, user.ID, loginAt))
|
||||
s.Require().NoError(s.repo.UpdateUserLastActiveAt(s.ctx, user.ID, activeAt))
|
||||
|
||||
var storedLoginAt sqlNullTime
|
||||
var storedActiveAt sqlNullTime
|
||||
s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
|
||||
SELECT last_login_at, last_active_at
|
||||
FROM users
|
||||
WHERE id = $1`,
|
||||
user.ID,
|
||||
).Scan(&storedLoginAt, &storedActiveAt))
|
||||
s.Require().True(storedLoginAt.Valid)
|
||||
s.Require().True(storedActiveAt.Valid)
|
||||
s.Require().True(storedLoginAt.Time.Equal(loginAt))
|
||||
s.Require().True(storedActiveAt.Time.Equal(activeAt))
|
||||
}
|
||||
|
||||
type sqlNullTime struct {
|
||||
Time time.Time
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (t *sqlNullTime) Scan(value any) error {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
t.Time = v
|
||||
t.Valid = true
|
||||
return nil
|
||||
case nil:
|
||||
t.Time = time.Time{}
|
||||
t.Valid = false
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(v string) *string {
|
||||
return &v
|
||||
}
|
||||
@@ -64,6 +64,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
|
||||
SetBalance(userIn.Balance).
|
||||
SetConcurrency(userIn.Concurrency).
|
||||
SetStatus(userIn.Status).
|
||||
SetSignupSource(userSignupSourceOrDefault(userIn.SignupSource)).
|
||||
SetNillableLastLoginAt(userIn.LastLoginAt).
|
||||
SetNillableLastActiveAt(userIn.LastActiveAt).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, nil, service.ErrEmailExists)
|
||||
@@ -151,6 +154,15 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
||||
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold).
|
||||
SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)).
|
||||
SetTotalRecharged(userIn.TotalRecharged)
|
||||
if userIn.SignupSource != "" {
|
||||
updateOp = updateOp.SetSignupSource(userIn.SignupSource)
|
||||
}
|
||||
if userIn.LastLoginAt != nil {
|
||||
updateOp = updateOp.SetLastLoginAt(*userIn.LastLoginAt)
|
||||
}
|
||||
if userIn.LastActiveAt != nil {
|
||||
updateOp = updateOp.SetLastActiveAt(*userIn.LastActiveAt)
|
||||
}
|
||||
if userIn.BalanceNotifyThreshold == nil {
|
||||
updateOp = updateOp.ClearBalanceNotifyThreshold()
|
||||
}
|
||||
@@ -300,6 +312,7 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
||||
|
||||
var field string
|
||||
defaultField := true
|
||||
nullsLastField := false
|
||||
switch sortBy {
|
||||
case "email":
|
||||
field = dbuser.FieldEmail
|
||||
@@ -322,6 +335,14 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
||||
case "created_at":
|
||||
field = dbuser.FieldCreatedAt
|
||||
defaultField = false
|
||||
case "last_login_at":
|
||||
field = dbuser.FieldLastLoginAt
|
||||
defaultField = false
|
||||
nullsLastField = true
|
||||
case "last_active_at":
|
||||
field = dbuser.FieldLastActiveAt
|
||||
defaultField = false
|
||||
nullsLastField = true
|
||||
default:
|
||||
field = dbuser.FieldID
|
||||
}
|
||||
@@ -330,11 +351,23 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
||||
if defaultField && field == dbuser.FieldID {
|
||||
return []func(*entsql.Selector){dbent.Asc(dbuser.FieldID)}
|
||||
}
|
||||
if nullsLastField {
|
||||
return []func(*entsql.Selector){
|
||||
entsql.OrderByField(field, entsql.OrderNullsLast()).ToFunc(),
|
||||
dbent.Asc(dbuser.FieldID),
|
||||
}
|
||||
}
|
||||
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(dbuser.FieldID)}
|
||||
}
|
||||
if defaultField && field == dbuser.FieldID {
|
||||
return []func(*entsql.Selector){dbent.Desc(dbuser.FieldID)}
|
||||
}
|
||||
if nullsLastField {
|
||||
return []func(*entsql.Selector){
|
||||
entsql.OrderByField(field, entsql.OrderDesc(), entsql.OrderNullsLast()).ToFunc(),
|
||||
dbent.Desc(dbuser.FieldID),
|
||||
}
|
||||
}
|
||||
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbuser.FieldID)}
|
||||
}
|
||||
|
||||
@@ -558,10 +591,21 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) {
|
||||
return
|
||||
}
|
||||
dst.ID = src.ID
|
||||
dst.SignupSource = src.SignupSource
|
||||
dst.LastLoginAt = src.LastLoginAt
|
||||
dst.LastActiveAt = src.LastActiveAt
|
||||
dst.CreatedAt = src.CreatedAt
|
||||
dst.UpdatedAt = src.UpdatedAt
|
||||
}
|
||||
|
||||
func userSignupSourceOrDefault(signupSource string) string {
|
||||
signupSource = strings.TrimSpace(signupSource)
|
||||
if signupSource == "" {
|
||||
return "email"
|
||||
}
|
||||
return signupSource
|
||||
}
|
||||
|
||||
// marshalExtraEmails serializes notify email entries to JSON for storage.
|
||||
func marshalExtraEmails(entries []service.NotifyEmailEntry) string {
|
||||
return service.MarshalNotifyEmails(entries)
|
||||
|
||||
@@ -4,6 +4,7 @@ package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -36,4 +37,86 @@ func (s *UserRepoSuite) TestList_DefaultSortByNewestFirst() {
|
||||
s.Require().Equal(first.ID, users[1].ID)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestCreateAndRead_PreservesSignupSourceAndActivityTimestamps() {
|
||||
lastLoginAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
lastActiveAt := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
|
||||
created := s.mustCreateUser(&service.User{
|
||||
Email: "identity-meta@example.com",
|
||||
SignupSource: "github",
|
||||
LastLoginAt: &lastLoginAt,
|
||||
LastActiveAt: &lastActiveAt,
|
||||
})
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, created.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("github", got.SignupSource)
|
||||
s.Require().NotNil(got.LastLoginAt)
|
||||
s.Require().NotNil(got.LastActiveAt)
|
||||
s.Require().True(got.LastLoginAt.Equal(lastLoginAt))
|
||||
s.Require().True(got.LastActiveAt.Equal(lastActiveAt))
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestUpdate_PersistsSignupSourceAndActivityTimestamps() {
|
||||
created := s.mustCreateUser(&service.User{Email: "identity-update@example.com"})
|
||||
lastLoginAt := time.Now().Add(-90 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
lastActiveAt := time.Now().Add(-15 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
|
||||
created.SignupSource = "oidc"
|
||||
created.LastLoginAt = &lastLoginAt
|
||||
created.LastActiveAt = &lastActiveAt
|
||||
|
||||
s.Require().NoError(s.repo.Update(s.ctx, created))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, created.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("oidc", got.SignupSource)
|
||||
s.Require().NotNil(got.LastLoginAt)
|
||||
s.Require().NotNil(got.LastActiveAt)
|
||||
s.Require().True(got.LastLoginAt.Equal(lastLoginAt))
|
||||
s.Require().True(got.LastActiveAt.Equal(lastActiveAt))
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestListWithFilters_SortByLastLoginAtDesc() {
|
||||
older := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
newer := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
|
||||
s.mustCreateUser(&service.User{Email: "nil-login@example.com"})
|
||||
s.mustCreateUser(&service.User{Email: "older-login@example.com", LastLoginAt: &older})
|
||||
s.mustCreateUser(&service.User{Email: "newer-login@example.com", LastLoginAt: &newer})
|
||||
|
||||
users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
SortBy: "last_login_at",
|
||||
SortOrder: "desc",
|
||||
}, service.UserListFilters{})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(users, 3)
|
||||
s.Require().Equal("newer-login@example.com", users[0].Email)
|
||||
s.Require().Equal("older-login@example.com", users[1].Email)
|
||||
s.Require().Equal("nil-login@example.com", users[2].Email)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestListWithFilters_SortByLastActiveAtAsc() {
|
||||
earlier := time.Now().Add(-3 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
later := time.Now().Add(-45 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
|
||||
s.mustCreateUser(&service.User{Email: "nil-active@example.com"})
|
||||
s.mustCreateUser(&service.User{Email: "later-active@example.com", LastActiveAt: &later})
|
||||
s.mustCreateUser(&service.User{Email: "earlier-active@example.com", LastActiveAt: &earlier})
|
||||
|
||||
users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
SortBy: "last_active_at",
|
||||
SortOrder: "asc",
|
||||
}, service.UserListFilters{})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(users, 3)
|
||||
s.Require().Equal("earlier-active@example.com", users[0].Email)
|
||||
s.Require().Equal("later-active@example.com", users[1].Email)
|
||||
s.Require().Equal("nil-active@example.com", users[2].Email)
|
||||
}
|
||||
|
||||
func TestUserRepoSortSuiteSmoke(_ *testing.T) {}
|
||||
|
||||
@@ -479,7 +479,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
service.SettingKeyOIDCConnectRedirectURL: "",
|
||||
service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
||||
service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
||||
service.SettingKeyOIDCConnectUsePKCE: "false",
|
||||
service.SettingKeyOIDCConnectUsePKCE: "true",
|
||||
service.SettingKeyOIDCConnectValidateIDToken: "true",
|
||||
service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
|
||||
service.SettingKeyOIDCConnectClockSkewSeconds: "120",
|
||||
@@ -549,7 +549,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"oidc_connect_redirect_url": "",
|
||||
"oidc_connect_frontend_redirect_url": "/auth/oidc/callback",
|
||||
"oidc_connect_token_auth_method": "client_secret_post",
|
||||
"oidc_connect_use_pkce": false,
|
||||
"oidc_connect_use_pkce": true,
|
||||
"oidc_connect_validate_id_token": true,
|
||||
"oidc_connect_allowed_signing_algs": "RS256,ES256,PS256",
|
||||
"oidc_connect_clock_skew_seconds": 120,
|
||||
|
||||
@@ -64,12 +64,26 @@ func RegisterAuthRoutes(
|
||||
}), h.Auth.ResetPassword)
|
||||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||||
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
||||
auth.POST("/oauth/pending/exchange",
|
||||
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.ExchangePendingOAuthCompletion,
|
||||
)
|
||||
auth.POST("/oauth/linuxdo/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-linuxdo-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CompleteLinuxDoOAuthRegistration,
|
||||
)
|
||||
auth.POST("/oauth/wechat/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-wechat-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CompleteWeChatOAuthRegistration,
|
||||
)
|
||||
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
||||
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
|
||||
auth.POST("/oauth/oidc/complete-registration",
|
||||
|
||||
@@ -44,6 +44,15 @@ func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, erro
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) GetUserAvatar(context.Context, int64) (*UserAvatar, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) UpsertUserAvatar(context.Context, int64, UpsertUserAvatarInput) (*UserAvatar, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) DeleteUserAvatar(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ func (s *userRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
func (s *userRepoStub) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) {
|
||||
panic("unexpected GetUserAvatar call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
|
||||
panic("unexpected UpsertUserAvatar call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||
panic("unexpected DeleteUserAvatar call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPendingAuthSessionNotFound = infraerrors.NotFound("PENDING_AUTH_SESSION_NOT_FOUND", "pending auth session not found")
|
||||
ErrPendingAuthSessionExpired = infraerrors.Unauthorized("PENDING_AUTH_SESSION_EXPIRED", "pending auth session has expired")
|
||||
ErrPendingAuthSessionConsumed = infraerrors.Unauthorized("PENDING_AUTH_SESSION_CONSUMED", "pending auth session has already been used")
|
||||
ErrPendingAuthCodeInvalid = infraerrors.Unauthorized("PENDING_AUTH_CODE_INVALID", "pending auth completion code is invalid")
|
||||
ErrPendingAuthCodeExpired = infraerrors.Unauthorized("PENDING_AUTH_CODE_EXPIRED", "pending auth completion code has expired")
|
||||
ErrPendingAuthCodeConsumed = infraerrors.Unauthorized("PENDING_AUTH_CODE_CONSUMED", "pending auth completion code has already been used")
|
||||
ErrPendingAuthBrowserMismatch = infraerrors.Unauthorized("PENDING_AUTH_BROWSER_MISMATCH", "pending auth completion code does not match this browser session")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPendingAuthTTL = 15 * time.Minute
|
||||
defaultPendingAuthCompletionTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
type PendingAuthIdentityKey struct {
|
||||
ProviderType string
|
||||
ProviderKey string
|
||||
ProviderSubject string
|
||||
}
|
||||
|
||||
type CreatePendingAuthSessionInput struct {
|
||||
SessionToken string
|
||||
Intent string
|
||||
Identity PendingAuthIdentityKey
|
||||
TargetUserID *int64
|
||||
RedirectTo string
|
||||
ResolvedEmail string
|
||||
RegistrationPasswordHash string
|
||||
BrowserSessionKey string
|
||||
UpstreamIdentityClaims map[string]any
|
||||
LocalFlowState map[string]any
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type IssuePendingAuthCompletionCodeInput struct {
|
||||
PendingAuthSessionID int64
|
||||
BrowserSessionKey string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type IssuePendingAuthCompletionCodeResult struct {
|
||||
Code string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type PendingIdentityAdoptionDecisionInput struct {
|
||||
PendingAuthSessionID int64
|
||||
IdentityID *int64
|
||||
AdoptDisplayName bool
|
||||
AdoptAvatar bool
|
||||
}
|
||||
|
||||
type AuthPendingIdentityService struct {
|
||||
entClient *dbent.Client
|
||||
}
|
||||
|
||||
func NewAuthPendingIdentityService(entClient *dbent.Client) *AuthPendingIdentityService {
|
||||
return &AuthPendingIdentityService{entClient: entClient}
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) CreatePendingSession(ctx context.Context, input CreatePendingAuthSessionInput) (*dbent.PendingAuthSession, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
sessionToken := strings.TrimSpace(input.SessionToken)
|
||||
if sessionToken == "" {
|
||||
var err error
|
||||
sessionToken, err = randomOpaqueToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
expiresAt := input.ExpiresAt.UTC()
|
||||
if expiresAt.IsZero() {
|
||||
expiresAt = time.Now().UTC().Add(defaultPendingAuthTTL)
|
||||
}
|
||||
|
||||
create := s.entClient.PendingAuthSession.Create().
|
||||
SetSessionToken(sessionToken).
|
||||
SetIntent(strings.TrimSpace(input.Intent)).
|
||||
SetProviderType(strings.TrimSpace(input.Identity.ProviderType)).
|
||||
SetProviderKey(strings.TrimSpace(input.Identity.ProviderKey)).
|
||||
SetProviderSubject(strings.TrimSpace(input.Identity.ProviderSubject)).
|
||||
SetRedirectTo(strings.TrimSpace(input.RedirectTo)).
|
||||
SetResolvedEmail(strings.TrimSpace(input.ResolvedEmail)).
|
||||
SetRegistrationPasswordHash(strings.TrimSpace(input.RegistrationPasswordHash)).
|
||||
SetBrowserSessionKey(strings.TrimSpace(input.BrowserSessionKey)).
|
||||
SetUpstreamIdentityClaims(copyPendingMap(input.UpstreamIdentityClaims)).
|
||||
SetLocalFlowState(copyPendingMap(input.LocalFlowState)).
|
||||
SetExpiresAt(expiresAt)
|
||||
if input.TargetUserID != nil {
|
||||
create = create.SetTargetUserID(*input.TargetUserID)
|
||||
}
|
||||
return create.Save(ctx)
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) IssueCompletionCode(ctx context.Context, input IssuePendingAuthCompletionCodeInput) (*IssuePendingAuthCompletionCodeResult, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
session, err := s.entClient.PendingAuthSession.Get(ctx, input.PendingAuthSessionID)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, ErrPendingAuthSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code, err := randomOpaqueToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ttl := input.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultPendingAuthCompletionTTL
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(ttl)
|
||||
|
||||
update := s.entClient.PendingAuthSession.UpdateOneID(session.ID).
|
||||
SetCompletionCodeHash(hashPendingAuthCode(code)).
|
||||
SetCompletionCodeExpiresAt(expiresAt)
|
||||
if strings.TrimSpace(input.BrowserSessionKey) != "" {
|
||||
update = update.SetBrowserSessionKey(strings.TrimSpace(input.BrowserSessionKey))
|
||||
}
|
||||
if _, err := update.Save(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &IssuePendingAuthCompletionCodeResult{
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) ConsumeCompletionCode(ctx context.Context, rawCode, browserSessionKey string) (*dbent.PendingAuthSession, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
codeHash := hashPendingAuthCode(strings.TrimSpace(rawCode))
|
||||
session, err := s.entClient.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.CompletionCodeHashEQ(codeHash)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, ErrPendingAuthCodeInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.consumeSession(ctx, session, browserSessionKey, ErrPendingAuthCodeExpired, ErrPendingAuthCodeConsumed)
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) ConsumeBrowserSession(ctx context.Context, sessionToken, browserSessionKey string) (*dbent.PendingAuthSession, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
session, err := s.getBrowserSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.consumeSession(ctx, session, browserSessionKey, ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed)
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) GetBrowserSession(ctx context.Context, sessionToken, browserSessionKey string) (*dbent.PendingAuthSession, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
session, err := s.getBrowserSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePendingSessionState(session, browserSessionKey, ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) getBrowserSession(ctx context.Context, sessionToken string) (*dbent.PendingAuthSession, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
sessionToken = strings.TrimSpace(sessionToken)
|
||||
if sessionToken == "" {
|
||||
return nil, ErrPendingAuthSessionNotFound
|
||||
}
|
||||
|
||||
session, err := s.entClient.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.SessionTokenEQ(sessionToken)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, ErrPendingAuthSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) consumeSession(
|
||||
ctx context.Context,
|
||||
session *dbent.PendingAuthSession,
|
||||
browserSessionKey string,
|
||||
expiredErr error,
|
||||
consumedErr error,
|
||||
) (*dbent.PendingAuthSession, error) {
|
||||
if err := validatePendingSessionState(session, browserSessionKey, expiredErr, consumedErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
updated, err := s.entClient.PendingAuthSession.UpdateOneID(session.ID).
|
||||
SetConsumedAt(now).
|
||||
SetCompletionCodeHash("").
|
||||
ClearCompletionCodeExpiresAt().
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func validatePendingSessionState(session *dbent.PendingAuthSession, browserSessionKey string, expiredErr error, consumedErr error) error {
|
||||
if session == nil {
|
||||
return ErrPendingAuthSessionNotFound
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if session.ConsumedAt != nil {
|
||||
return consumedErr
|
||||
}
|
||||
if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
|
||||
return expiredErr
|
||||
}
|
||||
if session.CompletionCodeExpiresAt != nil && now.After(*session.CompletionCodeExpiresAt) {
|
||||
return expiredErr
|
||||
}
|
||||
if strings.TrimSpace(session.BrowserSessionKey) != "" && strings.TrimSpace(browserSessionKey) != strings.TrimSpace(session.BrowserSessionKey) {
|
||||
return ErrPendingAuthBrowserMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthPendingIdentityService) UpsertAdoptionDecision(ctx context.Context, input PendingIdentityAdoptionDecisionInput) (*dbent.IdentityAdoptionDecision, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, fmt.Errorf("pending auth ent client is not configured")
|
||||
}
|
||||
|
||||
existing, err := s.entClient.IdentityAdoptionDecision.Query().
|
||||
Where(identityadoptiondecision.PendingAuthSessionIDEQ(input.PendingAuthSessionID)).
|
||||
Only(ctx)
|
||||
if err != nil && !dbent.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
create := s.entClient.IdentityAdoptionDecision.Create().
|
||||
SetPendingAuthSessionID(input.PendingAuthSessionID).
|
||||
SetAdoptDisplayName(input.AdoptDisplayName).
|
||||
SetAdoptAvatar(input.AdoptAvatar).
|
||||
SetDecidedAt(time.Now().UTC())
|
||||
if input.IdentityID != nil {
|
||||
create = create.SetIdentityID(*input.IdentityID)
|
||||
}
|
||||
return create.Save(ctx)
|
||||
}
|
||||
|
||||
update := s.entClient.IdentityAdoptionDecision.UpdateOneID(existing.ID).
|
||||
SetAdoptDisplayName(input.AdoptDisplayName).
|
||||
SetAdoptAvatar(input.AdoptAvatar)
|
||||
if input.IdentityID != nil {
|
||||
update = update.SetIdentityID(*input.IdentityID)
|
||||
}
|
||||
return update.Save(ctx)
|
||||
}
|
||||
|
||||
func copyPendingMap(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomOpaqueToken(byteLen int) (string, error) {
|
||||
if byteLen <= 0 {
|
||||
byteLen = 16
|
||||
}
|
||||
buf := make([]byte, byteLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashPendingAuthCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func newAuthPendingIdentityServiceTestClient(t *testing.T) (*AuthPendingIdentityService, *dbent.Client) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:auth_pending_identity_service?mode=memory&cache=shared")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
||||
require.NoError(t, err)
|
||||
|
||||
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
return NewAuthPendingIdentityService(client), client
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_CreatePendingSessionStoresSeparatedState(t *testing.T) {
|
||||
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
targetUser, err := client.User.Create().
|
||||
SetEmail("pending-target@example.com").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(RoleUser).
|
||||
SetStatus(StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
||||
Intent: "bind_current_user",
|
||||
Identity: PendingAuthIdentityKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: "wechat-open",
|
||||
ProviderSubject: "union-123",
|
||||
},
|
||||
TargetUserID: &targetUser.ID,
|
||||
RedirectTo: "/profile",
|
||||
ResolvedEmail: "user@example.com",
|
||||
BrowserSessionKey: "browser-1",
|
||||
UpstreamIdentityClaims: map[string]any{"nickname": "wx-user", "avatar_url": "https://cdn.example/avatar.png"},
|
||||
LocalFlowState: map[string]any{"step": "email_required"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.SessionToken)
|
||||
require.Equal(t, "bind_current_user", session.Intent)
|
||||
require.Equal(t, "wechat", session.ProviderType)
|
||||
require.NotNil(t, session.TargetUserID)
|
||||
require.Equal(t, targetUser.ID, *session.TargetUserID)
|
||||
require.Equal(t, "wx-user", session.UpstreamIdentityClaims["nickname"])
|
||||
require.Equal(t, "email_required", session.LocalFlowState["step"])
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_CompletionCodeIsBrowserBoundAndOneTime(t *testing.T) {
|
||||
svc, _ := newAuthPendingIdentityServiceTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
||||
Intent: "login",
|
||||
Identity: PendingAuthIdentityKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo-main",
|
||||
ProviderSubject: "subject-1",
|
||||
},
|
||||
BrowserSessionKey: "browser-expected",
|
||||
UpstreamIdentityClaims: map[string]any{"nickname": "linux-user"},
|
||||
LocalFlowState: map[string]any{"step": "pending"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
issued, err := svc.IssueCompletionCode(ctx, IssuePendingAuthCompletionCodeInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
BrowserSessionKey: "browser-expected",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, issued.Code)
|
||||
|
||||
_, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-other")
|
||||
require.ErrorIs(t, err, ErrPendingAuthBrowserMismatch)
|
||||
|
||||
consumed, err := svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expected")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, consumed.ConsumedAt)
|
||||
require.Empty(t, consumed.CompletionCodeHash)
|
||||
require.Nil(t, consumed.CompletionCodeExpiresAt)
|
||||
|
||||
_, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expected")
|
||||
require.ErrorIs(t, err, ErrPendingAuthCodeInvalid)
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_CompletionCodeExpires(t *testing.T) {
|
||||
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
||||
Intent: "login",
|
||||
Identity: PendingAuthIdentityKey{
|
||||
ProviderType: "oidc",
|
||||
ProviderKey: "https://issuer.example",
|
||||
ProviderSubject: "subject-1",
|
||||
},
|
||||
BrowserSessionKey: "browser-expired",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
issued, err := svc.IssueCompletionCode(ctx, IssuePendingAuthCompletionCodeInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
BrowserSessionKey: "browser-expired",
|
||||
TTL: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PendingAuthSession.UpdateOneID(session.ID).
|
||||
SetCompletionCodeExpiresAt(time.Now().UTC().Add(-time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.ConsumeCompletionCode(ctx, issued.Code, "browser-expired")
|
||||
require.ErrorIs(t, err, ErrPendingAuthCodeExpired)
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_UpsertAdoptionDecision(t *testing.T) {
|
||||
svc, client := newAuthPendingIdentityServiceTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := client.User.Create().
|
||||
SetEmail("adoption@example.com").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(RoleUser).
|
||||
SetStatus(StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, err := client.AuthIdentity.Create().
|
||||
SetUserID(user.ID).
|
||||
SetProviderType("wechat").
|
||||
SetProviderKey("wechat-open").
|
||||
SetProviderSubject("union-adoption").
|
||||
SetMetadata(map[string]any{}).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
||||
Intent: "bind_current_user",
|
||||
Identity: PendingAuthIdentityKey{
|
||||
ProviderType: "wechat",
|
||||
ProviderKey: "wechat-open",
|
||||
ProviderSubject: "union-adoption",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
first, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
AdoptDisplayName: true,
|
||||
AdoptAvatar: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, first.AdoptDisplayName)
|
||||
require.False(t, first.AdoptAvatar)
|
||||
require.Nil(t, first.IdentityID)
|
||||
|
||||
second, err := svc.UpsertAdoptionDecision(ctx, PendingIdentityAdoptionDecisionInput{
|
||||
PendingAuthSessionID: session.ID,
|
||||
IdentityID: &identity.ID,
|
||||
AdoptDisplayName: true,
|
||||
AdoptAvatar: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.ID, second.ID)
|
||||
require.NotNil(t, second.IdentityID)
|
||||
require.Equal(t, identity.ID, *second.IdentityID)
|
||||
require.True(t, second.AdoptAvatar)
|
||||
}
|
||||
|
||||
func TestAuthPendingIdentityService_ConsumeBrowserSession(t *testing.T) {
|
||||
svc, _ := newAuthPendingIdentityServiceTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
session, err := svc.CreatePendingSession(ctx, CreatePendingAuthSessionInput{
|
||||
Intent: "login",
|
||||
Identity: PendingAuthIdentityKey{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "subject-session-token",
|
||||
},
|
||||
BrowserSessionKey: "browser-session",
|
||||
LocalFlowState: map[string]any{
|
||||
"completion_response": map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-other")
|
||||
require.ErrorIs(t, err, ErrPendingAuthBrowserMismatch)
|
||||
|
||||
consumed, err := svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, consumed.ConsumedAt)
|
||||
|
||||
_, err = svc.ConsumeBrowserSession(ctx, session.SessionToken, "browser-session")
|
||||
require.ErrorIs(t, err, ErrPendingAuthSessionConsumed)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
@@ -106,6 +107,13 @@ func NewAuthService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) EntClient() *dbent.Client {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.entClient
|
||||
}
|
||||
|
||||
// Register 用户注册,返回token和用户
|
||||
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
||||
return s.RegisterWithVerification(ctx, email, password, "", "", "")
|
||||
@@ -205,6 +213,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
s.postAuthUserBootstrap(ctx, user, "email", true)
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
|
||||
// 标记邀请码为已使用(如果使用了邀请码)
|
||||
@@ -421,6 +430,7 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
|
||||
if !user.IsActive() {
|
||||
return "", nil, ErrUserNotActive
|
||||
}
|
||||
s.touchUserLogin(ctx, user.ID)
|
||||
|
||||
// 生成JWT token
|
||||
token, err := s.GenerateToken(user)
|
||||
@@ -501,6 +511,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
}
|
||||
} else {
|
||||
user = newUser
|
||||
s.postAuthUserBootstrap(ctx, user, inferLegacySignupSource(email), true)
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
}
|
||||
} else {
|
||||
@@ -520,6 +531,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err)
|
||||
}
|
||||
}
|
||||
s.touchUserLogin(ctx, user.ID)
|
||||
|
||||
token, err := s.GenerateToken(user)
|
||||
if err != nil {
|
||||
@@ -630,6 +642,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
user = newUser
|
||||
s.postAuthUserBootstrap(ctx, user, inferLegacySignupSource(email), true)
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
}
|
||||
} else {
|
||||
@@ -646,6 +659,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
}
|
||||
} else {
|
||||
user = newUser
|
||||
s.postAuthUserBootstrap(ctx, user, inferLegacySignupSource(email), true)
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
if invitationRedeemCode != nil {
|
||||
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||||
@@ -670,6 +684,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err)
|
||||
}
|
||||
}
|
||||
s.touchUserLogin(ctx, user.ID)
|
||||
|
||||
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
||||
if err != nil {
|
||||
@@ -678,63 +693,6 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
return tokenPair, user, nil
|
||||
}
|
||||
|
||||
// pendingOAuthTokenTTL is the validity period for pending OAuth tokens.
|
||||
const pendingOAuthTokenTTL = 10 * time.Minute
|
||||
|
||||
// pendingOAuthPurpose is the purpose claim value for pending OAuth registration tokens.
|
||||
const pendingOAuthPurpose = "pending_oauth_registration"
|
||||
|
||||
type pendingOAuthClaims struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Purpose string `json:"purpose"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// CreatePendingOAuthToken generates a short-lived JWT that carries the OAuth identity
|
||||
// while waiting for the user to supply an invitation code.
|
||||
func (s *AuthService) CreatePendingOAuthToken(email, username string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := &pendingOAuthClaims{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Purpose: pendingOAuthPurpose,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(pendingOAuthTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// VerifyPendingOAuthToken validates a pending OAuth token and returns the embedded identity.
|
||||
// Returns ErrInvalidToken when the token is invalid or expired.
|
||||
func (s *AuthService) VerifyPendingOAuthToken(tokenStr string) (email, username string, err error) {
|
||||
if len(tokenStr) > maxTokenLength {
|
||||
return "", "", ErrInvalidToken
|
||||
}
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||
token, parseErr := parser.ParseWithClaims(tokenStr, &pendingOAuthClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(s.cfg.JWT.Secret), nil
|
||||
})
|
||||
if parseErr != nil {
|
||||
return "", "", ErrInvalidToken
|
||||
}
|
||||
claims, ok := token.Claims.(*pendingOAuthClaims)
|
||||
if !ok || !token.Valid {
|
||||
return "", "", ErrInvalidToken
|
||||
}
|
||||
if claims.Purpose != pendingOAuthPurpose {
|
||||
return "", "", ErrInvalidToken
|
||||
}
|
||||
return claims.Email, claims.Username, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int64) {
|
||||
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
|
||||
return
|
||||
@@ -752,6 +710,95 @@ func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) postAuthUserBootstrap(ctx context.Context, user *User, signupSource string, touchLogin bool) {
|
||||
if user == nil || user.ID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(signupSource) == "" {
|
||||
signupSource = "email"
|
||||
}
|
||||
s.updateUserSignupSource(ctx, user.ID, signupSource)
|
||||
|
||||
if signupSource == "email" {
|
||||
s.ensureEmailAuthIdentity(ctx, user)
|
||||
}
|
||||
if touchLogin {
|
||||
s.touchUserLogin(ctx, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) updateUserSignupSource(ctx context.Context, userID int64, signupSource string) {
|
||||
if s == nil || s.entClient == nil || userID <= 0 {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(signupSource) == "" {
|
||||
return
|
||||
}
|
||||
if err := s.entClient.User.UpdateOneID(userID).
|
||||
SetSignupSource(signupSource).
|
||||
Exec(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to update signup source: user_id=%d source=%s err=%v", userID, signupSource, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) touchUserLogin(ctx context.Context, userID int64) {
|
||||
if s == nil || s.entClient == nil || userID <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.entClient.User.UpdateOneID(userID).
|
||||
SetLastLoginAt(now).
|
||||
SetLastActiveAt(now).
|
||||
Exec(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to touch login timestamps: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User) {
|
||||
if s == nil || s.entClient == nil || user == nil || user.ID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(user.Email))
|
||||
if email == "" || isReservedEmail(email) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.entClient.AuthIdentity.Create().
|
||||
SetUserID(user.ID).
|
||||
SetProviderType("email").
|
||||
SetProviderKey("email").
|
||||
SetProviderSubject(email).
|
||||
SetVerifiedAt(time.Now().UTC()).
|
||||
SetMetadata(map[string]any{
|
||||
"source": "auth_service_dual_write",
|
||||
}).
|
||||
OnConflictColumns(
|
||||
authidentity.FieldProviderType,
|
||||
authidentity.FieldProviderKey,
|
||||
authidentity.FieldProviderSubject,
|
||||
).
|
||||
DoNothing().
|
||||
Exec(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to ensure email auth identity: user_id=%d email=%s err=%v", user.ID, email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func inferLegacySignupSource(email string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
switch {
|
||||
case strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain):
|
||||
return "linuxdo"
|
||||
case strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain):
|
||||
return "oidc"
|
||||
case strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain):
|
||||
return "wechat"
|
||||
default:
|
||||
return "email"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email string) error {
|
||||
if s.settingService == nil {
|
||||
return nil
|
||||
@@ -834,7 +881,8 @@ func randomHexString(byteLength int) (string, error) {
|
||||
func isReservedEmail(email string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) ||
|
||||
strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain)
|
||||
strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) ||
|
||||
strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain)
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT access token
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build unit
|
||||
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type authIdentitySettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
if v, ok := s.values[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) Set(context.Context, string, string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
panic("unexpected GetMultiple call")
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
||||
panic("unexpected SetMultiple call")
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
panic("unexpected GetAll call")
|
||||
}
|
||||
|
||||
func (s *authIdentitySettingRepoStub) Delete(context.Context, string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func newAuthServiceWithEnt(t *testing.T) (*service.AuthService, service.UserRepository, *dbent.Client) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:auth_service_identity_sync?mode=memory&cache=shared")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
||||
require.NoError(t, err)
|
||||
|
||||
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
repo := repository.NewUserRepository(client, db)
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-auth-identity-secret",
|
||||
ExpireHour: 1,
|
||||
},
|
||||
Default: config.DefaultConfig{
|
||||
UserBalance: 3.5,
|
||||
UserConcurrency: 2,
|
||||
},
|
||||
}
|
||||
settingSvc := service.NewSettingService(&authIdentitySettingRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
},
|
||||
}, cfg)
|
||||
|
||||
svc := service.NewAuthService(client, repo, nil, nil, cfg, settingSvc, nil, nil, nil, nil, nil)
|
||||
return svc, repo, client
|
||||
}
|
||||
|
||||
func TestAuthServiceRegisterDualWritesEmailIdentity(t *testing.T) {
|
||||
svc, _, client := newAuthServiceWithEnt(t)
|
||||
ctx := context.Background()
|
||||
|
||||
token, user, err := svc.Register(ctx, "user@example.com", "password")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
require.NotNil(t, user)
|
||||
|
||||
storedUser, err := client.User.Get(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "email", storedUser.SignupSource)
|
||||
require.NotNil(t, storedUser.LastLoginAt)
|
||||
require.NotNil(t, storedUser.LastActiveAt)
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("email"),
|
||||
authidentity.ProviderKeyEQ("email"),
|
||||
authidentity.ProviderSubjectEQ("user@example.com"),
|
||||
).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, identity.UserID)
|
||||
require.NotNil(t, identity.VerifiedAt)
|
||||
}
|
||||
|
||||
func TestAuthServiceLoginTouchesLastLoginAt(t *testing.T) {
|
||||
svc, repo, client := newAuthServiceWithEnt(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &service.User{
|
||||
Email: "login@example.com",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 1,
|
||||
Concurrency: 1,
|
||||
}
|
||||
require.NoError(t, user.SetPassword("password"))
|
||||
require.NoError(t, repo.Create(ctx, user))
|
||||
|
||||
old := time.Now().Add(-2 * time.Hour).UTC().Round(time.Second)
|
||||
_, err := client.User.UpdateOneID(user.ID).
|
||||
SetLastLoginAt(old).
|
||||
SetLastActiveAt(old).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, gotUser, err := svc.Login(ctx, user.Email, "password")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
require.NotNil(t, gotUser)
|
||||
|
||||
storedUser, err := client.User.Get(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedUser.LastLoginAt)
|
||||
require.NotNil(t, storedUser.LastActiveAt)
|
||||
require.True(t, storedUser.LastLoginAt.After(old))
|
||||
require.True(t, storedUser.LastActiveAt.After(old))
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newAuthServiceForPendingOAuthTest() *AuthService {
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-pending-oauth",
|
||||
ExpireHour: 1,
|
||||
},
|
||||
}
|
||||
return NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_ValidToken 验证正常签发的 pending token 可以被成功解析。
|
||||
func TestVerifyPendingOAuthToken_ValidToken(t *testing.T) {
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
|
||||
token, err := svc.CreatePendingOAuthToken("user@example.com", "alice")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
email, username, err := svc.VerifyPendingOAuthToken(token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user@example.com", email)
|
||||
require.Equal(t, "alice", username)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_RegularJWTRejected 用普通 access token 尝试验证,应返回 ErrInvalidToken。
|
||||
func TestVerifyPendingOAuthToken_RegularJWTRejected(t *testing.T) {
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
|
||||
// 签发一个普通 access token(JWTClaims,无 Purpose 字段)
|
||||
accessToken, err := svc.GenerateToken(&User{
|
||||
ID: 1,
|
||||
Email: "user@example.com",
|
||||
Role: RoleUser,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = svc.VerifyPendingOAuthToken(accessToken)
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_WrongPurpose 手动构造 purpose 字段不匹配的 JWT,应返回 ErrInvalidToken。
|
||||
func TestVerifyPendingOAuthToken_WrongPurpose(t *testing.T) {
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
|
||||
now := time.Now()
|
||||
claims := &pendingOAuthClaims{
|
||||
Email: "user@example.com",
|
||||
Username: "alice",
|
||||
Purpose: "some_other_purpose",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := tok.SignedString([]byte(svc.cfg.JWT.Secret))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = svc.VerifyPendingOAuthToken(tokenStr)
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_MissingPurpose 手动构造无 purpose 字段的 JWT(模拟旧 token),应返回 ErrInvalidToken。
|
||||
func TestVerifyPendingOAuthToken_MissingPurpose(t *testing.T) {
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
|
||||
now := time.Now()
|
||||
claims := &pendingOAuthClaims{
|
||||
Email: "user@example.com",
|
||||
Username: "alice",
|
||||
Purpose: "", // 旧 token 无此字段,反序列化后为零值
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := tok.SignedString([]byte(svc.cfg.JWT.Secret))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = svc.VerifyPendingOAuthToken(tokenStr)
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_ExpiredToken 过期 token 应返回 ErrInvalidToken。
|
||||
func TestVerifyPendingOAuthToken_ExpiredToken(t *testing.T) {
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
claims := &pendingOAuthClaims{
|
||||
Email: "user@example.com",
|
||||
Username: "alice",
|
||||
Purpose: pendingOAuthPurpose,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(past),
|
||||
IssuedAt: jwt.NewNumericDate(past.Add(-10 * time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(past.Add(-10 * time.Minute)),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := tok.SignedString([]byte(svc.cfg.JWT.Secret))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = svc.VerifyPendingOAuthToken(tokenStr)
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_WrongSecret 不同密钥签发的 token 应返回 ErrInvalidToken。
|
||||
func TestVerifyPendingOAuthToken_WrongSecret(t *testing.T) {
|
||||
other := NewAuthService(nil, nil, nil, nil, &config.Config{
|
||||
JWT: config.JWTConfig{Secret: "other-secret"},
|
||||
}, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
token, err := other.CreatePendingOAuthToken("user@example.com", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
_, _, err = svc.VerifyPendingOAuthToken(token)
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
// TestVerifyPendingOAuthToken_TooLong 超长 token 应返回 ErrInvalidToken。
|
||||
func TestVerifyPendingOAuthToken_TooLong(t *testing.T) {
|
||||
svc := newAuthServiceForPendingOAuthTest()
|
||||
giant := make([]byte, maxTokenLength+1)
|
||||
for i := range giant {
|
||||
giant[i] = 'a'
|
||||
}
|
||||
_, _, err := svc.VerifyPendingOAuthToken(string(giant))
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
@@ -74,6 +74,9 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
||||
// OIDCConnectSyntheticEmailDomain 是 OIDC 用户的合成邮箱后缀(RFC 保留域名)。
|
||||
const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid"
|
||||
|
||||
// WeChatConnectSyntheticEmailDomain 是 WeChat Connect 用户的合成邮箱后缀(RFC 保留域名)。
|
||||
const WeChatConnectSyntheticEmailDomain = "@wechat-connect.invalid"
|
||||
|
||||
// Setting keys
|
||||
const (
|
||||
// 注册设置
|
||||
@@ -153,6 +156,29 @@ const (
|
||||
SettingKeyDefaultBalance = "default_balance" // 新用户默认余额
|
||||
SettingKeyDefaultSubscriptions = "default_subscriptions" // 新用户默认订阅列表(JSON)
|
||||
|
||||
// 第三方认证来源默认授予配置
|
||||
SettingKeyAuthSourceDefaultEmailBalance = "auth_source_default_email_balance"
|
||||
SettingKeyAuthSourceDefaultEmailConcurrency = "auth_source_default_email_concurrency"
|
||||
SettingKeyAuthSourceDefaultEmailSubscriptions = "auth_source_default_email_subscriptions"
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnSignup = "auth_source_default_email_grant_on_signup"
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind = "auth_source_default_email_grant_on_first_bind"
|
||||
SettingKeyAuthSourceDefaultLinuxDoBalance = "auth_source_default_linuxdo_balance"
|
||||
SettingKeyAuthSourceDefaultLinuxDoConcurrency = "auth_source_default_linuxdo_concurrency"
|
||||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions = "auth_source_default_linuxdo_subscriptions"
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup = "auth_source_default_linuxdo_grant_on_signup"
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind = "auth_source_default_linuxdo_grant_on_first_bind"
|
||||
SettingKeyAuthSourceDefaultOIDCBalance = "auth_source_default_oidc_balance"
|
||||
SettingKeyAuthSourceDefaultOIDCConcurrency = "auth_source_default_oidc_concurrency"
|
||||
SettingKeyAuthSourceDefaultOIDCSubscriptions = "auth_source_default_oidc_subscriptions"
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnSignup = "auth_source_default_oidc_grant_on_signup"
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind = "auth_source_default_oidc_grant_on_first_bind"
|
||||
SettingKeyAuthSourceDefaultWeChatBalance = "auth_source_default_wechat_balance"
|
||||
SettingKeyAuthSourceDefaultWeChatConcurrency = "auth_source_default_wechat_concurrency"
|
||||
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
|
||||
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
|
||||
|
||||
// 管理员 API Key
|
||||
SettingKeyAdminAPIKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成)
|
||||
|
||||
|
||||
@@ -13,14 +13,30 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIAccountScheduleLayerPreviousResponse = "previous_response_id"
|
||||
openAIAccountScheduleLayerSessionSticky = "session_hash"
|
||||
openAIAccountScheduleLayerLoadBalance = "load_balance"
|
||||
openAIAdvancedSchedulerSettingKey = "openai_advanced_scheduler_enabled"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIAdvancedSchedulerSettingCacheTTL = 5 * time.Second
|
||||
openAIAdvancedSchedulerSettingDBTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type cachedOpenAIAdvancedSchedulerSetting struct {
|
||||
enabled bool
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
var openAIAdvancedSchedulerSettingCache atomic.Value // *cachedOpenAIAdvancedSchedulerSetting
|
||||
var openAIAdvancedSchedulerSettingSF singleflight.Group
|
||||
|
||||
type OpenAIAccountScheduleRequest struct {
|
||||
GroupID *int64
|
||||
SessionHash string
|
||||
@@ -805,10 +821,56 @@ func (s *defaultOpenAIAccountScheduler) SnapshotMetrics() OpenAIAccountScheduler
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) getOpenAIAccountScheduler() OpenAIAccountScheduler {
|
||||
func (s *OpenAIGatewayService) openAIAdvancedSchedulerSettingRepo() SettingRepository {
|
||||
if s == nil || s.rateLimitService == nil || s.rateLimitService.settingService == nil {
|
||||
return nil
|
||||
}
|
||||
return s.rateLimitService.settingService.settingRepo
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerEnabled(ctx context.Context) bool {
|
||||
if cached, ok := openAIAdvancedSchedulerSettingCache.Load().(*cachedOpenAIAdvancedSchedulerSetting); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.enabled
|
||||
}
|
||||
}
|
||||
|
||||
result, _, _ := openAIAdvancedSchedulerSettingSF.Do(openAIAdvancedSchedulerSettingKey, func() (any, error) {
|
||||
if cached, ok := openAIAdvancedSchedulerSettingCache.Load().(*cachedOpenAIAdvancedSchedulerSetting); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.enabled, nil
|
||||
}
|
||||
}
|
||||
|
||||
enabled := false
|
||||
if repo := s.openAIAdvancedSchedulerSettingRepo(); repo != nil {
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAdvancedSchedulerSettingDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
value, err := repo.GetValue(dbCtx, openAIAdvancedSchedulerSettingKey)
|
||||
if err == nil {
|
||||
enabled = strings.EqualFold(strings.TrimSpace(value), "true")
|
||||
}
|
||||
}
|
||||
|
||||
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
||||
enabled: enabled,
|
||||
expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(),
|
||||
})
|
||||
return enabled, nil
|
||||
})
|
||||
|
||||
enabled, _ := result.(bool)
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) getOpenAIAccountScheduler(ctx context.Context) OpenAIAccountScheduler {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if !s.isOpenAIAdvancedSchedulerEnabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
s.openaiSchedulerOnce.Do(func() {
|
||||
if s.openaiAccountStats == nil {
|
||||
s.openaiAccountStats = newOpenAIAccountRuntimeStats()
|
||||
@@ -820,6 +882,11 @@ func (s *OpenAIGatewayService) getOpenAIAccountScheduler() OpenAIAccountSchedule
|
||||
return s.openaiScheduler
|
||||
}
|
||||
|
||||
func resetOpenAIAdvancedSchedulerSettingCacheForTest() {
|
||||
openAIAdvancedSchedulerSettingCache = atomic.Value{}
|
||||
openAIAdvancedSchedulerSettingSF = singleflight.Group{}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) SelectAccountWithScheduler(
|
||||
ctx context.Context,
|
||||
groupID *int64,
|
||||
@@ -830,7 +897,7 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler(
|
||||
requiredTransport OpenAIUpstreamTransport,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
decision := OpenAIAccountScheduleDecision{}
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
scheduler := s.getOpenAIAccountScheduler(ctx)
|
||||
if scheduler == nil {
|
||||
selection, err := s.SelectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs)
|
||||
decision.Layer = openAIAccountScheduleLayerLoadBalance
|
||||
@@ -856,7 +923,7 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler(
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, success bool, firstTokenMs *int) {
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
scheduler := s.getOpenAIAccountScheduler(context.Background())
|
||||
if scheduler == nil {
|
||||
return
|
||||
}
|
||||
@@ -864,7 +931,7 @@ func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) RecordOpenAIAccountSwitch() {
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
scheduler := s.getOpenAIAccountScheduler(context.Background())
|
||||
if scheduler == nil {
|
||||
return
|
||||
}
|
||||
@@ -872,7 +939,7 @@ func (s *OpenAIGatewayService) RecordOpenAIAccountSwitch() {
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) SnapshotOpenAIAccountSchedulerMetrics() OpenAIAccountSchedulerMetricsSnapshot {
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
scheduler := s.getOpenAIAccountScheduler(context.Background())
|
||||
if scheduler == nil {
|
||||
return OpenAIAccountSchedulerMetricsSnapshot{}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
@@ -18,6 +19,202 @@ type openAISnapshotCacheStub struct {
|
||||
accountsByID map[int64]*Account
|
||||
}
|
||||
|
||||
type schedulerTestOpenAIAccountRepo struct {
|
||||
AccountRepository
|
||||
accounts []Account
|
||||
}
|
||||
|
||||
func (r schedulerTestOpenAIAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
|
||||
for i := range r.accounts {
|
||||
if r.accounts[i].ID == id {
|
||||
return &r.accounts[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("account not found")
|
||||
}
|
||||
|
||||
func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
|
||||
var result []Account
|
||||
for _, acc := range r.accounts {
|
||||
if acc.Platform == platform {
|
||||
result = append(result, acc)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r schedulerTestOpenAIAccountRepo) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
var result []Account
|
||||
for _, acc := range r.accounts {
|
||||
if acc.Platform == platform {
|
||||
result = append(result, acc)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r schedulerTestOpenAIAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
return r.ListSchedulableByPlatform(ctx, platform)
|
||||
}
|
||||
|
||||
type schedulerTestConcurrencyCache struct {
|
||||
ConcurrencyCache
|
||||
loadBatchErr error
|
||||
loadMap map[int64]*AccountLoadInfo
|
||||
acquireResults map[int64]bool
|
||||
waitCounts map[int64]int
|
||||
skipDefaultLoad bool
|
||||
}
|
||||
|
||||
func (c schedulerTestConcurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
if c.acquireResults != nil {
|
||||
if result, ok := c.acquireResults[accountID]; ok {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c schedulerTestConcurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c schedulerTestConcurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts []AccountWithConcurrency) (map[int64]*AccountLoadInfo, error) {
|
||||
if c.loadBatchErr != nil {
|
||||
return nil, c.loadBatchErr
|
||||
}
|
||||
out := make(map[int64]*AccountLoadInfo, len(accounts))
|
||||
if c.skipDefaultLoad && c.loadMap != nil {
|
||||
for _, acc := range accounts {
|
||||
if load, ok := c.loadMap[acc.ID]; ok {
|
||||
out[acc.ID] = load
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
for _, acc := range accounts {
|
||||
if c.loadMap != nil {
|
||||
if load, ok := c.loadMap[acc.ID]; ok {
|
||||
out[acc.ID] = load
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[acc.ID] = &AccountLoadInfo{AccountID: acc.ID, LoadRate: 0}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c schedulerTestConcurrencyCache) GetAccountWaitingCount(ctx context.Context, accountID int64) (int, error) {
|
||||
if c.waitCounts != nil {
|
||||
if count, ok := c.waitCounts[accountID]; ok {
|
||||
return count, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type schedulerTestGatewayCache struct {
|
||||
sessionBindings map[string]int64
|
||||
deletedSessions map[string]int
|
||||
}
|
||||
|
||||
func (c *schedulerTestGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
if id, ok := c.sessionBindings[sessionHash]; ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, errors.New("not found")
|
||||
}
|
||||
|
||||
func (c *schedulerTestGatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
if c.sessionBindings == nil {
|
||||
c.sessionBindings = make(map[string]int64)
|
||||
}
|
||||
c.sessionBindings[sessionHash] = accountID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedulerTestGatewayCache) RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedulerTestGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
|
||||
if c.sessionBindings == nil {
|
||||
return nil
|
||||
}
|
||||
if c.deletedSessions == nil {
|
||||
c.deletedSessions = make(map[string]int)
|
||||
}
|
||||
c.deletedSessions[sessionHash]++
|
||||
delete(c.sessionBindings, sessionHash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSchedulerTestOpenAIWSV2Config() *config.Config {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.OAuthEnabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 3600
|
||||
return cfg
|
||||
}
|
||||
|
||||
type openAIAdvancedSchedulerSettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
value, err := s.GetValue(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Setting{Key: key, Value: value}, nil
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
if s == nil || s.values == nil {
|
||||
return "", ErrSettingNotFound
|
||||
}
|
||||
value, ok := s.values[key]
|
||||
if !ok {
|
||||
return "", ErrSettingNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) Set(context.Context, string, string) error {
|
||||
panic("unexpected call to Set")
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
panic("unexpected call to GetMultiple")
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
||||
panic("unexpected call to SetMultiple")
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
panic("unexpected call to GetAll")
|
||||
}
|
||||
|
||||
func (s *openAIAdvancedSchedulerSettingRepoStub) Delete(context.Context, string) error {
|
||||
panic("unexpected call to Delete")
|
||||
}
|
||||
|
||||
func newOpenAIAdvancedSchedulerRateLimitService(enabled string) *RateLimitService {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
repo := &openAIAdvancedSchedulerSettingRepoStub{
|
||||
values: map[string]string{},
|
||||
}
|
||||
if enabled != "" {
|
||||
repo.values[openAIAdvancedSchedulerSettingKey] = enabled
|
||||
}
|
||||
return &RateLimitService{
|
||||
settingService: NewSettingService(repo, &config.Config{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *openAISnapshotCacheStub) GetSnapshot(ctx context.Context, bucket SchedulerBucket) ([]*Account, bool, error) {
|
||||
if len(s.snapshotAccounts) == 0 {
|
||||
return nil, false, nil
|
||||
@@ -45,6 +242,138 @@ func (s *openAISnapshotCacheStub) GetAccount(ctx context.Context, accountID int6
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabledUsesLegacyLoadAwareness(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
ctx := context.Background()
|
||||
groupID := int64(10106)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 36001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 5,
|
||||
},
|
||||
{
|
||||
ID: 36002,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||
cache := &schedulerTestGatewayCache{}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
store := svc.getOpenAIWSStateStore()
|
||||
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_disabled_001", 36001, time.Hour))
|
||||
require.False(t, svc.isOpenAIAdvancedSchedulerEnabled(ctx))
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"resp_disabled_001",
|
||||
"",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(36002), selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||
require.False(t, decision.StickyPreviousHit)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPreviousResponseRouting(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
ctx := context.Background()
|
||||
groupID := int64(10107)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 37001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 5,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 37002,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.OAuthEnabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 3600
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &schedulerTestGatewayCache{},
|
||||
cfg: cfg,
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
store := svc.getOpenAIWSStateStore()
|
||||
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_enabled_001", 37001, time.Hour))
|
||||
require.True(t, svc.isOpenAIAdvancedSchedulerEnabled(ctx))
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"resp_enabled_001",
|
||||
"",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(37001), selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer)
|
||||
require.True(t, decision.StickyPreviousHit)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyRateLimitedAccountFallsBackToFreshCandidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(10101)
|
||||
@@ -53,10 +382,17 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyRateLimite
|
||||
staleBackup := &Account{ID: 31002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
freshSticky := &Account{ID: 31001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil}
|
||||
freshBackup := &Account{ID: 31002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
cache := &stubGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_rate_limited": 31001}}
|
||||
cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_rate_limited": 31001}}
|
||||
snapshotCache := &openAISnapshotCacheStub{snapshotAccounts: []*Account{staleSticky, staleBackup}, accountsByID: map[int64]*Account{31001: freshSticky, 31002: freshBackup}}
|
||||
snapshotService := &SchedulerSnapshotService{cache: snapshotCache}
|
||||
svc := &OpenAIGatewayService{accountRepo: stubOpenAIAccountRepo{accounts: []Account{*freshSticky, *freshBackup}}, cache: cache, cfg: &config.Config{}, schedulerSnapshot: snapshotService, concurrencyService: NewConcurrencyService(stubConcurrencyCache{})}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*freshSticky, *freshBackup}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
schedulerSnapshot: snapshotService,
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_rate_limited", "gpt-5.1", nil, OpenAIUpstreamTransportAny)
|
||||
require.NoError(t, err)
|
||||
@@ -76,7 +412,12 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_SkipsFreshlyRa
|
||||
freshSecondary := &Account{ID: 32002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
snapshotCache := &openAISnapshotCacheStub{snapshotAccounts: []*Account{stalePrimary, staleSecondary}, accountsByID: map[int64]*Account{32001: freshPrimary, 32002: freshSecondary}}
|
||||
snapshotService := &SchedulerSnapshotService{cache: snapshotCache}
|
||||
svc := &OpenAIGatewayService{accountRepo: stubOpenAIAccountRepo{accounts: []Account{*freshPrimary, *freshSecondary}}, cfg: &config.Config{}, schedulerSnapshot: snapshotService}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*freshPrimary, *freshSecondary}},
|
||||
cfg: &config.Config{},
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
schedulerSnapshot: snapshotService,
|
||||
}
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "gpt-5.1", nil)
|
||||
require.NoError(t, err)
|
||||
@@ -92,18 +433,19 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyDBRuntimeR
|
||||
staleBackup := &Account{ID: 33002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
dbSticky := Account{ID: 33001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil}
|
||||
dbBackup := Account{ID: 33002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
cache := &stubGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_db_runtime_recheck": 33001}}
|
||||
cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_db_runtime_recheck": 33001}}
|
||||
snapshotCache := &openAISnapshotCacheStub{
|
||||
snapshotAccounts: []*Account{staleSticky, staleBackup},
|
||||
accountsByID: map[int64]*Account{33001: staleSticky, 33002: staleBackup},
|
||||
}
|
||||
snapshotService := &SchedulerSnapshotService{cache: snapshotCache}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{dbSticky, dbBackup}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{dbSticky, dbBackup}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
schedulerSnapshot: snapshotService,
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_db_runtime_recheck", "gpt-5.1", nil, OpenAIUpstreamTransportAny)
|
||||
@@ -128,8 +470,9 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_DBRuntimeReche
|
||||
}
|
||||
snapshotService := &SchedulerSnapshotService{cache: snapshotCache}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{dbPrimary, dbSecondary}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{dbPrimary, dbSecondary}},
|
||||
cfg: &config.Config{},
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
schedulerSnapshot: snapshotService,
|
||||
}
|
||||
|
||||
@@ -153,7 +496,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseSticky(
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{}
|
||||
cache := &schedulerTestGatewayCache{}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.OAuthEnabled = true
|
||||
@@ -163,10 +506,11 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseSticky(
|
||||
cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 3600
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
store := svc.getOpenAIWSStateStore()
|
||||
@@ -204,17 +548,18 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky(t *testin
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
cache := &schedulerTestGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_abc": account.ID,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
@@ -260,7 +605,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyKeepsS
|
||||
Priority: 9,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
cache := &schedulerTestGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_sticky_busy": 21001,
|
||||
},
|
||||
@@ -273,7 +618,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyKeepsS
|
||||
cfg.Gateway.OpenAIWS.OAuthEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
concurrencyCache := schedulerTestConcurrencyCache{
|
||||
acquireResults: map[int64]bool{
|
||||
21001: false, // sticky 账号已满
|
||||
21002: true, // 若回退负载均衡会命中该账号(本测试要求不能切换)
|
||||
@@ -288,9 +633,10 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyKeepsS
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
@@ -328,17 +674,18 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky_ForceHTTP
|
||||
"openai_ws_force_http": true,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
cache := &schedulerTestGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_force_http": account.ID,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
@@ -387,15 +734,15 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_RequiredWSV2_SkipsStick
|
||||
},
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
cache := &schedulerTestGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_ws_only": 2201,
|
||||
},
|
||||
}
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
cfg := newSchedulerTestOpenAIWSV2Config()
|
||||
|
||||
// 构造“HTTP-only 账号负载更低”的场景,验证 required transport 会强制过滤。
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
concurrencyCache := schedulerTestConcurrencyCache{
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
2201: {AccountID: 2201, LoadRate: 0, WaitingCount: 0},
|
||||
2202: {AccountID: 2202, LoadRate: 90, WaitingCount: 5},
|
||||
@@ -403,9 +750,10 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_RequiredWSV2_SkipsStick
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
@@ -445,10 +793,11 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_RequiredWSV2_NoAvailabl
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &stubGatewayCache{},
|
||||
cfg: newOpenAIWSV2TestConfig(),
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &schedulerTestGatewayCache{},
|
||||
cfg: newSchedulerTestOpenAIWSV2Config(),
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
@@ -507,7 +856,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_LoadBalanceTopKFallback
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.2
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.1
|
||||
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
concurrencyCache := schedulerTestConcurrencyCache{
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
3001: {AccountID: 3001, LoadRate: 95, WaitingCount: 8},
|
||||
3002: {AccountID: 3002, LoadRate: 20, WaitingCount: 1},
|
||||
@@ -520,9 +869,10 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_LoadBalanceTopKFallback
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &stubGatewayCache{},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &schedulerTestGatewayCache{},
|
||||
cfg: cfg,
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
@@ -559,16 +909,17 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) {
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
cache := &schedulerTestGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_metrics": account.ID,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny)
|
||||
@@ -749,7 +1100,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_LoadBalanceDistributesA
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 1
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 1
|
||||
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
concurrencyCache := schedulerTestConcurrencyCache{
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
5101: {AccountID: 5101, LoadRate: 20, WaitingCount: 1},
|
||||
5102: {AccountID: 5102, LoadRate: 20, WaitingCount: 1},
|
||||
@@ -757,9 +1108,10 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_LoadBalanceDistributesA
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &stubGatewayCache{sessionBindings: map[string]int64{}},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &schedulerTestGatewayCache{sessionBindings: map[string]int64{}},
|
||||
cfg: cfg,
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
@@ -905,12 +1257,14 @@ func TestDefaultOpenAIAccountScheduler_ReportSwitchAndSnapshot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
require.GreaterOrEqual(t, snapshot.AccountSwitchTotal, int64(1))
|
||||
require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot)
|
||||
require.Equal(t, 7, svc.openAIWSLBTopK())
|
||||
require.Equal(t, openaiStickySessionTTL, svc.openAIWSSessionStickyTTL())
|
||||
|
||||
@@ -947,7 +1301,7 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t *
|
||||
require.True(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportHTTPSSE))
|
||||
require.False(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportResponsesWebsocketV2))
|
||||
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
cfg := newSchedulerTestOpenAIWSV2Config()
|
||||
scheduler.service = &OpenAIGatewayService{cfg: cfg}
|
||||
account := &Account{
|
||||
ID: 8801,
|
||||
|
||||
@@ -38,11 +38,12 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_UsesWSPassthroughSnapsh
|
||||
cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{*account}},
|
||||
cache: &stubGatewayCache{},
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*account}},
|
||||
cache: &schedulerTestGatewayCache{},
|
||||
cfg: cfg,
|
||||
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||
schedulerSnapshot: &SchedulerSnapshotService{cache: snapshotCache},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
|
||||
@@ -196,12 +196,25 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
|
||||
SettingHelpImageURL, SettingHelpText,
|
||||
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
|
||||
SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode,
|
||||
SettingPaymentVisibleMethodAlipayEnabled, SettingPaymentVisibleMethodAlipaySource,
|
||||
SettingPaymentVisibleMethodWxpayEnabled, SettingPaymentVisibleMethodWxpaySource,
|
||||
}
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get payment config settings: %w", err)
|
||||
}
|
||||
cfg := s.parsePaymentConfig(vals)
|
||||
if s.entClient != nil {
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.EnabledEQ(true)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled provider instances: %w", err)
|
||||
}
|
||||
cfg.EnabledTypes = applyVisibleMethodRoutingToEnabledTypes(cfg.EnabledTypes, vals, buildVisibleMethodSourceAvailability(instances))
|
||||
} else {
|
||||
cfg.EnabledTypes = applyVisibleMethodRoutingToEnabledTypes(cfg.EnabledTypes, vals, nil)
|
||||
}
|
||||
// Load Stripe publishable key from the first enabled Stripe provider instance
|
||||
cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
|
||||
return cfg, nil
|
||||
@@ -234,18 +247,23 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
|
||||
cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
|
||||
}
|
||||
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
|
||||
types := make([]string, 0, len(strings.Split(raw, ",")))
|
||||
for _, t := range strings.Split(raw, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
cfg.EnabledTypes = append(cfg.EnabledTypes, t)
|
||||
types = append(types, t)
|
||||
}
|
||||
}
|
||||
cfg.EnabledTypes = NormalizeVisibleMethods(types)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance.
|
||||
func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string {
|
||||
if s.entClient == nil {
|
||||
return ""
|
||||
}
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(
|
||||
paymentproviderinstance.EnabledEQ(true),
|
||||
@@ -385,3 +403,79 @@ func pcParseInt(s string, defaultVal int) int {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func buildVisibleMethodSourceAvailability(instances []*dbent.PaymentProviderInstance) map[string]bool {
|
||||
available := make(map[string]bool, 4)
|
||||
for _, inst := range instances {
|
||||
switch inst.ProviderKey {
|
||||
case payment.TypeAlipay:
|
||||
if inst.SupportedTypes == "" || payment.InstanceSupportsType(inst.SupportedTypes, payment.TypeAlipay) || payment.InstanceSupportsType(inst.SupportedTypes, payment.TypeAlipayDirect) {
|
||||
available[VisibleMethodSourceOfficialAlipay] = true
|
||||
}
|
||||
case payment.TypeWxpay:
|
||||
if inst.SupportedTypes == "" || payment.InstanceSupportsType(inst.SupportedTypes, payment.TypeWxpay) || payment.InstanceSupportsType(inst.SupportedTypes, payment.TypeWxpayDirect) {
|
||||
available[VisibleMethodSourceOfficialWechat] = true
|
||||
}
|
||||
case payment.TypeEasyPay:
|
||||
for _, supportedType := range splitTypes(inst.SupportedTypes) {
|
||||
switch NormalizeVisibleMethod(supportedType) {
|
||||
case payment.TypeAlipay:
|
||||
available[VisibleMethodSourceEasyPayAlipay] = true
|
||||
case payment.TypeWxpay:
|
||||
available[VisibleMethodSourceEasyPayWechat] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func applyVisibleMethodRoutingToEnabledTypes(base []string, vals map[string]string, available map[string]bool) []string {
|
||||
shouldExpose := map[string]bool{
|
||||
payment.TypeAlipay: visibleMethodShouldBeExposed(payment.TypeAlipay, vals, available),
|
||||
payment.TypeWxpay: visibleMethodShouldBeExposed(payment.TypeWxpay, vals, available),
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(base)+2)
|
||||
out := make([]string, 0, len(base)+2)
|
||||
appendType := func(paymentType string) {
|
||||
paymentType = NormalizeVisibleMethod(paymentType)
|
||||
if paymentType == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[paymentType]; ok {
|
||||
return
|
||||
}
|
||||
seen[paymentType] = struct{}{}
|
||||
out = append(out, paymentType)
|
||||
}
|
||||
|
||||
for _, paymentType := range base {
|
||||
visibleMethod := NormalizeVisibleMethod(paymentType)
|
||||
switch visibleMethod {
|
||||
case payment.TypeAlipay, payment.TypeWxpay:
|
||||
if shouldExpose[visibleMethod] {
|
||||
appendType(visibleMethod)
|
||||
}
|
||||
default:
|
||||
appendType(visibleMethod)
|
||||
}
|
||||
}
|
||||
|
||||
for _, visibleMethod := range []string{payment.TypeAlipay, payment.TypeWxpay} {
|
||||
if shouldExpose[visibleMethod] {
|
||||
appendType(visibleMethod)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func visibleMethodShouldBeExposed(method string, vals map[string]string, available map[string]bool) bool {
|
||||
enabledKey := visibleMethodEnabledSettingKey(method)
|
||||
sourceKey := visibleMethodSourceSettingKey(method)
|
||||
if enabledKey == "" || sourceKey == "" || vals[enabledKey] != "true" {
|
||||
return false
|
||||
}
|
||||
source := NormalizeVisibleMethodSource(method, vals[sourceKey])
|
||||
return source != "" && available[source]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestPcParseFloat(t *testing.T) {
|
||||
@@ -163,6 +171,20 @@ func TestParsePaymentConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled types are normalized to visible methods and deduplicated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
vals := map[string]string{
|
||||
SettingEnabledPaymentTypes: "alipay_direct, alipay, wxpay_direct, wxpay",
|
||||
}
|
||||
cfg := svc.parsePaymentConfig(vals)
|
||||
if len(cfg.EnabledTypes) != 2 {
|
||||
t.Fatalf("EnabledTypes len = %d, want 2", len(cfg.EnabledTypes))
|
||||
}
|
||||
if cfg.EnabledTypes[0] != "alipay" || cfg.EnabledTypes[1] != "wxpay" {
|
||||
t.Fatalf("EnabledTypes = %v, want [alipay wxpay]", cfg.EnabledTypes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty enabled types string", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
vals := map[string]string{
|
||||
@@ -204,3 +226,167 @@ func TestGetBasePaymentType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVisibleMethodRoutingToEnabledTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := []string{"alipay", "wxpay", "stripe"}
|
||||
vals := map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay,
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "true",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
}
|
||||
available := map[string]bool{
|
||||
VisibleMethodSourceOfficialAlipay: true,
|
||||
VisibleMethodSourceOfficialWechat: false,
|
||||
}
|
||||
|
||||
got := applyVisibleMethodRoutingToEnabledTypes(base, vals, available)
|
||||
want := []string{"alipay", "stripe"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("applyVisibleMethodRoutingToEnabledTypes len = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("applyVisibleMethodRoutingToEnabledTypes[%d] = %q, want %q (full=%v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVisibleMethodRoutingAddsConfiguredVisibleMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := []string{"stripe"}
|
||||
vals := map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceEasyPayAlipay,
|
||||
}
|
||||
available := map[string]bool{
|
||||
VisibleMethodSourceEasyPayAlipay: true,
|
||||
}
|
||||
|
||||
got := applyVisibleMethodRoutingToEnabledTypes(base, vals, available)
|
||||
want := []string{"stripe", "alipay"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("applyVisibleMethodRoutingToEnabledTypes len = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("applyVisibleMethodRoutingToEnabledTypes[%d] = %q, want %q (full=%v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVisibleMethodSourceAvailability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
instances := []*dbent.PaymentProviderInstance{
|
||||
{ProviderKey: payment.TypeAlipay, SupportedTypes: "alipay"},
|
||||
{ProviderKey: payment.TypeEasyPay, SupportedTypes: "wxpay_direct, alipay"},
|
||||
{ProviderKey: payment.TypeWxpay, SupportedTypes: "wxpay_direct"},
|
||||
}
|
||||
|
||||
got := buildVisibleMethodSourceAvailability(instances)
|
||||
if !got[VisibleMethodSourceOfficialAlipay] {
|
||||
t.Fatalf("expected %q to be available", VisibleMethodSourceOfficialAlipay)
|
||||
}
|
||||
if !got[VisibleMethodSourceEasyPayAlipay] {
|
||||
t.Fatalf("expected %q to be available", VisibleMethodSourceEasyPayAlipay)
|
||||
}
|
||||
if !got[VisibleMethodSourceOfficialWechat] {
|
||||
t.Fatalf("expected %q to be available", VisibleMethodSourceOfficialWechat)
|
||||
}
|
||||
if !got[VisibleMethodSourceEasyPayWechat] {
|
||||
t.Fatalf("expected %q to be available", VisibleMethodSourceEasyPayWechat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
|
||||
_, err := client.PaymentProviderInstance.Create().
|
||||
SetProviderKey(payment.TypeEasyPay).
|
||||
SetName("EasyPay Alipay").
|
||||
SetConfig("{}").
|
||||
SetSupportedTypes("alipay").
|
||||
SetEnabled(true).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create easypay instance: %v", err)
|
||||
}
|
||||
|
||||
svc := &PaymentConfigService{
|
||||
entClient: client,
|
||||
settingRepo: &paymentConfigSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingEnabledPaymentTypes: "alipay,wxpay,stripe",
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: "easypay",
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "true",
|
||||
SettingPaymentVisibleMethodWxpaySource: "wxpay",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := svc.GetPaymentConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPaymentConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{payment.TypeAlipay, payment.TypeStripe}
|
||||
if len(cfg.EnabledTypes) != len(want) {
|
||||
t.Fatalf("EnabledTypes len = %d, want %d (%v)", len(cfg.EnabledTypes), len(want), cfg.EnabledTypes)
|
||||
}
|
||||
for i := range want {
|
||||
if cfg.EnabledTypes[i] != want[i] {
|
||||
t.Fatalf("EnabledTypes[%d] = %q, want %q (full=%v)", i, cfg.EnabledTypes[i], want[i], cfg.EnabledTypes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:payment_config_service?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
type paymentConfigSettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *paymentConfigSettingRepoStub) Get(context.Context, string) (*Setting, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *paymentConfigSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
func (s *paymentConfigSettingRepoStub) Set(context.Context, string, string) error { return nil }
|
||||
func (s *paymentConfigSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
out[key] = s.values[key]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *paymentConfigSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
return s.values, nil
|
||||
}
|
||||
func (s *paymentConfigSettingRepoStub) Delete(context.Context, string) error { return nil }
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentSourceHostedRedirect = "hosted_redirect"
|
||||
PaymentSourceWechatInAppResume = "wechat_in_app_resume"
|
||||
|
||||
paymentResumeFallbackSigningKey = "sub2api-payment-resume"
|
||||
|
||||
SettingPaymentVisibleMethodAlipaySource = "payment_visible_method_alipay_source"
|
||||
SettingPaymentVisibleMethodWxpaySource = "payment_visible_method_wxpay_source"
|
||||
SettingPaymentVisibleMethodAlipayEnabled = "payment_visible_method_alipay_enabled"
|
||||
SettingPaymentVisibleMethodWxpayEnabled = "payment_visible_method_wxpay_enabled"
|
||||
|
||||
VisibleMethodSourceOfficialAlipay = "official_alipay"
|
||||
VisibleMethodSourceEasyPayAlipay = "easypay_alipay"
|
||||
VisibleMethodSourceOfficialWechat = "official_wxpay"
|
||||
VisibleMethodSourceEasyPayWechat = "easypay_wxpay"
|
||||
)
|
||||
|
||||
type ResumeTokenClaims struct {
|
||||
OrderID int64 `json:"oid"`
|
||||
UserID int64 `json:"uid,omitempty"`
|
||||
ProviderInstanceID string `json:"pi,omitempty"`
|
||||
ProviderKey string `json:"pk,omitempty"`
|
||||
PaymentType string `json:"pt,omitempty"`
|
||||
CanonicalReturnURL string `json:"ru,omitempty"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
}
|
||||
|
||||
type PaymentResumeService struct {
|
||||
signingKey []byte
|
||||
}
|
||||
|
||||
type visibleMethodLoadBalancer struct {
|
||||
inner payment.LoadBalancer
|
||||
configService *PaymentConfigService
|
||||
}
|
||||
|
||||
func NewPaymentResumeService(signingKey []byte) *PaymentResumeService {
|
||||
return &PaymentResumeService{signingKey: signingKey}
|
||||
}
|
||||
|
||||
func NormalizeVisibleMethod(method string) string {
|
||||
return payment.GetBasePaymentType(strings.TrimSpace(method))
|
||||
}
|
||||
|
||||
func NormalizeVisibleMethods(methods []string) []string {
|
||||
if len(methods) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(methods))
|
||||
out := make([]string, 0, len(methods))
|
||||
for _, method := range methods {
|
||||
normalized := NormalizeVisibleMethod(method)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizePaymentSource(source string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(source)) {
|
||||
case "", PaymentSourceHostedRedirect:
|
||||
return PaymentSourceHostedRedirect
|
||||
case "wechat_in_app", "wxpay_resume", PaymentSourceWechatInAppResume:
|
||||
return PaymentSourceWechatInAppResume
|
||||
default:
|
||||
return strings.TrimSpace(strings.ToLower(source))
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeVisibleMethodSource(method, source string) string {
|
||||
switch NormalizeVisibleMethod(method) {
|
||||
case payment.TypeAlipay:
|
||||
switch strings.TrimSpace(strings.ToLower(source)) {
|
||||
case VisibleMethodSourceOfficialAlipay, payment.TypeAlipay, payment.TypeAlipayDirect, "official":
|
||||
return VisibleMethodSourceOfficialAlipay
|
||||
case VisibleMethodSourceEasyPayAlipay, payment.TypeEasyPay:
|
||||
return VisibleMethodSourceEasyPayAlipay
|
||||
}
|
||||
case payment.TypeWxpay:
|
||||
switch strings.TrimSpace(strings.ToLower(source)) {
|
||||
case VisibleMethodSourceOfficialWechat, payment.TypeWxpay, payment.TypeWxpayDirect, "wechat", "official":
|
||||
return VisibleMethodSourceOfficialWechat
|
||||
case VisibleMethodSourceEasyPayWechat, payment.TypeEasyPay:
|
||||
return VisibleMethodSourceEasyPayWechat
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func VisibleMethodProviderKeyForSource(method, source string) (string, bool) {
|
||||
switch NormalizeVisibleMethodSource(method, source) {
|
||||
case VisibleMethodSourceOfficialAlipay:
|
||||
return payment.TypeAlipay, NormalizeVisibleMethod(method) == payment.TypeAlipay
|
||||
case VisibleMethodSourceEasyPayAlipay:
|
||||
return payment.TypeEasyPay, NormalizeVisibleMethod(method) == payment.TypeAlipay
|
||||
case VisibleMethodSourceOfficialWechat:
|
||||
return payment.TypeWxpay, NormalizeVisibleMethod(method) == payment.TypeWxpay
|
||||
case VisibleMethodSourceEasyPayWechat:
|
||||
return payment.TypeEasyPay, NormalizeVisibleMethod(method) == payment.TypeWxpay
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func newVisibleMethodLoadBalancer(inner payment.LoadBalancer, configService *PaymentConfigService) payment.LoadBalancer {
|
||||
if inner == nil || configService == nil || configService.settingRepo == nil {
|
||||
return inner
|
||||
}
|
||||
return &visibleMethodLoadBalancer{inner: inner, configService: configService}
|
||||
}
|
||||
|
||||
func (lb *visibleMethodLoadBalancer) GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) {
|
||||
return lb.inner.GetInstanceConfig(ctx, instanceID)
|
||||
}
|
||||
|
||||
func (lb *visibleMethodLoadBalancer) SelectInstance(ctx context.Context, providerKey string, paymentType payment.PaymentType, strategy payment.Strategy, orderAmount float64) (*payment.InstanceSelection, error) {
|
||||
visibleMethod := NormalizeVisibleMethod(paymentType)
|
||||
if providerKey != "" || (visibleMethod != payment.TypeAlipay && visibleMethod != payment.TypeWxpay) {
|
||||
return lb.inner.SelectInstance(ctx, providerKey, paymentType, strategy, orderAmount)
|
||||
}
|
||||
|
||||
enabledKey := visibleMethodEnabledSettingKey(visibleMethod)
|
||||
sourceKey := visibleMethodSourceSettingKey(visibleMethod)
|
||||
vals, err := lb.configService.settingRepo.GetMultiple(ctx, []string{enabledKey, sourceKey})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load visible method routing for %s: %w", visibleMethod, err)
|
||||
}
|
||||
if vals[enabledKey] != "true" {
|
||||
return nil, fmt.Errorf("visible payment method %s is disabled", visibleMethod)
|
||||
}
|
||||
|
||||
targetProviderKey, ok := VisibleMethodProviderKeyForSource(visibleMethod, vals[sourceKey])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("visible payment method %s has no valid source", visibleMethod)
|
||||
}
|
||||
return lb.inner.SelectInstance(ctx, targetProviderKey, paymentType, strategy, orderAmount)
|
||||
}
|
||||
|
||||
func visibleMethodEnabledSettingKey(method string) string {
|
||||
switch NormalizeVisibleMethod(method) {
|
||||
case payment.TypeAlipay:
|
||||
return SettingPaymentVisibleMethodAlipayEnabled
|
||||
case payment.TypeWxpay:
|
||||
return SettingPaymentVisibleMethodWxpayEnabled
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func visibleMethodSourceSettingKey(method string) string {
|
||||
switch NormalizeVisibleMethod(method) {
|
||||
case payment.TypeAlipay:
|
||||
return SettingPaymentVisibleMethodAlipaySource
|
||||
case payment.TypeWxpay:
|
||||
return SettingPaymentVisibleMethodWxpaySource
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func CanonicalizeReturnURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || !parsed.IsAbs() || parsed.Host == "" {
|
||||
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be an absolute http/https URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https")
|
||||
}
|
||||
parsed.Fragment = ""
|
||||
if parsed.Path == "" {
|
||||
parsed.Path = "/"
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) {
|
||||
if claims.OrderID <= 0 {
|
||||
return "", fmt.Errorf("resume token requires order id")
|
||||
}
|
||||
if claims.IssuedAt == 0 {
|
||||
claims.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal resume claims: %w", err)
|
||||
}
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return encodedPayload + "." + s.sign(encodedPayload), nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
|
||||
}
|
||||
if !hmac.Equal([]byte(parts[1]), []byte(s.sign(parts[0]))) {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
|
||||
}
|
||||
var claims ResumeTokenClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid")
|
||||
}
|
||||
if claims.OrderID <= 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) sign(payload string) string {
|
||||
key := s.signingKey
|
||||
if len(key) == 0 {
|
||||
key = []byte(paymentResumeFallbackSigningKey)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
func TestNormalizeVisibleMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := NormalizeVisibleMethods([]string{
|
||||
"alipay_direct",
|
||||
"alipay",
|
||||
" wxpay_direct ",
|
||||
"wxpay",
|
||||
"stripe",
|
||||
})
|
||||
|
||||
want := []string{"alipay", "wxpay", "stripe"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("NormalizeVisibleMethods len = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("NormalizeVisibleMethods[%d] = %q, want %q (full=%v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaymentSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{name: "empty uses default", input: "", expect: PaymentSourceHostedRedirect},
|
||||
{name: "wechat alias normalized", input: "wechat_in_app", expect: PaymentSourceWechatInAppResume},
|
||||
{name: "canonical value preserved", input: PaymentSourceWechatInAppResume, expect: PaymentSourceWechatInAppResume},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NormalizePaymentSource(tt.input); got != tt.expect {
|
||||
t.Fatalf("NormalizePaymentSource(%q) = %q, want %q", tt.input, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeReturnURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := CanonicalizeReturnURL("https://example.com/pay/result?b=2#a")
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalizeReturnURL returned error: %v", err)
|
||||
}
|
||||
if got != "https://example.com/pay/result?b=2" {
|
||||
t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://example.com/pay/result?b=2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := CanonicalizeReturnURL("/payment/result"); err == nil {
|
||||
t.Fatal("CanonicalizeReturnURL should reject relative URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentResumeTokenRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
|
||||
token, err := svc.CreateToken(ResumeTokenClaims{
|
||||
OrderID: 42,
|
||||
UserID: 7,
|
||||
ProviderInstanceID: "19",
|
||||
ProviderKey: "easypay",
|
||||
PaymentType: "wxpay",
|
||||
CanonicalReturnURL: "https://example.com/payment/result",
|
||||
IssuedAt: 1234567890,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateToken returned error: %v", err)
|
||||
}
|
||||
|
||||
claims, err := svc.ParseToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken returned error: %v", err)
|
||||
}
|
||||
if claims.OrderID != 42 || claims.UserID != 7 {
|
||||
t.Fatalf("claims mismatch: %+v", claims)
|
||||
}
|
||||
if claims.ProviderInstanceID != "19" || claims.ProviderKey != "easypay" || claims.PaymentType != "wxpay" {
|
||||
t.Fatalf("claims provider snapshot mismatch: %+v", claims)
|
||||
}
|
||||
if claims.CanonicalReturnURL != "https://example.com/payment/result" {
|
||||
t.Fatalf("claims return URL = %q", claims.CanonicalReturnURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVisibleMethodSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "alipay official alias", method: payment.TypeAlipay, input: "alipay", want: VisibleMethodSourceOfficialAlipay},
|
||||
{name: "alipay easypay alias", method: payment.TypeAlipay, input: "easypay", want: VisibleMethodSourceEasyPayAlipay},
|
||||
{name: "wxpay official alias", method: payment.TypeWxpay, input: "wxpay", want: VisibleMethodSourceOfficialWechat},
|
||||
{name: "wxpay easypay alias", method: payment.TypeWxpay, input: "easypay", want: VisibleMethodSourceEasyPayWechat},
|
||||
{name: "unsupported source", method: payment.TypeWxpay, input: "stripe", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NormalizeVisibleMethodSource(tt.method, tt.input); got != tt.want {
|
||||
t.Fatalf("NormalizeVisibleMethodSource(%q, %q) = %q, want %q", tt.method, tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleMethodProviderKeyForSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
source string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "official alipay", method: payment.TypeAlipay, source: VisibleMethodSourceOfficialAlipay, want: payment.TypeAlipay, ok: true},
|
||||
{name: "easypay alipay", method: payment.TypeAlipay, source: VisibleMethodSourceEasyPayAlipay, want: payment.TypeEasyPay, ok: true},
|
||||
{name: "official wechat", method: payment.TypeWxpay, source: VisibleMethodSourceOfficialWechat, want: payment.TypeWxpay, ok: true},
|
||||
{name: "easypay wechat", method: payment.TypeWxpay, source: VisibleMethodSourceEasyPayWechat, want: payment.TypeEasyPay, ok: true},
|
||||
{name: "mismatched method and source", method: payment.TypeAlipay, source: VisibleMethodSourceOfficialWechat, want: "", ok: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := VisibleMethodProviderKeyForSource(tt.method, tt.source)
|
||||
if got != tt.want || ok != tt.ok {
|
||||
t.Fatalf("VisibleMethodProviderKeyForSource(%q, %q) = (%q, %v), want (%q, %v)", tt.method, tt.source, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleMethodLoadBalancerUsesConfiguredSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inner := &captureLoadBalancer{}
|
||||
configService := &PaymentConfigService{
|
||||
settingRepo: &paymentSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay,
|
||||
},
|
||||
},
|
||||
}
|
||||
lb := newVisibleMethodLoadBalancer(inner, configService)
|
||||
|
||||
_, err := lb.SelectInstance(context.Background(), "", payment.TypeAlipay, payment.StrategyRoundRobin, 12.5)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectInstance returned error: %v", err)
|
||||
}
|
||||
if inner.lastProviderKey != payment.TypeAlipay {
|
||||
t.Fatalf("lastProviderKey = %q, want %q", inner.lastProviderKey, payment.TypeAlipay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleMethodLoadBalancerRejectsDisabledVisibleMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inner := &captureLoadBalancer{}
|
||||
configService := &PaymentConfigService{
|
||||
settingRepo: &paymentSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
},
|
||||
},
|
||||
}
|
||||
lb := newVisibleMethodLoadBalancer(inner, configService)
|
||||
|
||||
if _, err := lb.SelectInstance(context.Background(), "", payment.TypeWxpay, payment.StrategyRoundRobin, 9.9); err == nil {
|
||||
t.Fatal("SelectInstance should reject disabled visible method")
|
||||
}
|
||||
}
|
||||
|
||||
type paymentSettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *paymentSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, nil }
|
||||
func (s *paymentSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
func (s *paymentSettingRepoStub) Set(context.Context, string, string) error { return nil }
|
||||
func (s *paymentSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
out[key] = s.values[key]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *paymentSettingRepoStub) SetMultiple(context.Context, map[string]string) error { return nil }
|
||||
func (s *paymentSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
return s.values, nil
|
||||
}
|
||||
func (s *paymentSettingRepoStub) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type captureLoadBalancer struct {
|
||||
lastProviderKey string
|
||||
lastPaymentType string
|
||||
}
|
||||
|
||||
func (c *captureLoadBalancer) GetInstanceConfig(context.Context, int64) (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
func (c *captureLoadBalancer) SelectInstance(_ context.Context, providerKey string, paymentType payment.PaymentType, _ payment.Strategy, _ float64) (*payment.InstanceSelection, error) {
|
||||
c.lastProviderKey = providerKey
|
||||
c.lastPaymentType = paymentType
|
||||
return &payment.InstanceSelection{ProviderKey: providerKey, SupportedTypes: paymentType}, nil
|
||||
}
|
||||
@@ -65,15 +65,17 @@ func generateRandomString(n int) string {
|
||||
}
|
||||
|
||||
type CreateOrderRequest struct {
|
||||
UserID int64
|
||||
Amount float64
|
||||
PaymentType string
|
||||
ClientIP string
|
||||
IsMobile bool
|
||||
SrcHost string
|
||||
SrcURL string
|
||||
OrderType string
|
||||
PlanID int64
|
||||
UserID int64
|
||||
Amount float64
|
||||
PaymentType string
|
||||
ClientIP string
|
||||
IsMobile bool
|
||||
SrcHost string
|
||||
SrcURL string
|
||||
ReturnURL string
|
||||
PaymentSource string
|
||||
OrderType string
|
||||
PlanID int64
|
||||
}
|
||||
|
||||
type CreateOrderResponse struct {
|
||||
@@ -88,6 +90,7 @@ type CreateOrderResponse struct {
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaymentMode string `json:"payment_mode,omitempty"`
|
||||
ResumeToken string `json:"resume_token,omitempty"`
|
||||
}
|
||||
|
||||
type OrderListParams struct {
|
||||
@@ -165,10 +168,13 @@ type PaymentService struct {
|
||||
configService *PaymentConfigService
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
resumeService *PaymentResumeService
|
||||
}
|
||||
|
||||
func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository) *PaymentService {
|
||||
return &PaymentService{entClient: entClient, registry: registry, loadBalancer: loadBalancer, redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo}
|
||||
svc := &PaymentService{entClient: entClient, registry: registry, loadBalancer: newVisibleMethodLoadBalancer(loadBalancer, configService), redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo}
|
||||
svc.resumeService = NewPaymentResumeService(psResumeSigningKey(configService))
|
||||
return svc
|
||||
}
|
||||
|
||||
// --- Provider Registry ---
|
||||
@@ -262,6 +268,20 @@ func psNilIfEmpty(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *PaymentService) paymentResume() *PaymentResumeService {
|
||||
if s.resumeService != nil {
|
||||
return s.resumeService
|
||||
}
|
||||
return NewPaymentResumeService(psResumeSigningKey(s.configService))
|
||||
}
|
||||
|
||||
func psResumeSigningKey(configService *PaymentConfigService) []byte {
|
||||
if configService == nil {
|
||||
return nil
|
||||
}
|
||||
return configService.encryptionKey
|
||||
}
|
||||
|
||||
func psSliceContains(sl []string, s string) bool {
|
||||
for _, v := range sl {
|
||||
if v == s {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -114,6 +115,66 @@ type SettingService struct {
|
||||
webSearchManagerBuilder WebSearchManagerBuilder
|
||||
}
|
||||
|
||||
type ProviderDefaultGrantSettings struct {
|
||||
Balance float64
|
||||
Concurrency int
|
||||
Subscriptions []DefaultSubscriptionSetting
|
||||
GrantOnSignup bool
|
||||
GrantOnFirstBind bool
|
||||
}
|
||||
|
||||
type AuthSourceDefaultSettings struct {
|
||||
Email ProviderDefaultGrantSettings
|
||||
LinuxDo ProviderDefaultGrantSettings
|
||||
OIDC ProviderDefaultGrantSettings
|
||||
WeChat ProviderDefaultGrantSettings
|
||||
ForceEmailOnThirdPartySignup bool
|
||||
}
|
||||
|
||||
type authSourceDefaultKeySet struct {
|
||||
balance string
|
||||
concurrency string
|
||||
subscriptions string
|
||||
grantOnSignup string
|
||||
grantOnFirstBind string
|
||||
}
|
||||
|
||||
var (
|
||||
emailAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||||
balance: SettingKeyAuthSourceDefaultEmailBalance,
|
||||
concurrency: SettingKeyAuthSourceDefaultEmailConcurrency,
|
||||
subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions,
|
||||
grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
||||
grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
||||
}
|
||||
linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||||
balance: SettingKeyAuthSourceDefaultLinuxDoBalance,
|
||||
concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
||||
subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
||||
grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
||||
grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
||||
}
|
||||
oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||||
balance: SettingKeyAuthSourceDefaultOIDCBalance,
|
||||
concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency,
|
||||
subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
||||
grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
||||
grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
||||
}
|
||||
weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||||
balance: SettingKeyAuthSourceDefaultWeChatBalance,
|
||||
concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency,
|
||||
subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
||||
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
||||
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAuthSourceBalance = 0
|
||||
defaultAuthSourceConcurrency = 5
|
||||
)
|
||||
|
||||
// NewSettingService 创建系统设置服务实例
|
||||
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
|
||||
return &SettingService{
|
||||
@@ -212,6 +273,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
if oidcProviderName == "" {
|
||||
oidcProviderName = "OIDC"
|
||||
}
|
||||
weChatEnabled := isWeChatOAuthConfigured()
|
||||
|
||||
// Password reset requires email verification to be enabled
|
||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||
@@ -254,6 +316,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
WeChatOAuthEnabled: weChatEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||
OIDCOAuthEnabled: oidcEnabled,
|
||||
@@ -310,6 +373,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
@@ -344,6 +408,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
@@ -392,6 +457,14 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
||||
return result
|
||||
}
|
||||
|
||||
func isWeChatOAuthConfigured() bool {
|
||||
openConfigured := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID")) != "" &&
|
||||
strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET")) != ""
|
||||
mpConfigured := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) != "" &&
|
||||
strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) != ""
|
||||
return openConfigured || mpConfigured
|
||||
}
|
||||
|
||||
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
||||
func safeRawJSONArray(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
@@ -919,6 +992,74 @@ func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultS
|
||||
return parseDefaultSubscriptions(value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) {
|
||||
keys := []string{
|
||||
SettingKeyAuthSourceDefaultEmailBalance,
|
||||
SettingKeyAuthSourceDefaultEmailConcurrency,
|
||||
SettingKeyAuthSourceDefaultEmailSubscriptions,
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultLinuxDoBalance,
|
||||
SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
||||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultOIDCBalance,
|
||||
SettingKeyAuthSourceDefaultOIDCConcurrency,
|
||||
SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultWeChatBalance,
|
||||
SettingKeyAuthSourceDefaultWeChatConcurrency,
|
||||
SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
||||
SettingKeyForceEmailOnThirdPartySignup,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get auth source default settings: %w", err)
|
||||
}
|
||||
|
||||
return &AuthSourceDefaultSettings{
|
||||
Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys),
|
||||
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
|
||||
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
|
||||
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
|
||||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, subscriptions := range [][]DefaultSubscriptionSetting{
|
||||
settings.Email.Subscriptions,
|
||||
settings.LinuxDo.Subscriptions,
|
||||
settings.OIDC.Subscriptions,
|
||||
settings.WeChat.Subscriptions,
|
||||
} {
|
||||
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]string, 21)
|
||||
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
||||
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
||||
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
||||
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
||||
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
|
||||
|
||||
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
||||
return fmt.Errorf("update auth source default settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitializeDefaultSettings 初始化默认设置
|
||||
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
// 检查是否已有设置
|
||||
@@ -933,25 +1074,46 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
|
||||
// 初始化默认设置
|
||||
defaults := map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyEmailVerifyEnabled: "false",
|
||||
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||||
SettingKeySiteName: "Sub2API",
|
||||
SettingKeySiteLogo: "",
|
||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||
SettingKeyPurchaseSubscriptionURL: "",
|
||||
SettingKeyTableDefaultPageSize: "20",
|
||||
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
||||
SettingKeyCustomMenuItems: "[]",
|
||||
SettingKeyCustomEndpoints: "[]",
|
||||
SettingKeyOIDCConnectEnabled: "false",
|
||||
SettingKeyOIDCConnectProviderName: "OIDC",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
SettingKeySMTPPort: "587",
|
||||
SettingKeySMTPUseTLS: "false",
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyEmailVerifyEnabled: "false",
|
||||
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||||
SettingKeySiteName: "Sub2API",
|
||||
SettingKeySiteLogo: "",
|
||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||
SettingKeyPurchaseSubscriptionURL: "",
|
||||
SettingKeyTableDefaultPageSize: "20",
|
||||
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
||||
SettingKeyCustomMenuItems: "[]",
|
||||
SettingKeyCustomEndpoints: "[]",
|
||||
SettingKeyOIDCConnectEnabled: "false",
|
||||
SettingKeyOIDCConnectProviderName: "OIDC",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
SettingKeyAuthSourceDefaultEmailBalance: "0",
|
||||
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
|
||||
SettingKeyAuthSourceDefaultEmailSubscriptions: "[]",
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "true",
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
|
||||
SettingKeyAuthSourceDefaultLinuxDoBalance: "0",
|
||||
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5",
|
||||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]",
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "true",
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false",
|
||||
SettingKeyAuthSourceDefaultOIDCBalance: "0",
|
||||
SettingKeyAuthSourceDefaultOIDCConcurrency: "5",
|
||||
SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]",
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "true",
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false",
|
||||
SettingKeyAuthSourceDefaultWeChatBalance: "0",
|
||||
SettingKeyAuthSourceDefaultWeChatConcurrency: "5",
|
||||
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "true",
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
|
||||
SettingKeyForceEmailOnThirdPartySignup: "false",
|
||||
SettingKeySMTPPort: "587",
|
||||
SettingKeySMTPUseTLS: "false",
|
||||
// Model fallback defaults
|
||||
SettingKeyEnableModelFallback: "false",
|
||||
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
||||
@@ -1164,6 +1326,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
} else {
|
||||
result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken
|
||||
}
|
||||
result.OIDCConnectUsePKCE = true
|
||||
result.OIDCConnectValidateIDToken = true
|
||||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v)
|
||||
} else {
|
||||
@@ -1317,6 +1481,51 @@ func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings {
|
||||
result := ProviderDefaultGrantSettings{
|
||||
Balance: defaultAuthSourceBalance,
|
||||
Concurrency: defaultAuthSourceConcurrency,
|
||||
Subscriptions: []DefaultSubscriptionSetting{},
|
||||
GrantOnSignup: true,
|
||||
GrantOnFirstBind: false,
|
||||
}
|
||||
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil {
|
||||
result.Balance = v
|
||||
}
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil {
|
||||
result.Concurrency = v
|
||||
}
|
||||
if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil {
|
||||
result.Subscriptions = items
|
||||
}
|
||||
if raw, ok := settings[keys.grantOnSignup]; ok {
|
||||
result.GrantOnSignup = raw == "true"
|
||||
}
|
||||
if raw, ok := settings[keys.grantOnFirstBind]; ok {
|
||||
result.GrantOnFirstBind = raw == "true"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) {
|
||||
updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64)
|
||||
updates[keys.concurrency] = strconv.Itoa(settings.Concurrency)
|
||||
|
||||
subscriptions := settings.Subscriptions
|
||||
if subscriptions == nil {
|
||||
subscriptions = []DefaultSubscriptionSetting{}
|
||||
}
|
||||
raw, err := json.Marshal(subscriptions)
|
||||
if err != nil {
|
||||
raw = []byte("[]")
|
||||
}
|
||||
updates[keys.subscriptions] = string(raw)
|
||||
updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup)
|
||||
updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind)
|
||||
}
|
||||
|
||||
func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) {
|
||||
defaultPageSize := 20
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil {
|
||||
@@ -1539,6 +1748,7 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
|
||||
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.RedirectURL = strings.TrimSpace(v)
|
||||
}
|
||||
effective.UsePKCE = true
|
||||
|
||||
if !effective.Enabled {
|
||||
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||||
@@ -1587,9 +1797,6 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
|
||||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||||
}
|
||||
case "none":
|
||||
if !effective.UsePKCE {
|
||||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none")
|
||||
}
|
||||
default:
|
||||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
||||
}
|
||||
@@ -1737,6 +1944,8 @@ func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.
|
||||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||||
effective.ValidateIDToken = raw == "true"
|
||||
}
|
||||
effective.UsePKCE = true
|
||||
effective.ValidateIDToken = true
|
||||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.AllowedSigningAlgs = strings.TrimSpace(v)
|
||||
}
|
||||
@@ -1864,9 +2073,6 @@ func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||||
}
|
||||
case "none":
|
||||
if !effective.UsePKCE {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none")
|
||||
}
|
||||
default:
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type authSourceDefaultsRepoStub struct {
|
||||
values map[string]string
|
||||
updates map[string]string
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
panic("unexpected GetValue call")
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := s.values[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
s.updates = make(map[string]string, len(settings))
|
||||
for key, value := range settings {
|
||||
s.updates[key] = value
|
||||
if s.values == nil {
|
||||
s.values = map[string]string{}
|
||||
}
|
||||
s.values[key] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
panic("unexpected GetAll call")
|
||||
}
|
||||
|
||||
func (s *authSourceDefaultsRepoStub) Delete(ctx context.Context, key string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func TestSettingService_GetAuthSourceDefaultSettings_ParsesValuesAndDefaults(t *testing.T) {
|
||||
repo := &authSourceDefaultsRepoStub{
|
||||
values: map[string]string{
|
||||
SettingKeyAuthSourceDefaultEmailBalance: "12.5",
|
||||
SettingKeyAuthSourceDefaultEmailConcurrency: "7",
|
||||
SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":11,"validity_days":30}]`,
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "true",
|
||||
SettingKeyForceEmailOnThirdPartySignup: "true",
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
got, err := svc.GetAuthSourceDefaultSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 12.5, got.Email.Balance)
|
||||
require.Equal(t, 7, got.Email.Concurrency)
|
||||
require.Equal(t, []DefaultSubscriptionSetting{{GroupID: 11, ValidityDays: 30}}, got.Email.Subscriptions)
|
||||
require.False(t, got.Email.GrantOnSignup)
|
||||
require.False(t, got.Email.GrantOnFirstBind)
|
||||
require.Equal(t, 0.0, got.LinuxDo.Balance)
|
||||
require.Equal(t, 5, got.LinuxDo.Concurrency)
|
||||
require.Equal(t, []DefaultSubscriptionSetting{}, got.LinuxDo.Subscriptions)
|
||||
require.True(t, got.LinuxDo.GrantOnSignup)
|
||||
require.True(t, got.LinuxDo.GrantOnFirstBind)
|
||||
require.Equal(t, 5, got.OIDC.Concurrency)
|
||||
require.Equal(t, 5, got.WeChat.Concurrency)
|
||||
require.True(t, got.ForceEmailOnThirdPartySignup)
|
||||
}
|
||||
|
||||
func TestSettingService_UpdateAuthSourceDefaultSettings_PersistsAllKeys(t *testing.T) {
|
||||
repo := &authSourceDefaultsRepoStub{}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
err := svc.UpdateAuthSourceDefaultSettings(context.Background(), &AuthSourceDefaultSettings{
|
||||
Email: ProviderDefaultGrantSettings{
|
||||
Balance: 1.25,
|
||||
Concurrency: 3,
|
||||
Subscriptions: []DefaultSubscriptionSetting{{GroupID: 21, ValidityDays: 14}},
|
||||
GrantOnSignup: false,
|
||||
GrantOnFirstBind: true,
|
||||
},
|
||||
LinuxDo: ProviderDefaultGrantSettings{
|
||||
Balance: 2,
|
||||
Concurrency: 4,
|
||||
Subscriptions: []DefaultSubscriptionSetting{{GroupID: 22, ValidityDays: 30}},
|
||||
GrantOnSignup: true,
|
||||
GrantOnFirstBind: false,
|
||||
},
|
||||
OIDC: ProviderDefaultGrantSettings{
|
||||
Balance: 3,
|
||||
Concurrency: 5,
|
||||
Subscriptions: []DefaultSubscriptionSetting{{GroupID: 23, ValidityDays: 60}},
|
||||
GrantOnSignup: true,
|
||||
GrantOnFirstBind: true,
|
||||
},
|
||||
WeChat: ProviderDefaultGrantSettings{
|
||||
Balance: 4,
|
||||
Concurrency: 6,
|
||||
Subscriptions: []DefaultSubscriptionSetting{{GroupID: 24, ValidityDays: 90}},
|
||||
GrantOnSignup: false,
|
||||
GrantOnFirstBind: false,
|
||||
},
|
||||
ForceEmailOnThirdPartySignup: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.25000000", repo.updates[SettingKeyAuthSourceDefaultEmailBalance])
|
||||
require.Equal(t, "3", repo.updates[SettingKeyAuthSourceDefaultEmailConcurrency])
|
||||
require.Equal(t, "false", repo.updates[SettingKeyAuthSourceDefaultEmailGrantOnSignup])
|
||||
require.Equal(t, "true", repo.updates[SettingKeyAuthSourceDefaultEmailGrantOnFirstBind])
|
||||
require.Equal(t, "true", repo.updates[SettingKeyForceEmailOnThirdPartySignup])
|
||||
|
||||
var got []DefaultSubscriptionSetting
|
||||
require.NoError(t, json.Unmarshal([]byte(repo.updates[SettingKeyAuthSourceDefaultWeChatSubscriptions]), &got))
|
||||
require.Equal(t, []DefaultSubscriptionSetting{{GroupID: 24, ValidityDays: 90}}, got)
|
||||
}
|
||||
@@ -152,6 +152,7 @@ type PublicSettings struct {
|
||||
CustomEndpoints string // JSON array of custom endpoints
|
||||
|
||||
LinuxDoOAuthEnabled bool
|
||||
WeChatOAuthEnabled bool
|
||||
BackendModeEnabled bool
|
||||
PaymentEnabled bool
|
||||
OIDCOAuthEnabled bool
|
||||
|
||||
@@ -7,19 +7,27 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
Username string
|
||||
Notes string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Balance float64
|
||||
Concurrency int
|
||||
Status string
|
||||
AllowedGroups []int64
|
||||
TokenVersion int64 // Incremented on password change to invalidate existing tokens
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID int64
|
||||
Email string
|
||||
Username string
|
||||
Notes string
|
||||
AvatarURL string
|
||||
AvatarSource string
|
||||
AvatarMIME string
|
||||
AvatarByteSize int
|
||||
AvatarSHA256 string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Balance float64
|
||||
Concurrency int
|
||||
Status string
|
||||
AllowedGroups []int64
|
||||
TokenVersion int64 // Incremented on password change to invalidate existing tokens
|
||||
SignupSource string
|
||||
LastLoginAt *time.Time
|
||||
LastActiveAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
// map[groupID]rateMultiplier
|
||||
|
||||
@@ -2,9 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,10 +21,14 @@ var (
|
||||
ErrPasswordIncorrect = infraerrors.BadRequest("PASSWORD_INCORRECT", "current password is incorrect")
|
||||
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
|
||||
ErrNotifyCodeUserRateLimit = infraerrors.TooManyRequests("NOTIFY_CODE_USER_RATE_LIMIT", "too many verification codes requested, please try again later")
|
||||
ErrAvatarInvalid = infraerrors.BadRequest("AVATAR_INVALID", "avatar must be a valid image data URL or http(s) URL")
|
||||
ErrAvatarTooLarge = infraerrors.BadRequest("AVATAR_TOO_LARGE", "avatar image must be 100KB or smaller")
|
||||
ErrAvatarNotImage = infraerrors.BadRequest("AVATAR_NOT_IMAGE", "avatar content must be an image")
|
||||
)
|
||||
|
||||
const (
|
||||
maxNotifyEmails = 3 // Maximum number of notification emails per user
|
||||
maxNotifyEmails = 3 // Maximum number of notification emails per user
|
||||
maxInlineAvatarBytes = 100 * 1024
|
||||
|
||||
// User-level rate limiting for notify email verification codes
|
||||
notifyCodeUserRateLimit = 5
|
||||
@@ -47,6 +55,9 @@ type UserRepository interface {
|
||||
GetFirstAdmin(ctx context.Context) (*User, error)
|
||||
Update(ctx context.Context, user *User) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error)
|
||||
UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
|
||||
DeleteUserAvatar(ctx context.Context, userID int64) error
|
||||
|
||||
List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error)
|
||||
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error)
|
||||
@@ -71,11 +82,30 @@ type UserRepository interface {
|
||||
type UpdateProfileRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
}
|
||||
|
||||
type UserAvatar struct {
|
||||
StorageProvider string
|
||||
StorageKey string
|
||||
URL string
|
||||
ContentType string
|
||||
ByteSize int
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
type UpsertUserAvatarInput struct {
|
||||
StorageProvider string
|
||||
StorageKey string
|
||||
URL string
|
||||
ContentType string
|
||||
ByteSize int
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
@@ -115,6 +145,9 @@ func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("get user avatar: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -143,6 +176,27 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
user.Username = *req.Username
|
||||
}
|
||||
|
||||
if req.AvatarURL != nil {
|
||||
avatarValue := strings.TrimSpace(*req.AvatarURL)
|
||||
switch {
|
||||
case avatarValue == "":
|
||||
if err := s.userRepo.DeleteUserAvatar(ctx, userID); err != nil {
|
||||
return nil, fmt.Errorf("delete avatar: %w", err)
|
||||
}
|
||||
applyUserAvatar(user, nil)
|
||||
default:
|
||||
avatarInput, err := normalizeUserAvatarInput(avatarValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
avatar, err := s.userRepo.UpsertUserAvatar(ctx, userID, avatarInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert avatar: %w", err)
|
||||
}
|
||||
applyUserAvatar(user, avatar)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Concurrency != nil {
|
||||
user.Concurrency = *req.Concurrency
|
||||
}
|
||||
@@ -168,6 +222,87 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func applyUserAvatar(user *User, avatar *UserAvatar) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
if avatar == nil {
|
||||
user.AvatarURL = ""
|
||||
user.AvatarSource = ""
|
||||
user.AvatarMIME = ""
|
||||
user.AvatarByteSize = 0
|
||||
user.AvatarSHA256 = ""
|
||||
return
|
||||
}
|
||||
|
||||
user.AvatarURL = avatar.URL
|
||||
user.AvatarSource = avatar.StorageProvider
|
||||
user.AvatarMIME = avatar.ContentType
|
||||
user.AvatarByteSize = avatar.ByteSize
|
||||
user.AvatarSHA256 = avatar.SHA256
|
||||
}
|
||||
|
||||
func normalizeUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
if strings.HasPrefix(raw, "data:") {
|
||||
return normalizeInlineUserAvatarInput(raw)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed == nil {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
if strings.TrimSpace(parsed.Host) == "" {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
|
||||
return UpsertUserAvatarInput{
|
||||
StorageProvider: "remote_url",
|
||||
URL: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
||||
body := strings.TrimPrefix(raw, "data:")
|
||||
meta, encoded, ok := strings.Cut(body, ",")
|
||||
if !ok {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
meta = strings.TrimSpace(meta)
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
if !strings.HasSuffix(strings.ToLower(meta), ";base64") {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(meta[:len(meta)-len(";base64")])
|
||||
if contentType == "" || !strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarNotImage
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
||||
}
|
||||
if len(decoded) > maxInlineAvatarBytes {
|
||||
return UpsertUserAvatarInput{}, ErrAvatarTooLarge
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(decoded)
|
||||
return UpsertUserAvatarInput{
|
||||
StorageProvider: "inline",
|
||||
URL: raw,
|
||||
ContentType: contentType,
|
||||
ByteSize: len(decoded),
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// Security: Increments TokenVersion to invalidate all existing JWT tokens
|
||||
func (s *UserService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error {
|
||||
@@ -202,9 +337,25 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("get user avatar: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) hydrateUserAvatar(ctx context.Context, user *User) error {
|
||||
if s == nil || s.userRepo == nil || user == nil || user.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
avatar, err := s.userRepo.GetUserAvatar(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserAvatar(user, avatar)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取用户列表(管理员功能)
|
||||
func (s *UserService) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
users, pagination, err := s.userRepo.List(ctx, params)
|
||||
|
||||
@@ -4,6 +4,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -19,14 +22,65 @@ import (
|
||||
type mockUserRepo struct {
|
||||
updateBalanceErr error
|
||||
updateBalanceFn func(ctx context.Context, id int64, amount float64) error
|
||||
getByIDUser *User
|
||||
getByIDErr error
|
||||
updateFn func(ctx context.Context, user *User) error
|
||||
updateCalls int
|
||||
upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
|
||||
upsertAvatarArgs []UpsertUserAvatarInput
|
||||
deleteAvatarFn func(ctx context.Context, userID int64) error
|
||||
deleteAvatarIDs []int64
|
||||
getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
||||
func (m *mockUserRepo) GetByID(context.Context, int64) (*User, error) { return &User{}, nil }
|
||||
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
||||
func (m *mockUserRepo) GetByID(context.Context, int64) (*User, error) {
|
||||
if m.getByIDErr != nil {
|
||||
return nil, m.getByIDErr
|
||||
}
|
||||
if m.getByIDUser != nil {
|
||||
cloned := *m.getByIDUser
|
||||
return &cloned, nil
|
||||
}
|
||||
return &User{}, nil
|
||||
}
|
||||
func (m *mockUserRepo) GetByEmail(context.Context, string) (*User, error) { return &User{}, nil }
|
||||
func (m *mockUserRepo) GetFirstAdmin(context.Context) (*User, error) { return &User{}, nil }
|
||||
func (m *mockUserRepo) Update(context.Context, *User) error { return nil }
|
||||
func (m *mockUserRepo) Delete(context.Context, int64) error { return nil }
|
||||
func (m *mockUserRepo) Update(ctx context.Context, user *User) error {
|
||||
m.updateCalls++
|
||||
if m.updateFn != nil {
|
||||
return m.updateFn(ctx, user)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockUserRepo) Delete(context.Context, int64) error { return nil }
|
||||
func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) {
|
||||
if m.getAvatarFn != nil {
|
||||
return m.getAvatarFn(ctx, userID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
|
||||
m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
|
||||
if m.upsertAvatarFn != nil {
|
||||
return m.upsertAvatarFn(ctx, userID, input)
|
||||
}
|
||||
return &UserAvatar{
|
||||
StorageProvider: input.StorageProvider,
|
||||
StorageKey: input.StorageKey,
|
||||
URL: input.URL,
|
||||
ContentType: input.ContentType,
|
||||
ByteSize: input.ByteSize,
|
||||
SHA256: input.SHA256,
|
||||
}, nil
|
||||
}
|
||||
func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||
m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
|
||||
if m.deleteAvatarFn != nil {
|
||||
return m.deleteAvatarFn(ctx, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockUserRepo) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
@@ -200,3 +254,121 @@ func TestNewUserService_FieldsAssignment(t *testing.T) {
|
||||
require.Equal(t, auth, svc.authCacheInvalidator)
|
||||
require.Equal(t, cache, svc.billingCache)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_StoresInlineAvatarWithinLimit(t *testing.T) {
|
||||
raw := []byte("small-avatar")
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
expectedSum := sha256.Sum256(raw)
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 7,
|
||||
Email: "avatar@example.com",
|
||||
Username: "avatar-user",
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
updated, err := svc.UpdateProfile(context.Background(), 7, UpdateProfileRequest{
|
||||
AvatarURL: &dataURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.upsertAvatarArgs, 1)
|
||||
require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
|
||||
require.Equal(t, "image/png", repo.upsertAvatarArgs[0].ContentType)
|
||||
require.Equal(t, len(raw), repo.upsertAvatarArgs[0].ByteSize)
|
||||
require.Equal(t, hex.EncodeToString(expectedSum[:]), repo.upsertAvatarArgs[0].SHA256)
|
||||
require.Equal(t, dataURL, updated.AvatarURL)
|
||||
require.Equal(t, "inline", updated.AvatarSource)
|
||||
require.Equal(t, "image/png", updated.AvatarMIME)
|
||||
require.Equal(t, len(raw), updated.AvatarByteSize)
|
||||
require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) {
|
||||
raw := make([]byte, maxInlineAvatarBytes+1)
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 8,
|
||||
Email: "large-avatar@example.com",
|
||||
Username: "too-large",
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), 8, UpdateProfileRequest{
|
||||
AvatarURL: &dataURL,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrAvatarTooLarge)
|
||||
require.Empty(t, repo.upsertAvatarArgs)
|
||||
require.Empty(t, repo.deleteAvatarIDs)
|
||||
require.Zero(t, repo.updateCalls)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_StoresRemoteAvatarURL(t *testing.T) {
|
||||
remoteURL := "https://cdn.example.com/avatar.png"
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 9,
|
||||
Email: "remote-avatar@example.com",
|
||||
Username: "remote-avatar",
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
updated, err := svc.UpdateProfile(context.Background(), 9, UpdateProfileRequest{
|
||||
AvatarURL: &remoteURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.upsertAvatarArgs, 1)
|
||||
require.Equal(t, "remote_url", repo.upsertAvatarArgs[0].StorageProvider)
|
||||
require.Equal(t, remoteURL, repo.upsertAvatarArgs[0].URL)
|
||||
require.Equal(t, remoteURL, updated.AvatarURL)
|
||||
require.Equal(t, "remote_url", updated.AvatarSource)
|
||||
require.Zero(t, updated.AvatarByteSize)
|
||||
}
|
||||
|
||||
func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
|
||||
empty := ""
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 10,
|
||||
Email: "delete-avatar@example.com",
|
||||
Username: "delete-avatar",
|
||||
AvatarURL: "https://cdn.example.com/old.png",
|
||||
AvatarSource: "remote_url",
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
updated, err := svc.UpdateProfile(context.Background(), 10, UpdateProfileRequest{
|
||||
AvatarURL: &empty,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{10}, repo.deleteAvatarIDs)
|
||||
require.Empty(t, repo.upsertAvatarArgs)
|
||||
require.Empty(t, updated.AvatarURL)
|
||||
require.Empty(t, updated.AvatarSource)
|
||||
}
|
||||
|
||||
func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
getByIDUser: &User{
|
||||
ID: 12,
|
||||
Email: "profile-avatar@example.com",
|
||||
Username: "profile-avatar",
|
||||
},
|
||||
getAvatarFn: func(context.Context, int64) (*UserAvatar, error) {
|
||||
return &UserAvatar{
|
||||
StorageProvider: "remote_url",
|
||||
URL: "https://cdn.example.com/profile.png",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewUserService(repo, nil, nil, nil)
|
||||
|
||||
user, err := svc.GetProfile(context.Background(), 12)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://cdn.example.com/profile.png", user.AvatarURL)
|
||||
require.Equal(t, "remote_url", user.AvatarSource)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user