mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-01-26 11:44:04 +00:00
* feat: performance, integrations, advanced features * chore: fix linting problems * chore: suppressions and linting * chore(lint): pre-commit linting, fixes * feat: comprehensive input validation, security hardening, and regression testing - Add extensive input validation throughout codebase with proper error handling - Implement comprehensive security hardening with ReDoS protection and bounds checking - Add 3 new regression test suites covering critical bugs, security, and validation scenarios - Enhance rate limiting with memory management and configurable cleanup intervals - Update configuration security settings and improve Laravel integration - Fix TODO.md timestamps to reflect actual development timeline - Strengthen static analysis configuration and improve code quality standards * feat: configure static analysis tools and enhance development workflow - Complete configuration of Psalm, PHPStan, and Rector for harmonious static analysis. - Fix invalid configurations and tool conflicts that prevented proper code quality analysis. - Add comprehensive safe analysis script with interactive workflow, backup/restore capabilities, and dry-run modes. Update documentation with linting policy requiring issue resolution over suppression. - Clean completed items from TODO to focus on actionable improvements. - All static analysis tools now work together seamlessly to provide code quality insights without breaking existing functionality. * fix(test): update Invalid regex pattern expectation * chore: phpstan, psalm fixes * chore: phpstan, psalm fixes, more tests * chore: tooling tweaks, cleanup * chore: tweaks to get the tests pass * fix(lint): rector config tweaks and successful run * feat: refactoring, more tests, fixes, cleanup * chore: deduplication, use constants * chore: psalm fixes * chore: ignore phpstan deliberate errors in tests * chore: improve codebase, deduplicate code * fix: lint * chore: deduplication, codebase simplification, sonarqube fixes * fix: resolve SonarQube reliability rating issues Fix useless object instantiation warnings in test files by assigning instantiated objects to variables. This resolves the SonarQube reliability rating issue (was C, now targeting A). Changes: - tests/Strategies/MaskingStrategiesTest.php: Fix 3 instances - tests/Strategies/FieldPathMaskingStrategyTest.php: Fix 1 instance The tests use expectException() to verify that constructors throw exceptions for invalid input. SonarQube flagged standalone `new` statements as useless. Fixed by assigning to variables with explicit unset() and fail() calls. All tests pass (623/623) and static analysis tools pass. * fix: resolve more SonarQube detected issues * fix: resolve psalm detected issues * fix: resolve more SonarQube detected issues * fix: resolve psalm detected issues * fix: duplications * fix: resolve SonarQube reliability rating issues * fix: resolve psalm and phpstan detected issues
296 lines
9.3 KiB
PHP
296 lines
9.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests;
|
|
|
|
use Ivuorinen\MonologGdprFilter\RateLimiter;
|
|
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRateLimitConfigurationException;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
#[CoversClass(RateLimiter::class)]
|
|
final class RateLimiterComprehensiveTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
// Clear all rate limiter data before each test
|
|
RateLimiter::clearAll();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// Clean up after each test
|
|
RateLimiter::clearAll();
|
|
}
|
|
|
|
public function testGetRemainingRequestsReturnsZeroWhenNoKey(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
|
|
|
|
// Key doesn't exist yet, should use fallback to 0
|
|
$remaining = $limiter->getRemainingRequests('nonexistent_key');
|
|
|
|
// Since key doesn't exist and getStats returns the value, it should be 10 (max - 0 current)
|
|
$this->assertGreaterThanOrEqual(0, $remaining);
|
|
}
|
|
|
|
public function testGlobalCleanupTriggeredAfterInterval(): void
|
|
{
|
|
// Set a very short cleanup interval for testing
|
|
RateLimiter::setCleanupInterval(60); // 1 minute
|
|
|
|
$limiter = new RateLimiter(maxRequests: 5, windowSeconds: 1); // 1 second window
|
|
|
|
// Make some requests
|
|
$limiter->isAllowed('test_key_1');
|
|
$limiter->isAllowed('test_key_2');
|
|
|
|
// Wait for window to expire
|
|
sleep(2);
|
|
|
|
// Get memory stats before
|
|
$statsBefore = RateLimiter::getMemoryStats();
|
|
|
|
// Trigger cleanup by making a request after the interval
|
|
// We need to manipulate lastCleanup to trigger cleanup
|
|
$reflection = new \ReflectionClass(RateLimiter::class);
|
|
$lastCleanupProp = $reflection->getProperty('lastCleanup');
|
|
$lastCleanupProp->setValue(null, time() - 301); // Set to 301 seconds ago
|
|
|
|
// This should trigger cleanup
|
|
$limiter->isAllowed('test_key_3');
|
|
|
|
// Old keys should be cleaned up
|
|
$statsAfter = RateLimiter::getMemoryStats();
|
|
|
|
// Verify cleanup happened (lastCleanup should be updated)
|
|
$this->assertGreaterThanOrEqual($statsBefore['last_cleanup'], $statsAfter['last_cleanup']);
|
|
}
|
|
|
|
public function testPerformGlobalCleanupRemovesEmptyKeys(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 5, windowSeconds: 1);
|
|
|
|
// Add requests
|
|
$limiter->isAllowed('key1');
|
|
$limiter->isAllowed('key2');
|
|
|
|
// Wait for window to expire
|
|
sleep(2);
|
|
|
|
// Trigger cleanup by manipulating lastCleanup
|
|
$reflection = new \ReflectionClass(RateLimiter::class);
|
|
$lastCleanupProp = $reflection->getProperty('lastCleanup');
|
|
$lastCleanupProp->setValue(null, time() - 301);
|
|
|
|
// This should trigger cleanup which removes expired keys
|
|
$limiter->isAllowed('new_key');
|
|
|
|
$stats = RateLimiter::getMemoryStats();
|
|
|
|
// Only new_key should remain
|
|
$this->assertLessThanOrEqual(1, $stats['total_keys']);
|
|
}
|
|
|
|
public function testSetCleanupIntervalValidation(): void
|
|
{
|
|
// Test minimum value
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
RateLimiter::setCleanupInterval(30); // Below minimum of 60
|
|
}
|
|
|
|
public function testSetCleanupIntervalTooLarge(): void
|
|
{
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
RateLimiter::setCleanupInterval(700000); // Above maximum of 604800
|
|
}
|
|
|
|
public function testSetCleanupIntervalNegative(): void
|
|
{
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
RateLimiter::setCleanupInterval(-10);
|
|
}
|
|
|
|
public function testSetCleanupIntervalZero(): void
|
|
{
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
RateLimiter::setCleanupInterval(0);
|
|
}
|
|
|
|
public function testSetCleanupIntervalValid(): void
|
|
{
|
|
RateLimiter::setCleanupInterval(120);
|
|
|
|
$stats = RateLimiter::getMemoryStats();
|
|
$this->assertSame(120, $stats['cleanup_interval']);
|
|
|
|
// Reset to default
|
|
RateLimiter::setCleanupInterval(300);
|
|
}
|
|
|
|
public function testValidateKeyWithControlCharacters(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
|
|
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
$this->expectExceptionMessage('control characters');
|
|
|
|
// Key with null byte (control character)
|
|
$limiter->isAllowed("key\x00with\x00null");
|
|
}
|
|
|
|
public function testValidateKeyWithOtherControlCharacters(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
|
|
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
|
|
// Key with other control characters
|
|
$limiter->isAllowed("key\x01\x02\x03");
|
|
}
|
|
|
|
public function testClearKeyValidatesKey(): void
|
|
{
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
|
|
RateLimiter::clearKey('');
|
|
}
|
|
|
|
public function testClearKeyWithControlCharacters(): void
|
|
{
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
|
|
RateLimiter::clearKey("bad\x00key");
|
|
}
|
|
|
|
public function testClearKeyWithTooLongKey(): void
|
|
{
|
|
$this->expectException(InvalidRateLimitConfigurationException::class);
|
|
|
|
$longKey = str_repeat('a', 251);
|
|
RateLimiter::clearKey($longKey);
|
|
}
|
|
|
|
public function testGetStatsWithExpiredTimestamps(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 5, windowSeconds: 1);
|
|
|
|
// Make some requests
|
|
$limiter->isAllowed('test_key');
|
|
$limiter->isAllowed('test_key');
|
|
|
|
// Wait for window to expire
|
|
sleep(2);
|
|
|
|
// Get stats - should filter out expired timestamps
|
|
$stats = $limiter->getStats('test_key');
|
|
|
|
$this->assertSame(0, $stats['current_requests']);
|
|
$this->assertSame(5, $stats['remaining_requests']);
|
|
}
|
|
|
|
public function testIsAllowedFiltersExpiredRequests(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 2, windowSeconds: 1);
|
|
|
|
// Fill up the limit
|
|
$this->assertTrue($limiter->isAllowed('key'));
|
|
$this->assertTrue($limiter->isAllowed('key'));
|
|
$this->assertFalse($limiter->isAllowed('key')); // Limit reached
|
|
|
|
// Wait for window to expire
|
|
sleep(2);
|
|
|
|
// Should be allowed again after window expires
|
|
$this->assertTrue($limiter->isAllowed('key'));
|
|
}
|
|
|
|
public function testGetTimeUntilResetWithNoRequests(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
|
|
|
|
$time = $limiter->getTimeUntilReset('never_used_key');
|
|
|
|
$this->assertSame(0, $time);
|
|
}
|
|
|
|
public function testGetTimeUntilResetWithEmptyArray(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
|
|
|
|
// Make a request then clear it
|
|
$limiter->isAllowed('test_key');
|
|
RateLimiter::clearKey('test_key');
|
|
|
|
$time = $limiter->getTimeUntilReset('test_key');
|
|
|
|
$this->assertSame(0, $time);
|
|
}
|
|
|
|
public function testMemoryStatsEstimation(): void
|
|
{
|
|
RateLimiter::clearAll();
|
|
|
|
$limiter = new RateLimiter(maxRequests: 100, windowSeconds: 60);
|
|
|
|
// Make several requests across different keys
|
|
for ($i = 0; $i < 10; $i++) {
|
|
$limiter->isAllowed("key_$i");
|
|
$limiter->isAllowed("key_$i");
|
|
}
|
|
|
|
$stats = RateLimiter::getMemoryStats();
|
|
|
|
$this->assertSame(10, $stats['total_keys']);
|
|
$this->assertSame(20, $stats['total_timestamps']); // 2 per key
|
|
$this->assertGreaterThan(0, $stats['estimated_memory_bytes']);
|
|
|
|
// Estimated memory should be: 10 keys * 50 + 20 timestamps * 8 = 500 + 160 = 660
|
|
$this->assertSame(660, $stats['estimated_memory_bytes']);
|
|
}
|
|
|
|
public function testPerformGlobalCleanupKeepsValidTimestamps(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 10, windowSeconds: 5); // 5 second window
|
|
|
|
// Add some requests
|
|
$limiter->isAllowed('key1');
|
|
$limiter->isAllowed('key2');
|
|
|
|
sleep(1);
|
|
|
|
// Add more recent requests
|
|
$limiter->isAllowed('key1');
|
|
$limiter->isAllowed('key3');
|
|
|
|
sleep(1);
|
|
|
|
// Trigger cleanup
|
|
$reflection = new \ReflectionClass(RateLimiter::class);
|
|
$lastCleanupProp = $reflection->getProperty('lastCleanup');
|
|
$lastCleanupProp->setValue(null, time() - 301);
|
|
|
|
$limiter->isAllowed('key4');
|
|
|
|
// All keys should still exist because they're within the 5-second window
|
|
$stats = RateLimiter::getMemoryStats();
|
|
$this->assertGreaterThanOrEqual(3, $stats['total_keys']);
|
|
}
|
|
|
|
public function testRateLimiterWithVeryShortWindow(): void
|
|
{
|
|
$limiter = new RateLimiter(maxRequests: 2, windowSeconds: 1);
|
|
|
|
$this->assertTrue($limiter->isAllowed('fast_key'));
|
|
$this->assertTrue($limiter->isAllowed('fast_key'));
|
|
$this->assertFalse($limiter->isAllowed('fast_key'));
|
|
|
|
// Immediate stats
|
|
$stats = $limiter->getStats('fast_key');
|
|
$this->assertSame(2, $stats['current_requests']);
|
|
$this->assertSame(0, $stats['remaining_requests']);
|
|
$this->assertGreaterThan(0, $stats['time_until_reset']);
|
|
}
|
|
}
|