lolly/internal/handler/autoindex_test.go
xfy b62a3f12da feat(handler): add autoindex module for directory listing
Add nginx-like autoindex functionality with three output formats:
- HTML: styled directory listing with sortable columns
- JSON: structured API-friendly output
- XML: machine-readable format

Configuration options:
- auto_index: enable/disable directory listing
- auto_index_format: output format (html/json/xml)
- auto_index_localtime: use local time instead of GMT
- auto_index_exact_size: show exact bytes vs human-readable

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

440 lines
11 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 提供 HTTP 请求处理功能。
package handler
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/valyala/fasthttp"
)
func TestGenerateAutoIndex_HTML(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建测试文件和目录
if err := os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "file2.html"), []byte("<html>content2</html>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, ".hidden"), []byte("hidden"), 0o644); err != nil {
t.Fatal(err)
}
// 测试 HTML 格式
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test/")
config := AutoIndexConfig{
Format: "html",
Localtime: false,
ExactSize: false,
}
if !GenerateAutoIndex(ctx, tmpDir, "/test/", config) {
t.Fatal("GenerateAutoIndex returned false")
}
if ct := string(ctx.Response.Header.ContentType()); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %s, want text/html; charset=utf-8", ct)
}
body := string(ctx.Response.Body())
// 检查包含文件名
if !containsAll(body, "file1.txt", "file2.html", "subdir") {
t.Errorf("HTML body missing expected files: %s", body)
}
// 检查隐藏文件不显示
if containsAll(body, ".hidden") {
t.Errorf("HTML body should not contain hidden file: %s", body)
}
// 检查目录有斜杠后缀
if !containsAll(body, "subdir/") {
t.Errorf("HTML body directory should have / suffix: %s", body)
}
}
func TestGenerateAutoIndex_JSON(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建测试文件
if err := os.WriteFile(filepath.Join(tmpDir, "test.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
// 测试 JSON 格式
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/api/")
config := AutoIndexConfig{
Format: "json",
}
if !GenerateAutoIndex(ctx, tmpDir, "/api/", config) {
t.Fatal("GenerateAutoIndex returned false")
}
if ct := string(ctx.Response.Header.ContentType()); ct != "application/json" {
t.Errorf("Content-Type = %s, want application/json", ct)
}
body := string(ctx.Response.Body())
// 检查 JSON 格式
if !containsAll(body, `"name"`, `"type"`, `"mtime"`, "test.json") {
t.Errorf("JSON body missing expected fields: %s", body)
}
}
func TestGenerateAutoIndex_XML(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建测试文件
if err := os.WriteFile(filepath.Join(tmpDir, "data.xml"), []byte("<data/>"), 0o644); err != nil {
t.Fatal(err)
}
// 测试 XML 格式
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/xml/")
config := AutoIndexConfig{
Format: "xml",
}
if !GenerateAutoIndex(ctx, tmpDir, "/xml/", config) {
t.Fatal("GenerateAutoIndex returned false")
}
if ct := string(ctx.Response.Header.ContentType()); ct != "text/xml; charset=utf-8" {
t.Errorf("Content-Type = %s, want text/xml; charset=utf-8", ct)
}
body := string(ctx.Response.Body())
// 检查 XML 格式
if !containsAll(body, `<list`, `path=`, `<element`, "data.xml") {
t.Errorf("XML body missing expected elements: %s", body)
}
}
func TestGenerateAutoIndex_Sorting(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建文件和目录(按不同顺序)
if err := os.WriteFile(filepath.Join(tmpDir, "z_file.txt"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tmpDir, "a_dir"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "m_file.txt"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
// 测试排序
ctx := &fasthttp.RequestCtx{}
config := AutoIndexConfig{Format: "json"}
if !GenerateAutoIndex(ctx, tmpDir, "/", config) {
t.Fatal("GenerateAutoIndex returned false")
}
body := string(ctx.Response.Body())
// 目录应该排在前面
dirIdx := indexOf(body, `"a_dir"`)
zFileIdx := indexOf(body, `"z_file.txt"`)
mFileIdx := indexOf(body, `"m_file.txt"`)
if dirIdx == -1 || zFileIdx == -1 || mFileIdx == -1 {
t.Fatalf("Missing entries in body: %s", body)
}
if dirIdx > zFileIdx || dirIdx > mFileIdx {
t.Errorf("Directories should come first: dirIdx=%d, zFileIdx=%d, mFileIdx=%d", dirIdx, zFileIdx, mFileIdx)
}
}
func TestGenerateAutoIndex_SizeFormatting(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建不同大小的文件
smallFile := filepath.Join(tmpDir, "small.txt")
if err := os.WriteFile(smallFile, make([]byte, 100), 0o644); err != nil {
t.Fatal(err)
}
// 测试人类可读格式
ctx := &fasthttp.RequestCtx{}
config := AutoIndexConfig{
Format: "html",
ExactSize: false,
}
if !GenerateAutoIndex(ctx, tmpDir, "/", config) {
t.Fatal("GenerateAutoIndex returned false")
}
body := string(ctx.Response.Body())
if !containsAll(body, "small.txt") {
t.Errorf("Missing file in output: %s", body)
}
}
func TestFormatSize(t *testing.T) {
tests := []struct {
size int64
expected string
}{
{0, "0"},
{100, "100"},
{1024, "1.0K"},
{1536, "1.5K"},
{1048576, "1.0M"},
{1572864, "1.5M"},
{1073741824, "1.0G"},
{1610612736, "1.5G"},
}
for _, tt := range tests {
result := formatSize(tt.size)
if result != tt.expected {
t.Errorf("formatSize(%d) = %s, want %s", tt.size, result, tt.expected)
}
}
}
func TestEscapeHTML(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"<script>", "&lt;script&gt;"},
{`"quoted"`, "&quot;quoted&quot;"},
{"a&b", "a&amp;b"},
{"'single'", "&#39;single&#39;"},
}
for _, tt := range tests {
result := escapeHTML(tt.input)
if result != tt.expected {
t.Errorf("escapeHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
func TestEscapeURL(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"file name.txt", "file%20name.txt"},
{"file?name.txt", "file%3Fname.txt"},
{"safe-file_123.txt", "safe-file_123.txt"},
}
for _, tt := range tests {
result := escapeURL(tt.input)
if result != tt.expected {
t.Errorf("escapeURL(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
// 辅助函数
func containsAll(s string, substrs ...string) bool {
for _, substr := range substrs {
if !contains(s, substr) {
return false
}
}
return true
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
// 确保 autoindex 在 StaticHandler 中工作
func TestStaticHandler_AutoIndex(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_static_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建测试文件
if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// 创建处理器并启用 autoindex
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
handler.SetAutoIndex(true, "html", false, false)
// 测试目录请求
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/")
handler.Handle(ctx)
if ctx.Response.StatusCode() != 200 {
t.Errorf("Status = %d, want 200", ctx.Response.StatusCode())
}
body := string(ctx.Response.Body())
if !containsAll(body, "test.txt") {
t.Errorf("AutoIndex response missing file: %s", body)
}
}
// 测试 autoindex 关闭时返回 403
func TestStaticHandler_AutoIndex_Disabled(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_disabled_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建处理器(不启用 autoindex
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
// 测试目录请求
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/")
handler.Handle(ctx)
if ctx.Response.StatusCode() != 403 {
t.Errorf("Status = %d, want 403", ctx.Response.StatusCode())
}
}
// 测试时间格式
func TestGenerateAutoIndex_TimeFormat(t *testing.T) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_time_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建文件
testFile := filepath.Join(tmpDir, "time.txt")
if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
// 测试 GMT 时间
ctx1 := &fasthttp.RequestCtx{}
config1 := AutoIndexConfig{Format: "html", Localtime: false}
GenerateAutoIndex(ctx1, tmpDir, "/", config1)
// 测试本地时间
ctx2 := &fasthttp.RequestCtx{}
config2 := AutoIndexConfig{Format: "html", Localtime: true}
GenerateAutoIndex(ctx2, tmpDir, "/", config2)
// 两个响应应该都成功
if ctx1.Response.StatusCode() != 200 || ctx2.Response.StatusCode() != 200 {
t.Errorf("Expected status 200 for both time formats")
}
// 验证时间格式存在格式02-Jan-2006 15:04
body1 := string(ctx1.Response.Body())
if len(body1) < 10 {
t.Errorf("HTML body too short")
}
}
// 确保编译时检查接口
func TestAutoIndexConfig_CompileTimeCheck(t *testing.T) {
// 确保 AutoIndexConfig 结构体字段正确
config := AutoIndexConfig{
Format: "html",
Localtime: true,
ExactSize: true,
}
if config.Format != "html" {
t.Errorf("Format = %s, want html", config.Format)
}
}
// 基准测试
func BenchmarkGenerateAutoIndex_HTML(b *testing.B) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "autoindex_bench")
if err != nil {
b.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// 创建 100 个文件
for i := 0; i < 100; i++ {
if err := os.WriteFile(filepath.Join(tmpDir, "file"+string(rune('0'+i%10))+string(rune('0'+i/10))+".txt"), []byte("content"), 0o644); err != nil {
b.Fatal(err)
}
}
config := AutoIndexConfig{Format: "html"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := &fasthttp.RequestCtx{}
GenerateAutoIndex(ctx, tmpDir, "/", config)
}
}
// 确保时间包导入
var _ = time.Second