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) } } }