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