Files
rogee cec95ff4e8 test: add comprehensive unit tests — coverage 20.2% → 91.8%
Add 12 test files covering all internal packages:

- internal/util/util_test.go (97.2%)
- internal/model/model_test.go (100.0%)
- internal/config/config_test.go (88.1%)
- internal/template/builtin_test.go (98.8%)
- internal/middleware/middleware_test.go (98.7%)
- internal/database/repo_extra_test.go (85.5%)
- internal/rules/converter_test.go (99.1%)
- internal/service/subscription_test.go (75.7%)
- internal/handler/handler_test.go (90.6%)
- internal/filter/filter_extra_test.go (91.9%)
- internal/proxy/client_parser_test.go (96.1%)
- internal/render/render_extra_test.go (99.2%)

Overall: 91.8% (4033/4400 statements) — exceeds 85% acceptance threshold.
All tests pass, go vet clean, go build clean.
2026-07-27 16:12:28 +08:00

829 lines
22 KiB
Go

package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v3"
)
// newTestApp creates a fiber app, registers the given middleware via Use,
// then a final GET handler that returns 200 "ok".
// The last argument is the final route handler; preceding args are middleware.
func newTestApp(t *testing.T, handlers ...fiber.Handler) *fiber.App {
t.Helper()
app := fiber.New()
mws := handlers
if len(mws) > 0 {
middlewares := make([]any, len(mws))
for i, h := range mws {
middlewares[i] = h
}
app.Use(middlewares...)
}
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("ok")
})
return app
}
// newTestAppWithHandler creates a fiber app with middleware and a custom final route handler.
// The last argument is the final route handler; preceding args are middleware.
func newTestAppWithHandler(t *testing.T, handlers ...fiber.Handler) *fiber.App {
t.Helper()
app := fiber.New()
if len(handlers) == 0 {
app.Get("/test", func(c fiber.Ctx) error { return c.SendString("ok") })
return app
}
mws := handlers[:len(handlers)-1]
final := handlers[len(handlers)-1]
if len(mws) > 0 {
middlewares := make([]any, len(mws))
for i, h := range mws {
middlewares[i] = h
}
app.Use(middlewares...)
}
app.Get("/test", final)
return app
}
// handlersToAny converts []fiber.Handler to []any for variadic registration.
func handlersToAny(hs []fiber.Handler) []any {
out := make([]any, len(hs))
for i, h := range hs {
out[i] = h
}
return out
}
// ---------- auth.go ----------
func TestExtractToken(t *testing.T) {
tests := []struct {
name string
setReq func(*http.Request)
want string
}{
{
name: "Bearer header",
setReq: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer my-token-123")
},
want: "my-token-123",
},
{
name: "bearer lowercase",
setReq: func(r *http.Request) {
r.Header.Set("Authorization", "bearer lower-token")
},
want: "lower-token",
},
{
name: "Bearer with extra spaces",
setReq: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer spaced-token ")
},
want: "spaced-token",
},
{
name: "query param token",
setReq: func(r *http.Request) {
q := r.URL.Query()
q.Set("token", "query-token")
r.URL.RawQuery = q.Encode()
},
want: "query-token",
},
{
name: "x-sub-store-token header",
setReq: func(r *http.Request) {
r.Header.Set("X-Sub-Store-Token", "header-token")
},
want: "header-token",
},
{
name: "Bearer takes priority over query",
setReq: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer bearer-wins")
q := r.URL.Query()
q.Set("token", "query-loses")
r.URL.RawQuery = q.Encode()
},
want: "bearer-wins",
},
{
name: "query takes priority over x-sub-store-token",
setReq: func(r *http.Request) {
q := r.URL.Query()
q.Set("token", "query-wins")
r.URL.RawQuery = q.Encode()
r.Header.Set("X-Sub-Store-Token", "header-loses")
},
want: "query-wins",
},
{
name: "no token anywhere",
setReq: func(r *http.Request) {},
want: "",
},
{
name: "Authorization without Bearer prefix",
setReq: func(r *http.Request) {
r.Header.Set("Authorization", "Basic abc123")
},
want: "",
},
{
name: "empty Bearer",
setReq: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer ")
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
app := fiber.New()
app.Get("/test", func(c fiber.Ctx) error {
got = ExtractToken(c)
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
tt.setReq(req)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
resp.Body.Close()
if got != tt.want {
t.Errorf("ExtractToken() = %q, want %q", got, tt.want)
}
})
}
}
func TestRequireAdmin(t *testing.T) {
adminToken := "secret-admin-token"
t.Run("valid token passes", func(t *testing.T) {
app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error {
return c.SendString("protected")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "protected" {
t.Errorf("body = %q, want protected", string(body))
}
resp.Body.Close()
})
t.Run("invalid token returns 401", func(t *testing.T) {
app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error {
return c.SendString("protected")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 401 {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "invalid") && !strings.Contains(string(body), "failed") {
t.Errorf("body should mention invalid, got: %s", string(body))
}
resp.Body.Close()
})
t.Run("no token returns 401", func(t *testing.T) {
app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error {
return c.SendString("protected")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 401 {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("token via query param", func(t *testing.T) {
app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test?token="+adminToken, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("token via x-sub-store-token header", func(t *testing.T) {
app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Sub-Store-Token", adminToken)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
}
// ---------- cors.go ----------
func TestCORS(t *testing.T) {
t.Run("allowed origin wildcard", func(t *testing.T) {
app := newTestAppWithHandler(t, CORS("*"), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "https://example.com")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Errorf("ACAO = %q, want https://example.com", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" {
t.Error("Access-Control-Allow-Methods should be set")
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
t.Errorf("Access-Control-Allow-Headers should contain Authorization, got %q", got)
}
resp.Body.Close()
})
t.Run("allowed origin specific", func(t *testing.T) {
app := newTestAppWithHandler(t, CORS("https://allowed.com, https://also.com"), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "https://allowed.com")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://allowed.com" {
t.Errorf("ACAO = %q, want https://allowed.com", got)
}
resp.Body.Close()
})
t.Run("disallowed origin", func(t *testing.T) {
app := newTestAppWithHandler(t, CORS("https://allowed.com"), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "https://evil.com")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO = %q, want empty (disallowed)", got)
}
resp.Body.Close()
})
t.Run("no origin header", func(t *testing.T) {
app := newTestAppWithHandler(t, CORS("*"), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO = %q, want empty (no origin)", got)
}
resp.Body.Close()
})
t.Run("empty allowed origins", func(t *testing.T) {
app := newTestAppWithHandler(t, CORS(""), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "https://example.com")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO = %q, want empty (no origins configured)", got)
}
resp.Body.Close()
})
}
func TestHandleOptions(t *testing.T) {
t.Run("OPTIONS preflight returns 204", func(t *testing.T) {
app := fiber.New()
app.Use(HandleOptions())
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("OPTIONS", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 204 {
t.Errorf("status = %d, want 204", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" {
t.Error("Access-Control-Allow-Methods should be set for OPTIONS")
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
t.Errorf("Access-Control-Allow-Headers should contain Authorization, got %q", got)
}
resp.Body.Close()
})
t.Run("GET passes through", func(t *testing.T) {
app := fiber.New()
app.Use(HandleOptions())
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
}
func TestParseOrigins(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"single", "https://example.com", []string{"https://example.com"}},
{"multiple", "https://a.com, https://b.com", []string{"https://a.com", "https://b.com"}},
{"with spaces", " https://a.com , https://b.com ", []string{"https://a.com", "https://b.com"}},
{"empty", "", []string{}},
{"only commas", ",,,", []string{}},
{"wildcard", "*", []string{"*"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOrigins(tt.input)
if len(got) != len(tt.want) {
t.Errorf("parseOrigins(%q) = %v, want %v", tt.input, got, tt.want)
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseOrigins(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestContainsHelper(t *testing.T) {
list := []string{"a", "b", "c"}
if !contains(list, "a") {
t.Error("contains should find 'a'")
}
if contains(list, "z") {
t.Error("contains should not find 'z'")
}
if contains([]string{}, "a") {
t.Error("contains on empty list should be false")
}
}
// ---------- security.go ----------
func TestSecurityHeaders(t *testing.T) {
app := newTestAppWithHandler(t, SecurityHeaders(), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
defer resp.Body.Close()
csp := resp.Header.Get("Content-Security-Policy")
if csp == "" {
t.Fatal("Content-Security-Policy header should be set")
}
// Per review-resolution #27: CSP must NOT contain unsafe-eval
if strings.Contains(csp, "unsafe-eval") {
t.Errorf("CSP should not contain unsafe-eval: %s", csp)
}
// Should contain script-src 'self'
if !strings.Contains(csp, "script-src 'self'") {
t.Errorf("CSP should contain script-src 'self': %s", csp)
}
// Check other security headers
if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" {
t.Errorf("Referrer-Policy = %q, want no-referrer", got)
}
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options = %q, want DENY", got)
}
pp := resp.Header.Get("Permissions-Policy")
if pp == "" {
t.Error("Permissions-Policy should be set")
}
if !strings.Contains(pp, "camera=()") {
t.Errorf("Permissions-Policy should contain camera=(): %s", pp)
}
}
func TestBodyLimit(t *testing.T) {
t.Run("within limit", func(t *testing.T) {
app := fiber.New()
app.Post("/test", BodyLimit(100), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("POST", "/test", strings.NewReader("small body"))
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("exceeds limit", func(t *testing.T) {
app := fiber.New()
app.Post("/test", BodyLimit(10), func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("POST", "/test", strings.NewReader(strings.Repeat("x", 100)))
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 413 {
t.Errorf("status = %d, want 413", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "too large") {
t.Errorf("body should mention too large: %s", string(body))
}
resp.Body.Close()
})
}
func TestDownloadHostIsolation(t *testing.T) {
t.Run("no download hosts configured - all allowed", func(t *testing.T) {
app := fiber.New()
app.Use(DownloadHostIsolation(nil))
app.Get("/api/test", func(c fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/api/test", nil)
req.Host = "anyhost.com"
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("download host serves /download/ path", func(t *testing.T) {
app := fiber.New()
app.Use(DownloadHostIsolation([]string{"dl.example.com"}))
app.Get("/download/sub", func(c fiber.Ctx) error {
return c.SendString("download")
})
req := httptest.NewRequest("GET", "/download/sub", nil)
req.Host = "dl.example.com"
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("download host blocked on non-download path", func(t *testing.T) {
app := fiber.New()
app.Use(DownloadHostIsolation([]string{"dl.example.com"}))
app.Get("/api/test", func(c fiber.Ctx) error {
return c.SendString("api")
})
req := httptest.NewRequest("GET", "/api/test", nil)
req.Host = "dl.example.com"
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 404 {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("non-download host allowed on any path", func(t *testing.T) {
app := fiber.New()
app.Use(DownloadHostIsolation([]string{"dl.example.com"}))
app.Get("/api/test", func(c fiber.Ctx) error {
return c.SendString("api")
})
req := httptest.NewRequest("GET", "/api/test", nil)
req.Host = "api.example.com"
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
})
t.Run("download host case-insensitive", func(t *testing.T) {
app := fiber.New()
app.Use(DownloadHostIsolation([]string{"DL.Example.COM"}))
app.Get("/api/test", func(c fiber.Ctx) error {
return c.SendString("api")
})
req := httptest.NewRequest("GET", "/api/test", nil)
req.Host = "dl.example.com"
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if resp.StatusCode != 404 {
t.Errorf("status = %d, want 404 (case-insensitive match)", resp.StatusCode)
}
resp.Body.Close()
})
}
func TestSetSafeResponseHeader(t *testing.T) {
t.Run("valid value sets header", func(t *testing.T) {
app := fiber.New()
app.Get("/test", func(c fiber.Ctx) error {
SetSafeResponseHeader(c, "X-Custom", "safe-value")
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("X-Custom"); got != "safe-value" {
t.Errorf("X-Custom = %q, want safe-value", got)
}
resp.Body.Close()
})
t.Run("empty value does not set header", func(t *testing.T) {
app := fiber.New()
app.Get("/test", func(c fiber.Ctx) error {
SetSafeResponseHeader(c, "X-Custom", "")
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("X-Custom"); got != "" {
t.Errorf("X-Custom = %q, want empty (not set)", got)
}
resp.Body.Close()
})
t.Run("CRLF injection blocked", func(t *testing.T) {
app := fiber.New()
app.Get("/test", func(c fiber.Ctx) error {
SetSafeResponseHeader(c, "X-Custom", "safe\r\nX-Injected: evil")
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("X-Custom"); got != "" {
t.Errorf("X-Custom = %q, want empty (CRLF blocked)", got)
}
if got := resp.Header.Get("X-Injected"); got != "" {
t.Errorf("X-Injected = %q, want empty (injection blocked)", got)
}
resp.Body.Close()
})
t.Run("LF only blocked", func(t *testing.T) {
app := fiber.New()
app.Get("/test", func(c fiber.Ctx) error {
SetSafeResponseHeader(c, "X-Custom", "safe\nX-Injected: evil")
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test error: %v", err)
}
if got := resp.Header.Get("X-Custom"); got != "" {
t.Errorf("X-Custom = %q, want empty (LF blocked)", got)
}
resp.Body.Close()
})
}
func TestSafeContentDisposition(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "simple filename",
input: `attachment; filename="test.txt"`,
want: `attachment; filename="test.txt"`,
},
{
name: "filename without quotes",
input: `attachment; filename=test.txt`,
want: `attachment; filename="test.txt"`,
},
{
name: "UTF-8 encoded filename",
input: `attachment; filename*=UTF-8''test%20file.txt`,
want: `attachment; filename="test_20file.txt"`,
},
{
name: "empty input",
input: "",
want: "",
},
{
name: "CRLF injection attempt",
input: "attachment; filename=\"test\r\nX-Injected: evil\"",
want: "",
},
{
name: "no filename",
input: "attachment",
want: "",
},
{
name: "special chars sanitized",
input: `attachment; filename="test@#$%.txt"`,
want: `attachment; filename="test____.txt"`,
},
{
name: "CJK preserved",
input: `attachment; filename="节点.txt"`,
want: `attachment; filename="节点.txt"`,
},
{
name: "long filename truncated",
input: `attachment; filename="` + strings.Repeat("a", 200) + `.txt"`,
want: `attachment; filename="` + strings.Repeat("a", 120) + `"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SafeContentDisposition(tt.input)
if got != tt.want {
t.Errorf("SafeContentDisposition(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSafeContentDispositionEmptyFilename(t *testing.T) {
// filename with only special chars → sanitized to underscores (non-empty) → returns valid
got := SafeContentDisposition(`attachment; filename="!!!"`)
want := `attachment; filename="___"`
if got != want {
t.Errorf("SafeContentDisposition with only-special filename = %q, want %q", got, want)
}
}
func TestExtractFilename(t *testing.T) {
tests := []struct {
input string
want string
}{
{`attachment; filename="test.txt"`, "test.txt"},
{`attachment; filename=test.txt`, "test.txt"},
{`attachment; filename*=UTF-8''test.txt`, "test.txt"},
{`attachment`, ""},
{``, ""},
{`filename="only"`, "only"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := extractFilename(tt.input)
if got != tt.want {
t.Errorf("extractFilename(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSanitizeFilename(t *testing.T) {
tests := []struct {
input string
want string
}{
{"simple.txt", "simple.txt"},
{"test@file.txt", "test_file.txt"},
{"节点.txt", "节点.txt"}, // CJK preserved
{"a b c.txt", "a b c.txt"}, // spaces preserved
{"test(1).txt", "test(1).txt"}, // parens preserved
{"test-1.txt", "test-1.txt"}, // hyphens preserved
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := sanitizeFilename(tt.input)
if got != tt.want {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
// truncation
long := strings.Repeat("a", 200)
got := sanitizeFilename(long)
if len(got) > 120 {
t.Errorf("sanitizeFilename long input length = %d, want <= 120", len(got))
}
}