Files
hfb_sys/backend/internal/modules/distribution/sms_resolver_test.go
T
yml2213 dc13b5dc82 feat(distribution): 支持分站独立短信验证码通道
- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain
  解析专属通道,未启用或解析失败时回退主站默认通道
- 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码
- provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据
- 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内)
- 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用
- 两套前端新增短信配置入口、必填与手机号校验、发送冷却
- 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
2026-09-16 14:06:01 +08:00

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)
}
}