fix(upgrade): preserve legacy auth and payment compatibility
This commit is contained in:
@@ -304,8 +304,8 @@ type UpdateSettingsRequest struct {
|
|||||||
OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"`
|
OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"`
|
||||||
OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"`
|
OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"`
|
||||||
OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"`
|
OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"`
|
||||||
OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"`
|
OIDCConnectUsePKCE *bool `json:"oidc_connect_use_pkce"`
|
||||||
OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"`
|
OIDCConnectValidateIDToken *bool `json:"oidc_connect_validate_id_token"`
|
||||||
OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"`
|
OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"`
|
||||||
OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"`
|
OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"`
|
||||||
OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"`
|
OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"`
|
||||||
@@ -682,6 +682,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generic OIDC 参数验证
|
// Generic OIDC 参数验证
|
||||||
|
oidcUsePKCE := previousSettings.OIDCConnectUsePKCE
|
||||||
|
oidcValidateIDToken := previousSettings.OIDCConnectValidateIDToken
|
||||||
if req.OIDCConnectEnabled {
|
if req.OIDCConnectEnabled {
|
||||||
req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName)
|
req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName)
|
||||||
req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID)
|
req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID)
|
||||||
@@ -716,11 +718,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath))
|
req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath))
|
||||||
req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath))
|
req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath))
|
||||||
req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath))
|
req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath))
|
||||||
if !req.OIDCConnectUsePKCE {
|
if req.OIDCConnectUsePKCE != nil {
|
||||||
req.OIDCConnectUsePKCE = previousSettings.OIDCConnectUsePKCE
|
oidcUsePKCE = *req.OIDCConnectUsePKCE
|
||||||
}
|
}
|
||||||
if !req.OIDCConnectValidateIDToken {
|
if req.OIDCConnectValidateIDToken != nil {
|
||||||
req.OIDCConnectValidateIDToken = previousSettings.OIDCConnectValidateIDToken
|
oidcValidateIDToken = *req.OIDCConnectValidateIDToken
|
||||||
}
|
}
|
||||||
if req.OIDCConnectClockSkewSeconds == 0 {
|
if req.OIDCConnectClockSkewSeconds == 0 {
|
||||||
req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds
|
req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds
|
||||||
@@ -795,7 +797,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.OIDCConnectValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" {
|
if oidcValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" {
|
||||||
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1076,8 +1078,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
OIDCConnectRedirectURL: req.OIDCConnectRedirectURL,
|
OIDCConnectRedirectURL: req.OIDCConnectRedirectURL,
|
||||||
OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL,
|
OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL,
|
||||||
OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod,
|
OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod,
|
||||||
OIDCConnectUsePKCE: req.OIDCConnectUsePKCE,
|
OIDCConnectUsePKCE: oidcUsePKCE,
|
||||||
OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken,
|
OIDCConnectValidateIDToken: oidcValidateIDToken,
|
||||||
OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs,
|
OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs,
|
||||||
OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds,
|
OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds,
|
||||||
OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified,
|
OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified,
|
||||||
|
|||||||
@@ -247,6 +247,94 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
|||||||
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
|
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
repo := &settingHandlerRepoStub{
|
||||||
|
values: map[string]string{
|
||||||
|
service.SettingKeyPromoCodeEnabled: "true",
|
||||||
|
service.SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||||
|
service.SettingPaymentVisibleMethodAlipaySource: "",
|
||||||
|
service.SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||||
|
service.SettingPaymentVisibleMethodWxpaySource: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||||
|
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"promo_code_enabled": false,
|
||||||
|
}
|
||||||
|
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, "", repo.values[service.SettingPaymentVisibleMethodAlipaySource])
|
||||||
|
require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingHandler_UpdateSettings_PersistsExplicitFalseOIDCCompatibilityFlags(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
repo := &settingHandlerRepoStub{
|
||||||
|
values: map[string]string{
|
||||||
|
service.SettingKeyPromoCodeEnabled: "true",
|
||||||
|
service.SettingKeyOIDCConnectEnabled: "true",
|
||||||
|
service.SettingKeyOIDCConnectProviderName: "OIDC",
|
||||||
|
service.SettingKeyOIDCConnectClientID: "oidc-client",
|
||||||
|
service.SettingKeyOIDCConnectClientSecret: "oidc-secret",
|
||||||
|
service.SettingKeyOIDCConnectIssuerURL: "https://issuer.example.com",
|
||||||
|
service.SettingKeyOIDCConnectAuthorizeURL: "https://issuer.example.com/auth",
|
||||||
|
service.SettingKeyOIDCConnectTokenURL: "https://issuer.example.com/token",
|
||||||
|
service.SettingKeyOIDCConnectUserInfoURL: "https://issuer.example.com/userinfo",
|
||||||
|
service.SettingKeyOIDCConnectJWKSURL: "https://issuer.example.com/jwks",
|
||||||
|
service.SettingKeyOIDCConnectScopes: "openid email profile",
|
||||||
|
service.SettingKeyOIDCConnectRedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback",
|
||||||
|
service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
||||||
|
service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
||||||
|
service.SettingKeyOIDCConnectUsePKCE: "true",
|
||||||
|
service.SettingKeyOIDCConnectValidateIDToken: "true",
|
||||||
|
service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256",
|
||||||
|
service.SettingKeyOIDCConnectClockSkewSeconds: "120",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||||
|
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"promo_code_enabled": true,
|
||||||
|
"oidc_connect_enabled": true,
|
||||||
|
"oidc_connect_use_pkce": false,
|
||||||
|
"oidc_connect_validate_id_token": false,
|
||||||
|
"oidc_connect_allowed_signing_algs": "",
|
||||||
|
}
|
||||||
|
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, "false", repo.values[service.SettingKeyOIDCConnectUsePKCE])
|
||||||
|
require.Equal(t, "false", repo.values[service.SettingKeyOIDCConnectValidateIDToken])
|
||||||
|
|
||||||
|
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, false, data["oidc_connect_use_pkce"])
|
||||||
|
require.Equal(t, false, data["oidc_connect_validate_id_token"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(t *testing.T) {
|
func TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
repo := &settingHandlerRepoStub{
|
repo := &settingHandlerRepoStub{
|
||||||
|
|||||||
@@ -62,10 +62,13 @@ type migrationChecksumCompatibilityRule struct {
|
|||||||
// 规则必须同时匹配「迁移名 + 数据库 checksum + 当前文件 checksum」且两者都落在该迁移的已知版本集合内才会放行,
|
// 规则必须同时匹配「迁移名 + 数据库 checksum + 当前文件 checksum」且两者都落在该迁移的已知版本集合内才会放行,
|
||||||
// 避免放宽全局校验,也允许将误改的历史 migration 回滚为已发布版本而不要求人工修 checksum。
|
// 避免放宽全局校验,也允许将误改的历史 migration 回滚为已发布版本而不要求人工修 checksum。
|
||||||
var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibilityRule{
|
var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibilityRule{
|
||||||
"054_drop_legacy_cache_columns.sql": newMigrationChecksumCompatibilityRule("82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d", "182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4"),
|
"054_drop_legacy_cache_columns.sql": newMigrationChecksumCompatibilityRule("82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d", "182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4"),
|
||||||
"061_add_usage_log_request_type.sql": newMigrationChecksumCompatibilityRule("66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c", "08a248652cbab7cfde147fc6ef8cda464f2477674e20b718312faa252e0481c0", "222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3"),
|
"061_add_usage_log_request_type.sql": newMigrationChecksumCompatibilityRule("66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c", "08a248652cbab7cfde147fc6ef8cda464f2477674e20b718312faa252e0481c0", "222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3"),
|
||||||
"109_auth_identity_compat_backfill.sql": newMigrationChecksumCompatibilityRule("2b380305e73ff0c13aa8c811e45897f2b36ca4a438f7b3e8f98e19ecb6bae0b3", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee"),
|
"109_auth_identity_compat_backfill.sql": newMigrationChecksumCompatibilityRule("2b380305e73ff0c13aa8c811e45897f2b36ca4a438f7b3e8f98e19ecb6bae0b3", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee"),
|
||||||
"119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"),
|
"118_wechat_dual_mode_and_auth_source_defaults.sql": newMigrationChecksumCompatibilityRule("b54194d7a3e4fbf710e0a3590d22a2fe7966804c487052a356e0b55f53ef96b0", "e0cdf835d6c688d64100f483d31bc02ac9ebad414bf1837af239a84bf75b8227"),
|
||||||
|
"119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"),
|
||||||
|
"120_enforce_payment_orders_out_trade_no_unique_notx.sql": newMigrationChecksumCompatibilityRule("707431450603e70a43ce9fbd61e0c12fa67da4875158ccefabacea069587ab22", "04b082b5a239c525154fe9185d324ee2b05ff90da9297e10dba19f9be79aa59a"),
|
||||||
|
"123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。
|
// ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) {
|
|||||||
require.True(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum))
|
require.True(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrationChecksumCompatibilityRules_CoverEditedUpgradeCompatibilityMigrations(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"118_wechat_dual_mode_and_auth_source_defaults.sql",
|
||||||
|
"120_enforce_payment_orders_out_trade_no_unique_notx.sql",
|
||||||
|
"123_fix_legacy_auth_source_grant_on_signup_defaults.sql",
|
||||||
|
} {
|
||||||
|
rule, ok := migrationChecksumCompatibilityRules[name]
|
||||||
|
require.Truef(t, ok, "missing compatibility rule for %s", name)
|
||||||
|
require.NotEmpty(t, rule.fileChecksum)
|
||||||
|
require.NotEmpty(t, rule.acceptedDBChecksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureAtlasBaselineAligned(t *testing.T) {
|
func TestEnsureAtlasBaselineAligned(t *testing.T) {
|
||||||
t.Run("skip_when_no_legacy_table", func(t *testing.T) {
|
t.Run("skip_when_no_legacy_table", func(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
|
|||||||
@@ -45,10 +45,18 @@ func (s *PaymentConfigService) pcApplyEnabledVisibleMethodInstances(ctx context.
|
|||||||
for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
|
for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
|
||||||
matching := filterEnabledVisibleMethodInstances(instances, method)
|
matching := filterEnabledVisibleMethodInstances(instances, method)
|
||||||
providerKey, err := s.resolveVisibleMethodProviderKey(ctx, method, matching)
|
providerKey, err := s.resolveVisibleMethodProviderKey(ctx, method, matching)
|
||||||
if err != nil || providerKey == "" {
|
if err != nil {
|
||||||
delete(filtered, method)
|
delete(filtered, method)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if providerKey == "" {
|
||||||
|
if len(matching) == 0 {
|
||||||
|
delete(filtered, method)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[method] = matching
|
||||||
|
continue
|
||||||
|
}
|
||||||
selectedInstances := filterVisibleMethodInstancesByProviderKey(instances, method, providerKey)
|
selectedInstances := filterVisibleMethodInstancesByProviderKey(instances, method, providerKey)
|
||||||
if len(selectedInstances) == 0 {
|
if len(selectedInstances) == 0 {
|
||||||
delete(filtered, method)
|
delete(filtered, method)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUnionFloat(t *testing.T) {
|
func TestUnionFloat(t *testing.T) {
|
||||||
@@ -402,3 +403,59 @@ func TestGetAvailableMethodLimitsUsesConfiguredVisibleMethodSource(t *testing.T)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAvailableMethodLimitsPreservesLegacyCrossProviderBehaviorWhenVisibleMethodSourceMissing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newPaymentConfigServiceTestClient(t)
|
||||||
|
|
||||||
|
_, err := client.PaymentProviderInstance.Create().
|
||||||
|
SetProviderKey(payment.TypeAlipay).
|
||||||
|
SetName("Official Alipay").
|
||||||
|
SetConfig("{}").
|
||||||
|
SetSupportedTypes("alipay").
|
||||||
|
SetLimits(`{"alipay":{"singleMin":10,"singleMax":100}}`).
|
||||||
|
SetEnabled(true).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.PaymentProviderInstance.Create().
|
||||||
|
SetProviderKey(payment.TypeEasyPay).
|
||||||
|
SetName("EasyPay Mixed").
|
||||||
|
SetConfig("{}").
|
||||||
|
SetSupportedTypes("alipay,wxpay").
|
||||||
|
SetLimits(`{"alipay":{"singleMin":20,"singleMax":200},"wxpay":{"singleMin":40,"singleMax":400}}`).
|
||||||
|
SetEnabled(true).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.PaymentProviderInstance.Create().
|
||||||
|
SetProviderKey(payment.TypeWxpay).
|
||||||
|
SetName("Official WeChat").
|
||||||
|
SetConfig("{}").
|
||||||
|
SetSupportedTypes("wxpay").
|
||||||
|
SetLimits(`{"wxpay":{"singleMin":30,"singleMax":300}}`).
|
||||||
|
SetEnabled(true).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc := &PaymentConfigService{
|
||||||
|
entClient: client,
|
||||||
|
settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.GetAvailableMethodLimits(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
alipayLimits, ok := resp.Methods[payment.TypeAlipay]
|
||||||
|
require.True(t, ok, "expected alipay limits to remain visible")
|
||||||
|
require.Equal(t, 10.0, alipayLimits.SingleMin)
|
||||||
|
require.Equal(t, 200.0, alipayLimits.SingleMax)
|
||||||
|
|
||||||
|
wxpayLimits, ok := resp.Methods[payment.TypeWxpay]
|
||||||
|
require.True(t, ok, "expected wxpay limits to remain visible")
|
||||||
|
require.Equal(t, 30.0, wxpayLimits.SingleMin)
|
||||||
|
require.Equal(t, 400.0, wxpayLimits.SingleMax)
|
||||||
|
|
||||||
|
require.Equal(t, 10.0, resp.GlobalMin)
|
||||||
|
require.Equal(t, 400.0, resp.GlobalMax)
|
||||||
|
}
|
||||||
|
|||||||
@@ -586,7 +586,60 @@ func TestVisibleMethodLoadBalancerUsesConfiguredSourceWhenMultipleProvidersEnabl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVisibleMethodLoadBalancerRejectsMissingOrInvalidSourceWhenMultipleProvidersEnabled(t *testing.T) {
|
func TestVisibleMethodLoadBalancerPreservesLegacyCrossProviderRoutingWhenSourceMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newPaymentConfigServiceTestClient(t)
|
||||||
|
|
||||||
|
_, err := client.PaymentProviderInstance.Create().
|
||||||
|
SetProviderKey(payment.TypeAlipay).
|
||||||
|
SetName("Official Alipay").
|
||||||
|
SetConfig("{}").
|
||||||
|
SetSupportedTypes("alipay").
|
||||||
|
SetEnabled(true).
|
||||||
|
SetSortOrder(1).
|
||||||
|
Save(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create official provider: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.PaymentProviderInstance.Create().
|
||||||
|
SetProviderKey(payment.TypeEasyPay).
|
||||||
|
SetName("EasyPay Alipay").
|
||||||
|
SetConfig("{}").
|
||||||
|
SetSupportedTypes("alipay").
|
||||||
|
SetEnabled(true).
|
||||||
|
SetSortOrder(2).
|
||||||
|
Save(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create easypay provider: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := &captureLoadBalancer{}
|
||||||
|
configService := &PaymentConfigService{
|
||||||
|
entClient: client,
|
||||||
|
settingRepo: &paymentConfigSettingRepoStub{
|
||||||
|
values: map[string]string{
|
||||||
|
visibleMethodSourceSettingKey(payment.TypeAlipay): "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lb := newVisibleMethodLoadBalancer(inner, configService)
|
||||||
|
|
||||||
|
_, err = lb.SelectInstance(ctx, "", payment.TypeAlipay, payment.StrategyRoundRobin, 9.9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectInstance returned error: %v", err)
|
||||||
|
}
|
||||||
|
if inner.lastProviderKey != "" {
|
||||||
|
t.Fatalf("lastProviderKey = %q, want legacy cross-provider empty key", inner.lastProviderKey)
|
||||||
|
}
|
||||||
|
if inner.lastPaymentType != payment.TypeAlipay {
|
||||||
|
t.Fatalf("lastPaymentType = %q, want %q", inner.lastPaymentType, payment.TypeAlipay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleMethodLoadBalancerRejectsInvalidSourceWhenMultipleProvidersEnabled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -595,12 +648,6 @@ func TestVisibleMethodLoadBalancerRejectsMissingOrInvalidSourceWhenMultipleProvi
|
|||||||
sourceValue string
|
sourceValue string
|
||||||
wantMessage string
|
wantMessage string
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
name: "missing alipay source",
|
|
||||||
method: payment.TypeAlipay,
|
|
||||||
sourceValue: "",
|
|
||||||
wantMessage: "alipay source is required when the visible method is enabled",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "invalid wxpay source",
|
name: "invalid wxpay source",
|
||||||
method: payment.TypeWxpay,
|
method: payment.TypeWxpay,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -166,15 +167,21 @@ func (s *PaymentConfigService) resolveVisibleMethodSourceProviderKey(ctx context
|
|||||||
if s != nil && s.settingRepo != nil && sourceKey != "" {
|
if s != nil && s.settingRepo != nil && sourceKey != "" {
|
||||||
value, err := s.settingRepo.GetValue(ctx, sourceKey)
|
value, err := s.settingRepo.GetValue(ctx, sourceKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("get %s: %w", sourceKey, err)
|
if !errors.Is(err, ErrSettingNotFound) {
|
||||||
|
return "", fmt.Errorf("get %s: %w", sourceKey, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawSource = value
|
||||||
}
|
}
|
||||||
rawSource = value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedSource, err := normalizeVisibleMethodSettingSource(method, rawSource, true)
|
normalizedSource, err := normalizeVisibleMethodSettingSource(method, rawSource, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if normalizedSource == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
providerKey, ok := VisibleMethodProviderKeyForSource(method, normalizedSource)
|
providerKey, ok := VisibleMethodProviderKeyForSource(method, normalizedSource)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", infraerrors.BadRequest(
|
return "", infraerrors.BadRequest(
|
||||||
@@ -200,6 +207,9 @@ func (s *PaymentConfigService) resolveVisibleMethodProviderKey(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if providerKey == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
selected := selectVisibleMethodInstanceByProviderKey(matching, providerKey)
|
selected := selectVisibleMethodInstanceByProviderKey(matching, providerKey)
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
return "", infraerrors.BadRequest(
|
return "", infraerrors.BadRequest(
|
||||||
@@ -237,5 +247,11 @@ func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if providerKey == "" {
|
||||||
|
if len(matching) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &dbent.PaymentProviderInstance{ProviderKey: ""}, nil
|
||||||
|
}
|
||||||
return selectVisibleMethodInstanceByProviderKey(matching, providerKey), nil
|
return selectVisibleMethodInstanceByProviderKey(matching, providerKey), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,19 @@ func mergeWeChatConnectCapabilitySettings(settings map[string]string, base confi
|
|||||||
mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
|
mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
|
||||||
|
|
||||||
if openConfigured || mpConfigured || mobileConfigured {
|
if openConfigured || mpConfigured || mobileConfigured {
|
||||||
return parseWeChatConnectCapabilitySettings(settings, enabled, mode)
|
openEnabled := strings.TrimSpace(rawOpen) == "true"
|
||||||
|
mpEnabled := strings.TrimSpace(rawMP) == "true"
|
||||||
|
mobileEnabled := strings.TrimSpace(rawMobile) == "true"
|
||||||
|
_, enabledConfigured := settings[SettingKeyWeChatConnectEnabled]
|
||||||
|
if !enabledConfigured &&
|
||||||
|
enabled &&
|
||||||
|
!openEnabled &&
|
||||||
|
!mpEnabled &&
|
||||||
|
!mobileEnabled &&
|
||||||
|
(base.OpenEnabled || base.MPEnabled || base.MobileEnabled) {
|
||||||
|
return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
|
||||||
|
}
|
||||||
|
return openEnabled, mpEnabled, mobileEnabled
|
||||||
}
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return false, false, false
|
return false, false, false
|
||||||
@@ -1921,14 +1933,9 @@ func isFalseSettingValue(value string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) {
|
func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) {
|
||||||
|
_ = enabled
|
||||||
source = strings.TrimSpace(source)
|
source = strings.TrimSpace(source)
|
||||||
if source == "" {
|
if source == "" {
|
||||||
if enabled {
|
|
||||||
return "", infraerrors.BadRequest(
|
|
||||||
"INVALID_PAYMENT_VISIBLE_METHOD_SOURCE",
|
|
||||||
fmt.Sprintf("%s source is required when the visible method is enabled", method),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,36 @@ func TestSettingService_GetWeChatConnectOAuthConfig_FallsBackToConfigWhenDatabas
|
|||||||
require.Empty(t, got.RedirectURL)
|
require.Empty(t, got.RedirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSettingService_GetWeChatConnectOAuthConfig_IgnoresSyntheticDisabledCapabilitiesFromMigration118(t *testing.T) {
|
||||||
|
repo := &settingWeChatRepoStub{
|
||||||
|
values: map[string]string{
|
||||||
|
SettingKeyWeChatConnectOpenEnabled: "false",
|
||||||
|
SettingKeyWeChatConnectMPEnabled: "false",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSettingService(repo, &config.Config{
|
||||||
|
WeChat: config.WeChatConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
OpenEnabled: true,
|
||||||
|
MPEnabled: true,
|
||||||
|
Mode: "open",
|
||||||
|
OpenAppID: "wx-open-config",
|
||||||
|
OpenAppSecret: "wx-open-secret",
|
||||||
|
MPAppID: "wx-mp-config",
|
||||||
|
MPAppSecret: "wx-mp-secret",
|
||||||
|
FrontendRedirectURL: "/auth/wechat/config-callback",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := svc.GetWeChatConnectOAuthConfig(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got.Enabled)
|
||||||
|
require.True(t, got.OpenEnabled)
|
||||||
|
require.True(t, got.MPEnabled)
|
||||||
|
require.Equal(t, "wx-open-config", got.AppIDForMode("open"))
|
||||||
|
require.Equal(t, "wx-mp-config", got.AppIDForMode("mp"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSettingService_ParseSettings_FallsBackToConfigForWeChatAdminView(t *testing.T) {
|
func TestSettingService_ParseSettings_FallsBackToConfigForWeChatAdminView(t *testing.T) {
|
||||||
svc := NewSettingService(&settingWeChatRepoStub{values: map[string]string{}}, &config.Config{
|
svc := NewSettingService(&settingWeChatRepoStub{values: map[string]string{}}, &config.Config{
|
||||||
WeChat: config.WeChatConnectConfig{
|
WeChat: config.WeChatConnectConfig{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ VALUES
|
|||||||
(
|
(
|
||||||
'wechat_connect_open_enabled',
|
'wechat_connect_open_enabled',
|
||||||
CASE
|
CASE
|
||||||
|
WHEN NOT EXISTS (SELECT 1 FROM settings WHERE key = 'wechat_connect_enabled') THEN ''
|
||||||
WHEN COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_enabled'), 'false') <> 'true' THEN 'false'
|
WHEN COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_enabled'), 'false') <> 'true' THEN 'false'
|
||||||
WHEN LOWER(TRIM(COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_mode'), 'open'))) = 'mp' THEN 'false'
|
WHEN LOWER(TRIM(COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_mode'), 'open'))) = 'mp' THEN 'false'
|
||||||
ELSE 'true'
|
ELSE 'true'
|
||||||
@@ -11,6 +12,7 @@ VALUES
|
|||||||
(
|
(
|
||||||
'wechat_connect_mp_enabled',
|
'wechat_connect_mp_enabled',
|
||||||
CASE
|
CASE
|
||||||
|
WHEN NOT EXISTS (SELECT 1 FROM settings WHERE key = 'wechat_connect_enabled') THEN ''
|
||||||
WHEN COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_enabled'), 'false') <> 'true' THEN 'false'
|
WHEN COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_enabled'), 'false') <> 'true' THEN 'false'
|
||||||
WHEN LOWER(TRIM(COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_mode'), 'open'))) = 'mp' THEN 'true'
|
WHEN LOWER(TRIM(COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_mode'), 'open'))) = 'mp' THEN 'true'
|
||||||
ELSE 'false'
|
ELSE 'false'
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
-- Build the payment order uniqueness guarantee online.
|
-- Build the payment order uniqueness guarantee online.
|
||||||
-- Create the new partial unique index concurrently first so writes keep flowing,
|
-- Create the new partial unique index concurrently first so writes keep flowing,
|
||||||
-- then remove the legacy index name once the replacement is ready.
|
-- then remove the legacy index name once the replacement is ready.
|
||||||
DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique
|
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique
|
||||||
ON payment_orders (out_trade_no)
|
ON payment_orders (out_trade_no)
|
||||||
WHERE out_trade_no <> '';
|
WHERE out_trade_no <> '';
|
||||||
|
|||||||
@@ -1,39 +1,3 @@
|
|||||||
WITH migration_110 AS (
|
-- Intentionally left as a no-op.
|
||||||
SELECT applied_at
|
-- Legacy installs may have intentionally kept the original signup grant defaults,
|
||||||
FROM schema_migrations
|
-- and we cannot distinguish those cases safely from untouched migration 110 rows.
|
||||||
WHERE filename = '110_pending_auth_and_provider_default_grants.sql'
|
|
||||||
),
|
|
||||||
legacy_provider_defaults AS (
|
|
||||||
SELECT provider_type
|
|
||||||
FROM (
|
|
||||||
VALUES ('email'), ('linuxdo'), ('oidc'), ('wechat')
|
|
||||||
) AS providers(provider_type)
|
|
||||||
CROSS JOIN migration_110
|
|
||||||
JOIN settings balance
|
|
||||||
ON balance.key = 'auth_source_default_' || providers.provider_type || '_balance'
|
|
||||||
JOIN settings concurrency
|
|
||||||
ON concurrency.key = 'auth_source_default_' || providers.provider_type || '_concurrency'
|
|
||||||
JOIN settings subscriptions
|
|
||||||
ON subscriptions.key = 'auth_source_default_' || providers.provider_type || '_subscriptions'
|
|
||||||
JOIN settings grant_on_signup
|
|
||||||
ON grant_on_signup.key = 'auth_source_default_' || providers.provider_type || '_grant_on_signup'
|
|
||||||
JOIN settings grant_on_first_bind
|
|
||||||
ON grant_on_first_bind.key = 'auth_source_default_' || providers.provider_type || '_grant_on_first_bind'
|
|
||||||
WHERE balance.value = '0'
|
|
||||||
AND concurrency.value = '5'
|
|
||||||
AND subscriptions.value = '[]'
|
|
||||||
AND grant_on_signup.value = 'true'
|
|
||||||
AND grant_on_first_bind.value = 'false'
|
|
||||||
AND balance.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute'
|
|
||||||
AND concurrency.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute'
|
|
||||||
AND subscriptions.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute'
|
|
||||||
AND grant_on_signup.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute'
|
|
||||||
AND grant_on_first_bind.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute'
|
|
||||||
)
|
|
||||||
UPDATE settings
|
|
||||||
SET
|
|
||||||
value = 'false',
|
|
||||||
updated_at = NOW()
|
|
||||||
FROM legacy_provider_defaults
|
|
||||||
WHERE settings.key = 'auth_source_default_' || legacy_provider_defaults.provider_type || '_grant_on_signup'
|
|
||||||
AND settings.value = 'true';
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults(t *testing.T)
|
|||||||
require.NotContains(t, sql, "UPDATE settings")
|
require.NotContains(t, sql, "UPDATE settings")
|
||||||
require.NotContains(t, sql, "SET value = 'false'")
|
require.NotContains(t, sql, "SET value = 'false'")
|
||||||
require.True(t, strings.Contains(sql, "ON CONFLICT (key) DO NOTHING"))
|
require.True(t, strings.Contains(sql, "ON CONFLICT (key) DO NOTHING"))
|
||||||
|
require.Contains(t, sql, "THEN ''")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthIdentityReportTypeWideningRunsBeforeLongReportWritersAndStillReconcilesAt121(t *testing.T) {
|
func TestAuthIdentityReportTypeWideningRunsBeforeLongReportWritersAndStillReconcilesAt121(t *testing.T) {
|
||||||
@@ -63,6 +64,7 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) {
|
|||||||
|
|
||||||
followupSQL := string(followupContent)
|
followupSQL := string(followupContent)
|
||||||
require.Contains(t, followupSQL, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique")
|
require.Contains(t, followupSQL, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique")
|
||||||
|
require.NotContains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique")
|
||||||
require.Contains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no")
|
require.Contains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no")
|
||||||
require.Contains(t, followupSQL, "WHERE out_trade_no <> ''")
|
require.Contains(t, followupSQL, "WHERE out_trade_no <> ''")
|
||||||
|
|
||||||
@@ -92,9 +94,7 @@ func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sql := string(content)
|
sql := string(content)
|
||||||
require.Contains(t, sql, "110_pending_auth_and_provider_default_grants.sql")
|
require.Contains(t, sql, "Intentionally left as a no-op")
|
||||||
require.Contains(t, sql, "schema_migrations")
|
require.NotContains(t, sql, "UPDATE settings")
|
||||||
require.Contains(t, sql, "updated_at")
|
require.NotContains(t, sql, "value = 'false'")
|
||||||
require.Contains(t, sql, "'_grant_on_signup'")
|
|
||||||
require.Contains(t, sql, "value = 'false'")
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user