Files
xlgo-core/validation/validation_test.go
T
杭州明婳科技 6595e94677 fix: 多模型评审终审收口 — P0安全 + 主线A并发治理 + 文档对齐
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见
last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。
go build / vet / test -race ./... 全绿。

安全(P0):
- JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言
- JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm)
- 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size
- HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control)

并发/资源(主线A 闭合):
- 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage /
  DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争
- trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown
- ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron)
- ratelimit failClosed atomic.Bool;Recover Written() 守卫
- cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态)

终审剩余项收口(H-A~M-H + L 系列):
- 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns)
- JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用
- GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU
- config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady
- compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold
- 删 redisLimiters 死代码;CLI 输入校验 + 回滚

文档:
- README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明)
- docs/README 索引修正;config 注释修正(RS256 已不支持)

破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:03 +08:00

438 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package validation_test
import (
"testing"
"github.com/EthanCodeCraft/xlgo-core/validation"
"golang.org/x/crypto/bcrypt"
)
// ===== Password Tests =====
func TestValidatePassword(t *testing.T) {
tests := []struct {
password string
valid bool
msg string
}{
{"Abc12345", true, ""}, // 有效
{"abc12345", false, "密码必须包含大写字母"}, // 缺大写
{"ABC12345", false, "密码必须包含小写字母"}, // 缺小写
{"Abcdefgh", false, "密码必须包含数字"}, // 缺数字
{"Abc123", false, "密码长度不能少于8位"}, // 太短
{"", false, "密码长度不能少于8位"}, // 空
}
for _, tt := range tests {
valid, msg := validation.ValidatePassword(tt.password)
if valid != tt.valid {
t.Errorf("ValidatePassword(%s) valid = %v, want %v", tt.password, valid, tt.valid)
}
if !valid && msg != tt.msg {
t.Errorf("ValidatePassword(%s) msg = %s, want %s", tt.password, msg, tt.msg)
}
}
}
func TestValidatePasswordWithConfig(t *testing.T) {
// 自定义配置:不要求特殊字符
config := validation.PasswordConfig{
MinLength: 6,
MaxLength: 20,
RequireUpper: false,
RequireLower: true,
RequireDigit: true,
RequireSpecial: false,
}
valid, msg := validation.ValidatePasswordWithConfig("abc123", config)
if !valid {
t.Errorf("ValidatePasswordWithConfig should be valid: %s", msg)
}
// 要求特殊字符
config2 := validation.PasswordConfig{
MinLength: 8,
MaxLength: 20,
RequireUpper: true,
RequireLower: true,
RequireDigit: true,
RequireSpecial: true,
}
valid2, msg2 := validation.ValidatePasswordWithConfig("Abc12345", config2)
if valid2 {
t.Error("Should require special character")
}
if msg2 != "密码必须包含特殊字符" {
t.Errorf("msg = %s, want '密码必须包含特殊字符'", msg2)
}
// 包含特殊字符
valid3, msg3 := validation.ValidatePasswordWithConfig("Abc123!@#", config2)
if !valid3 {
t.Errorf("Should be valid: %s", msg3)
}
}
func TestDefaultPasswordConfig(t *testing.T) {
cfg := validation.DefaultPasswordConfig
if cfg.MinLength != 8 {
t.Errorf("MinLength = %d, want 8", cfg.MinLength)
}
if cfg.MaxLength != 72 {
t.Errorf("MaxLength = %d, want 72 (P1 #17: bcrypt 72-byte truncation boundary)", cfg.MaxLength)
}
if !cfg.RequireUpper || !cfg.RequireLower || !cfg.RequireDigit {
t.Error("Default config should require upper, lower, digit")
}
}
// ===== Hash Password Tests =====
func TestHashPassword(t *testing.T) {
password := "testPassword123"
hash, err := validation.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword error: %v", err)
}
// 验证 hash 不等于原密码
if hash == password {
t.Error("Hash should not equal original password")
}
// 验证 hash 非空
if hash == "" {
t.Error("Hash should not be empty")
}
// 验证可以匹配
if !validation.CheckPassword(hash, password) {
t.Error("CheckPassword should return true for correct password")
}
// 验证错误密码不匹配
if validation.CheckPassword(hash, "wrongPassword") {
t.Error("CheckPassword should return false for wrong password")
}
}
func TestHashPasswordWithCost(t *testing.T) {
password := "testPassword123"
// 测试不同 cost
hash, err := validation.HashPasswordWithCost(password, 10)
if err != nil {
t.Fatalf("HashPasswordWithCost error: %v", err)
}
cost, err := validation.GetPasswordCost(hash)
if err != nil {
t.Fatalf("GetPasswordCost error: %v", err)
}
if cost != 10 {
t.Errorf("Cost = %d, want 10", cost)
}
// 测试 cost 边界
hash2, err := validation.HashPasswordWithCost(password, 3) // 低于 MinCost
if err != nil {
t.Fatalf("HashPasswordWithCost with low cost error: %v", err)
}
cost2, _ := validation.GetPasswordCost(hash2)
if cost2 < bcrypt.MinCost {
t.Errorf("Cost should be at least MinCost")
}
}
func TestCheckPasswordAndUpgrade(t *testing.T) {
password := "testPassword123"
// 使用低 cost 创建 hash
hash, _ := validation.HashPasswordWithCost(password, 4)
// 检查并尝试升级
match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, 12)
if err != nil {
t.Fatalf("CheckPasswordAndUpgrade error: %v", err)
}
if !match {
t.Error("Password should match")
}
if !needUpgrade {
t.Error("Should need upgrade (cost 4 -> 12)")
}
if newHash == "" {
t.Error("New hash should be provided")
}
// 验证新 hash 可用
if !validation.CheckPassword(newHash, password) {
t.Error("New hash should work")
}
// 使用高 cost,不需要升级
hash2, _ := validation.HashPasswordWithCost(password, 12)
match2, needUpgrade2, _, _ := validation.CheckPasswordAndUpgrade(hash2, password, 12)
if !match2 {
t.Error("Password should match")
}
if needUpgrade2 {
t.Error("Should not need upgrade")
}
// 错误密码
match3, _, _, _ := validation.CheckPasswordAndUpgrade(hash, "wrongPassword", 12)
if match3 {
t.Error("Wrong password should not match")
}
}
func TestGetPasswordCost(t *testing.T) {
password := "testPassword123"
hash, _ := validation.HashPasswordWithCost(password, 12)
cost, err := validation.GetPasswordCost(hash)
if err != nil {
t.Fatalf("GetPasswordCost error: %v", err)
}
if cost != 12 {
t.Errorf("Cost = %d, want 12", cost)
}
// 无效 hash
_, err = validation.GetPasswordCost("invalidHash")
if err == nil {
t.Error("GetPasswordCost should fail with invalid hash")
}
}
// ===== Validation Errors Tests =====
func TestValidationErrors(t *testing.T) {
errors := validation.ValidationErrors{
{Field: "name", Label: "姓名", Message: "必填"},
{Field: "email", Label: "邮箱", Message: "格式错误"},
}
// Error 方法
errStr := errors.Error()
if errStr != "姓名: 必填; 邮箱: 格式错误" {
t.Errorf("Error() = %s", errStr)
}
// ToMap 方法
m := errors.ToMap()
if m["name"] != "必填" {
t.Error("ToMap failed")
}
// ToLabelMap 方法
lm := errors.ToLabelMap()
if lm["姓名"] != "必填" {
t.Error("ToLabelMap failed")
}
// First 方法
first := errors.First()
if first.Field != "name" {
t.Error("First failed")
}
// FirstMessage 方法
msg := errors.FirstMessage()
if msg != "必填" {
t.Errorf("FirstMessage = %s", msg)
}
// 空 errors
emptyErrors := validation.ValidationErrors{}
if emptyErrors.First() != nil {
t.Error("Empty First should return nil")
}
if emptyErrors.FirstMessage() != "" {
t.Error("Empty FirstMessage should return empty")
}
}
// ===== Struct Validation Tests =====
type TestUser struct {
Name string `json:"name" label:"姓名" binding:"required" msg_required:"姓名不能为空"`
Email string `json:"email" label:"邮箱" binding:"required,email" msg_required:"邮箱不能为空" msg_email:"邮箱格式不正确"`
Age int `json:"age" label:"年龄" binding:"gte=0,lte=150"`
Password string `json:"password" binding:"min=8" msg_min:"密码至少8位"`
}
func TestValidateStruct(t *testing.T) {
validation.InitValidator()
// 有效数据
validUser := TestUser{
Name: "张三",
Email: "test@example.com",
Age: 25,
Password: "password123",
}
errors := validation.ValidateStruct(validUser)
if errors != nil {
t.Errorf("Valid struct should have no errors: %v", errors)
}
// 无效数据
invalidUser := TestUser{
Name: "",
Email: "invalid-email",
Age: 200,
Password: "short",
}
errors2 := validation.ValidateStruct(invalidUser)
if errors2 == nil {
t.Error("Invalid struct should have errors")
}
// 检查错误数量
if len(errors2) < 3 {
t.Errorf("Should have at least 3 errors, got %d", len(errors2))
}
// 检查自定义消息
firstMsg := errors2.FirstMessage()
if firstMsg != "姓名不能为空" {
t.Errorf("FirstMessage = %s, want '姓名不能为空'", firstMsg)
}
}
func TestValidateStructNil(t *testing.T) {
// 空结构体
emptyUser := TestUser{}
errors := validation.ValidateStruct(emptyUser)
if errors == nil {
t.Error("Empty struct should have errors")
}
}
type TestPhone struct {
Phone string `json:"phone" binding:"phone" msg_phone:"手机号格式不正确"`
}
func TestValidatePhone(t *testing.T) {
validation.InitValidator()
// 有效手机号
valid := TestPhone{Phone: "13812345678"}
errors := validation.ValidateStruct(valid)
if errors != nil {
t.Errorf("Valid phone should pass: %v", errors)
}
// 无效手机号 - 长度错误
invalidLen := TestPhone{Phone: "1234567"}
errors2 := validation.ValidateStruct(invalidLen)
if errors2 == nil {
t.Error("Invalid phone length should fail")
}
// 无效手机号 - 不以1开头
invalidPrefix := TestPhone{Phone: "23812345678"}
errors3 := validation.ValidateStruct(invalidPrefix)
if errors3 == nil {
t.Error("Phone not starting with 1 should fail")
}
// P1 #17:长度合规、以 1 开头但含非数字字符必须拒绝(原实现会误通过)。
invalidNonDigit := TestPhone{Phone: "1abcdefghij"}
if errs := validation.ValidateStruct(invalidNonDigit); errs == nil {
t.Error("Phone with non-digit chars should fail (P1 #17)")
}
}
type TestUsername struct {
Username string `json:"username" binding:"username" msg_username:"用户名格式不正确"`
}
func TestValidateUsername(t *testing.T) {
validation.InitValidator()
tests := []struct {
username string
valid bool
}{
{"abc123", true}, // 有效
{"Abc123", true}, // 有效(大写开头)
{"user_name", true}, // 有效(包含下划线)
{"123abc", false}, // 无效(数字开头)
{"ab", false}, // 无效(太短)
{"a!bc", false}, // 无效(特殊字符)
}
for _, tt := range tests {
u := TestUsername{Username: tt.username}
errors := validation.ValidateStruct(u)
valid := errors == nil
if valid != tt.valid {
t.Errorf("Username %s: valid=%v, want %v", tt.username, valid, tt.valid)
}
}
}
// TestIDCardChecksum_M518 位身份证须校验校验位(修复前仅查长度+格式)。
func TestIDCardChecksum_M5(t *testing.T) {
validation.InitValidator()
type TestIDCard struct {
ID string `json:"id" binding:"idcard"`
}
tests := []struct {
id string
valid bool
}{
{"11010519491231002X", true}, // 合法(校验位正确)
{"110101199003078478", true}, // 合法(校验位正确)
{"110101199003078475", false}, // 校验位错误(末位改坏)
{"11010119900307847X", false}, // 18 位但校验位错
{"123456789012345", true}, // 15 位旧号段仅查格式
{"123456789012345678", false}, // 18 位但校验位错
{"123", false}, // 长度不符
}
for _, tt := range tests {
u := TestIDCard{ID: tt.id}
errs := validation.ValidateStruct(u)
valid := errs == nil
if valid != tt.valid {
t.Errorf("IDCard %s: valid=%v, want %v", tt.id, valid, tt.valid)
}
}
}
// ===== Benchmarks =====
func BenchmarkHashPassword(b *testing.B) {
password := "testPassword123"
for i := 0; i < b.N; i++ {
validation.HashPassword(password)
}
}
func BenchmarkCheckPassword(b *testing.B) {
password := "testPassword123"
hash, _ := validation.HashPassword(password)
b.ResetTimer()
for i := 0; i < b.N; i++ {
validation.CheckPassword(hash, password)
}
}
func BenchmarkValidatePassword(b *testing.B) {
password := "TestPassword123"
for i := 0; i < b.N; i++ {
validation.ValidatePassword(password)
}
}