- settings.go:5 组 save handler 改 JSON 绑定(siteSettingsRequest/ uploadSettingsRequest/downloadSettingsRequest/commentSettingsRequest/ navLinkSettingsRequest);request struct 变更子函数签名(不再触碰 c.PostForm),upload/navlink/download 的 action 分发保留,未知 action 返回 400;enabled 用 *bool(nil 沿用旧默认启用语义) - favicon/logo 上传拆出:POST /api/admin/settings/site/favicon|logo (multipart,图片类别校验 + 旧本地文件替换),SiteSettingsSave 只 处理文本/URL/clear(存储路径校验保留 illegal_dir 400) - main.go:设置旧 POST 路由移除,新 /api/admin/settings 分组注册 - 模板:base.html 新增 blogSettingsForm 委托(data-api-url/data-action/ data-confirm → POST + redirect/alert);settings_site 主表单 JSON + logo/favicon 选择即上传;navlinks/upload/download/comments 页全部 小表单改委托(约 14 个) - blogForm:剔除 file 字段(文件走 multipart) - 测试:TestStorageDirTraversalRejected、TestAddUploadFileTypeRejectsDangerousExtensions 更新 JSON 断言;env 路由补 /api/admin/settings - main_test 冒烟补设置 API 断言;go build/vet/test 全绿
523 lines
17 KiB
Go
523 lines
17 KiB
Go
package handlers
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/gin-contrib/sessions"
|
||
"github.com/gin-contrib/sessions/cookie"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/glebarez/sqlite"
|
||
"gorm.io/gorm"
|
||
|
||
"go_blog/middleware"
|
||
"go_blog/models"
|
||
)
|
||
|
||
// securityTestEnv 搭建与生产中间件链一致的路由器
|
||
// (sessions -> CSRF -> 用户上下文),外加待测路由。
|
||
type securityTestEnv struct {
|
||
router *gin.Engine
|
||
db *gorm.DB
|
||
storageDir string
|
||
limiter *LoginRateLimiter
|
||
}
|
||
|
||
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
|
||
|
||
func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||
t.Helper()
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open sqlite: %v", err)
|
||
}
|
||
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{},
|
||
&models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{},
|
||
&models.DownloadBaseURL{}, &models.Comment{}); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
|
||
storageDir := t.TempDir()
|
||
|
||
// 初始化上传策略,使 ValidateUpload 接受 .txt 文件。
|
||
db.Create(&models.SiteSetting{ID: 1})
|
||
db.Create(&models.CommentConfig{ID: 1, Enabled: true, AllowGuest: true})
|
||
db.Create(&models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: 1024 * 1024, StorageDir: "attachments"})
|
||
db.Create(&models.UploadFileType{Extension: ".txt", MimeType: "text/plain", Category: models.CategoryDocument, Enabled: true})
|
||
models.LoadConfigCache(db)
|
||
|
||
// 初始化用户。
|
||
mustUser(t, db, "admin", models.RoleAdmin)
|
||
alice := mustUser(t, db, "alice", models.RoleAuthor)
|
||
bob := mustUser(t, db, "bob", models.RoleAuthor)
|
||
|
||
// 每位作者初始化一篇文章。
|
||
aliceArt := models.Article{AuthorID: alice.ID, Title: "alice post", Slug: "alice-post", Content: "x", Status: models.ArticlePublished}
|
||
bobArt := models.Article{AuthorID: bob.ID, Title: "bob post", Slug: "bob-post", Content: "x", Status: models.ArticlePublished}
|
||
db.Create(&aliceArt)
|
||
db.Create(&bobArt)
|
||
|
||
r := gin.New()
|
||
if err := r.SetTrustedProxies(nil); err != nil {
|
||
t.Fatalf("set trusted proxies: %v", err)
|
||
}
|
||
r.LoadHTMLGlob("../templates/**/*.html")
|
||
store := cookie.NewStore([]byte("test-secret"))
|
||
limiter := NewLoginLimiter()
|
||
r.Use(sessions.Sessions("blog_session", store))
|
||
r.Use(middleware.CSRFProtect())
|
||
r.Use(middleware.SetUserContext(db))
|
||
|
||
r.GET("/login", LoginPage())
|
||
r.GET("/register", RegisterPage(db))
|
||
r.GET("/rss", RSSFeed(db))
|
||
|
||
api := r.Group("/api")
|
||
{
|
||
api.POST("/auth/login", Login(db, limiter))
|
||
api.POST("/auth/logout", Logout())
|
||
api.POST("/auth/register", Register(db))
|
||
api.POST("/article/:slug/comments", PostComment(db))
|
||
}
|
||
|
||
protected := r.Group("/my", middleware.AuthRequired(db))
|
||
{
|
||
protected.POST("/articles/attachments", UploadAttachment(db, storageDir))
|
||
protected.POST("/articles/attachments/:id/delete", DeleteAttachment(db, storageDir))
|
||
protected.GET("/articles/:id/attachments", ListAttachments(db))
|
||
protected.GET("/whoami", func(c *gin.Context) {
|
||
uid, _ := sessionAuthorID(c)
|
||
c.String(http.StatusOK, "uid=%d", uid)
|
||
})
|
||
}
|
||
|
||
// 个人资料路由(头像上传 XSS 链回归覆盖,#21)。
|
||
profile := r.Group("/profile", middleware.AuthRequired(db))
|
||
{
|
||
profile.POST("", UpdateProfile(db, storageDir))
|
||
profile.POST("/avatar", UploadAvatar(db, storageDir))
|
||
}
|
||
|
||
// 上传设置 API(危险扩展名黑名单覆盖,#21)。
|
||
adminSettingsAPI := r.Group("/api/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||
{
|
||
adminSettingsAPI.POST("/upload", UploadSettingsSave(db))
|
||
}
|
||
|
||
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
|
||
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||
{
|
||
admin.GET("/users/:id/edit", UserEditPage(db))
|
||
admin.GET("/comments", CommentListPage(db))
|
||
}
|
||
|
||
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||
{
|
||
usersAPI.POST("", UserCreate(db))
|
||
usersAPI.PUT("/:id", UserUpdate(db))
|
||
usersAPI.DELETE("/:id", UserDelete(db))
|
||
}
|
||
|
||
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter}
|
||
}
|
||
|
||
func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
|
||
t.Helper()
|
||
u := models.User{Username: username, DisplayName: username, Role: role, Status: models.StatusNormal}
|
||
if err := u.SetPassword("pw-" + username); err != nil {
|
||
t.Fatalf("set password: %v", err)
|
||
}
|
||
if err := db.Create(&u).Error; err != nil {
|
||
t.Fatalf("create user %s: %v", username, err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
// login 执行完整登录流程(GET 表单获取 CSRF 令牌,再 POST 凭据),
|
||
// 返回认证后的会话 Cookie。
|
||
func (e *securityTestEnv) login(t *testing.T, username string) string {
|
||
t.Helper()
|
||
|
||
// 匿名 GET 获取 CSRF 令牌 + 会话 Cookie。
|
||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||
w := httptest.NewRecorder()
|
||
e.router.ServeHTTP(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GET /login: status = %d", w.Code)
|
||
}
|
||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||
if m == nil {
|
||
t.Fatal("login page did not render a CSRF token")
|
||
}
|
||
cookie := e.sessionCookie(w)
|
||
|
||
// 携带令牌 POST 凭据(JSON API)。
|
||
w = postJSON(e, http.MethodPost, "/api/auth/login", cookie, m[1], gin.H{
|
||
"username": username,
|
||
"password": "pw-" + username,
|
||
})
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("POST /api/auth/login (%s): status = %d, body %s", username, w.Code, w.Body.String())
|
||
}
|
||
authCookie := e.sessionCookie(w)
|
||
if authCookie == "" {
|
||
t.Fatal("login did not set a session cookie")
|
||
}
|
||
return authCookie
|
||
}
|
||
|
||
// sessionCookie 从记录器中提取 blog_session Cookie。当存在多个 Set-Cookie
|
||
// 头时(例如中间件和处理器都保存了会话),最后一个才是生效值——
|
||
// 浏览器按顺序应用它们。
|
||
func (e *securityTestEnv) sessionCookie(w *httptest.ResponseRecorder) string {
|
||
cookie := ""
|
||
for _, c := range w.Result().Cookies() {
|
||
if c.Name == "blog_session" {
|
||
cookie = c.Name + "=" + c.Value
|
||
}
|
||
}
|
||
return cookie
|
||
}
|
||
|
||
func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, contentType string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(method, path, body)
|
||
if contentType != "" {
|
||
req.Header.Set("Content-Type", contentType)
|
||
}
|
||
if cookie != "" {
|
||
req.Header.Set("Cookie", cookie)
|
||
}
|
||
w := httptest.NewRecorder()
|
||
e.router.ServeHTTP(w, req)
|
||
return w
|
||
}
|
||
|
||
// postJSON 是 JSON 请求小助手,以 X-CSRF-Token 请求头发送令牌(AJAX 模式)。
|
||
func postJSON(e *securityTestEnv, method, path, cookie, csrfToken string, body interface{}) *httptest.ResponseRecorder {
|
||
var buf bytes.Buffer
|
||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||
panic(err)
|
||
}
|
||
req := httptest.NewRequest(method, path, &buf)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if csrfToken != "" {
|
||
req.Header.Set("X-CSRF-Token", csrfToken)
|
||
}
|
||
if cookie != "" {
|
||
req.Header.Set("Cookie", cookie)
|
||
}
|
||
w := httptest.NewRecorder()
|
||
e.router.ServeHTTP(w, req)
|
||
return w
|
||
}
|
||
|
||
// respCode 从 JSON 错误响应中提取 code 字段。
|
||
func respCode(w *httptest.ResponseRecorder) string {
|
||
var r struct {
|
||
Code string `json:"code"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &r)
|
||
return r.Code
|
||
}
|
||
|
||
// respRedirect 从 JSON 成功响应中提取 redirect 字段。
|
||
func respRedirect(w *httptest.ResponseRecorder) string {
|
||
var r struct {
|
||
Redirect string `json:"redirect"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &r)
|
||
return r.Redirect
|
||
}
|
||
|
||
// respOK 报告 JSON 响应是否成功(ok=true)。
|
||
func respOK(w *httptest.ResponseRecorder) bool {
|
||
var r struct {
|
||
OK bool `json:"ok"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &r)
|
||
return r.OK
|
||
}
|
||
|
||
func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var buf strings.Builder
|
||
mw := multipart.NewWriter(&buf)
|
||
if articleID != "" {
|
||
mw.WriteField("article_id", articleID)
|
||
} else {
|
||
mw.WriteField("session_token", "test-pending-token")
|
||
}
|
||
mw.WriteField("_csrf", csrfToken)
|
||
fw, _ := mw.CreateFormFile("file", "hello.txt")
|
||
fw.Write([]byte("hello world"))
|
||
mw.Close()
|
||
return e.do(http.MethodPost, "/my/articles/attachments", cookie, strings.NewReader(buf.String()), mw.FormDataContentType())
|
||
}
|
||
|
||
// csrfTokenFor 为已认证会话获取一个全新的 CSRF 令牌。
|
||
func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string {
|
||
t.Helper()
|
||
w := e.do(http.MethodGet, "/login", cookie, nil, "")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GET /login: status = %d", w.Code)
|
||
}
|
||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||
if m == nil {
|
||
t.Fatal("login page did not render a CSRF token")
|
||
}
|
||
return m[1]
|
||
}
|
||
|
||
func TestLoginRotatesSession(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
|
||
// 获取匿名会话(登录前的 Cookie)。
|
||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||
w := httptest.NewRecorder()
|
||
e.router.ServeHTTP(w, req)
|
||
preLoginCookie := e.sessionCookie(w)
|
||
if preLoginCookie == "" {
|
||
t.Fatal("expected anonymous session cookie")
|
||
}
|
||
|
||
authCookie := e.login(t, "alice")
|
||
if authCookie == preLoginCookie {
|
||
t.Fatal("session cookie was not rotated on login (fixation risk)")
|
||
}
|
||
|
||
// 认证会话可正常工作。
|
||
w = e.do(http.MethodGet, "/my/whoami", authCookie, nil, "")
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "uid=") {
|
||
t.Fatalf("authenticated request failed: status=%d body=%s", w.Code, w.Body.String())
|
||
}
|
||
|
||
// 旧的(被固定的)会话不得携带登录状态。
|
||
w = e.do(http.MethodGet, "/my/whoami", preLoginCookie, nil, "")
|
||
if w.Code != http.StatusFound {
|
||
t.Fatalf("pre-login session still authenticated after login: status=%d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAttachmentListRequiresOwnership(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
var aliceArt, bobArt models.Article
|
||
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
|
||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||
|
||
alice := e.login(t, "alice")
|
||
|
||
// 自己的文章:允许。
|
||
w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", aliceArt.ID), alice, nil, "")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("list own attachments: status = %d, want 200", w.Code)
|
||
}
|
||
|
||
// 他人的文章:禁止。
|
||
w = e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), alice, nil, "")
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("list other user's attachments: status = %d, want 403", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAttachmentUploadRequiresOwnership(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
var bobArt models.Article
|
||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||
|
||
alice := e.login(t, "alice")
|
||
token := e.csrfTokenFor(t, alice)
|
||
|
||
// 上传待绑定附件(article_id=0 + 会话令牌):允许。
|
||
w := e.upload(t, alice, token, "")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String())
|
||
}
|
||
|
||
// 上传到他人文章:禁止。
|
||
w = e.upload(t, alice, token, fmt.Sprint(bobArt.ID))
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("upload to other user's article: status = %d, want 403", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
var aliceArt, bobArt models.Article
|
||
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
|
||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||
|
||
alice := e.login(t, "alice")
|
||
token := e.csrfTokenFor(t, alice)
|
||
|
||
// Alice 上传附件到自己的文章。
|
||
w := e.upload(t, alice, token, fmt.Sprint(aliceArt.ID))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("upload: status = %d, body %s", w.Code, w.Body.String())
|
||
}
|
||
|
||
// Bob 上传附件到自己的文章。
|
||
bob := e.login(t, "bob")
|
||
bobToken := e.csrfTokenFor(t, bob)
|
||
w = e.upload(t, bob, bobToken, fmt.Sprint(bobArt.ID))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String())
|
||
}
|
||
|
||
var bobAtt models.Attachment
|
||
if err := e.db.Where("uploader_id = ?", userIDByUsername(t, e.db, "bob")).First(&bobAtt).Error; err != nil {
|
||
t.Fatalf("bob attachment not found: %v", err)
|
||
}
|
||
|
||
// Alice 不能删除 Bob 的附件。
|
||
form := url.Values{}
|
||
form.Set("_csrf", token)
|
||
w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), alice,
|
||
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code)
|
||
}
|
||
|
||
// Bob 可以删除自己的附件。
|
||
form.Set("_csrf", bobToken)
|
||
w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), bob,
|
||
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||
}
|
||
|
||
// Bob 的附件记录应该已删除。
|
||
var count int64
|
||
e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count)
|
||
if count != 0 {
|
||
t.Fatal("attachment was not deleted")
|
||
}
|
||
}
|
||
|
||
func TestAttachmentCSRFEnforced(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
alice := e.login(t, "alice")
|
||
|
||
// 未携带 CSRF 令牌的 POST 必须在到达处理器前被拒绝。
|
||
var buf strings.Builder
|
||
mw := multipart.NewWriter(&buf)
|
||
fw, _ := mw.CreateFormFile("file", "hello.txt")
|
||
fw.Write([]byte("hello"))
|
||
mw.Close()
|
||
w := e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType())
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("upload without CSRF token: status = %d, want 403", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAttachmentAdminOverride(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
var bobArt models.Article
|
||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||
|
||
admin := e.login(t, "admin")
|
||
token := e.csrfTokenFor(t, admin)
|
||
|
||
// 管理员可以列出和上传到任意文章。
|
||
w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), admin, nil, "")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("admin list: status = %d, want 200", w.Code)
|
||
}
|
||
w = e.upload(t, admin, token, fmt.Sprint(bobArt.ID))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("admin upload: status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||
}
|
||
|
||
// 清理测试期间创建的文件(尽力而为)。
|
||
entries, _ := os.ReadDir(filepath.Join(e.storageDir, "attachments"))
|
||
for _, ent := range entries {
|
||
os.Remove(filepath.Join(e.storageDir, "attachments", ent.Name()))
|
||
}
|
||
}
|
||
|
||
func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) {
|
||
e := newSecurityTestEnv(t)
|
||
admin := e.login(t, "admin")
|
||
token := e.csrfTokenFor(t, admin)
|
||
|
||
var alice models.User
|
||
if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||
t.Fatalf("alice not found: %v", err)
|
||
}
|
||
|
||
// 恶意的 :id 值。在 #19 修复前,GORM 会把非数值的单一字符串条件
|
||
// 原样插值进 WHERE 子句(例如 WHERE 1 OR 1=1)。
|
||
ids := []string{
|
||
"1 OR 1=1",
|
||
"1;--",
|
||
"1) OR (1=1",
|
||
"1 UNION SELECT 1",
|
||
"alice",
|
||
}
|
||
|
||
for _, id := range ids {
|
||
// GET 编辑页必须重定向而非渲染匹配到的行。
|
||
w := e.do(http.MethodGet, "/admin/users/"+url.PathEscape(id)+"/edit", admin, nil, "")
|
||
if w.Code != http.StatusFound {
|
||
t.Fatalf("GET edit with id %q: status = %d, want 302", id, w.Code)
|
||
}
|
||
if loc := w.Header().Get("Location"); loc != "/admin/users" {
|
||
t.Fatalf("GET edit with id %q: location = %q, want /admin/users", id, loc)
|
||
}
|
||
|
||
// PUT 更新不得修改任何内容(尝试提权)。
|
||
w = postJSON(e, http.MethodPut, "/api/admin/users/"+url.PathEscape(id), admin, token,
|
||
gin.H{"role": models.RoleAdmin, "status": 1, "display_name": "hacked"})
|
||
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
|
||
t.Fatalf("PUT edit with id %q: status = %d, code = %q", id, w.Code, respCode(w))
|
||
}
|
||
|
||
// DELETE 删除不得删除任何内容。
|
||
w = postJSON(e, http.MethodDelete, "/api/admin/users/"+url.PathEscape(id), admin, token, nil)
|
||
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
|
||
t.Fatalf("DELETE user with id %q: status = %d, code = %q", id, w.Code, respCode(w))
|
||
}
|
||
}
|
||
|
||
// 任何载荷都不应修改或删除用户。
|
||
var count int64
|
||
e.db.Model(&models.User{}).Count(&count)
|
||
if count != 3 {
|
||
t.Fatalf("user count = %d, want 3 (injection removed rows)", count)
|
||
}
|
||
var check models.User
|
||
if err := e.db.Where("username = ?", "alice").First(&check).Error; err != nil {
|
||
t.Fatalf("alice gone: %v", err)
|
||
}
|
||
if check.Role != models.RoleAuthor || check.DisplayName != "alice" {
|
||
t.Fatalf("alice modified via id injection: role=%q display=%q", check.Role, check.DisplayName)
|
||
}
|
||
|
||
// 健全性检查:合法的数值 id 仍然有效。
|
||
w := e.do(http.MethodGet, fmt.Sprintf("/admin/users/%d/edit", alice.ID), admin, nil, "")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GET edit with valid id: status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
func userIDByUsername(t *testing.T, db *gorm.DB, username string) uint {
|
||
t.Helper()
|
||
var u models.User
|
||
if err := db.Where("username = ?", username).First(&u).Error; err != nil {
|
||
t.Fatalf("user %s not found: %v", username, err)
|
||
}
|
||
return u.ID
|
||
}
|