Files
go_blog/handlers/p3_upload_test.go
T
kevin 7f0beb5b0c 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 ./... 全绿
2026-08-27 19:59:25 +08:00

156 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"go_blog/models"
)
// TestUploadAttachmentRejectsMismatchedContent 覆盖 SECURITY_TODO #14
// 扩展名白名单仅是头部级别的;字节必须与配置的 MIME 类型匹配
//(携带 PNG 字节的 .txt 文件是伪装载荷)。
func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
var aliceArt models.Article
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
artID := strconv.FormatUint(uint64(aliceArt.ID), 10)
// 声称 .txt、实际 PNG 字节 -> 拒绝 400。
var buf strings.Builder
mw := multipart.NewWriter(&buf)
mw.WriteField("article_id", artID)
mw.WriteField("_csrf", token)
fw, _ := mw.CreateFormFile("file", "photo.txt")
fw.Write(pngBytes(t))
mw.Close()
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())
}
// 真正的文本可通过。
buf.Reset()
mw = multipart.NewWriter(&buf)
mw.WriteField("article_id", artID)
mw.WriteField("_csrf", token)
fw, _ = mw.CreateFormFile("file", "notes.txt")
fw.Write([]byte("hello plain text"))
mw.Close()
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())
}
}
// TestRSSUsesConfiguredSiteURL 覆盖 SECURITY_TODO #16:配置后 feed 链接使用
// 规范化的站点 URL,否则回退到请求的 Host(并输出日志警告)。
func TestRSSUsesConfiguredSiteURL(t *testing.T) {
e := newSecurityTestEnv(t)
// 未设置:回退到请求的 Host。
req := httptest.NewRequest(http.MethodGet, "/rss", nil)
req.Host = "evil.example.com"
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /rss: status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), "http://evil.example.com") {
t.Fatal("fallback did not use the request Host")
}
// 已配置:固定 URL 生效,Host 头被忽略。
var s models.SiteSetting
if err := e.db.First(&s, 1).Error; err != nil {
t.Fatalf("load site setting: %v", err)
}
s.SiteURL = "https://blog.example.com"
if err := e.db.Save(&s).Error; err != nil {
t.Fatalf("save site setting: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/rss", nil)
req.Host = "evil.example.com"
w = httptest.NewRecorder()
e.router.ServeHTTP(w, req)
body := w.Body.String()
if !strings.Contains(body, "https://blog.example.com") {
t.Fatal("configured SiteURL was not used in the feed")
}
if strings.Contains(body, "evil.example.com") {
t.Fatal("request Host leaked into RSS link despite SiteURL being set")
}
}
// TestAdminCommentListFollowsGravatarSwitch 覆盖 SECURITY_TODO #15
// 平台开关关闭时后台审核列表不输出 Gravatar URL
// 管理员重新启用后再使用。
func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) {
e := newSecurityTestEnv(t)
admin := e.login(t, "admin")
var art models.Article
e.db.Where("slug = ?", "alice-post").First(&art)
e.db.Create(&models.Comment{
ArticleID: art.ID, AuthorName: "Ann", Email: "ann@example.com",
Content: "hello", Status: models.CommentApproved, IPAddress: "127.0.0.1",
})
// 关闭(新默认):列表中没有 gravatar.com 条目。
w := e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("GET /admin/comments: status = %d", w.Code)
}
if strings.Contains(w.Body.String(), "gravatar.com") {
t.Fatal("admin comment list emitted Gravatar URLs while disabled")
}
// 开启:Gravatar URL 出现(遵循平台策略)。
e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("use_gravatar", true)
models.LoadConfigCache(e.db)
w = e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("GET /admin/comments (enabled): status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), "gravatar.com") {
t.Fatal("admin comment list missing Gravatar URLs while enabled")
}
}
// TestContentMatchesTypeTable 驱动纯匹配函数(SECURITY_TODO #14)。
func TestContentMatchesTypeTable(t *testing.T) {
txt := &models.UploadFileType{MimeType: "text/plain"}
pngType := &models.UploadFileType{MimeType: "image/png"}
noPolicy := &models.UploadFileType{MimeType: ""}
cases := []struct {
name string
typ *models.UploadFileType
content []byte
want bool
}{
{"txt-real", txt, []byte("just text content"), true},
{"txt-png-bytes", txt, pngBytes(t), false},
{"png-real", pngType, pngBytes(t), true},
{"png-text-bytes", pngType, []byte("not an image at all"), false},
{"empty-policy", noPolicy, pngBytes(t), true},
{"empty-content", txt, nil, true},
{"octet-stream-wildcard", &models.UploadFileType{MimeType: "application/octet-stream"}, pngBytes(t), true},
{"charset-parameter", &models.UploadFileType{MimeType: "text/plain; charset=utf-8"}, []byte("abc"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := contentMatchesType(tc.typ, tc.content); got != tc.want {
t.Fatalf("contentMatchesType(%q) = %v, want %v", tc.typ.MimeType, got, tc.want)
}
})
}
}