// repo_robustness_test.go — repository 层鲁棒性测试 // 覆盖:重复主键、唯一索引冲突、大量数据分页正确性、 // SQL 注入防护(参数化查询验证)、软删除后查询、 // 空字符串/极值/特殊字符输入、上下文取消 package repository import ( "context" "fmt" "strings" "sync" "testing" "github.com/user-management-system/internal/domain" ) // ============================================================ // 1. 唯一索引冲突 // ============================================================ func TestRepo_Robust_DuplicateUsername(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() u1 := &domain.User{Username: "dupuser", Password: "hash", Status: domain.UserStatusActive} if err := repo.Create(ctx, u1); err != nil { t.Fatalf("第一次创建应成功: %v", err) } u2 := &domain.User{Username: "dupuser", Password: "hash2", Status: domain.UserStatusActive} err := repo.Create(ctx, u2) if err == nil { t.Error("重复用户名应返回唯一索引冲突错误") } } func TestRepo_Robust_DuplicateEmail(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() email := "dup@example.com" repo.Create(ctx, &domain.User{Username: "user1", Email: domain.StrPtr(email), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck err := repo.Create(ctx, &domain.User{Username: "user2", Email: domain.StrPtr(email), Password: "h", Status: domain.UserStatusActive}) if err == nil { t.Error("重复邮箱应返回唯一索引冲突错误") } } func TestRepo_Robust_DuplicatePhone(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() phone := "13900000001" repo.Create(ctx, &domain.User{Username: "pa", Phone: domain.StrPtr(phone), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck err := repo.Create(ctx, &domain.User{Username: "pb", Phone: domain.StrPtr(phone), Password: "h", Status: domain.UserStatusActive}) if err == nil { t.Error("重复手机号应返回唯一索引冲突错误") } } func TestRepo_Robust_MultipleNullEmail(t *testing.T) { // NULL 不触发唯一约束,多个用户可以都没有邮箱 db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() for i := 0; i < 5; i++ { err := repo.Create(ctx, &domain.User{ Username: fmt.Sprintf("nomail%d", i), Email: nil, // NULL Password: "hash", Status: domain.UserStatusActive, }) if err != nil { t.Fatalf("NULL email 用户%d 创建失败: %v", i, err) } } } // ============================================================ // 2. 查询不存在的记录 // ============================================================ func TestRepo_Robust_GetByID_NotFound(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) _, err := repo.GetByID(context.Background(), 99999) if err == nil { t.Error("查询不存在的 ID 应返回错误") } } func TestRepo_Robust_GetByUsername_NotFound(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) _, err := repo.GetByUsername(context.Background(), "ghost") if err == nil { t.Error("查询不存在的用户名应返回错误") } } func TestRepo_Robust_GetByEmail_NotFound(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) _, err := repo.GetByEmail(context.Background(), "nope@none.com") if err == nil { t.Error("查询不存在的邮箱应返回错误") } } func TestRepo_Robust_GetByPhone_NotFound(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) _, err := repo.GetByPhone(context.Background(), "00000000000") if err == nil { t.Error("查询不存在的手机号应返回错误") } } // ============================================================ // 3. 软删除后的查询行为 // ============================================================ func TestRepo_Robust_SoftDelete_HiddenFromGet(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() u := &domain.User{Username: "softdel", Password: "h", Status: domain.UserStatusActive} repo.Create(ctx, u) //nolint:errcheck id := u.ID if err := repo.Delete(ctx, id); err != nil { t.Fatalf("Delete: %v", err) } _, err := repo.GetByID(ctx, id) if err == nil { t.Error("软删除后 GetByID 应返回错误(记录被隐藏)") } } func TestRepo_Robust_SoftDelete_HiddenFromList(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() for i := 0; i < 3; i++ { repo.Create(ctx, &domain.User{Username: fmt.Sprintf("listdel%d", i), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck } users, total, _ := repo.List(ctx, 0, 100) initialCount := len(users) initialTotal := total // 删除第一个 repo.Delete(ctx, users[0].ID) //nolint:errcheck users2, total2, _ := repo.List(ctx, 0, 100) if len(users2) != initialCount-1 { t.Errorf("删除后 List 应减少 1 条,实际 %d -> %d", initialCount, len(users2)) } if total2 != initialTotal-1 { t.Errorf("删除后 total 应减少 1,实际 %d -> %d", initialTotal, total2) } } func TestRepo_Robust_DeleteNonExistent(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) // 软删除一个不存在的 ID,GORM 通常返回 nil(RowsAffected=0 不报错) err := repo.Delete(context.Background(), 99999) _ = err // 不 panic 即可 } // ============================================================ // 4. SQL 注入防护(参数化查询) // ============================================================ func TestRepo_Robust_SQLInjection_GetByUsername(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() // 先插入一个真实用户 repo.Create(ctx, &domain.User{Username: "legit", Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck // 注入载荷:尝试用 OR '1'='1' 绕过查询 injections := []string{ "' OR '1'='1", "'; DROP TABLE users; --", `" OR "1"="1`, "admin'--", "legit' UNION SELECT * FROM users --", } for _, payload := range injections { _, err := repo.GetByUsername(ctx, payload) if err == nil { t.Errorf("SQL 注入载荷 %q 不应返回用户(应返回 not found)", payload) } } } func TestRepo_Robust_SQLInjection_Search(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() repo.Create(ctx, &domain.User{Username: "victim", Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck injections := []string{ "' OR '1'='1", "%; SELECT * FROM users; --", "victim' UNION SELECT username FROM users --", } for _, payload := range injections { users, _, err := repo.Search(ctx, payload, 0, 100) if err != nil { continue // 参数化查询报错也可接受 } for _, u := range users { if u.Username == "victim" && !strings.Contains(payload, "victim") { t.Errorf("SQL 注入载荷 %q 不应返回不匹配的用户", payload) } } } } func TestRepo_Robust_SQLInjection_ExistsByUsername(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() repo.Create(ctx, &domain.User{Username: "realuser", Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck // 这些载荷不应导致 ExistsByUsername("' OR '1'='1") 返回 true(找到不存在的用户) exists, err := repo.ExistsByUsername(ctx, "' OR '1'='1") if err != nil { t.Logf("ExistsByUsername SQL注入: err=%v (可接受)", err) return } if exists { t.Error("SQL 注入载荷在 ExistsByUsername 中不应返回 true") } } // ============================================================ // 5. 分页边界值 // ============================================================ func TestRepo_Robust_List_ZeroOffset(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() for i := 0; i < 5; i++ { repo.Create(ctx, &domain.User{Username: fmt.Sprintf("pg%d", i), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck } users, total, err := repo.List(ctx, 0, 3) if err != nil { t.Fatalf("List: %v", err) } if len(users) != 3 { t.Errorf("offset=0, limit=3 应返回 3 条,实际 %d", len(users)) } if total != 5 { t.Errorf("total 应为 5,实际 %d", total) } } func TestRepo_Robust_List_OffsetBeyondTotal(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() for i := 0; i < 3; i++ { repo.Create(ctx, &domain.User{Username: fmt.Sprintf("ov%d", i), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck } users, total, err := repo.List(ctx, 100, 10) if err != nil { t.Fatalf("List: %v", err) } if len(users) != 0 { t.Errorf("offset 超过总数应返回空列表,实际 %d 条", len(users)) } if total != 3 { t.Errorf("total 应为 3,实际 %d", total) } } func TestRepo_Robust_List_LargeLimit(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() for i := 0; i < 10; i++ { repo.Create(ctx, &domain.User{Username: fmt.Sprintf("ll%d", i), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck } users, _, err := repo.List(ctx, 0, 999999) if err != nil { t.Fatalf("List with huge limit: %v", err) } if len(users) != 10 { t.Errorf("超大 limit 应返回全部 10 条,实际 %d", len(users)) } } func TestRepo_Robust_List_EmptyDB(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) users, total, err := repo.List(context.Background(), 0, 20) if err != nil { t.Fatalf("空 DB List 应无错误: %v", err) } if total != 0 { t.Errorf("空 DB total 应为 0,实际 %d", total) } if len(users) != 0 { t.Errorf("空 DB 应返回空列表,实际 %d 条", len(users)) } } // ============================================================ // 6. 搜索边界值 // ============================================================ func TestRepo_Robust_Search_EmptyKeyword(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() for i := 0; i < 5; i++ { repo.Create(ctx, &domain.User{Username: fmt.Sprintf("sk%d", i), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck } users, total, err := repo.Search(ctx, "", 0, 20) // 空关键字 → LIKE '%%' 匹配所有;验证不报错 if err != nil { t.Fatalf("空关键字 Search 应无错误: %v", err) } if total < 5 { t.Errorf("空关键字应匹配所有用户(>=5),实际 total=%d,rows=%d", total, len(users)) } } func TestRepo_Robust_Search_SpecialCharsKeyword(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() repo.Create(ctx, &domain.User{Username: "normaluser", Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck // 含 LIKE 元字符 for _, kw := range []string{"%", "_", "\\", "%_%", "%%"} { _, _, err := repo.Search(ctx, kw, 0, 10) if err != nil { t.Logf("特殊关键字 %q 搜索出错(可接受): %v", kw, err) } // 主要验证不 panic } } func TestRepo_Robust_Search_VeryLongKeyword(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() longKw := strings.Repeat("a", 10000) _, _, err := repo.Search(ctx, longKw, 0, 10) _ = err // 不应 panic } // ============================================================ // 7. 超长字段存储 // ============================================================ func TestRepo_Robust_LongFieldValues(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() u := &domain.User{ Username: strings.Repeat("x", 45), // varchar(50) 以内 Password: strings.Repeat("y", 200), Nickname: strings.Repeat("n", 45), Status: domain.UserStatusActive, } err := repo.Create(ctx, u) // SQLite 不严格限制 varchar 长度,期望成功;其他数据库可能截断或报错 if err != nil { t.Logf("超长字段创建结果: %v(SQLite 可能允许)", err) } } // ============================================================ // 8. UpdateLastLogin 特殊 IP // ============================================================ func TestRepo_Robust_UpdateLastLogin_EmptyIP(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() u := &domain.User{Username: "iptest", Password: "h", Status: domain.UserStatusActive} repo.Create(ctx, u) //nolint:errcheck // 空 IP 不应报错 if err := repo.UpdateLastLogin(ctx, u.ID, ""); err != nil { t.Errorf("空 IP UpdateLastLogin 应无错误: %v", err) } } func TestRepo_Robust_UpdateLastLogin_LongIP(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() u := &domain.User{Username: "longiptest", Password: "h", Status: domain.UserStatusActive} repo.Create(ctx, u) //nolint:errcheck longIP := strings.Repeat("1", 500) err := repo.UpdateLastLogin(ctx, u.ID, longIP) _ = err // SQLite 宽容,不 panic 即可 } // ============================================================ // 9. 并发写入安全(SQLite 序列化写入) // ============================================================ func TestRepo_Robust_ConcurrentCreate_NoDeadlock(t *testing.T) { db := openTestDB(t) // 启用 WAL 模式可减少锁冲突,这里使用默认设置 repo := NewUserRepository(db) ctx := context.Background() const goroutines = 20 var wg sync.WaitGroup var mu sync.Mutex // SQLite 只允许单写,用互斥锁序列化 errorCount := 0 for i := 0; i < goroutines; i++ { wg.Add(1) go func(idx int) { defer wg.Done() mu.Lock() defer mu.Unlock() err := repo.Create(ctx, &domain.User{ Username: fmt.Sprintf("concurrent_%d", idx), Password: "hash", Status: domain.UserStatusActive, }) if err != nil { errorCount++ } }(i) } wg.Wait() if errorCount > 0 { t.Errorf("序列化并发写入:%d/%d 次失败", errorCount, goroutines) } } func TestRepo_Robust_ConcurrentReadWrite_NoDataRace(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() // 预先插入数据 for i := 0; i < 10; i++ { repo.Create(ctx, &domain.User{Username: fmt.Sprintf("rw%d", i), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck } var wg sync.WaitGroup var writeMu sync.Mutex for i := 0; i < 30; i++ { wg.Add(1) go func(idx int) { defer wg.Done() if idx%5 == 0 { writeMu.Lock() repo.UpdateStatus(ctx, int64(idx%10)+1, domain.UserStatusActive) //nolint:errcheck writeMu.Unlock() } else { repo.GetByID(ctx, int64(idx%10)+1) //nolint:errcheck } }(i) } wg.Wait() // 无 panic / 数据竞争即通过 } // ============================================================ // 10. Exists 方法边界 // ============================================================ func TestRepo_Robust_ExistsByUsername_EmptyString(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) // 查询空字符串用户名,不应 panic exists, err := repo.ExistsByUsername(context.Background(), "") if err != nil { t.Logf("ExistsByUsername('') err: %v", err) } _ = exists } func TestRepo_Robust_ExistsByEmail_NilEquivalent(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) // 查询空邮箱 exists, err := repo.ExistsByEmail(context.Background(), "") _ = err _ = exists } func TestRepo_Robust_ExistsByPhone_SQLInjection(t *testing.T) { db := openTestDB(t) repo := NewUserRepository(db) ctx := context.Background() repo.Create(ctx, &domain.User{Username: "phoneuser", Phone: domain.StrPtr("13900000001"), Password: "h", Status: domain.UserStatusActive}) //nolint:errcheck exists, err := repo.ExistsByPhone(ctx, "' OR '1'='1") if err != nil { t.Logf("ExistsByPhone SQL注入 err: %v", err) return } if exists { t.Error("SQL 注入载荷在 ExistsByPhone 中不应返回 true") } }