package imgcache import ( "testing" ) func TestParseImageURL(t *testing.T) { tests := []struct { name string input string want *ParsedURL wantErr error }{ { name: "basic path with size", input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp", want: &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Query: "", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, }, }, { name: "original size with 0x0", input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg", want: &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Query: "", Size: Size{Width: 0, Height: 0}, Format: FormatJPEG, }, }, { name: "original size with orig keyword", input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png", want: &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Query: "", Size: Size{Width: 0, Height: 0}, Format: FormatPNG, }, }, { name: "path with query string", input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp", want: &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Query: "arg1=val1&arg2=val2", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, }, }, { name: "deep nested path", input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif", want: &ParsedURL{ Host: "cdn.example.com", Path: "/a/b/c/d/image.jpg", Query: "", Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF, }, }, { name: "jpg alias for jpeg", input: "/v1/image/example.com/img.png/100x100.jpg", want: &ParsedURL{ Host: "example.com", Path: "/img.png", Query: "", Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, }, }, { name: "gif format", input: "/v1/image/example.com/animated.gif/200x200.gif", want: &ParsedURL{ Host: "example.com", Path: "/animated.gif", Query: "", Size: Size{Width: 200, Height: 200}, Format: FormatGIF, }, }, { name: "missing prefix", input: "/image/cdn.example.com/photo.jpg/800x600.webp", wantErr: ErrInvalidPath, }, { name: "empty path after prefix", input: "/v1/image/", wantErr: ErrMissingHost, }, { name: "host only, no path or size", input: "/v1/image/cdn.example.com", wantErr: ErrMissingSize, }, { name: "invalid size format", input: "/v1/image/cdn.example.com/photo.jpg/invalid.webp", wantErr: ErrInvalidSize, }, { name: "unsupported format", input: "/v1/image/cdn.example.com/photo.jpg/800x600.bmp", wantErr: ErrInvalidFormat, }, { name: "dimension too large", input: "/v1/image/cdn.example.com/photo.jpg/10000x600.webp", wantErr: ErrDimensionTooLarge, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseImageURL(tt.input) if tt.wantErr != nil { if err == nil { t.Errorf("ParseImageURL() error = nil, wantErr %v", tt.wantErr) return } if !errorIs(err, tt.wantErr) { t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr) } return } if err != nil { t.Errorf("ParseImageURL() unexpected error = %v", err) return } if got.Host != tt.want.Host { t.Errorf("Host = %q, want %q", got.Host, tt.want.Host) } if got.Path != tt.want.Path { t.Errorf("Path = %q, want %q", got.Path, tt.want.Path) } if got.Query != tt.want.Query { t.Errorf("Query = %q, want %q", got.Query, tt.want.Query) } if got.Size != tt.want.Size { t.Errorf("Size = %v, want %v", got.Size, tt.want.Size) } if got.Format != tt.want.Format { t.Errorf("Format = %q, want %q", got.Format, tt.want.Format) } }) } } func TestParseImagePath(t *testing.T) { // ParseImagePath is for chi wildcard capture (no /v1/image/ prefix) tests := []struct { name string input string want *ParsedURL wantErr bool }{ { name: "chi wildcard capture", input: "cdn.example.com/photos/cat.jpg/800x600.webp", want: &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, }, }, { name: "with leading slash from chi", input: "/cdn.example.com/photos/cat.jpg/800x600.webp", want: &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseImagePath(tt.input) if (err != nil) != tt.wantErr { t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { return } if got.Host != tt.want.Host { t.Errorf("Host = %q, want %q", got.Host, tt.want.Host) } if got.Path != tt.want.Path { t.Errorf("Path = %q, want %q", got.Path, tt.want.Path) } if got.Size != tt.want.Size { t.Errorf("Size = %v, want %v", got.Size, tt.want.Size) } if got.Format != tt.want.Format { t.Errorf("Format = %q, want %q", got.Format, tt.want.Format) } }) } } func TestParsedURL_ToImageRequest(t *testing.T) { parsed := &ParsedURL{ Host: "cdn.example.com", Path: "/photos/cat.jpg", Query: "version=2", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, } req := parsed.ToImageRequest() if req.SourceHost != parsed.Host { t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host) } if req.SourcePath != parsed.Path { t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path) } if req.SourceQuery != parsed.Query { t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query) } if req.Size != parsed.Size { t.Errorf("Size = %v, want %v", req.Size, parsed.Size) } if req.Format != parsed.Format { t.Errorf("Format = %q, want %q", req.Format, parsed.Format) } } func TestParseImageURL_PathTraversal(t *testing.T) { // All path traversal attempts should be rejected tests := []struct { name string input string }{ { name: "simple parent directory", input: "/v1/image/cdn.example.com/../etc/passwd/800x600.jpeg", }, { name: "double parent directory", input: "/v1/image/cdn.example.com/../../etc/passwd/800x600.jpeg", }, { name: "parent in middle of path", input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg", }, { name: "encoded parent directory", input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg", }, { name: "double encoded parent", input: "/v1/image/cdn.example.com/photos/%252e%252e/secret/800x600.jpeg", }, { name: "backslash traversal", input: "/v1/image/cdn.example.com/photos/..\\secret/800x600.jpeg", }, { name: "mixed slashes", input: "/v1/image/cdn.example.com/photos/../\\../secret/800x600.jpeg", }, { name: "null byte injection", input: "/v1/image/cdn.example.com/photos/image.jpg%00.png/800x600.jpeg", }, { name: "parent at start of path", input: "/v1/image/cdn.example.com/../800x600.jpeg", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := ParseImageURL(tt.input) if err == nil { t.Error("ParseImageURL() should reject path traversal attempts") } if err != ErrPathTraversal { t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err) } }) } } func TestParseImagePath_PathTraversal(t *testing.T) { // Test path traversal via ParseImagePath (chi wildcard) tests := []struct { name string input string }{ { name: "parent directory in path", input: "cdn.example.com/photos/../secret/800x600.jpeg", }, { name: "encoded traversal", input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := ParseImagePath(tt.input) if err == nil { t.Error("ParseImagePath() should reject path traversal attempts") } if err != ErrPathTraversal { t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err) } }) } } // errorIs checks if err matches target (handles wrapped errors). func errorIs(err, target error) bool { if err == target { return true } // Check if error message contains target message for wrapped errors if err != nil && target != nil { return contains(err.Error(), target.Error()) } return false } func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) } func containsAt(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false }