argo-cd/util/glob/glob_test.go
Nitish Kumar 6ba0727217
fix: improve error message when hydrateTo sync path does not exist yet (#27336)
Signed-off-by: nitishfy <justnitish06@gmail.com>
2026-04-14 13:50:52 +00:00

337 lines
9.8 KiB
Go
Raw Permalink 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 glob
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
extglob "github.com/gobwas/glob"
"github.com/stretchr/testify/require"
)
// Test helpers - these access internal variables for testing purposes
// resetGlobCacheForTest clears the cached glob patterns for testing.
func resetGlobCacheForTest() {
globCacheLock.Lock()
defer globCacheLock.Unlock()
globCache.Clear()
}
// isPatternCached returns true if the pattern (with optional separators) is cached.
func isPatternCached(pattern string, separators ...rune) bool {
globCacheLock.Lock()
defer globCacheLock.Unlock()
_, ok := globCache.Get(cacheKey(pattern, separators...))
return ok
}
// globCacheLen returns the number of cached patterns.
func globCacheLen() int {
globCacheLock.Lock()
defer globCacheLock.Unlock()
return globCache.Len()
}
func matchWithCompiler(pattern, text string, compiler compileFn, separators ...rune) bool {
compiled, err := getOrCompile(pattern, compiler, separators...)
if err != nil {
return false
}
return compiled.Match(text)
}
func countingCompiler() (compileFn, *int32) {
var compileCount int32
compiler := func(pattern string, separators ...rune) (extglob.Glob, error) {
atomic.AddInt32(&compileCount, 1)
return extglob.Compile(pattern, separators...)
}
return compiler, &compileCount
}
func Test_Match(t *testing.T) {
tests := []struct {
name string
input string
pattern string
result bool
}{
{"Exact match", "hello", "hello", true},
{"Non-match exact", "hello", "hell", false},
{"Long glob match", "hello", "hell*", true},
{"Short glob match", "hello", "h*", true},
{"Glob non-match", "hello", "e*", false},
{"Invalid pattern", "e[[a*", "e[[a*", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := Match(tt.pattern, tt.input)
require.Equal(t, tt.result, res)
})
}
}
func Test_MatchList(t *testing.T) {
tests := []struct {
name string
input string
list []string
patternMatch string
result bool
}{
{"Exact name in list", "test", []string{"test"}, EXACT, true},
{"Exact name not in list", "test", []string{"other"}, EXACT, false},
{"Exact name not in list, multiple elements", "test", []string{"some", "other"}, EXACT, false},
{"Exact name not in list, list empty", "test", []string{}, EXACT, false},
{"Exact name not in list, empty element", "test", []string{""}, EXACT, false},
{"Glob name in list, but exact wanted", "test", []string{"*"}, EXACT, false},
{"Glob name in list with simple wildcard", "test", []string{"*"}, GLOB, true},
{"Glob name in list without wildcard", "test", []string{"test"}, GLOB, true},
{"Glob name in list, multiple elements", "test", []string{"other*", "te*"}, GLOB, true},
{"match everything but specified word: fail", "disallowed", []string{"/^((?!disallowed).)*$/"}, REGEXP, false},
{"match everything but specified word: pass", "allowed", []string{"/^((?!disallowed).)*$/"}, REGEXP, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := MatchStringInList(tt.list, tt.input, tt.patternMatch)
require.Equal(t, tt.result, res)
})
}
}
func Test_MatchWithError(t *testing.T) {
tests := []struct {
name string
input string
pattern string
result bool
expectedErr string
}{
{"Exact match", "hello", "hello", true, ""},
{"Non-match exact", "hello", "hell", false, ""},
{"Long glob match", "hello", "hell*", true, ""},
{"Short glob match", "hello", "h*", true, ""},
{"Glob non-match", "hello", "e*", false, ""},
{"Invalid pattern", "e[[a*", "e[[a*", false, "unexpected end of input"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := MatchWithError(tt.pattern, tt.input)
require.Equal(t, tt.result, res)
if tt.expectedErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.expectedErr)
}
})
}
}
func Test_GlobCaching(t *testing.T) {
// Clear cache before test
resetGlobCacheForTest()
compiler, compileCount := countingCompiler()
pattern := "test*pattern"
text := "testABCpattern"
// First call should compile and cache
result1 := matchWithCompiler(pattern, text, compiler)
require.True(t, result1)
// Verify pattern is cached
require.True(t, isPatternCached(pattern), "pattern should be cached after first Match call")
// Second call should use cached value
result2 := matchWithCompiler(pattern, text, compiler)
require.True(t, result2)
// Results should be consistent
require.Equal(t, result1, result2)
require.Equal(t, int32(1), atomic.LoadInt32(compileCount), "glob should compile once for the cached pattern")
}
func Test_GlobCachingConcurrent(t *testing.T) {
// Clear cache before test
resetGlobCacheForTest()
compiler, compileCount := countingCompiler()
pattern := "concurrent*test"
text := "concurrentABCtest"
var wg sync.WaitGroup
numGoroutines := 100
errChan := make(chan error, numGoroutines)
for range numGoroutines {
wg.Go(func() {
result := matchWithCompiler(pattern, text, compiler)
if !result {
errChan <- errors.New("expected match to return true")
}
})
}
wg.Wait()
close(errChan)
// Check for any errors from goroutines
for err := range errChan {
t.Error(err)
}
// Verify pattern is cached
require.True(t, isPatternCached(pattern))
require.Equal(t, 1, globCacheLen(), "should only have one cached entry for the pattern")
require.Equal(t, int32(1), atomic.LoadInt32(compileCount), "glob should compile once for the cached pattern")
}
func Test_GlobCacheLRUEviction(t *testing.T) {
// Clear cache before test
resetGlobCacheForTest()
// Fill cache beyond DefaultGlobCacheSize
for i := range DefaultGlobCacheSize + 100 {
pattern := fmt.Sprintf("pattern-%d-*", i)
Match(pattern, "pattern-0-test")
}
// Cache size should be limited to DefaultGlobCacheSize
require.Equal(t, DefaultGlobCacheSize, globCacheLen(), "cache size should be limited to DefaultGlobCacheSize")
// The oldest patterns should be evicted
oldest := fmt.Sprintf("pattern-%d-*", 0)
require.False(t, isPatternCached(oldest), "oldest pattern should be evicted")
// The most recently used patterns should still be cached
require.True(t, isPatternCached(fmt.Sprintf("pattern-%d-*", DefaultGlobCacheSize+99)), "most recent pattern should be cached")
}
func Test_GlobCacheKeyIncludesSeparators(t *testing.T) {
resetGlobCacheForTest()
compiler, compileCount := countingCompiler()
pattern := "a*b"
textWithSlash := "a/b"
// Without separators, '*' matches '/' so "a/b" matches "a*b"
require.True(t, matchWithCompiler(pattern, textWithSlash, compiler))
require.Equal(t, int32(1), atomic.LoadInt32(compileCount))
// With separator '/', '*' does NOT match '/' so "a/b" should NOT match "a*b"
require.False(t, matchWithCompiler(pattern, textWithSlash, compiler, '/'))
require.Equal(t, int32(2), atomic.LoadInt32(compileCount), "same pattern with different separators must compile separately")
// Both entries should be independently cached
require.True(t, isPatternCached(pattern))
require.True(t, isPatternCached(pattern, '/'))
require.Equal(t, 2, globCacheLen())
// Subsequent calls should use cache (no additional compiles)
matchWithCompiler(pattern, textWithSlash, compiler)
matchWithCompiler(pattern, textWithSlash, compiler, '/')
require.Equal(t, int32(2), atomic.LoadInt32(compileCount), "cached patterns should not recompile")
}
func Test_InvalidGlobNotCached(t *testing.T) {
// Clear cache before test
resetGlobCacheForTest()
invalidPattern := "e[[a*"
text := "test"
// Match should return false for invalid pattern
result := Match(invalidPattern, text)
require.False(t, result)
// Invalid patterns should NOT be cached
require.False(t, isPatternCached(invalidPattern), "invalid pattern should not be cached")
// Also test with MatchWithError
_, err := MatchWithError(invalidPattern, text)
require.Error(t, err)
// Still should not be cached after MatchWithError
require.False(t, isPatternCached(invalidPattern), "invalid pattern should not be cached after MatchWithError")
}
func Test_SetCacheSize(t *testing.T) {
resetGlobCacheForTest()
customSize := 5
SetCacheSize(customSize)
defer SetCacheSize(DefaultGlobCacheSize)
for i := range customSize + 3 {
Match(fmt.Sprintf("setsize-%d-*", i), "setsize-0-test")
}
require.Equal(t, customSize, globCacheLen(), "cache size should respect the custom size set via SetCacheSize")
require.False(t, isPatternCached("setsize-0-*"), "oldest pattern should be evicted with custom cache size")
require.True(t, isPatternCached(fmt.Sprintf("setsize-%d-*", customSize+2)), "most recent pattern should be cached")
}
// BenchmarkMatch_WithCache benchmarks Match with caching (cache hit)
func BenchmarkMatch_WithCache(b *testing.B) {
pattern := "proj:*/app-*"
text := "proj:myproject/app-frontend"
// Warm up the cache
Match(pattern, text)
for b.Loop() {
Match(pattern, text)
}
}
// BenchmarkMatch_WithoutCache simulates the OLD behavior (compile every time)
// by calling glob.Compile + Match directly, bypassing the cache entirely.
func BenchmarkMatch_WithoutCache(b *testing.B) {
pattern := "proj:*/app-*"
text := "proj:myproject/app-frontend"
for b.Loop() {
compiled, err := extglob.Compile(pattern)
if err != nil {
b.Fatal(err)
}
compiled.Match(text)
}
}
// BenchmarkGlobCompile measures raw glob.Compile cost
func BenchmarkGlobCompile(b *testing.B) {
pattern := "proj:*/app-*"
for b.Loop() {
_, _ = extglob.Compile(pattern)
}
}
// BenchmarkMatch_RBACSimulation simulates real RBAC evaluation scenario
// 50 policies × 1 app = what happens per application in List
func BenchmarkMatch_RBACSimulation(b *testing.B) {
patterns := make([]string, 50)
for i := range 50 {
patterns[i] = fmt.Sprintf("proj:team-%d/*", i)
}
text := "proj:team-25/my-app"
// With caching: patterns are compiled once
for b.Loop() {
for _, pattern := range patterns {
Match(pattern, text)
}
}
}