diff --git a/src/Condition.php b/src/Condition.php index f42de31..2731c9b 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -421,6 +421,14 @@ private function matchesLogical(array $attributes): bool private function matchesEqual(mixed $value): bool { foreach ($this->values as $expected) { + if (\is_string($expected) && \is_string($value)) { + if (\strtolower($expected) === \strtolower($value)) { + return true; + } + + continue; + } + if ($expected === $value) { return true; } @@ -435,12 +443,24 @@ private function matchesEqual(mixed $value): bool private function matchesContains(mixed $value, array $needles): bool { if (\is_array($value)) { - return \count(array_intersect($value, $needles)) > 0; + // Mirror the old array_intersect() semantics (scalars compared as + // strings, so 200 matches '200') while folding case. + foreach ($value as $item) { + foreach ($needles as $needle) { + if (\is_scalar($item) && \is_scalar($needle) + && \strtolower((string) $item) === \strtolower((string) $needle)) { + return true; + } + } + } + + return false; } if (\is_string($value)) { + $haystack = \strtolower($value); foreach ($needles as $needle) { - if (\is_string($needle) && $needle !== '' && str_contains($value, $needle)) { + if (\is_string($needle) && $needle !== '' && str_contains($haystack, \strtolower($needle))) { return true; } } @@ -481,7 +501,7 @@ private function matchesPrefix(mixed $value): bool return false; } - return str_starts_with($value, $prefix); + return str_starts_with(\strtolower($value), \strtolower($prefix)); } private function matchesSuffix(mixed $value): bool @@ -492,7 +512,7 @@ private function matchesSuffix(mixed $value): bool return false; } - return str_ends_with($value, $suffix); + return str_ends_with(\strtolower($value), \strtolower($suffix)); } /** diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index aad0d25..e6d0dfe 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -170,6 +170,50 @@ public function testConditionSerializationRoundTrip(): void $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web'])); } + public function testMatchingIsCaseInsensitive(): void + { + // equal: rule value and resolved attribute differ only in case. + $country = Condition::equal('country', ['in']); + $this->assertTrue($country->matches(['country' => 'IN'])); + $this->assertTrue($country->matches(['country' => 'In'])); + $this->assertFalse($country->matches(['country' => 'US'])); + + // notEqual inherits the same folding. + $notCountry = Condition::notEqual('country', 'in'); + $this->assertFalse($notCountry->matches(['country' => 'IN'])); + $this->assertTrue($notCountry->matches(['country' => 'US'])); + + // contains: both the string-haystack and array-membership forms fold case. + $stringContains = Condition::contains('userAgent', ['CURL']); + $this->assertTrue($stringContains->matches(['userAgent' => 'curl/8.4'])); + + $arrayContains = Condition::contains('tags', ['Security']); + $this->assertTrue($arrayContains->matches(['tags' => ['security', 'waf']])); + + // Array membership keeps the old array_intersect() loose scalar + // semantics: an int needle still matches a numeric-string element. + $numericContains = Condition::contains('codes', [200]); + $this->assertTrue($numericContains->matches(['codes' => ['200', '404']])); + + // prefix / suffix fold case too. + $startsWith = Condition::startsWith('path', '/API'); + $this->assertTrue($startsWith->matches(['path' => '/api/v1'])); + + $endsWith = Condition::endsWith('path', '.JSON'); + $this->assertTrue($endsWith->matches(['path' => '/status.json'])); + + // Non-string values keep strict semantics (no accidental coercion). + $numeric = Condition::equal('count', [10]); + $this->assertTrue($numeric->matches(['count' => 10])); + $this->assertFalse($numeric->matches(['count' => '10'])); + + // Ordering operators stay case-sensitive so string boundaries remain + // deterministic — only equality/substring matching folds case. + $between = Condition::between('name', 'BANANA', 'CHERRY'); + $this->assertFalse($between->matches(['name' => 'banana'])); + $this->assertTrue($between->matches(['name' => 'CANARY'])); + } + public function testInvalidMethodThrowsException(): void { $this->expectException(ConditionException::class);