From 7f0beb5b0c3e4617abe940918789a7c98a45bbd2 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 19:59:25 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=20api=5Ftest.go?= =?UTF-8?q?=E2=80=94=E2=80=94/api=20=E8=AE=A4=E8=AF=81/=E8=B6=8A=E6=9D=83/?= =?UTF-8?q?=E6=96=87=E7=AB=A0/=E8=AF=84=E8=AE=BA/=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestAPIAuthRequiredReturnsJSON:未登录打 /api 路由返回 401 JSON (携带合法 CSRF 模拟真实前端顺序) - TestAPIAdminRequiredReturnsJSON:非管理员 403 api_forbidden - TestAPICSRFHeaderRequired:缺 CSRF 头 403 - TestAPILoginRoleRedirect:登录成功按角色返回 redirect(/admin | /) - TestAPIArticleCRUD:admin 创建(slug 自动生成)/更新/校验 400/软删除 - TestAPIMyArticlesOwnership:跨作者 update 404 / delete 无效 / 创建成功 - TestAPICommentValidationCodes:评论校验 code + 成功 redirect 锚点 - TestAPIRegisterConflictAndMismatch:409 用户名冲突 / 400 密码不一致 - 测试环境补齐:env 路由增 /api/admin/articles 与 /api/my/articles CRUD; attachments URL 迁 /api(upload/delete 改 DELETE+CSRF 头), p3 附件用例 URL 同步 - 新增 anonSession/csrfTokenFrom/loginRequest/itoa/deleteAttachment 辅助 - go build/vet/test ./... 全绿 --- handlers/api_test.go | 237 +++++++++++++++++++++++++++++++++++++ handlers/p3_upload_test.go | 4 +- handlers/security_test.go | 81 ++++++++++--- 3 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 handlers/api_test.go diff --git a/handlers/api_test.go b/handlers/api_test.go new file mode 100644 index 0000000..210f9c6 --- /dev/null +++ b/handlers/api_test.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// --- 认证中间件(/api 前缀) --- + +// TestAPIAuthRequiredReturnsJSON 断言 /api 路由未登录时返回 401 JSON +// 而非 302 重定向(页面路由保持原行为)。 +func TestAPIAuthRequiredReturnsJSON(t *testing.T) { + e := newSecurityTestEnv(t) + + cases := []struct { + method string + path string + body gin.H + }{ + {http.MethodPost, "/api/admin/articles", gin.H{"title": "x", "content": "y"}}, + {http.MethodPut, "/api/my/articles/1", gin.H{"title": "x"}}, + {http.MethodPost, "/api/profile", gin.H{"display_name": "x"}}, + {http.MethodDelete, "/api/my/articles/1", nil}, + } + for _, tc := range cases { + // 模拟真实前端:匿名会话已持有 CSRF 令牌(CSRF 中间件先于 + // AuthRequired 校验,无 token 的请求会先得到 403)。 + anonCookie, w := e.anonSession() + token := e.csrfTokenFrom(t, w) + w = postJSON(e, tc.method, tc.path, anonCookie, token, tc.body) + if w.Code != http.StatusUnauthorized || respCode(w) != "api_unauthorized" { + t.Fatalf("%s %s: status=%d code=%q body=%s, want 401/api_unauthorized", + tc.method, tc.path, w.Code, respCode(w), w.Body.String()) + } + } +} + +// TestAPIAdminRequiredReturnsJSON 断言非管理员访问 /api/admin 返回 403 JSON。 +func TestAPIAdminRequiredReturnsJSON(t *testing.T) { + e := newSecurityTestEnv(t) + alice := e.login(t, "alice") + token := e.csrfTokenFor(t, alice) + + w := postJSON(e, http.MethodPost, "/api/admin/users", alice, token, + gin.H{"username": "x", "password": "longenough"}) + if w.Code != http.StatusForbidden || respCode(w) != "api_forbidden" { + t.Fatalf("admin api: status=%d code=%q, want 403/api_forbidden", w.Code, respCode(w)) + } +} + +// TestAPICSRFHeaderRequired 断言 /api POST 缺少 CSRF 头时被中间件拒绝(403)。 +func TestAPICSRFHeaderRequired(t *testing.T) { + e := newSecurityTestEnv(t) + w := e.do(http.MethodPost, "/api/auth/login", "", nil, "application/json") + if w.Code != http.StatusForbidden { + t.Fatalf("csrf-less API POST: status=%d, want 403", w.Code) + } +} + +// --- 认证端点 --- + +// TestAPILoginRoleRedirect 断言登录成功按角色返回 redirect。 +func TestAPILoginRoleRedirect(t *testing.T) { + e := newSecurityTestEnv(t) + + // 管理员 → /admin + w := e.loginRequest(t, "admin", "pw-admin") + if w == nil { + t.Fatal("admin login failed") + } + if respRedirect(w) != "/admin" { + t.Fatalf("admin redirect=%q, want /admin", respRedirect(w)) + } + + // 普通用户 → / + w = e.loginRequest(t, "alice", "pw-alice") + if respRedirect(w) != "/" { + t.Fatalf("author redirect=%q, want /", respRedirect(w)) + } +} + +// --- 文章 CRUD API --- + +// TestAPIArticleCRUD 覆盖 admin 创建/更新/软删除文章的完整链路。 +func TestAPIArticleCRUD(t *testing.T) { + e := newSecurityTestEnv(t) + admin := e.login(t, "admin") + token := e.csrfTokenFor(t, admin) + + // 创建(草稿)。 + create := gin.H{"title": "api post", "status": "0", "content": "# hi"} + w := postJSON(e, http.MethodPost, "/api/admin/articles", admin, token, create) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("create: status=%d body=%s", w.Code, w.Body.String()) + } + var article models.Article + if err := e.db.Where("title = ?", "api post").First(&article).Error; err != nil { + t.Fatalf("load created article: %v", err) + } + if article.Slug == "" { + t.Fatal("created article has empty slug") + } + + // 更新。 + update := gin.H{"title": "api post v2", "content": "changed", "status": "1"} + w = postJSON(e, http.MethodPut, "/api/admin/articles/"+itoa(article.ID), admin, token, update) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("update: status=%d body=%s", w.Code, w.Body.String()) + } + if err := e.db.First(&article, article.ID).Error; err != nil { + t.Fatalf("reload article: %v", err) + } + if article.Title != "api post v2" || article.Status != models.ArticlePublished { + t.Fatalf("update not applied: title=%q status=%d", article.Title, article.Status) + } + + // 校验错误:缺标题 → 400。 + w = postJSON(e, http.MethodPost, "/api/admin/articles", admin, token, gin.H{"content": "x"}) + if w.Code != http.StatusBadRequest || respCode(w) != "article_title_required" { + t.Fatalf("missing title: status=%d code=%q", w.Code, respCode(w)) + } + + // 删除(软删除)。 + w = postJSON(e, http.MethodDelete, "/api/admin/articles/"+itoa(article.ID), admin, token, nil) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("delete: status=%d body=%s", w.Code, w.Body.String()) + } + var count int64 + e.db.Unscoped().Model(&models.Article{}).Where("id = ?", article.ID).Count(&count) + if count != 1 { + t.Fatal("article row missing after soft delete") + } +} + +// --- 我的文章 API --- + +// TestAPIMyArticlesOwnership 断言普通用户只能创建/更新/删除自己的文章。 +func TestAPIMyArticlesOwnership(t *testing.T) { + e := newSecurityTestEnv(t) + alice := e.login(t, "alice") + token := e.csrfTokenFor(t, alice) + bob := e.login(t, "bob") + bobToken := e.csrfTokenFor(t, bob) + + bobArt := models.Article{} + if err := e.db.Where("slug = ?", "bob-post").First(&bobArt).Error; err != nil { + t.Fatalf("load bob article: %v", err) + } + + // bob 不能更新 alice 的文章 → 404。 + aliceArt := models.Article{} + if err := e.db.Where("slug = ?", "alice-post").First(&aliceArt).Error; err != nil { + t.Fatalf("load alice article: %v", err) + } + w := postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(aliceArt.ID), bob, bobToken, + gin.H{"title": "hijacked"}) + if w.Code != http.StatusNotFound || respCode(w) != "article_not_found" { + t.Fatalf("cross-owner update: status=%d code=%q, want 404/article_not_found", w.Code, respCode(w)) + } + w = postJSON(e, http.MethodDelete, "/api/my/articles/"+itoa(aliceArt.ID), bob, bobToken, nil) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("cross-owner delete: status=%d body=%s", w.Code, w.Body.String()) + } + // 删除只影响自己的行:bob 的删除目标仍是 alice 行 —— author_id 过滤 + // 使 DELETE 命中 0 行,实际上等价于无操作;验证 alice 文章仍在。 + var count int64 + e.db.Model(&models.Article{}).Where("slug = ?", "alice-post").Count(&count) + if count != 1 { + t.Fatal("alice article affected by bob's delete") + } + + // alice 创建自己的文章。 + w = postJSON(e, http.MethodPost, "/api/my/articles", alice, token, + gin.H{"title": "my post", "content": "hi"}) + if w.Code != http.StatusOK || respRedirect(w) != "/my/articles" { + t.Fatalf("my create: status=%d body=%s", w.Code, w.Body.String()) + } +} + +// --- 评论 API --- + +// TestAPICommentValidationCodes 断言评论校验错误带语义化 code。 +func TestAPICommentValidationCodes(t *testing.T) { + e := newSecurityTestEnv(t) + anonCookie, w := e.anonSession() + token := e.csrfTokenFrom(t, w) + + w = postJSON(e, http.MethodPost, "/api/article/alice-post/comments", anonCookie, token, gin.H{}) + if w.Code != http.StatusBadRequest || respCode(w) != "comments_required_name" { + t.Fatalf("empty comment: status=%d code=%q", w.Code, respCode(w)) + } + + w = postJSON(e, http.MethodPost, "/api/article/alice-post/comments", anonCookie, token, gin.H{ + "name": "x", "email": "x@example.com", "content": "ok", + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("valid comment: status=%d body=%s", w.Code, w.Body.String()) + } + if respRedirect(w) != "/article/alice-post#comment-1" { + t.Fatalf("comment redirect=%q", respRedirect(w)) + } +} + +// --- 注册 API --- + +// TestAPIRegisterConflictAndMismatch 断言用户名冲突 409、密码不一致 400。 +func TestAPIRegisterConflictAndMismatch(t *testing.T) { + e := newSecurityTestEnv(t) + if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil { + t.Fatalf("enable registration: %v", err) + } + // 匿名会话 + CSRF。 + req, w := e.anonSession() + token := e.csrfTokenFrom(t, w) + + body := gin.H{ + "username": "alice", + "password": "secret1", + "confirm_password": "secret1", + } + ww := postJSON(e, http.MethodPost, "/api/auth/register", req, token, body) + if ww.Code != http.StatusConflict || respCode(ww) != "user_username_exists" { + t.Fatalf("duplicate username: status=%d code=%q, want 409/user_username_exists", + ww.Code, respCode(ww)) + } + + body["username"] = "newuser" + body["confirm_password"] = "different" + ww = postJSON(e, http.MethodPost, "/api/auth/register", req, token, body) + if ww.Code != http.StatusBadRequest || respCode(ww) != "register_password_mismatch" { + t.Fatalf("password mismatch: status=%d code=%q", ww.Code, respCode(ww)) + } +} diff --git a/handlers/p3_upload_test.go b/handlers/p3_upload_test.go index af89838..73cb8d4 100644 --- a/handlers/p3_upload_test.go +++ b/handlers/p3_upload_test.go @@ -31,7 +31,7 @@ func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { fw, _ := mw.CreateFormFile("file", "photo.txt") fw.Write(pngBytes(t)) mw.Close() - w := e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) + w := e.do(http.MethodPost, "/api/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) if w.Code != http.StatusBadRequest { t.Fatalf("mismatched content: status = %d, want 400 (body %s)", w.Code, w.Body.String()) } @@ -44,7 +44,7 @@ func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { fw, _ = mw.CreateFormFile("file", "notes.txt") fw.Write([]byte("hello plain text")) mw.Close() - w = e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) + w = e.do(http.MethodPost, "/api/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType()) if w.Code != http.StatusOK { t.Fatalf("genuine text upload: status = %d, want 200 (body %s)", w.Code, w.Body.String()) } diff --git a/handlers/security_test.go b/handlers/security_test.go index 13cbaae..956fb57 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "testing" @@ -95,15 +96,22 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { 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) }) } + myAPI := r.Group("/api/my/articles", middleware.AuthRequired(db)) + { + myAPI.POST("", MyArticleCreate(db)) + myAPI.PUT("/:id", MyArticleUpdate(db)) + myAPI.DELETE("/:id", MyArticleDelete(db)) + myAPI.POST("/attachments", UploadAttachment(db, storageDir)) + myAPI.DELETE("/attachments/:id", DeleteAttachment(db, storageDir)) + myAPI.GET("/:id/attachments", ListAttachments(db)) + } + // 个人资料 API(头像上传 XSS 链回归覆盖,#21)。 profileAPI := r.Group("/api/profile", middleware.AuthRequired(db)) { @@ -131,6 +139,13 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { usersAPI.DELETE("/:id", UserDelete(db)) } + articleAPI := r.Group("/api/admin/articles", middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + articleAPI.POST("", ArticleCreate(db, "/admin")) + articleAPI.PUT("/:id", ArticleUpdate(db, "/admin/articles")) + articleAPI.DELETE("/:id", ArticleDelete(db, "/admin/articles")) + } + return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter} } @@ -260,11 +275,24 @@ func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID stri } 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()) + req := httptest.NewRequest(http.MethodPost, "/api/my/articles/attachments", strings.NewReader(buf.String())) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("X-CSRF-Token", csrfToken) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + return w +} + +// deleteAttachment 以 DELETE + CSRF 头删除附件。 +func (e *securityTestEnv) deleteAttachment(t *testing.T, cookie, csrfToken string, id uint) *httptest.ResponseRecorder { + t.Helper() + return postJSON(e, http.MethodDelete, fmt.Sprintf("/api/my/articles/attachments/%d", id), cookie, csrfToken, nil) } // csrfTokenFor 为已认证会话获取一个全新的 CSRF 令牌。 @@ -274,6 +302,18 @@ func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string { if w.Code != http.StatusOK { t.Fatalf("GET /login: status = %d", w.Code) } + return e.csrfTokenFrom(t, w) +} + +// anonSession 获取匿名会话 Cookie(GET /login)。 +func (e *securityTestEnv) anonSession() (string, *httptest.ResponseRecorder) { + w := e.do(http.MethodGet, "/login", "", nil, "") + return e.sessionCookie(w), w +} + +// csrfTokenFrom 从 GET /login 响应体解析 CSRF 令牌。 +func (e *securityTestEnv) csrfTokenFrom(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() m := csrfTokenRe.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatal("login page did not render a CSRF token") @@ -281,6 +321,20 @@ func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string { return m[1] } +// loginRequest 以 JSON 方式以给定凭据提交登录,返回响应。 +func (e *securityTestEnv) loginRequest(t *testing.T, username, password string) *httptest.ResponseRecorder { + t.Helper() + anonCookie, w := e.anonSession() + token := e.csrfTokenFrom(t, w) + return postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token, + gin.H{"username": username, "password": password}) +} + +// itoa 将 uint 转为十进制字符串(测试辅助)。 +func itoa(v uint) string { + return strconv.FormatUint(uint64(v), 10) +} + func TestLoginRotatesSession(t *testing.T) { e := newSecurityTestEnv(t) @@ -320,13 +374,13 @@ func TestAttachmentListRequiresOwnership(t *testing.T) { alice := e.login(t, "alice") // 自己的文章:允许。 - w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", aliceArt.ID), alice, nil, "") + w := e.do(http.MethodGet, fmt.Sprintf("/api/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, "") + w = e.do(http.MethodGet, fmt.Sprintf("/api/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) } @@ -382,18 +436,13 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) { } // 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") + w = e.deleteAttachment(t, alice, token, bobAtt.ID) 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") + w = e.deleteAttachment(t, bob, bobToken, bobAtt.ID) if w.Code != http.StatusOK { t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String()) } @@ -416,7 +465,7 @@ func TestAttachmentCSRFEnforced(t *testing.T) { 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()) + w := e.do(http.MethodPost, "/api/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) } @@ -431,7 +480,7 @@ func TestAttachmentAdminOverride(t *testing.T) { token := e.csrfTokenFor(t, admin) // 管理员可以列出和上传到任意文章。 - w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), admin, nil, "") + w := e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", bobArt.ID), admin, nil, "") if w.Code != http.StatusOK { t.Fatalf("admin list: status = %d, want 200", w.Code) }