test: 新增 api_test.go——/api 认证/越权/文章/评论/注册端到端断言

- 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 ./... 全绿
This commit is contained in:
2026-08-27 19:59:25 +08:00
parent 76b744d5b5
commit 7f0beb5b0c
3 changed files with 304 additions and 18 deletions
+237
View File
@@ -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))
}
}
+2 -2
View File
@@ -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())
}
+65 -16
View File
@@ -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 获取匿名会话 CookieGET /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)
}