Files
velero/pkg/util/wildcard/expand_test.go
T
3b34fb8eff
Run the E2E test on kind / get-go-version (push) Failing after 1m5s
Run the E2E test on kind / build (push) Has been skipped
Run the E2E test on kind / setup-test-matrix (push) Successful in 2s
Run the E2E test on kind / run-e2e-test (push) Has been skipped
Main CI / get-go-version (push) Successful in 11s
Main CI / Build (push) Failing after 33s
Close stale issues and PRs / stale (push) Successful in 12s
Trivy Nightly Scan / Trivy nightly scan (velero, main) (push) Failing after 1m53s
Trivy Nightly Scan / Trivy nightly scan (velero-plugin-for-aws, main) (push) Failing after 1m8s
Trivy Nightly Scan / Trivy nightly scan (velero-plugin-for-gcp, main) (push) Failing after 1m11s
Trivy Nightly Scan / Trivy nightly scan (velero-plugin-for-microsoft-azure, main) (push) Failing after 1m13s
Fix wildcard expansion when includes is empty and excludes has wildcards (#9684)
* Fix wildcard expansion when includes is empty and excludes has wildcards

When a Backup CR is applied via kubectl with empty includedNamespaces
and a wildcard in excludedNamespaces, ShouldExpandWildcards triggers
expansion. The empty includes expands to nil, but wildcardExpanded is
set to true, causing ShouldInclude to return false for all namespaces.

Populate expanded includes with all active namespaces when the original
includes was empty (meaning "include all") so that the wildcardExpanded
check does not falsely reject everything.

Signed-off-by: Joseph <jvaikath@redhat.com>

* Changelog

Signed-off-by: Joseph <jvaikath@redhat.com>

* Normalize empty includes to * instead of active namespaces list

This ensures consistent behavior between CLI and kubectl-apply paths
for Namespace CR inclusion when excludes contain wildcards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Move empty includes normalization to backup controller

Instead of normalizing empty IncludedNamespaces to ["*"] in the
collections layer's ExpandIncludesExcludes, do it earlier in
prepareBackupRequest. This ensures the spec is correct before any
downstream processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Update TestProcessBackupCompletions for wildcard normalization

Add IncludedNamespaces: []string{"*"} to all expected BackupSpec
structs, reflecting the new prepareBackupRequest normalization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Add checks around empty includenamespaces

Signed-off-by: Joseph <jvaikath@redhat.com>

* gofmt

Signed-off-by: Joseph <jvaikath@redhat.com>

---------

Signed-off-by: Joseph <jvaikath@redhat.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 13:24:22 -07:00

629 lines
18 KiB
Go

package wildcard
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldExpandWildcards(t *testing.T) {
tests := []struct {
name string
includes []string
excludes []string
expected bool
}{
{
name: "no wildcards",
includes: []string{"ns1", "ns2"},
excludes: []string{"ns3", "ns4"},
expected: false,
},
{
name: "includes has star - should not expand",
includes: []string{"*"},
excludes: []string{"ns1"},
expected: false,
},
{
name: "includes has star after a wildcard pattern - should not expand",
includes: []string{"ns*", "*"},
excludes: []string{"ns1"},
expected: false,
},
{
name: "includes has wildcard pattern",
includes: []string{"ns*"},
excludes: []string{"ns1"},
expected: true,
},
{
name: "excludes has wildcard pattern",
includes: []string{"ns1"},
excludes: []string{"ns*"},
expected: true,
},
{
name: "both have wildcard patterns",
includes: []string{"app-*"},
excludes: []string{"test-*"},
expected: true,
},
{
name: "includes has star and wildcard - star takes precedence",
includes: []string{"*", "ns*"},
excludes: []string{},
expected: false,
},
{
name: "double asterisk should be detected as wildcard",
includes: []string{"**"},
excludes: []string{},
expected: true, // ** is a wildcard pattern (but will error during validation)
},
{
name: "empty slices",
includes: []string{},
excludes: []string{},
expected: false,
},
{
name: "empty includes with wildcard excludes - should not expand",
includes: []string{},
excludes: []string{"ns*"},
expected: false,
},
{
name: "complex wildcard patterns",
includes: []string{"*-prod"},
excludes: []string{"test-*-staging"},
expected: true,
},
{
name: "question mark wildcard",
includes: []string{"ns?"},
excludes: []string{},
expected: true, // question mark is now considered a wildcard
},
{
name: "character class wildcard",
includes: []string{"ns[abc]"},
excludes: []string{},
expected: true, // character class is considered wildcard
},
{
name: "brace alternatives wildcard",
includes: []string{"ns{prod,staging}"},
excludes: []string{},
expected: false, // brace alternatives are not supported
},
{
name: "dot is literal - not wildcard",
includes: []string{"app.prod"},
excludes: []string{},
expected: false, // dot is literal, not wildcard
},
{
name: "plus is literal - not wildcard",
includes: []string{"app+"},
excludes: []string{},
expected: false, // plus is literal, not wildcard
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ShouldExpandWildcards(tt.includes, tt.excludes)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExpandWildcards(t *testing.T) {
tests := []struct {
name string
activeNamespaces []string
includes []string
excludes []string
expectedIncludes []string
expectedExcludes []string
expectError bool
}{
{
name: "no wildcards",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"ns1", "ns4"},
excludes: []string{"ns2"},
expectedIncludes: []string{"ns1", "ns4"},
expectedExcludes: []string{"ns2"},
expectError: false,
},
{
name: "wildcard in includes",
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "test-ns"},
includes: []string{"app-*"},
excludes: []string{"test-ns"},
expectedIncludes: []string{"app-prod", "app-staging"},
expectedExcludes: []string{"test-ns"},
expectError: false,
},
{
name: "wildcard in excludes",
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "test-ns"},
includes: []string{"app-prod"},
excludes: []string{"*-staging"},
expectedIncludes: []string{"app-prod"},
expectedExcludes: []string{"app-staging"},
expectError: false,
},
{
name: "wildcards in both",
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "db-staging", "test-ns"},
includes: []string{"*-prod"},
excludes: []string{"*-staging"},
expectedIncludes: []string{"app-prod", "db-prod"},
expectedExcludes: []string{"app-staging", "db-staging"},
expectError: false,
},
{
name: "star pattern in includes",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"*"},
excludes: []string{},
expectedIncludes: []string{"ns1", "ns2", "ns3"},
expectedExcludes: nil,
expectError: false,
},
{
name: "empty active namespaces",
activeNamespaces: []string{},
includes: []string{"app-*"},
excludes: []string{"test-*"},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: false,
},
{
name: "empty includes and excludes",
activeNamespaces: []string{"ns1", "ns2"},
includes: []string{},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: false,
},
{
name: "complex patterns",
activeNamespaces: []string{"my-app-prod", "my-app-staging", "your-app-prod", "system-ns"},
includes: []string{"*-app-*"},
excludes: []string{"*-staging"},
expectedIncludes: []string{"my-app-prod", "my-app-staging", "your-app-prod"},
expectedExcludes: []string{"my-app-staging"},
expectError: false,
},
{
name: "double asterisk should error",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"**"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true, // ** is invalid
},
{
name: "double asterisk in pattern should error",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"app-**"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true, // app-** contains ** which is invalid
},
{
name: "question mark patterns",
activeNamespaces: []string{"ns1", "ns2", "ns10", "test"},
includes: []string{"ns?"},
excludes: []string{},
expectedIncludes: []string{"ns1", "ns2"}, // ? matches single character
expectedExcludes: nil,
expectError: false,
},
{
name: "character class patterns",
activeNamespaces: []string{"nsa", "nsb", "nsc", "nsx", "ns1"},
includes: []string{"ns[abc]"},
excludes: []string{},
expectedIncludes: []string{"nsa", "nsb", "nsc"}, // [abc] matches a, b, or c
expectedExcludes: nil,
expectError: false,
},
{
name: "brace alternative patterns",
activeNamespaces: []string{"app-prod", "app-staging", "app-dev", "db-prod"},
includes: []string{"app-{prod,staging}"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true,
},
{
name: "literal dot and plus patterns",
activeNamespaces: []string{"app.prod", "app-prod", "app+", "app"},
includes: []string{"app.prod", "app+"},
excludes: []string{},
expectedIncludes: []string{"app.prod", "app+"}, // . and + are literal
expectedExcludes: nil,
expectError: false,
},
{
name: "unsupported regex patterns should error",
activeNamespaces: []string{"ns1", "ns2"},
includes: []string{"ns(1|2)"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true, // |, (, ) are not supported
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
includes, excludes, err := ExpandWildcards(tt.activeNamespaces, tt.includes, tt.excludes)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.ElementsMatch(t, tt.expectedIncludes, includes)
assert.ElementsMatch(t, tt.expectedExcludes, excludes)
})
}
}
func TestExpandWildcardsPrivate(t *testing.T) {
tests := []struct {
name string
patterns []string
activeNamespaces []string
expected []string
expectError bool
}{
{
name: "empty patterns",
patterns: []string{},
activeNamespaces: []string{"ns1", "ns2"},
expected: nil,
expectError: false,
},
{
name: "non-wildcard patterns",
patterns: []string{"ns1", "ns3"},
activeNamespaces: []string{"ns1", "ns2"},
expected: []string{"ns1", "ns3"}, // includes ns3 even if not in active
expectError: false,
},
{
name: "star pattern",
patterns: []string{"*"},
activeNamespaces: []string{"ns1", "ns2", "ns3"},
expected: []string{"ns1", "ns2", "ns3"},
expectError: false,
},
{
name: "simple wildcard",
patterns: []string{"app-*"},
activeNamespaces: []string{"app-prod", "app-staging", "db-prod"},
expected: []string{"app-prod", "app-staging"},
expectError: false,
},
{
name: "multiple patterns",
patterns: []string{"app-*", "db-prod", "*-test"},
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "service-test", "other"},
expected: []string{"app-prod", "app-staging", "db-prod", "service-test"},
expectError: false,
},
{
name: "wildcard with no matches",
patterns: []string{"missing-*"},
activeNamespaces: []string{"app-prod", "db-staging"},
expected: []string{}, // returns empty slice, not nil
expectError: false,
},
{
name: "duplicate matches from multiple patterns",
patterns: []string{"app-*", "*-prod"},
activeNamespaces: []string{"app-prod", "app-staging", "db-prod"},
expected: []string{"app-prod", "app-staging", "db-prod"}, // no duplicates
expectError: false,
},
{
name: "question mark pattern - glob wildcard",
patterns: []string{"ns?"},
activeNamespaces: []string{"ns1", "ns2", "ns10"},
expected: []string{"ns1", "ns2"}, // ? is a glob pattern for single character
expectError: false,
},
{
name: "character class patterns",
patterns: []string{"ns[12]"},
activeNamespaces: []string{"ns1", "ns2", "ns3", "nsa"},
expected: []string{"ns1", "ns2"}, // [12] matches 1 or 2
expectError: false,
},
{
name: "character range patterns",
patterns: []string{"ns[a-c]"},
activeNamespaces: []string{"nsa", "nsb", "nsc", "nsd", "ns1"},
expected: []string{"nsa", "nsb", "nsc"}, // [a-c] matches a to c
expectError: false,
},
{
name: "double asterisk should error",
patterns: []string{"**"},
activeNamespaces: []string{"app-prod", "app.staging", "db/prod"},
expected: nil,
expectError: true, // ** is not allowed
},
{
name: "unsupported regex symbols should error",
patterns: []string{"ns(1|2)"},
activeNamespaces: []string{"ns1", "ns2"},
expected: nil,
expectError: true, // |, (, ) not supported
},
{
name: "double asterisk should error",
patterns: []string{"**"},
activeNamespaces: []string{"ns1", "ns2"},
expected: nil,
expectError: true, // ** not allowed
},
{
name: "double asterisk in pattern should error",
patterns: []string{"app-**-prod"},
activeNamespaces: []string{"app-prod"},
expected: nil,
expectError: true, // ** not allowed anywhere
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandWildcards(tt.patterns, tt.activeNamespaces)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.expected == nil {
assert.Nil(t, result)
} else if len(tt.expected) == 0 {
assert.Empty(t, result)
} else {
assert.ElementsMatch(t, tt.expected, result)
}
})
}
}
func TestValidateBracePatterns(t *testing.T) {
tests := []struct {
name string
pattern string
expectError bool
errorMsg string
}{
// Valid square bracket patterns
{
name: "valid square bracket pattern",
pattern: "ns[abc]",
expectError: false,
},
{
name: "valid square bracket pattern with range",
pattern: "ns[a-z]",
expectError: false,
},
{
name: "valid square bracket pattern with numbers",
pattern: "ns[0-9]",
expectError: false,
},
{
name: "valid square bracket pattern with mixed",
pattern: "ns[a-z0-9]",
expectError: false,
},
{
name: "valid square bracket pattern with single character",
pattern: "ns[a]",
expectError: false,
},
{
name: "valid square bracket pattern with text before and after",
pattern: "prefix-[abc]-suffix",
expectError: false,
},
// Unclosed opening brackets
{
name: "unclosed opening bracket at end",
pattern: "ns[abc",
expectError: true,
errorMsg: "unclosed bracket",
},
{
name: "unclosed opening bracket at start",
pattern: "[abc",
expectError: true,
errorMsg: "unclosed bracket",
},
{
name: "unclosed opening bracket in middle",
pattern: "ns[abc-test",
expectError: true,
errorMsg: "unclosed bracket",
},
// Unmatched closing brackets
{
name: "unmatched closing bracket at end",
pattern: "ns-abc]",
expectError: true,
errorMsg: "unmatched closing bracket",
},
{
name: "unmatched closing bracket at start",
pattern: "]ns-abc",
expectError: true,
errorMsg: "unmatched closing bracket",
},
{
name: "unmatched closing bracket in middle",
pattern: "ns-]abc",
expectError: true,
errorMsg: "unmatched closing bracket",
},
{
name: "extra closing bracket after valid pair",
pattern: "ns[abc]]",
expectError: true,
errorMsg: "unmatched closing bracket",
},
// Empty bracket patterns
{
name: "completely empty brackets",
pattern: "ns[]",
expectError: true,
errorMsg: "empty bracket pattern",
},
{
name: "empty brackets at start",
pattern: "[]ns",
expectError: true,
errorMsg: "empty bracket pattern",
},
{
name: "empty brackets standalone",
pattern: "[]",
expectError: true,
errorMsg: "empty bracket pattern",
},
// Edge cases
{
name: "empty pattern",
pattern: "",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBracePatterns(tt.pattern)
if tt.expectError {
require.Error(t, err, "Expected error for pattern: %s", tt.pattern)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain: %s", tt.errorMsg)
}
} else {
assert.NoError(t, err, "Expected no error for pattern: %s", tt.pattern)
}
})
}
}
// Edge case tests
func TestExpandWildcardsEdgeCases(t *testing.T) {
t.Run("nil inputs", func(t *testing.T) {
includes, excludes, err := ExpandWildcards(nil, nil, nil)
require.NoError(t, err)
assert.Nil(t, includes)
assert.Nil(t, excludes)
})
t.Run("empty string patterns", func(t *testing.T) {
activeNamespaces := []string{"ns1", "ns2"}
result, err := expandWildcards([]string{""}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{""}, result) // empty string is treated as literal
})
t.Run("whitespace patterns", func(t *testing.T) {
activeNamespaces := []string{"ns1", " ", "ns2"}
result, err := expandWildcards([]string{" "}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{" "}, result)
})
t.Run("special characters in namespace names", func(t *testing.T) {
activeNamespaces := []string{"ns-1", "ns_2", "ns.3", "ns@4"}
result, err := expandWildcards([]string{"ns*"}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"ns-1", "ns_2", "ns.3", "ns@4"}, result)
})
t.Run("mixed literal and wildcard patterns", func(t *testing.T) {
activeNamespaces := []string{"app.prod", "app-prod", "app_prod", "test.ns"}
result, err := expandWildcards([]string{"app.prod", "app?prod"}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"app.prod", "app-prod", "app_prod"}, result)
})
t.Run("conservative asterisk validation", func(t *testing.T) {
tests := []struct {
name string
pattern string
shouldError bool
}{
{"single asterisk", "*", false},
{"double asterisk", "**", true},
{"triple asterisk", "***", true},
{"quadruple asterisk", "****", true},
{"mixed with double", "app-**", true},
{"double in middle", "app-**-prod", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := expandWildcards([]string{tt.pattern}, []string{"test"})
if tt.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
})
t.Run("malformed pattern validation", func(t *testing.T) {
tests := []struct {
name string
pattern string
shouldError bool
}{
{"unclosed bracket", "ns[abc", true},
{"valid bracket", "ns[abc]", false},
{"empty bracket", "ns[]", true}, // empty brackets are invalid
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := expandWildcards([]string{tt.pattern}, []string{"test"})
if tt.shouldError {
assert.Error(t, err, "Expected error for pattern: %s", tt.pattern)
} else {
assert.NoError(t, err, "Expected no error for pattern: %s", tt.pattern)
}
})
}
})
}