UserUpdate/UserDelete 的 adminCount<=1 计数与 Save/Delete 非原子,两个 并发的'降级/删除倒数第二位管理员'请求可同时通过检查,站点失去管理员 (MySQL 下真实窗口;SQLite 单写锁下窗口极小)。 - handlers/admin_user.go: 检查+写入包进 db.Transaction; ensureNotLastAdmin 在事务内锁定管理员集合后再计数: MySQL 用 SELECT ... FOR UPDATE(gorm clause.Locking)串行化 并发事务;SQLite 无 FOR UPDATE,叠加进程内互斥锁 lastAdminMu (应用按设计单实例部署),计数在写锁串行化后重读 - 顺带修正:原实现中 f.Role 覆盖 user.Role 后检查恒为 true 的语义 保留(基于原角色判定),避免降级检查被新值掩盖 - 测试: TestConcurrentLastAdminDowngrade(两位管理员并发降级: 恰好 1 成功 1 拒绝、最终至少保留一位管理员;-race 通过)
83 lines
2.3 KiB
Go
83 lines
2.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"go_blog/models"
|
|
)
|
|
|
|
// TestConcurrentLastAdminDowngrade 覆盖 SECURITY_TODO #30:两个并发的
|
|
// "降级倒数第二位管理员"请求不再能同时通过检查——最终恰好一位管理员
|
|
// 被降级、另一位被拒绝,且站点至少保留一位管理员。旧实现(计数与写入
|
|
// 非原子)下两个请求都会成功,管理员清零(变异测试可验证)。
|
|
func TestConcurrentLastAdminDowngrade(t *testing.T) {
|
|
e := newSecurityTestEnv(t)
|
|
|
|
// 增加第二位管理员(admin2);alice/bob 保持 author。
|
|
admin2 := mustUser(t, e.db, "admin2", models.RoleAdmin)
|
|
var admin1 models.User
|
|
if err := e.db.Where("username = ?", "admin").First(&admin1).Error; err != nil {
|
|
t.Fatalf("load admin1: %v", err)
|
|
}
|
|
|
|
// 两位管理员分别用自己的会话并发发起"降级自己"的请求。
|
|
session1 := e.login(t, "admin")
|
|
token1 := e.csrfTokenFor(t, session1)
|
|
session2 := e.login(t, "admin2")
|
|
token2 := e.csrfTokenFor(t, session2)
|
|
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
codes := make(chan int, 2)
|
|
requests := []struct {
|
|
id uint
|
|
cookie string
|
|
token string
|
|
}{
|
|
{admin1.ID, session1, token1},
|
|
{admin2.ID, session2, token2},
|
|
}
|
|
for _, req := range requests {
|
|
wg.Add(1)
|
|
go func(id uint, cookie, token string) {
|
|
defer wg.Done()
|
|
<-start
|
|
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", id), cookie, token,
|
|
gin.H{"role": models.RoleAuthor, "status": models.StatusNormal})
|
|
codes <- w.Code
|
|
}(req.id, req.cookie, req.token)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(codes)
|
|
|
|
var success, rejected int
|
|
for code := range codes {
|
|
switch code {
|
|
case http.StatusOK:
|
|
success++
|
|
case http.StatusForbidden:
|
|
rejected++
|
|
default:
|
|
t.Fatalf("unexpected status %d (body-level check skipped; want 200 or 403)", code)
|
|
}
|
|
}
|
|
if success != 1 || rejected != 1 {
|
|
t.Fatalf("concurrent demotions: success=%d rejected=%d, want exactly 1/1", success, rejected)
|
|
}
|
|
|
|
// 最终至少保留一位管理员。
|
|
var count int64
|
|
if err := e.db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&count).Error; err != nil {
|
|
t.Fatalf("count admins: %v", err)
|
|
}
|
|
if count < 1 {
|
|
t.Fatal("no admin remains after concurrent demotions")
|
|
}
|
|
}
|