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

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