- 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 ./... 全绿
238 lines
8.4 KiB
Go
238 lines
8.4 KiB
Go
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))
|
|
}
|
|
}
|