lolly/internal/handler/static_test.go
xfy b6f8894d78 test(handler,middleware,server): 新增 try_files、错误页面、pprof 单元测试
- static_test.go: 新增 try_files 配置解析、占位符解析、SPA 场景测试
- errorpage_test.go: 新增错误页面管理器完整测试覆盖
- errorintercept_test.go: 新增错误拦截中间件功能测试
- pprof_test.go: 新增 pprof 性能分析端点测试

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 11:15:39 +08:00

1142 lines
33 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 handler 提供静态文件处理器功能的测试。
//
// 该文件测试静态文件处理器模块的各项功能,包括:
// - 正常文件访问
// - 嵌套路径文件
// - 目录索引文件
// - 路径遍历安全检查
// - 索引文件优先级
// - 构造函数
// - 文件缓存设置
// - Gzip 静态文件设置
// - HEAD 请求处理
// - 预压缩文件支持
// - 大文件处理
// - 符号链接处理
//
// 作者xfy
package handler
import (
"os"
"path/filepath"
"testing"
"github.com/valyala/fasthttp"
)
// newTestHandler 创建测试用的静态文件处理器
func newTestHandler(t *testing.T, root string) *StaticHandler {
t.Helper()
return NewStaticHandler(root, "/", []string{"index.html", "index.htm"}, false) // 测试时禁用 sendfile
}
// newTestContext 创建测试用的 fasthttp 请求上下文
func newTestContext(t *testing.T, path string) *fasthttp.RequestCtx {
t.Helper()
var ctx fasthttp.RequestCtx
ctx.Request.SetRequestURI(path)
return &ctx
}
// TestStaticHandlerHandle 测试静态文件处理器
func TestStaticHandlerHandle(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string) // 在临时目录中设置测试文件
path string // 请求路径
wantStatus int // 期望的 HTTP 状态码
wantContent string // 期望的响应内容(可选)
skipContent bool // 是否跳过内容验证
}{
{
name: "正常文件访问",
setup: func(t *testing.T, root string) {
t.Helper()
content := "hello world"
if err := os.WriteFile(filepath.Join(root, "test.txt"), []byte(content), 0644); err != nil {
t.Fatalf("创建测试文件失败: %v", err)
}
},
path: "/test.txt",
wantStatus: fasthttp.StatusOK,
wantContent: "hello world",
},
{
name: "嵌套路径文件",
setup: func(t *testing.T, root string) {
t.Helper()
subDir := filepath.Join(root, "sub", "dir")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("创建子目录失败: %v", err)
}
content := "nested file content"
if err := os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte(content), 0644); err != nil {
t.Fatalf("创建嵌套文件失败: %v", err)
}
},
path: "/sub/dir/nested.txt",
wantStatus: fasthttp.StatusOK,
wantContent: "nested file content",
},
{
name: "目录带索引文件",
setup: func(t *testing.T, root string) {
t.Helper()
dir := filepath.Join(root, "withindex")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
content := "index page"
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0644); err != nil {
t.Fatalf("创建索引文件失败: %v", err)
}
},
path: "/withindex/",
wantStatus: fasthttp.StatusOK,
wantContent: "index page",
},
{
name: "目录无索引文件",
setup: func(t *testing.T, root string) {
t.Helper()
dir := filepath.Join(root, "noindex")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
},
path: "/noindex/",
wantStatus: fasthttp.StatusForbidden,
skipContent: true,
},
{
name: "文件不存在",
setup: func(t *testing.T, root string) {
t.Helper()
// 不创建任何文件
},
path: "/nonexistent.txt",
wantStatus: fasthttp.StatusNotFound,
skipContent: true,
},
{
name: "空路径访问根目录无索引",
setup: func(t *testing.T, root string) {
t.Helper()
// root 目录没有索引文件
},
path: "/",
wantStatus: fasthttp.StatusForbidden,
skipContent: true,
},
{
name: "根目录有索引文件",
setup: func(t *testing.T, root string) {
t.Helper()
content := "root index"
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte(content), 0644); err != nil {
t.Fatalf("创建根索引文件失败: %v", err)
}
},
path: "/",
wantStatus: fasthttp.StatusOK,
wantContent: "root index",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建临时目录
tmpDir := t.TempDir()
// 设置测试文件
tt.setup(t, tmpDir)
// 创建处理器和上下文
handler := newTestHandler(t, tmpDir)
ctx := newTestContext(t, tt.path)
// 执行请求
handler.Handle(ctx)
// 验证状态码
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("Handle() 状态码 = %d, want %d", got, tt.wantStatus)
}
// 验证内容(如果需要)
if !tt.skipContent && tt.wantContent != "" {
got := string(ctx.Response.Body())
if got != tt.wantContent {
t.Errorf("Handle() 内容 = %q, want %q", got, tt.wantContent)
}
}
})
}
}
// TestStaticHandlerHandle_PathTraversalSecurity 测试路径遍历安全检查
// 注意fasthttp 会自动规范化路径,移除 ../ 组件
// 安全检查 strings.Contains(path, "..") 检测文件名中包含 ".." 的情况
func TestStaticHandlerHandle_PathTraversalSecurity(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string)
path string
wantStatus int
description string // 说明测试预期行为
}{
{
name: "文件名包含双点 - 安全检查拦截",
setup: func(t *testing.T, root string) {
t.Helper()
// 不创建任何文件
},
path: "/file..txt",
wantStatus: fasthttp.StatusForbidden,
description: "路径包含 '..' 字符串,触发安全检查返回 403",
},
{
name: "路径末尾双点 - 安全检查拦截",
setup: func(t *testing.T, root string) {
t.Helper()
},
path: "/foo/..",
wantStatus: fasthttp.StatusForbidden,
description: "路径末尾包含 '..',触发安全检查返回 403",
},
{
name: "隐藏文件 .hidden - 文件不存在",
setup: func(t *testing.T, root string) {
t.Helper()
},
path: "/.hidden",
wantStatus: fasthttp.StatusNotFound,
description: "单点开头的隐藏文件不触发安全检查,文件不存在返回 404",
},
{
name: "文件名包含多点 ...txt - 安全检查拦截",
setup: func(t *testing.T, root string) {
t.Helper()
},
path: "/file...txt",
wantStatus: fasthttp.StatusForbidden,
description: "包含连续多点(含 '..')触发安全检查返回 403",
},
{
name: "fasthttp 规范化后的路径 - 文件不存在",
setup: func(t *testing.T, root string) {
t.Helper()
// fasthttp 将 /../secret.txt 规范化为 /secret.txt
},
path: "/../secret.txt",
wantStatus: fasthttp.StatusNotFound,
description: "fasthttp 自动规范化路径移除 ../,结果路径文件不存在返回 404",
},
{
name: "URL 编码路径遍历 - fasthttp 规范化",
setup: func(t *testing.T, root string) {
t.Helper()
// fasthttp 解码 %2e%2e 为 .. 并规范化路径
},
path: "/%2e%2e/secret.txt",
wantStatus: fasthttp.StatusNotFound,
description: "fasthttp 解码 URL 编码后规范化路径,文件不存在返回 404",
},
{
name: "混合 URL 编码 - fasthttp 规范化",
setup: func(t *testing.T, root string) {
t.Helper()
},
path: "/%2e%2e%2fsecret.txt",
wantStatus: fasthttp.StatusNotFound,
description: "fasthttp 解码并规范化路径,文件不存在返回 404",
},
{
name: "路径中含 ../ - fasthttp 规范化",
setup: func(t *testing.T, root string) {
t.Helper()
// 创建目标文件供测试
if err := os.WriteFile(filepath.Join(root, "bar.txt"), []byte("bar"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
},
path: "/foo/../bar.txt",
wantStatus: fasthttp.StatusOK,
description: "fasthttp 规范化 /foo/../bar.txt 为 /bar.txt文件存在返回 200",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tt.setup(t, tmpDir)
handler := newTestHandler(t, tmpDir)
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("Handle() 状态码 = %d, want %d\n说明: %s", got, tt.wantStatus, tt.description)
}
})
}
}
// TestStaticHandlerHandle_IndexFallback 测试索引文件优先级
func TestStaticHandlerHandle_IndexFallback(t *testing.T) {
t.Run("优先 index.html", func(t *testing.T) {
tmpDir := t.TempDir()
dir := filepath.Join(tmpDir, "testdir")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
// 创建两个索引文件
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("html content"), 0644); err != nil {
t.Fatalf("创建 index.html 失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "index.htm"), []byte("htm content"), 0644); err != nil {
t.Fatalf("创建 index.htm 失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
ctx := newTestContext(t, "/testdir/")
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
// 应返回 index.html 而非 index.htm
got := string(ctx.Response.Body())
if got != "html content" {
t.Errorf("内容 = %q, want %q", got, "html content")
}
})
t.Run("无 index.html 时使用 index.htm", func(t *testing.T) {
tmpDir := t.TempDir()
dir := filepath.Join(tmpDir, "testdir")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
// 仅创建 index.htm
if err := os.WriteFile(filepath.Join(dir, "index.htm"), []byte("htm content"), 0644); err != nil {
t.Fatalf("创建 index.htm 失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
ctx := newTestContext(t, "/testdir/")
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
got := string(ctx.Response.Body())
if got != "htm content" {
t.Errorf("内容 = %q, want %q", got, "htm content")
}
})
t.Run("无索引文件时返回 403", func(t *testing.T) {
tmpDir := t.TempDir()
dir := filepath.Join(tmpDir, "testdir")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
// 创建一个非索引文件
if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("other content"), 0644); err != nil {
t.Fatalf("创建 other.txt 失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
ctx := newTestContext(t, "/testdir/")
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != fasthttp.StatusForbidden {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusForbidden)
}
})
t.Run("目录不带斜杠结尾", func(t *testing.T) {
tmpDir := t.TempDir()
dir := filepath.Join(tmpDir, "testdir")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
// 创建索引文件
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0644); err != nil {
t.Fatalf("创建 index.html 失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
ctx := newTestContext(t, "/testdir") // 不带斜杠
handler.Handle(ctx)
// 目录不带斜杠也应该能访问索引文件
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
})
}
// TestNewStaticHandler 测试静态文件处理器构造函数
func TestNewStaticHandler(t *testing.T) {
t.Run("正常创建", func(t *testing.T) {
root := "/var/www"
index := []string{"index.html", "index.htm"}
handler := NewStaticHandler(root, "/", index, true)
if handler == nil {
t.Fatal("NewStaticHandler() 返回 nil")
}
if handler.root != root {
t.Errorf("handler.root = %q, want %q", handler.root, root)
}
if len(handler.index) != len(index) {
t.Errorf("len(handler.index) = %d, want %d", len(handler.index), len(index))
}
})
t.Run("空索引列表", func(t *testing.T) {
handler := NewStaticHandler("/var/www", "/", nil, false)
if handler == nil {
t.Fatal("NewStaticHandler() 返回 nil")
}
if handler.index != nil {
t.Errorf("handler.index 应为 nil")
}
})
}
// TestStaticHandler_SetFileCache 测试设置文件缓存
func TestStaticHandler_SetFileCache(t *testing.T) {
handler := NewStaticHandler("/var/www", "/", nil, false)
// 设置 nil 缓存
handler.SetFileCache(nil)
if handler.fileCache != nil {
t.Error("Expected nil fileCache")
}
// 设置非 nil 缓存(使用 mock 或简单验证)
// 由于 FileCache 接口需要实现,这里主要验证不会 panic
}
// TestStaticHandler_SetGzipStatic 测试设置 Gzip 静态文件
func TestStaticHandler_SetGzipStatic(t *testing.T) {
handler := NewStaticHandler("/var/www", "/", nil, false)
// 启用 gzip
handler.SetGzipStatic(true, []string{".gz", ".gzip"})
if handler.gzipStatic == nil {
t.Error("Expected gzipStatic to be non-nil")
}
// 禁用 gzip
handler.SetGzipStatic(false, nil)
// gzipStatic 保持不变SetGzipStatic 只在 enabled=true 时设置)
}
// TestStaticHandler_Handle_HeadRequest 测试 HEAD 请求
func TestStaticHandler_Handle_HeadRequest(t *testing.T) {
tmpDir := t.TempDir()
content := "head request test"
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644); err != nil {
t.Fatalf("创建测试文件失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test.txt")
ctx.Request.Header.SetMethod("HEAD")
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
// 验证 Content-Type 设置正确
ct := string(ctx.Response.Header.ContentType())
if ct == "" {
t.Error("Expected Content-Type to be set")
}
}
// TestStaticHandler_Handle_WithCache 测试带缓存的文件处理
func TestStaticHandler_Handle_WithCache(t *testing.T) {
tmpDir := t.TempDir()
content := "cached content test"
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644); err != nil {
t.Fatalf("创建测试文件失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
// 设置 nil 缓存,验证不会 panic
handler.SetFileCache(nil)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test.txt")
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
}
// TestStaticHandler_Handle_Precompressed 测试预压缩文件
func TestStaticHandler_Handle_Precompressed(t *testing.T) {
tmpDir := t.TempDir()
// 创建原始文件和 gzip 压缩版本
content := "test content for gzip"
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644); err != nil {
t.Fatalf("创建测试文件失败: %v", err)
}
// 创建 .gz 文件(模拟预压缩)
gzContent := []byte("gzipped content")
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt.gz"), gzContent, 0644); err != nil {
t.Fatalf("创建 gzip 文件失败: %v", err)
}
handler := NewStaticHandler(tmpDir, "/", nil, false)
handler.SetGzipStatic(true, []string{".gz"})
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test.txt")
ctx.Request.Header.Set("Accept-Encoding", "gzip")
handler.Handle(ctx)
// 应返回预压缩内容
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
}
// TestStaticHandler_Handle_LargeFile 测试大文件处理
func TestStaticHandler_Handle_LargeFile(t *testing.T) {
tmpDir := t.TempDir()
// 创建一个较大的文件 (> 8KB 触发 sendfile 路径)
largeContent := make([]byte, 16*1024)
for i := range largeContent {
largeContent[i] = byte(i % 256)
}
tmpFile := filepath.Join(tmpDir, "large.bin")
if err := os.WriteFile(tmpFile, largeContent, 0644); err != nil {
t.Fatalf("创建大文件失败: %v", err)
}
// 使用 sendfile 启用的处理器
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, true)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/large.bin")
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
}
// TestStaticHandler_Handle_Symlink 测试符号链接处理
func TestStaticHandler_Handle_Symlink(t *testing.T) {
tmpDir := t.TempDir()
// 创建目标文件
targetContent := "symlink target"
targetFile := filepath.Join(tmpDir, "target.txt")
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("创建目标文件失败: %v", err)
}
// 创建符号链接
linkFile := filepath.Join(tmpDir, "link.txt")
if err := os.Symlink(targetFile, linkFile); err != nil {
t.Fatalf("创建符号链接失败: %v", err)
}
handler := newTestHandler(t, tmpDir)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/link.txt")
handler.Handle(ctx)
// 符号链接应该能正常访问
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
}
// TestStaticHandler_SetTryFiles 测试 SetTryFiles 配置设置
func TestStaticHandler_SetTryFiles(t *testing.T) {
tests := []struct {
name string
tryFiles []string
tryFilesPass bool
wantTryFiles []string
wantPass bool
}{
{
name: "基本配置",
tryFiles: []string{"$uri", "$uri/", "/index.html"},
tryFilesPass: false,
wantTryFiles: []string{"$uri", "$uri/", "/index.html"},
wantPass: false,
},
{
name: "启用 tryFilesPass",
tryFiles: []string{"$uri", "/fallback.html"},
tryFilesPass: true,
wantTryFiles: []string{"$uri", "/fallback.html"},
wantPass: true,
},
{
name: "空配置",
tryFiles: []string{},
tryFilesPass: false,
wantTryFiles: []string{},
wantPass: false,
},
{
name: "单一项配置",
tryFiles: []string{"/app.html"},
tryFilesPass: false,
wantTryFiles: []string{"/app.html"},
wantPass: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewStaticHandler("/var/www", "/", []string{"index.html"}, false)
router := NewRouter()
handler.SetTryFiles(tt.tryFiles, tt.tryFilesPass, router)
// 验证配置
if len(handler.tryFiles) != len(tt.wantTryFiles) {
t.Errorf("tryFiles length = %d, want %d", len(handler.tryFiles), len(tt.wantTryFiles))
}
for i, v := range tt.wantTryFiles {
if handler.tryFiles[i] != v {
t.Errorf("tryFiles[%d] = %q, want %q", i, handler.tryFiles[i], v)
}
}
if handler.tryFilesPass != tt.wantPass {
t.Errorf("tryFilesPass = %v, want %v", handler.tryFilesPass, tt.wantPass)
}
if handler.router != router {
t.Error("router 未正确设置")
}
})
}
}
// TestStaticHandler_resolveTryFilePath 测试 resolveTryFilePath 占位符解析
func TestStaticHandler_resolveTryFilePath(t *testing.T) {
handler := NewStaticHandler("/var/www", "/", []string{"index.html"}, false)
tests := []struct {
name string
tryFile string
relPath string
wantResult string
}{
{
name: "$uri 占位符",
tryFile: "$uri",
relPath: "/api/user",
wantResult: "/api/user",
},
{
name: "$uri/ 占位符",
tryFile: "$uri/",
relPath: "/api/user",
wantResult: "/api/user/",
},
{
name: "绝对路径",
tryFile: "/index.html",
relPath: "/api/user",
wantResult: "index.html",
},
{
name: "普通文件名",
tryFile: "fallback.html",
relPath: "/api/user",
wantResult: "fallback.html",
},
{
name: "根路径 $uri",
tryFile: "$uri",
relPath: "/",
wantResult: "/",
},
{
name: "嵌套路径 $uri",
tryFile: "$uri",
relPath: "/assets/js/app.js",
wantResult: "/assets/js/app.js",
},
{
name: "带查询风格路径",
tryFile: "$uri",
relPath: "/path/to/file.txt",
wantResult: "/path/to/file.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := handler.resolveTryFilePath(tt.tryFile, tt.relPath)
if got != tt.wantResult {
t.Errorf("resolveTryFilePath(%q, %q) = %q, want %q", tt.tryFile, tt.relPath, got, tt.wantResult)
}
})
}
}
// TestStaticHandler_handleTryFiles 测试 handleTryFiles 功能
func TestStaticHandler_handleTryFiles(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string)
tryFiles []string
path string
wantStatus int
wantContent string
skipContent bool
}{
{
name: "$uri 找到文件",
setup: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "app.js"), []byte("app content"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
},
tryFiles: []string{"$uri", "$uri/", "/index.html"},
path: "/app.js",
wantStatus: fasthttp.StatusOK,
wantContent: "app content",
},
{
name: "$uri 未找到回退到 $uri/",
setup: func(t *testing.T, root string) {
dir := filepath.Join(root, "assets")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("assets index"), 0644); err != nil {
t.Fatalf("创建索引文件失败: %v", err)
}
},
tryFiles: []string{"$uri", "$uri/", "/index.html"},
path: "/assets",
wantStatus: fasthttp.StatusOK,
wantContent: "assets index",
},
{
name: "回退到 fallback 文件",
setup: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("spa fallback"), 0644); err != nil {
t.Fatalf("创建 fallback 文件失败: %v", err)
}
},
tryFiles: []string{"$uri", "$uri/", "/index.html"},
path: "/nonexistent",
wantStatus: fasthttp.StatusOK,
wantContent: "spa fallback",
},
{
name: "所有 try_files 都未找到",
setup: func(t *testing.T, root string) {
// 不创建任何文件
},
tryFiles: []string{"$uri", "$uri/", "/index.html"},
path: "/nonexistent",
wantStatus: fasthttp.StatusNotFound,
skipContent: true,
},
{
name: "嵌套目录回退",
setup: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "app.html"), []byte("app shell"), 0644); err != nil {
t.Fatalf("创建 fallback 文件失败: %v", err)
}
},
tryFiles: []string{"$uri", "/app.html"},
path: "/user/profile",
wantStatus: fasthttp.StatusOK,
wantContent: "app shell",
},
{
name: "路径前缀剥离",
setup: func(t *testing.T, root string) {
apiDir := filepath.Join(root, "api")
if err := os.MkdirAll(apiDir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(apiDir, "data.json"), []byte("json data"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
},
tryFiles: []string{"$uri"},
path: "/static/api/data.json",
wantStatus: fasthttp.StatusNotFound, // 路径前缀剥离后找不到
skipContent: true,
},
{
name: "空 try_files 数组",
setup: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "test.txt"), []byte("test"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
},
tryFiles: []string{},
path: "/test.txt",
wantStatus: fasthttp.StatusOK, // 空 try_files 走标准处理流程
wantContent: "test",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tt.setup(t, tmpDir)
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
handler.SetTryFiles(tt.tryFiles, false, nil)
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
}
if !tt.skipContent && tt.wantContent != "" {
got := string(ctx.Response.Body())
if got != tt.wantContent {
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
}
}
})
}
}
// TestStaticHandler_handleInternalRedirect 测试内部重定向功能
func TestStaticHandler_handleInternalRedirect(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string)
tryFiles []string
tryFilesPass bool
path string
wantStatus int
wantContent string
skipContent bool
}{
{
name: "tryFilesPass false 直接服务文件",
setup: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("index content"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
},
tryFiles: []string{"$uri", "/index.html"},
tryFilesPass: false,
path: "/nonexistent",
wantStatus: fasthttp.StatusOK,
wantContent: "index content",
},
{
name: "tryFilesPass true 触发重定向",
setup: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "fallback.txt"), []byte("fallback content"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
},
tryFiles: []string{"$uri", "/fallback.txt"},
tryFilesPass: true,
path: "/nonexistent",
wantStatus: fasthttp.StatusOK,
wantContent: "fallback content",
},
{
name: "内部重定向目标不存在",
setup: func(t *testing.T, root string) {
// 不创建 fallback 文件
},
tryFiles: []string{"$uri", "/fallback.html"},
tryFilesPass: false,
path: "/nonexistent",
wantStatus: fasthttp.StatusNotFound,
skipContent: true,
},
{
name: "内部重定向目标是目录",
setup: func(t *testing.T, root string) {
dir := filepath.Join(root, "fallback")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
// 在 fallback 目录中创建一个 index.html
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("fallback index"), 0644); err != nil {
t.Fatalf("创建 index.html 失败: %v", err)
}
},
tryFiles: []string{"$uri", "$uri/", "/fallback"},
tryFilesPass: false,
path: "/nonexistent",
wantStatus: fasthttp.StatusOK,
wantContent: "fallback index",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tt.setup(t, tmpDir)
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
router := NewRouter()
handler.SetTryFiles(tt.tryFiles, tt.tryFilesPass, router)
// 注册路由处理器用于测试 tryFilesPass 重定向
if tt.tryFilesPass {
router.GET("/{filepath:*}", func(ctx *fasthttp.RequestCtx) {
// 通配符路由,可以匹配任何路径
path := string(ctx.Path())
// 从 root 读取文件
filePath := filepath.Join(tmpDir, path[1:]) // 去掉开头的 /
data, err := os.ReadFile(filePath)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusNotFound)
ctx.SetBodyString("Not Found")
return
}
ctx.SetStatusCode(fasthttp.StatusOK)
ctx.SetBody(data)
})
}
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
}
if !tt.skipContent && tt.wantContent != "" {
got := string(ctx.Response.Body())
if got != tt.wantContent {
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
}
}
})
}
}
// TestStaticHandler_TryFilesSPA 测试 SPA 场景下的 try_files
func TestStaticHandler_TryFilesSPA(t *testing.T) {
tmpDir := t.TempDir()
// 创建 SPA 文件结构
// index.html - 主应用入口
if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("<!DOCTYPE html><html><body>SPA App</body></html>"), 0644); err != nil {
t.Fatalf("创建 index.html 失败: %v", err)
}
// 静态资源文件
assetsDir := filepath.Join(tmpDir, "assets")
if err := os.MkdirAll(assetsDir, 0755); err != nil {
t.Fatalf("创建 assets 目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log('app')"), 0644); err != nil {
t.Fatalf("创建 app.js 失败: %v", err)
}
if err := os.WriteFile(filepath.Join(assetsDir, "style.css"), []byte("body { margin: 0 }"), 0644); err != nil {
t.Fatalf("创建 style.css 失败: %v", err)
}
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
handler.SetTryFiles([]string{"$uri", "$uri/", "/index.html"}, false, nil)
tests := []struct {
name string
path string
wantStatus int
wantContent string
}{
{
name: "访问存在的静态资源",
path: "/assets/app.js",
wantStatus: fasthttp.StatusOK,
wantContent: "console.log('app')",
},
{
name: "访问存在的 CSS 文件",
path: "/assets/style.css",
wantStatus: fasthttp.StatusOK,
wantContent: "body { margin: 0 }",
},
{
name: "访问前端路由回退到 index.html",
path: "/dashboard",
wantStatus: fasthttp.StatusOK,
wantContent: "<!DOCTYPE html><html><body>SPA App</body></html>",
},
{
name: "访问嵌套前端路由",
path: "/user/profile/settings",
wantStatus: fasthttp.StatusOK,
wantContent: "<!DOCTYPE html><html><body>SPA App</body></html>",
},
{
name: "访问根路径",
path: "/",
wantStatus: fasthttp.StatusOK,
wantContent: "<!DOCTYPE html><html><body>SPA App</body></html>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
}
got := string(ctx.Response.Body())
if got != tt.wantContent {
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
}
})
}
}
// TestStaticHandler_TryFilesWithPathPrefix 测试带路径前缀的 try_files
func TestStaticHandler_TryFilesWithPathPrefix(t *testing.T) {
tmpDir := t.TempDir()
// 创建 API 模拟文件
apiDir := filepath.Join(tmpDir, "api")
if err := os.MkdirAll(apiDir, 0755); err != nil {
t.Fatalf("创建 api 目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(apiDir, "users.json"), []byte("[]"), 0644); err != nil {
t.Fatalf("创建 users.json 失败: %v", err)
}
// 创建静态文件
if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("static index"), 0644); err != nil {
t.Fatalf("创建 index.html 失败: %v", err)
}
handler := NewStaticHandler(tmpDir, "/static", []string{"index.html"}, false)
handler.SetTryFiles([]string{"$uri", "$uri/", "/index.html"}, false, nil)
tests := []struct {
name string
path string
wantStatus int
wantContent string
skipContent bool
}{
{
name: "带前缀访问文件",
path: "/static/api/users.json",
wantStatus: fasthttp.StatusOK,
wantContent: "[]",
},
{
name: "带前缀访问目录",
path: "/static/api/",
wantStatus: fasthttp.StatusOK, // 目录无索引文件,但会回退到 /index.html
wantContent: "static index",
},
{
name: "前缀剥离后回退",
path: "/static/unknown",
wantStatus: fasthttp.StatusOK,
wantContent: "static index",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
}
if !tt.skipContent {
got := string(ctx.Response.Body())
if got != tt.wantContent {
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
}
}
})
}
}
// TestStaticHandler_TryFilesEdgeCases 测试 try_files 边界情况
func TestStaticHandler_TryFilesEdgeCases(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
if err := os.WriteFile(filepath.Join(tmpDir, "file with spaces.txt"), []byte("spaces"), 0644); err != nil {
t.Fatalf("创建带空格文件失败: %v", err)
}
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
handler.SetTryFiles([]string{"$uri", "/index.html"}, false, nil)
tests := []struct {
name string
path string
wantStatus int
}{
{
name: "路径遍历攻击被阻止 - fasthttp 规范化",
path: "/../secret",
wantStatus: fasthttp.StatusNotFound, // fasthttp 规范化为 /secret文件不存在返回 404
},
{
name: "双点号在路径中被阻止",
path: "/file..name",
wantStatus: fasthttp.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
}
})
}
}