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