537 lines
16 KiB
Go
537 lines
16 KiB
Go
// 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")
|
||
}
|
||
}
|