Files
go_blog/main_test.go
kevin efc30156b9 feat: 站点设置接口 JSON 化——/api/admin/settings/*
- settings.go:5 组 save handler 改 JSON 绑定(siteSettingsRequest/
  uploadSettingsRequest/downloadSettingsRequest/commentSettingsRequest/
  navLinkSettingsRequest);request struct 变更子函数签名(不再触碰
  c.PostForm),upload/navlink/download 的 action 分发保留,未知
  action 返回 400;enabled 用 *bool(nil 沿用旧默认启用语义)
- favicon/logo 上传拆出:POST /api/admin/settings/site/favicon|logo
  (multipart,图片类别校验 + 旧本地文件替换),SiteSettingsSave 只
  处理文本/URL/clear(存储路径校验保留 illegal_dir 400)
- main.go:设置旧 POST 路由移除,新 /api/admin/settings 分组注册
- 模板:base.html 新增 blogSettingsForm 委托(data-api-url/data-action/
  data-confirm → POST + redirect/alert);settings_site 主表单 JSON +
  logo/favicon 选择即上传;navlinks/upload/download/comments 页全部
  小表单改委托(约 14 个)
- blogForm:剔除 file 字段(文件走 multipart)
- 测试:TestStorageDirTraversalRejected、TestAddUploadFileTypeRejectsDangerousExtensions
  更新 JSON 断言;env 路由补 /api/admin/settings
- main_test 冒烟补设置 API 断言;go build/vet/test 全绿
2026-08-27 19:52:07 +08:00

196 lines
6.3 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"go_blog/config"
"go_blog/handlers"
)
// newUploadsRouter 在模拟真实布局的临时存储根目录上构建
// 使用生产上传路由的路由器:SQLite 数据库文件位于根目录中,
// 上传文件位于子目录中。
func newUploadsRouter(t *testing.T, storageDir string) (*gin.Engine, string) {
t.Helper()
gin.SetMode(gin.TestMode)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "blog.db"), []byte("fake sqlite"), 0644); err != nil {
t.Fatalf("seed blog.db: %v", err)
}
r := gin.New()
registerUploadRoutes(r.Group("/uploads"), root, storageDir)
return r, root
}
func doGet(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
func seedUploadFile(t *testing.T, root, sub, name string) {
t.Helper()
dir := filepath.Join(root, sub)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte("content"), 0644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
func TestUploadsWhitelistHidesStorageRoot(t *testing.T) {
r, root := newUploadsRouter(t, "")
for _, sub := range []string{"attachments", "avatars", "logos"} {
seedUploadFile(t, root, sub, "file.txt")
}
// 存储根目录中的数据库文件必须不可下载。
if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound {
t.Fatalf("GET /uploads/blog.db = %d, want 404 (database leak)", w.Code)
}
// 任何地方都不允许目录列表。
for _, p := range []string{
"/uploads", "/uploads/",
"/uploads/attachments/", "/uploads/avatars/", "/uploads/logos/",
} {
if w := doGet(t, r, p); w.Code != http.StatusNotFound {
t.Fatalf("GET %s = %d, want 404 (no directory listing)", p, w.Code)
}
}
// 路径穿越尝试不得逃逸出子目录。
for _, p := range []string{
"/uploads/attachments/../blog.db",
"/uploads/attachments/..%2f..%2fblog.db",
"/uploads/attachments/%2e%2e/blog.db",
} {
if w := doGet(t, r, p); w.Code == http.StatusOK {
t.Fatalf("GET %s = %d, want non-200 (traversal)", p, w.Code)
}
}
// 白名单子目录中的文件仍然可以访问。
for _, p := range []string{
"/uploads/attachments/file.txt",
"/uploads/avatars/file.txt",
"/uploads/logos/file.txt",
} {
if w := doGet(t, r, p); w.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", p, w.Code)
}
}
}
func TestUploadsWhitelistCustomStorageDir(t *testing.T) {
r, root := newUploadsRouter(t, "files")
seedUploadFile(t, root, "files", "a.bin")
if w := doGet(t, r, "/uploads/files/a.bin"); w.Code != http.StatusOK {
t.Fatalf("GET /uploads/files/a.bin = %d, want 200", w.Code)
}
// 默认目录保持挂载以向后兼容。
seedUploadFile(t, root, "attachments", "old.txt")
if w := doGet(t, r, "/uploads/attachments/old.txt"); w.Code != http.StatusOK {
t.Fatalf("GET /uploads/attachments/old.txt = %d, want 200", w.Code)
}
if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound {
t.Fatalf("GET /uploads/blog.db = %d, want 404", w.Code)
}
}
func TestUploadsWhitelistUnsafeStorageDirFallsBack(t *testing.T) {
for _, dir := range []string{"../evil", "/etc", "..", "a/../../b", "."} {
r, root := newUploadsRouter(t, dir)
seedUploadFile(t, root, "attachments", "file.txt")
if w := doGet(t, r, "/uploads/attachments/file.txt"); w.Code != http.StatusOK {
t.Fatalf("storage dir %q: fallback mount broken: %d", dir, w.Code)
}
if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound {
t.Fatalf("storage dir %q: /uploads/blog.db = %d, want 404", dir, w.Code)
}
}
}
func TestUploadsWhitelistStorageDirDedup(t *testing.T) {
// 与已知目录相同的存储目录不能因重复路由而 panic。
r, root := newUploadsRouter(t, "avatars")
seedUploadFile(t, root, "avatars", "me.jpg")
if w := doGet(t, r, "/uploads/avatars/me.jpg"); w.Code != http.StatusOK {
t.Fatalf("GET /uploads/avatars/me.jpg = %d, want 200", w.Code)
}
}
// TestRegisterRoutesSmoke 通过完整的路由注册冒烟测试:
// 1. 路由冲突(静态段 attachment 与 :id 参数段共存)会在此处 panic;
// 2. 断言 /api 搬移端点已在正确的 HTTP 方法下注册。
func TestRegisterRoutesSmoke(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
registerRoutes(r, &config.Config{}, nil, handlers.NewLoginLimiter())
want := map[string]string{
// 既有 JSON API。
"GET /api/articles": "",
// 认证 API。
"POST /api/auth/login": "",
"POST /api/auth/register": "",
"POST /api/auth/logout": "",
// 评论 API。
"POST /api/article/:slug/comments": "",
"POST /api/admin/comments/:id/approve": "",
"POST /api/admin/comments/:id/reject": "",
"POST /api/admin/comments/:id/delete": "",
// 文章 CRUD API。
"POST /api/admin/articles": "",
"PUT /api/admin/articles/:id": "",
"DELETE /api/admin/articles/:id": "",
"POST /api/my/articles": "",
"PUT /api/my/articles/:id": "",
"DELETE /api/my/articles/:id": "",
// 用户 CRUD API。
"POST /api/admin/users": "",
"PUT /api/admin/users/:id": "",
"DELETE /api/admin/users/:id": "",
// 设置 API。
"POST /api/admin/settings/site": "",
"POST /api/admin/settings/site/favicon": "",
"POST /api/admin/settings/site/logo": "",
"POST /api/admin/settings/navlinks": "",
"POST /api/admin/settings/upload": "",
"POST /api/admin/settings/download": "",
"POST /api/admin/settings/comments": "",
// 搬移的附件/头像端点。
"POST /api/admin/articles/attachments": "",
"DELETE /api/admin/articles/attachments/:id": "",
"GET /api/admin/articles/:id/attachments": "",
"POST /api/profile/avatar": "",
"POST /api/profile": "",
"POST /api/my/articles/attachments": "",
"DELETE /api/my/articles/attachments/:id": "",
"GET /api/my/articles/:id/attachments": "",
}
for route := range want {
found := false
for _, rt := range r.Routes() {
if rt.Method+" "+rt.Path == route {
found = true
break
}
}
if !found {
t.Errorf("route %s not registered", route)
}
}
}