// Package handler 提供静态文件处理器功能的测试。 // // 该文件测试静态文件处理器模块的各项功能,包括: // - 正常文件访问 // - 嵌套路径文件 // - 目录索引文件 // - 路径遍历安全检查 // - 索引文件优先级 // - 构造函数 // - 文件缓存设置 // - Gzip 静态文件设置 // - HEAD 请求处理 // - 预压缩文件支持 // - 大文件处理 // - 符号链接处理 // // 作者:xfy package handler import ( "os" "path/filepath" "testing" "time" "github.com/valyala/fasthttp" "rua.plus/lolly/internal/cache" "rua.plus/lolly/internal/testutil" ) // newTestHandler 创建测试用的静态文件处理器。 // // 参数: // - t: 测试上下文,用于标记 helper // - root: 静态文件根目录 // // 返回值: // - *StaticHandler: 配置好索引文件的静态文件处理器(禁用 sendfile) func newTestHandler(t *testing.T, root string) *StaticHandler { t.Helper() return NewStaticHandler(root, "/", []string{"index.html", "index.htm"}, false) // 测试时禁用 sendfile } // newTestContext 创建测试用的 fasthttp 请求上下文。 // // 参数: // - t: 测试上下文,用于标记 helper // - path: 请求路径 // // 返回值: // - *fasthttp.RequestCtx: 初始化好的请求上下文 func newTestContext(t *testing.T, path string) *fasthttp.RequestCtx { t.Helper() return testutil.NewRequestCtx("GET", path) } // TestStaticHandlerHandle 测试静态文件处理器 func TestStaticHandlerHandle(t *testing.T) { tests := []struct { setup func(t *testing.T, root string) name string path string wantContent string wantStatus int 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), 0o644); 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, 0o755); err != nil { t.Fatalf("创建子目录失败: %v", err) } content := "nested file content" if err := os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte(content), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } content := "index page" if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } }, path: "/noindex/", wantStatus: fasthttp.StatusForbidden, skipContent: true, }, { name: "文件不存在", setup: func(_ *testing.T, _ string) { t.Helper() // 不创建任何文件 }, path: "/nonexistent.txt", wantStatus: fasthttp.StatusNotFound, skipContent: true, }, { name: "空路径访问根目录无索引", setup: func(_ *testing.T, _ 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), 0o644); 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 { setup func(t *testing.T, root string) name string path string description string wantStatus int }{ { name: "文件名包含双点 - 安全检查拦截", setup: func(_ *testing.T, _ string) { t.Helper() // 不创建任何文件 }, path: "/file..txt", wantStatus: fasthttp.StatusForbidden, description: "路径包含 '..' 字符串,触发安全检查返回 403", }, { name: "路径末尾双点 - 安全检查拦截", setup: func(_ *testing.T, _ string) { t.Helper() }, path: "/foo/..", wantStatus: fasthttp.StatusForbidden, description: "路径末尾包含 '..',触发安全检查返回 403", }, { name: "隐藏文件 .hidden - 文件不存在", setup: func(_ *testing.T, _ string) { t.Helper() }, path: "/.hidden", wantStatus: fasthttp.StatusNotFound, description: "单点开头的隐藏文件不触发安全检查,文件不存在返回 404", }, { name: "文件名包含多点 ...txt - 安全检查拦截", setup: func(_ *testing.T, _ string) { t.Helper() }, path: "/file...txt", wantStatus: fasthttp.StatusForbidden, description: "包含连续多点(含 '..')触发安全检查返回 403", }, { name: "fasthttp 规范化后的路径 - 文件不存在", setup: func(_ *testing.T, _ string) { t.Helper() // fasthttp 将 /../secret.txt 规范化为 /secret.txt }, path: "/../secret.txt", wantStatus: fasthttp.StatusNotFound, description: "fasthttp 自动规范化路径移除 ../,结果路径文件不存在返回 404", }, { name: "URL 编码路径遍历 - fasthttp 规范化", setup: func(_ *testing.T, _ string) { t.Helper() // fasthttp 解码 %2e%2e 为 .. 并规范化路径 }, path: "/%2e%2e/secret.txt", wantStatus: fasthttp.StatusNotFound, description: "fasthttp 解码 URL 编码后规范化路径,文件不存在返回 404", }, { name: "混合 URL 编码 - fasthttp 规范化", setup: func(_ *testing.T, _ 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"), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } // 创建两个索引文件 if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("html content"), 0o644); err != nil { t.Fatalf("创建 index.html 失败: %v", err) } if err := os.WriteFile(filepath.Join(dir, "index.htm"), []byte("htm content"), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } // 仅创建 index.htm if err := os.WriteFile(filepath.Join(dir, "index.htm"), []byte("htm content"), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } // 创建一个非索引文件 if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("other content"), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } // 创建索引文件 if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0o644); 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") } // root 被规范化为带尾部斜杠的形式 expectedRoot := "/var/www/" if handler.root != expectedRoot { t.Errorf("handler.root = %q, want %q", handler.root, expectedRoot) } 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, nil, []string{".gz", ".gzip"}) if handler.gzipStatic == nil { t.Error("Expected gzipStatic to be non-nil") } // 禁用 gzip handler.SetGzipStatic(false, nil, 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), 0o644); 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), 0o644); 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), 0o644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } // 创建 .gz 文件(模拟预压缩) gzContent := []byte("gzipped content") if err := os.WriteFile(filepath.Join(tmpDir, "test.txt.gz"), gzContent, 0o644); err != nil { t.Fatalf("创建 gzip 文件失败: %v", err) } handler := NewStaticHandler(tmpDir, "/", nil, false) handler.SetGzipStatic(true, nil, []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, 0o644); 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), 0o644); 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 wantTryFiles []string tryFilesPass bool 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 { setup func(t *testing.T, root string) name string path string wantContent string tryFiles []string wantStatus int skipContent bool }{ { name: "$uri 找到文件", setup: func(t *testing.T, root string) { if err := os.WriteFile(filepath.Join(root, "app.js"), []byte("app content"), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("assets index"), 0o644); 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"), 0o644); 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(_ *testing.T, _ 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"), 0o644); 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } if err := os.WriteFile(filepath.Join(apiDir, "data.json"), []byte("json data"), 0o644); 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"), 0o644); 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 { setup func(t *testing.T, root string) name string path string wantContent string tryFiles []string wantStatus int tryFilesPass bool skipContent bool }{ { name: "tryFilesPass false 直接服务文件", setup: func(t *testing.T, root string) { if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("index content"), 0o644); 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"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } }, tryFiles: []string{"$uri", "/fallback.txt"}, tryFilesPass: true, path: "/nonexistent", wantStatus: fasthttp.StatusOK, wantContent: "fallback content", }, { name: "内部重定向目标不存在", setup: func(_ *testing.T, _ 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, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } // 在 fallback 目录中创建一个 index.html if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("fallback index"), 0o644); 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("SPA App"), 0o644); err != nil { t.Fatalf("创建 index.html 失败: %v", err) } // 静态资源文件 assetsDir := filepath.Join(tmpDir, "assets") if err := os.MkdirAll(assetsDir, 0o755); err != nil { t.Fatalf("创建 assets 目录失败: %v", err) } if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log('app')"), 0o644); err != nil { t.Fatalf("创建 app.js 失败: %v", err) } if err := os.WriteFile(filepath.Join(assetsDir, "style.css"), []byte("body { margin: 0 }"), 0o644); 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 wantContent string wantStatus int }{ { 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: "SPA App", }, { name: "访问嵌套前端路由", path: "/user/profile/settings", wantStatus: fasthttp.StatusOK, wantContent: "SPA App", }, { name: "访问根路径", path: "/", wantStatus: fasthttp.StatusOK, wantContent: "SPA App", }, } 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, 0o755); err != nil { t.Fatalf("创建 api 目录失败: %v", err) } if err := os.WriteFile(filepath.Join(apiDir, "users.json"), []byte("[]"), 0o644); err != nil { t.Fatalf("创建 users.json 失败: %v", err) } // 创建静态文件 if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("static index"), 0o644); 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 wantContent string wantStatus int 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_Alias 测试 alias 路径替换功能 func TestStaticHandler_Alias(t *testing.T) { tests := []struct { setup func(t *testing.T, aliasDir string) name string alias string pathPrefix string path string wantContent string wantStatus int skipContent bool }{ { name: "alias 基础替换", setup: func(t *testing.T, aliasDir string) { if err := os.WriteFile(filepath.Join(aliasDir, "logo.png"), []byte("png content"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } }, alias: "/alias/images/", pathPrefix: "/images/", path: "/images/logo.png", wantStatus: fasthttp.StatusOK, wantContent: "png content", }, { name: "alias 嵌套路径", setup: func(t *testing.T, aliasDir string) { subDir := filepath.Join(aliasDir, "icons") if err := os.MkdirAll(subDir, 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } if err := os.WriteFile(filepath.Join(subDir, "app.png"), []byte("app icon"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } }, alias: "/alias/img/", pathPrefix: "/images/", path: "/images/icons/app.png", wantStatus: fasthttp.StatusOK, wantContent: "app icon", }, { name: "alias 目录索引", setup: func(t *testing.T, aliasDir string) { if err := os.WriteFile(filepath.Join(aliasDir, "index.html"), []byte("alias index"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } }, alias: "/alias/images/", pathPrefix: "/images/", path: "/images/", wantStatus: fasthttp.StatusOK, wantContent: "alias index", }, { name: "alias 文件不存在", setup: func(_ *testing.T, _ string) { // 不创建任何文件 }, alias: "/alias/images/", pathPrefix: "/images/", path: "/images/notfound.png", wantStatus: fasthttp.StatusNotFound, skipContent: true, }, { name: "root 与 alias 互斥 - 使用 alias", setup: func(t *testing.T, aliasDir string) { if err := os.WriteFile(filepath.Join(aliasDir, "file.txt"), []byte("from alias"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } }, alias: "/alias/images/", pathPrefix: "/images/", path: "/images/file.txt", wantStatus: fasthttp.StatusOK, wantContent: "from alias", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 创建临时目录作为 alias 目录 aliasDir := t.TempDir() tt.setup(t, aliasDir) // 创建处理器(使用 alias) handler := &StaticHandler{ alias: aliasDir, pathPrefix: tt.pathPrefix, pathPrefixLen: len(tt.pathPrefix), index: []string{"index.html"}, } 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_AliasVsRoot 测试 alias 和 root 的行为区别 func TestStaticHandler_AliasVsRoot(t *testing.T) { // root 目录 rootDir := t.TempDir() // alias 目录 aliasDir := t.TempDir() // 在 root 创建子目录和文件 imagesDir := filepath.Join(rootDir, "images") if err := os.MkdirAll(imagesDir, 0o755); err != nil { t.Fatalf("创建 images 目录失败: %v", err) } if err := os.WriteFile(filepath.Join(imagesDir, "logo.png"), []byte("from root"), 0o644); err != nil { t.Fatalf("创建 root 文件失败: %v", err) } if err := os.WriteFile(filepath.Join(aliasDir, "logo.png"), []byte("from alias"), 0o644); err != nil { t.Fatalf("创建 alias 文件失败: %v", err) } tests := []struct { name string handler *StaticHandler path string wantContent string }{ { name: "root 模式:请求路径附加到 root", handler: NewStaticHandler(rootDir, "/", []string{"index.html"}, false), path: "/images/logo.png", wantContent: "from root", // /var/www/images/logo.png }, { name: "alias 模式:请求路径替换匹配部分", handler: &StaticHandler{ alias: aliasDir, pathPrefix: "/images/", pathPrefixLen: len("/images/"), index: []string{"index.html"}, }, path: "/images/logo.png", wantContent: "from alias", // /alias/logo.png(images/被替换) }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := newTestContext(t, tt.path) tt.handler.Handle(ctx) got := string(ctx.Response.Body()) if got != tt.wantContent { t.Errorf("内容 = %q, want %q", got, tt.wantContent) } }) } } // TestStaticHandler_SetAlias 测试 SetAlias 方法 func TestStaticHandler_SetAlias(t *testing.T) { t.Run("设置 alias", func(t *testing.T) { handler := NewStaticHandler("/root", "/", nil, false) if handler.alias != "" { t.Error("初始 alias 应为空") } handler.SetAlias("/alias") if handler.alias != "/alias" { t.Errorf("GetAlias() = %q, want %q", handler.alias, "/alias") } if handler.root != "" { t.Error("设置 alias 后 root 应被清空") } }) t.Run("设置 root 清除 alias", func(t *testing.T) { handler := NewStaticHandler("/root", "/", nil, false) // root 被规范化为带尾部斜杠的形式 expectedRoot := "/root/" if handler.root != expectedRoot { t.Errorf("root = %q, want %q", handler.root, expectedRoot) } if handler.alias != "" { t.Error("初始 alias 应为空") } }) } // TestStaticHandler_AliasWithTryFiles 测试 alias 与 try_files 组合 func TestStaticHandler_AliasWithTryFiles(t *testing.T) { aliasDir := t.TempDir() // 创建测试文件 if err := os.WriteFile(filepath.Join(aliasDir, "app.js"), []byte("app content"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } if err := os.WriteFile(filepath.Join(aliasDir, "index.html"), []byte("fallback"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } handler := &StaticHandler{ alias: aliasDir, pathPrefix: "/static/", pathPrefixLen: len("/static/"), index: []string{"index.html"}, } handler.SetTryFiles([]string{"$uri", "/index.html"}, false, nil) tests := []struct { name string path string wantContent string wantStatus int }{ { name: "找到文件", path: "/static/app.js", wantStatus: fasthttp.StatusOK, wantContent: "app content", }, { name: "回退到 index.html", path: "/static/nonexistent", wantStatus: fasthttp.StatusOK, wantContent: "fallback", }, } 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_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"), 0o644); 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) } }) } } // TestStaticHandler_LargeFileContentType 测试大文件 sendfile 路径的 Content-Type func TestStaticHandler_LargeFileContentType(t *testing.T) { tmpDir := t.TempDir() // 创建大于 8KB 的文件 largeContent := make([]byte, 16*1024) for i := range largeContent { largeContent[i] = byte(i % 256) } tests := []struct { ext string expected string }{ {".css", "text/css; charset=utf-8"}, {".js", "text/javascript; charset=utf-8"}, {".webmanifest", "application/manifest+json"}, {".webm", "video/webm"}, {".otf", "font/otf"}, } for _, tc := range tests { t.Run(tc.ext, func(t *testing.T) { filePath := filepath.Join(tmpDir, "large"+tc.ext) if err := os.WriteFile(filePath, largeContent, 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } handler := NewStaticHandler(tmpDir, "/", nil, true) // 启用 sendfile ctx := &fasthttp.RequestCtx{} ctx.Request.SetRequestURI("/large" + tc.ext) handler.Handle(ctx) if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } ct := string(ctx.Response.Header.ContentType()) if ct != tc.expected { t.Errorf("Content-Type = %q, want %q", ct, tc.expected) } }) } } // TestResolveTryFilePathWithDynamicSuffix 测试动态后缀解析 func TestResolveTryFilePathWithDynamicSuffix(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: "$uri.html 正常", tryFile: "$uri.html", relPath: "/about", wantResult: "/about.html"}, {name: "$uri.json 正常", tryFile: "$uri.json", relPath: "/api/data", wantResult: "/api/data.json"}, {name: "$uri.css 正常", tryFile: "$uri.css", relPath: "/styles/main", wantResult: "/styles/main.css"}, // 动态后缀 - 根路径边界(返回空字符串) {name: "$uri.html 根路径", tryFile: "$uri.html", relPath: "/", wantResult: ""}, {name: "$uri.json 根路径", tryFile: "$uri.json", relPath: "/", wantResult: ""}, // 动态后缀 - 子目录路径(正常处理) {name: "$uri.html 子目录", tryFile: "$uri.html", relPath: "/api/", wantResult: "/api/.html"}, {name: "$uri.json 子目录", tryFile: "$uri.json", relPath: "/v1/", wantResult: "/v1/.json"}, // 绝对路径 {name: "绝对路径", tryFile: "/index.html", relPath: "/api/user", wantResult: "index.html"}, {name: "绝对路径嵌套", tryFile: "/fallback/app.html", relPath: "/any", wantResult: "fallback/app.html"}, // 相对路径 {name: "相对路径", tryFile: "fallback.html", relPath: "/api/user", wantResult: "fallback.html"}, {name: "相对路径带连字符", tryFile: "app-shell.html", relPath: "/any", wantResult: "app-shell.html"}, } 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_TryFilesWithDynamicSuffix 测试动态后缀集成 func TestStaticHandler_TryFilesWithDynamicSuffix(t *testing.T) { tmpDir := t.TempDir() // 创建测试文件 if err := os.WriteFile(filepath.Join(tmpDir, "about.html"), []byte("about page"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } if err := os.MkdirAll(filepath.Join(tmpDir, "api"), 0o755); err != nil { t.Fatalf("创建目录失败: %v", err) } if err := os.WriteFile(filepath.Join(tmpDir, "api", "data.json"), []byte("{\"data\":true}"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("fallback"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false) handler.SetTryFiles([]string{"$uri", "$uri.html", "/index.html"}, false, nil) tests := []struct { name string path string wantContent string wantStatus int }{ { name: "找到 $uri.html", path: "/about", wantStatus: fasthttp.StatusOK, wantContent: "about page", }, { name: "回退到 /index.html", path: "/nonexistent", wantStatus: fasthttp.StatusOK, wantContent: "fallback", }, { name: "根路径回退到 /index.html", path: "/", wantStatus: fasthttp.StatusOK, wantContent: "fallback", }, } 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 got := string(ctx.Response.Body()); got != tt.wantContent { t.Errorf("内容 = %q, want %q", got, tt.wantContent) } }) } } // TestStaticHandler_TryFilesRootPathFallback 测试根路径回退 func TestStaticHandler_TryFilesRootPathFallback(t *testing.T) { tmpDir := t.TempDir() // 创建 index.html 作为根路径回退 if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("root fallback"), 0o644); err != nil { t.Fatalf("创建文件失败: %v", err) } // 注意:不创建 /.html 文件,测试根路径边界情况 handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false) handler.SetTryFiles([]string{"$uri", "$uri.html", "/index.html"}, false, nil) ctx := newTestContext(t, "/") handler.Handle(ctx) // 验证根路径请求正确回退到 /index.html if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } if got := string(ctx.Response.Body()); got != "root fallback" { t.Errorf("内容 = %q, want %q", got, "root fallback") } } // TestStaticHandler_SetSymlinkCheck 测试 SetSymlinkCheck 方法 func TestStaticHandler_SetSymlinkCheck(t *testing.T) { handler := NewStaticHandler("/var/www", "/", nil, false) if handler.symlinkCheck { t.Error("初始 symlinkCheck 应为 false") } handler.SetSymlinkCheck(true) if !handler.symlinkCheck { t.Error("SetSymlinkCheck(true) 后 symlinkCheck 应为 true") } handler.SetSymlinkCheck(false) if handler.symlinkCheck { t.Error("SetSymlinkCheck(false) 后 symlinkCheck 应为 false") } } // TestStaticHandler_SetInternal 测试 SetInternal 方法 func TestStaticHandler_SetInternal(t *testing.T) { handler := NewStaticHandler("/var/www", "/", nil, false) if handler.internal { t.Error("初始 internal 应为 false") } handler.SetInternal(true) if !handler.internal { t.Error("SetInternal(true) 后 internal 应为 true") } handler.SetInternal(false) if handler.internal { t.Error("SetInternal(false) 后 internal 应为 false") } } // TestStaticHandler_SetCacheTTL 测试 SetCacheTTL 方法 func TestStaticHandler_SetCacheTTL(t *testing.T) { handler := NewStaticHandler("/var/www", "/", nil, false) if handler.cacheTTL != 0 { t.Error("初始 cacheTTL 应为 0") } handler.SetCacheTTL(5 * time.Second) if handler.cacheTTL != 5*time.Second { t.Errorf("SetCacheTTL 后 cacheTTL = %v, want %v", handler.cacheTTL, 5*time.Second) } handler.SetCacheTTL(0) if handler.cacheTTL != 0 { t.Error("SetCacheTTL(0) 后 cacheTTL 应为 0") } } // TestStaticHandler_InternalRestriction 测试 internal 访问限制 func TestStaticHandler_InternalRestriction(t *testing.T) { tmpDir := t.TempDir() content := "internal content" if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0o644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } handler := newTestHandler(t, tmpDir) handler.SetInternal(true) t.Run("外部请求返回 404", func(t *testing.T) { ctx := newTestContext(t, "/test.txt") handler.Handle(ctx) if got := ctx.Response.StatusCode(); got != fasthttp.StatusNotFound { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusNotFound) } }) t.Run("内部重定向允许访问", func(t *testing.T) { ctx := newTestContext(t, "/test.txt") // 标记为内部重定向 ctx.SetUserValue("__internal_redirect__", "/test.txt") handler.Handle(ctx) if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } }) } // TestStaticHandler_ValidateSymlink 测试符号链接验证 func TestStaticHandler_ValidateSymlink(t *testing.T) { tmpDir := t.TempDir() // 创建目标文件和目录 targetDir := filepath.Join(tmpDir, "target") if err := os.MkdirAll(targetDir, 0o755); err != nil { t.Fatalf("创建目标目录失败: %v", err) } targetFile := filepath.Join(targetDir, "secret.txt") if err := os.WriteFile(targetFile, []byte("secret content"), 0o644); err != nil { t.Fatalf("创建目标文件失败: %v", err) } // 创建允许的根目录 allowedDir := filepath.Join(tmpDir, "allowed") if err := os.MkdirAll(allowedDir, 0o755); err != nil { t.Fatalf("创建允许目录失败: %v", err) } t.Run("安全符号链接 - 在允许范围内", func(t *testing.T) { // 在允许目录内创建符号链接 linkFile := filepath.Join(allowedDir, "link.txt") allowedTarget := filepath.Join(allowedDir, "actual.txt") if err := os.WriteFile(allowedTarget, []byte("allowed content"), 0o644); err != nil { t.Fatalf("创建实际文件失败: %v", err) } if err := os.Symlink(allowedTarget, linkFile); err != nil { t.Fatalf("创建符号链接失败: %v", err) } handler := NewStaticHandler(allowedDir, "/", nil, false) handler.SetSymlinkCheck(true) ctx := newTestContext(t, "/link.txt") handler.Handle(ctx) if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } }) t.Run("不安全符号链接 - 指向允许范围外", func(t *testing.T) { // 创建指向允许目录外的符号链接 unsafeLink := filepath.Join(allowedDir, "unsafe.txt") if err := os.Symlink(targetFile, unsafeLink); err != nil { t.Fatalf("创建不安全符号链接失败: %v", err) } handler := NewStaticHandler(allowedDir, "/", nil, false) handler.SetSymlinkCheck(true) ctx := newTestContext(t, "/unsafe.txt") 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) { normalFile := filepath.Join(allowedDir, "normal.txt") if err := os.WriteFile(normalFile, []byte("normal content"), 0o644); err != nil { t.Fatalf("创建普通文件失败: %v", err) } handler := NewStaticHandler(allowedDir, "/", nil, false) handler.SetSymlinkCheck(true) ctx := newTestContext(t, "/normal.txt") handler.Handle(ctx) if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } }) t.Run("未启用符号链接检查", func(t *testing.T) { // 创建指向允许目录外的符号链接 externalLink := filepath.Join(allowedDir, "external.txt") if err := os.Symlink(targetFile, externalLink); err != nil { t.Fatalf("创建外部符号链接失败: %v", err) } handler := NewStaticHandler(allowedDir, "/", nil, false) // 不启用符号链接检查 ctx := newTestContext(t, "/external.txt") handler.Handle(ctx) // 未启用检查时,可以访问符号链接 if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } }) } // TestStaticHandler_ValidateSymlink_WithAlias 测试 alias 模式下的符号链接验证 func TestStaticHandler_ValidateSymlink_WithAlias(t *testing.T) { tmpDir := t.TempDir() // 创建 alias 目录 aliasDir := filepath.Join(tmpDir, "alias") if err := os.MkdirAll(aliasDir, 0o755); err != nil { t.Fatalf("创建 alias 目录失败: %v", err) } // 在 alias 目录内创建文件和符号链接 actualFile := filepath.Join(aliasDir, "actual.txt") if err := os.WriteFile(actualFile, []byte("actual content"), 0o644); err != nil { t.Fatalf("创建实际文件失败: %v", err) } linkFile := filepath.Join(aliasDir, "link.txt") if err := os.Symlink(actualFile, linkFile); err != nil { t.Fatalf("创建符号链接失败: %v", err) } handler := &StaticHandler{ alias: aliasDir, pathPrefix: "/static/", pathPrefixLen: len("/static/"), } handler.SetSymlinkCheck(true) ctx := newTestContext(t, "/static/link.txt") handler.Handle(ctx) if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK) } } // TestStaticHandler_ValidateSymlink_NoRootOrAlias 测试无 root/alias 时符号链接验证 func TestStaticHandler_ValidateSymlink_NoRootOrAlias(t *testing.T) { tmpDir := t.TempDir() // 创建文件和符号链接 targetFile := filepath.Join(tmpDir, "target.txt") if err := os.WriteFile(targetFile, []byte("target"), 0o644); err != nil { t.Fatalf("创建目标文件失败: %v", err) } linkFile := filepath.Join(tmpDir, "link.txt") if err := os.Symlink(targetFile, linkFile); err != nil { t.Fatalf("创建符号链接失败: %v", err) } // 创建无 root/alias 的处理器 handler := NewStaticHandler("", "/", nil, false) handler.SetSymlinkCheck(true) // 直接调用 validateSymlink err := handler.validateSymlink(linkFile) if err == nil { t.Error("无 root/alias 时验证符号链接应返回错误") } } // TestStaticHandler_Handle_WithCacheTTL 测试带 TTL 的缓存处理 func TestStaticHandler_Handle_WithCacheTTL(t *testing.T) { tmpDir := t.TempDir() content := "cached with ttl" testFile := filepath.Join(tmpDir, "test.txt") if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } // 创建带缓存的处理器 handler := newTestHandler(t, tmpDir) handler.SetFileCache(cache.NewFileCache(100, 1024*1024, time.Hour)) handler.SetCacheTTL(5 * time.Second) // 第一次请求,填充缓存 ctx1 := newTestContext(t, "/test.txt") handler.Handle(ctx1) if got := ctx1.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("第一次请求状态码 = %d, want %d", got, fasthttp.StatusOK) } // 第二次请求,应该命中缓存 ctx2 := newTestContext(t, "/test.txt") handler.Handle(ctx2) if got := ctx2.Response.StatusCode(); got != fasthttp.StatusOK { t.Errorf("第二次请求状态码 = %d, want %d", got, fasthttp.StatusOK) } // 内容应该一致 if string(ctx1.Response.Body()) != string(ctx2.Response.Body()) { t.Error("缓存内容不一致") } } // TestStaticHandler_Handle_CacheTTLExpired 测试 TTL 过期后重新验证 func TestStaticHandler_Handle_CacheTTLExpired(t *testing.T) { tmpDir := t.TempDir() content := "initial content" testFile := filepath.Join(tmpDir, "test.txt") if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } // 创建带缓存的处理器,TTL 设置很短 handler := newTestHandler(t, tmpDir) handler.SetFileCache(cache.NewFileCache(100, 1024*1024, time.Hour)) handler.SetCacheTTL(100 * time.Millisecond) // 第一次请求,填充缓存 ctx1 := newTestContext(t, "/test.txt") handler.Handle(ctx1) // 等待 TTL 过期 time.Sleep(150 * time.Millisecond) // 修改文件 newContent := "updated content" if err := os.WriteFile(testFile, []byte(newContent), 0o644); err != nil { t.Fatalf("更新文件失败: %v", err) } // 第二次请求,TTL 过期后应该重新读取 ctx2 := newTestContext(t, "/test.txt") handler.Handle(ctx2) if got := string(ctx2.Response.Body()); got != newContent { t.Errorf("TTL 过期后内容 = %q, want %q", got, newContent) } } // TestStaticHandler_Handle_CacheModTimeChanged 测试文件修改后缓存更新 func TestStaticHandler_Handle_CacheModTimeChanged(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.txt") initialContent := "initial" if err := os.WriteFile(testFile, []byte(initialContent), 0o644); err != nil { t.Fatalf("创建测试文件失败: %v", err) } // 创建带缓存的处理器 handler := newTestHandler(t, tmpDir) handler.SetFileCache(cache.NewFileCache(100, 1024*1024, time.Hour)) // 第一次请求,填充缓存 ctx1 := newTestContext(t, "/test.txt") handler.Handle(ctx1) // 修改文件 time.Sleep(10 * time.Millisecond) // 确保 ModTime 变化 newContent := "modified" if err := os.WriteFile(testFile, []byte(newContent), 0o644); err != nil { t.Fatalf("修改文件失败: %v", err) } // 第二次请求,应该检测到文件变化并更新缓存 ctx2 := newTestContext(t, "/test.txt") handler.Handle(ctx2) if got := string(ctx2.Response.Body()); got != newContent { t.Errorf("修改后内容 = %q, want %q", got, newContent) } } // TestStaticHandler_SetExpires 测试 SetExpires 方法。 func TestStaticHandler_SetExpires(t *testing.T) { tmpDir := t.TempDir() handler := newTestHandler(t, tmpDir) // 默认为空 if handler.expires != "" { t.Errorf("默认 expires = %q, want empty", handler.expires) } // 设置 expires handler.SetExpires("30d") if handler.expires != "30d" { t.Errorf("expires = %q, want 30d", handler.expires) } // 设置 off handler.SetExpires("off") if handler.expires != "off" { t.Errorf("expires = %q, want off", handler.expires) } // 设置 max handler.SetExpires("max") if handler.expires != "max" { t.Errorf("expires = %q, want max", handler.expires) } } // TestSetCacheHeaders 测试 setCacheHeaders 方法。 func TestSetCacheHeaders(t *testing.T) { tests := []struct { name string expires string wantCacheCtrl string wantExpiresSet bool // 是否设置 Expires 头 }{ { name: "empty_expires", expires: "", wantCacheCtrl: "", }, { name: "off_expires", expires: "off", wantCacheCtrl: "", }, { name: "epoch_expires", expires: "epoch", wantCacheCtrl: "no-cache, no-store, must-revalidate", wantExpiresSet: true, }, { name: "max_expires", expires: "max", wantCacheCtrl: "public, max-age=315360000, immutable", wantExpiresSet: true, }, { name: "duration_expires", expires: "1h", wantCacheCtrl: "public, max-age=3600", wantExpiresSet: true, }, { name: "complex_duration", expires: "1d1h", wantCacheCtrl: "public, max-age=90000", wantExpiresSet: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() handler := newTestHandler(t, tmpDir) handler.SetExpires(tt.expires) ctx := newTestContext(t, "/test.txt") handler.setCacheHeaders(ctx) cacheCtrl := string(ctx.Response.Header.Peek("Cache-Control")) if tt.wantCacheCtrl == "" { if cacheCtrl != "" { t.Errorf("Cache-Control = %q, want empty", cacheCtrl) } } else { if cacheCtrl != tt.wantCacheCtrl { t.Errorf("Cache-Control = %q, want %q", cacheCtrl, tt.wantCacheCtrl) } } expires := string(ctx.Response.Header.Peek("Expires")) if tt.wantExpiresSet && expires == "" { t.Error("Expected Expires header to be set") } }) } } // TestParseExpires 测试 parseExpires 函数。 func TestParseExpires(t *testing.T) { tests := []struct { name string expires string wantSecs int64 }{ {"empty", "", 0}, {"off", "off", 0}, {"max", "max", 315360000}, {"epoch", "epoch", -1}, {"seconds", "30s", 30}, {"minutes", "5m", 300}, {"hours", "2h", 7200}, {"days", "1d", 86400}, {"complex", "1d1h1m1s", 90061}, {"multiple_days", "30d", 2592000}, {"mixed", "7d12h", 648000}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseExpires(tt.expires) if got != tt.wantSecs { t.Errorf("parseExpires(%q) = %d, want %d", tt.expires, got, tt.wantSecs) } }) } }