- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain 解析专属通道,未启用或解析失败时回退主站默认通道 - 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码 - provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据 - 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内) - 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用 - 两套前端新增短信配置入口、必填与手机号校验、发送冷却 - 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package distribution
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/pkg/crypto"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func TestMaskSecret(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"", ""},
|
|
{"123", "******"},
|
|
{"123456", "******"},
|
|
{"1234567", "123******567"},
|
|
{"LTAI5tSecret123", "LTA******123"},
|
|
}
|
|
for _, c := range cases {
|
|
got := MaskSecret(c.input)
|
|
if got != c.want {
|
|
t.Errorf("MaskSecret(%q) = %q, want %q", c.input, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildProvider(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
|
|
// 1. mock provider
|
|
mockCfg := SiteSMSConfig{
|
|
Enabled: true,
|
|
Provider: "mock",
|
|
}
|
|
provider, err := BuildProvider(mockCfg, logger)
|
|
if err != nil || provider == nil {
|
|
t.Fatalf("expected mock provider, got err: %v, provider: %v", err, provider)
|
|
}
|
|
|
|
// 2. aliyun missing fields
|
|
invalidAliyunCfg := SiteSMSConfig{
|
|
Enabled: true,
|
|
Provider: "aliyun",
|
|
AccessKeyID: "id",
|
|
}
|
|
_, err = BuildProvider(invalidAliyunCfg, logger)
|
|
if err != ErrSMSConfigInvalid {
|
|
t.Fatalf("expected ErrSMSConfigInvalid, got: %v", err)
|
|
}
|
|
|
|
// 3. disabled returns nil
|
|
disabledCfg := SiteSMSConfig{
|
|
Enabled: false,
|
|
Provider: "mock",
|
|
}
|
|
p, err := BuildProvider(disabledCfg, logger)
|
|
if err != nil || p != nil {
|
|
t.Fatalf("expected nil provider for disabled, got %v, %v", p, err)
|
|
}
|
|
}
|
|
|
|
func TestSMSResolver_ResolveAndInvalidate(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db, nil)
|
|
resolver := NewSMSResolver(repo, zap.NewNop())
|
|
|
|
ctx := t.Context()
|
|
|
|
// 1. 站点未配置短信 -> 返回 nil (回退主站)
|
|
siteNoSMS := model.DistributionSite{
|
|
SiteCode: "site_no_sms",
|
|
SiteName: "无短信站",
|
|
Domain: "nosms.com",
|
|
Status: "active",
|
|
}
|
|
if err := db.Create(&siteNoSMS).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
p, err := resolver.ResolveSMSProvider(ctx, "site_no_sms", "")
|
|
if err != nil || p != nil {
|
|
t.Fatalf("expected nil provider for unconfigured site, got %v, %v", p, err)
|
|
}
|
|
|
|
// 2. 站点配置了 mock 自定义短信并启用
|
|
mockJSON := `{"enabled":true,"provider":"mock","sign_name":"【分站签名】","login_template_code":"SMS_001"}`
|
|
siteWithSMS := model.DistributionSite{
|
|
SiteCode: "site_with_sms",
|
|
SiteName: "独立短信站",
|
|
Domain: "customsms.com",
|
|
Status: "active",
|
|
SMSConfigJSON: mockJSON,
|
|
}
|
|
if err := db.Create(&siteWithSMS).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 通过 siteCode 解析
|
|
p1, err := resolver.ResolveSMSProvider(ctx, "site_with_sms", "")
|
|
if err != nil || p1 == nil {
|
|
t.Fatalf("expected resolved mock provider, got %v, err=%v", p1, err)
|
|
}
|
|
|
|
// 通过 domain 解析
|
|
p2, err := resolver.ResolveSMSProvider(ctx, "", "customsms.com")
|
|
if err != nil || p2 == nil {
|
|
t.Fatalf("expected resolved mock provider by domain, got %v, err=%v", p2, err)
|
|
}
|
|
|
|
// 验证缓存命中
|
|
if p1 != p2 {
|
|
t.Fatalf("expected same cached provider instance")
|
|
}
|
|
|
|
// 3. 清理缓存后重新解析
|
|
resolver.InvalidateCache(siteWithSMS.ID)
|
|
if len(resolver.providers) != 0 {
|
|
t.Fatalf("expected empty cache after InvalidateCache")
|
|
}
|
|
}
|
|
|
|
func TestRepository_GetAndUpdateSiteSMSConfig(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db, nil)
|
|
ctx := t.Context()
|
|
|
|
site := model.DistributionSite{
|
|
SiteCode: "site_cfg_test",
|
|
SiteName: "配置测试站",
|
|
Domain: "cfg.com",
|
|
Status: "active",
|
|
}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 1. 获取初始配置(空)
|
|
dto, err := repo.GetSiteSMSConfig(ctx, 0, site.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSiteSMSConfig failed: %v", err)
|
|
}
|
|
if dto.Enabled || dto.HasSecret {
|
|
t.Fatalf("expected default empty config, got %+v", dto)
|
|
}
|
|
|
|
// 2. 更新配置(包含密钥)
|
|
enabled := true
|
|
provider := "aliyun"
|
|
ak := "LTAI_TEST_KEY"
|
|
secret := "SECRET_TEST_998877"
|
|
sign := "【我的分站】"
|
|
template := "SMS_12345"
|
|
updated, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
Enabled: &enabled,
|
|
Provider: &provider,
|
|
AccessKeyID: &ak,
|
|
AccessKeySecret: &secret,
|
|
SignName: &sign,
|
|
LoginTemplateCode: &template,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSiteSMSConfig failed: %v", err)
|
|
}
|
|
if !updated.Enabled || !updated.HasSecret || updated.SignName != sign {
|
|
t.Fatalf("expected updated config, got %+v", updated)
|
|
}
|
|
if updated.MaskedSecret == secret {
|
|
t.Fatalf("secret should be masked in DTO")
|
|
}
|
|
|
|
// 3. 再次更新,不传 AccessKeySecret(留空),应保留原密钥
|
|
newSign := "【更新后的签名】"
|
|
emptySecret := ""
|
|
updated2, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
SignName: &newSign,
|
|
AccessKeySecret: &emptySecret,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSiteSMSConfig failed: %v", err)
|
|
}
|
|
if updated2.SignName != newSign {
|
|
t.Fatalf("SignName not updated: %s", updated2.SignName)
|
|
}
|
|
if !updated2.HasSecret {
|
|
t.Fatalf("existing secret should be preserved when updating with empty secret")
|
|
}
|
|
|
|
// 4. 验证从 SiteDTO 获取时不泄露 SMSConfig(已隔离)
|
|
foundSite, err := repo.FindSite(ctx, 0, site.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = foundSite
|
|
|
|
// 5. 验证内部解密读取方法能够解密原密钥
|
|
decryptedCfg, err := repo.GetDecryptedSiteSMSConfig(ctx, 0, site.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDecryptedSiteSMSConfig failed: %v", err)
|
|
}
|
|
if decryptedCfg.AccessKeySecret != secret {
|
|
t.Fatalf("expected decrypted secret %q, got %q", secret, decryptedCfg.AccessKeySecret)
|
|
}
|
|
|
|
// 6. 验证开启状态下缺少必填项时拦截报错
|
|
emptySign := ""
|
|
_, err = repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
SignName: &emptySign,
|
|
})
|
|
if !errors.Is(err, ErrSMSConfigInvalid) {
|
|
t.Fatalf("expected ErrSMSConfigInvalid when required field is empty, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSMSResolver_TestSendSMS(t *testing.T) {
|
|
resolver := NewSMSResolver(nil, zap.NewNop())
|
|
ctx := t.Context()
|
|
|
|
// 1. 空手机号报错
|
|
err := resolver.TestSendSMS(ctx, 1, SiteSMSConfig{}, "")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty phone")
|
|
}
|
|
|
|
// 2. mock provider 测试发送成功
|
|
err = resolver.TestSendSMS(ctx, 1, SiteSMSConfig{
|
|
Provider: "mock",
|
|
}, "13800138000")
|
|
if err != nil {
|
|
t.Fatalf("expected success for mock provider test send, got %v", err)
|
|
}
|
|
|
|
// 3. 60秒内同站点同手机号触发频控限制
|
|
err = resolver.TestSendSMS(ctx, 1, SiteSMSConfig{
|
|
Provider: "mock",
|
|
}, "13800138000")
|
|
if !errors.Is(err, ErrSMSTestRateLimited) {
|
|
t.Fatalf("expected ErrSMSTestRateLimited on immediate repeat test, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRepository_SMSConfigEncryptionAtRest(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db, nil)
|
|
|
|
// 使用 32 字节 AES 密钥构建 FieldEncryptor
|
|
encryptor, err := crypto.NewFieldEncryptor("0123456789abcdef0123456789abcdef")
|
|
if err != nil {
|
|
t.Fatalf("failed to create field encryptor: %v", err)
|
|
}
|
|
repo.SetEncryptor(encryptor)
|
|
ctx := t.Context()
|
|
|
|
site := model.DistributionSite{
|
|
SiteCode: "site_enc_test",
|
|
SiteName: "加密测试站",
|
|
Domain: "enctest.com",
|
|
Status: "active",
|
|
}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 1. 验证写入非法 provider 被拒绝
|
|
badProvider := "tencent"
|
|
_, err = repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
Provider: &badProvider,
|
|
})
|
|
if !errors.Is(err, ErrSMSConfigInvalid) {
|
|
t.Fatalf("expected ErrSMSConfigInvalid for unapproved provider, got %v", err)
|
|
}
|
|
|
|
// 2. 写入合法 Aliyun 配置
|
|
enabled := true
|
|
provider := "aliyun"
|
|
ak := "LTAI_PLAINTEXT_ID"
|
|
secret := "SUPER_SECRET_KEY_999888"
|
|
sign := "【测试加固】"
|
|
template := "SMS_666777"
|
|
updated, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
Enabled: &enabled,
|
|
Provider: &provider,
|
|
AccessKeyID: &ak,
|
|
AccessKeySecret: &secret,
|
|
SignName: &sign,
|
|
LoginTemplateCode: &template,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSiteSMSConfig failed: %v", err)
|
|
}
|
|
if !updated.HasSecret || updated.MaskedSecret == secret {
|
|
t.Fatalf("expected secret masked in DTO: %+v", updated)
|
|
}
|
|
|
|
// 3. 直接查底层 DB 原始数据,断言落库 JSON 绝不含明文 secret
|
|
var rawSite model.DistributionSite
|
|
if err := db.First(&rawSite, site.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(rawSite.SMSConfigJSON, secret) {
|
|
t.Fatalf("CRITICAL: raw database JSON contains plaintext secret! JSON=%s", rawSite.SMSConfigJSON)
|
|
}
|
|
|
|
// 4. 读取解密配置,断言可精准还原明文
|
|
decryptedCfg, err := repo.GetDecryptedSiteSMSConfig(ctx, 0, site.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDecryptedSiteSMSConfig failed: %v", err)
|
|
}
|
|
if decryptedCfg.AccessKeySecret != secret {
|
|
t.Fatalf("decrypted secret mismatch: got %q, want %q", decryptedCfg.AccessKeySecret, secret)
|
|
}
|
|
|
|
// 5. SMSResolver 能正确使用该站点 provider
|
|
resolver := NewSMSResolver(repo, zap.NewNop())
|
|
p, err := resolver.ResolveSMSProvider(ctx, "site_enc_test", "")
|
|
if err != nil || p == nil {
|
|
t.Fatalf("expected resolved aliyun provider, got %v, err=%v", p, err)
|
|
}
|
|
}
|
|
|
|
func TestRepository_SMSConfigDecryptFailure(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db, nil)
|
|
|
|
encryptor, err := crypto.NewFieldEncryptor("0123456789abcdef0123456789abcdef")
|
|
if err != nil {
|
|
t.Fatalf("failed to create field encryptor: %v", err)
|
|
}
|
|
repo.SetEncryptor(encryptor)
|
|
ctx := t.Context()
|
|
|
|
// 直接写入一段无法解密的密文,模拟密钥轮换/密文损坏
|
|
corrupted := `{"enabled":true,"provider":"mock","access_key_secret":"@@not-a-valid-cipher@@"}`
|
|
site := model.DistributionSite{
|
|
SiteCode: "site_bad_cipher",
|
|
SiteName: "损坏密文站",
|
|
Domain: "badcipher.com",
|
|
Status: "active",
|
|
SMSConfigJSON: corrupted,
|
|
}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 1. 管理侧读取应显式报错,而不是把密文当明文返回
|
|
if _, err := repo.GetDecryptedSiteSMSConfig(ctx, 0, site.ID); err == nil {
|
|
t.Fatal("expected decrypt error for corrupted ciphertext, got nil")
|
|
}
|
|
|
|
// 2. 登录链路应优雅降级:返回 nil provider(回退主站)而不是抛错
|
|
resolver := NewSMSResolver(repo, zap.NewNop())
|
|
p, err := resolver.ResolveSMSProvider(ctx, "site_bad_cipher", "")
|
|
if err != nil || p != nil {
|
|
t.Fatalf("expected graceful fallback to nil provider, got %v, err=%v", p, err)
|
|
}
|
|
}
|
|
|
|
func TestRepository_SMSProviderWhitelist(t *testing.T) {
|
|
db := distributionTestDB(t)
|
|
repo := NewRepository(db, nil) // 默认不允许 mock(生产语义)
|
|
ctx := t.Context()
|
|
|
|
site := model.DistributionSite{
|
|
SiteCode: "site_provider_wl",
|
|
SiteName: "服务商白名单站",
|
|
Domain: "providerwl.com",
|
|
Status: "active",
|
|
}
|
|
if err := db.Create(&site).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
enabled := true
|
|
mockProvider := "mock"
|
|
// 1. 默认环境下 mock 被拒绝
|
|
if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
Enabled: &enabled,
|
|
Provider: &mockProvider,
|
|
}); !errors.Is(err, ErrSMSConfigInvalid) {
|
|
t.Fatalf("expected ErrSMSConfigInvalid for mock in production semantics, got %v", err)
|
|
}
|
|
|
|
// 2. 非生产环境显式放行后 mock 可用
|
|
repo.SetAllowMockSMS(true)
|
|
if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
|
|
Enabled: &enabled,
|
|
Provider: &mockProvider,
|
|
}); err != nil {
|
|
t.Fatalf("expected mock provider to be accepted when allowed, got %v", err)
|
|
}
|
|
}
|