Files
user-system/internal/repository/repo_robustness_test.go

537 lines
16 KiB
Go
Raw Normal View History

// 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)
// 软删除一个不存在的 IDGORM 通常返回 nilRowsAffected=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=%drows=%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("超长字段创建结果: %vSQLite 可能允许)", 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")
}
}