mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-01-26 11:44:04 +00:00
* fix(style): resolve PHPCS line-length warnings in source files * fix(style): resolve PHPCS line-length warnings in test files * feat(audit): add structured audit logging with ErrorContext and AuditContext - ErrorContext: standardized error information with sensitive data sanitization - AuditContext: structured context for audit entries with operation types - StructuredAuditLogger: enhanced audit logger wrapper with timing support * feat(recovery): add recovery mechanism for failed masking operations - FailureMode enum: FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE modes - RecoveryStrategy interface and RecoveryResult value object - RetryStrategy: exponential backoff with configurable attempts - FallbackMaskStrategy: type-aware fallback values * feat(strategies): add CallbackMaskingStrategy for custom masking logic - Wraps custom callbacks as MaskingStrategy implementations - Factory methods: constant(), hash(), partial() for common use cases - Supports exact match and prefix match for field paths * docs: add framework integration guides and examples - symfony-integration.md: Symfony service configuration and Monolog setup - psr3-decorator.md: PSR-3 logger decorator pattern implementation - framework-examples.md: CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15 - docker-development.md: Docker development environment guide * chore(docker): add Docker development environment - Dockerfile: PHP 8.2-cli-alpine with Xdebug for coverage - docker-compose.yml: development services with volume mounts * feat(demo): add interactive GDPR pattern tester playground - PatternTester.php: pattern testing utility with strategy support - index.php: web API endpoint with JSON response handling - playground.html: interactive web interface for testing patterns * docs(todo): update with completed medium priority items - Mark all PHPCS warnings as fixed (81 → 0) - Document new Audit and Recovery features - Update test count to 1,068 tests with 2,953 assertions - Move remaining items to low priority * feat: add advanced architecture, documentation, and coverage improvements - Add architecture improvements: - ArrayAccessorInterface and DotArrayAccessor for decoupled array access - MaskingOrchestrator for single-responsibility masking coordination - GdprProcessorBuilder for fluent configuration - MaskingPluginInterface and AbstractMaskingPlugin for plugin architecture - PluginAwareProcessor for plugin hook execution - AuditLoggerFactory for instance-based audit logger creation - Add advanced features: - SerializedDataProcessor for handling print_r/var_export/serialize output - KAnonymizer with GeneralizationStrategy for GDPR k-anonymity - RetentionPolicy for configurable data retention periods - StreamingProcessor for memory-efficient large log processing - Add comprehensive documentation: - docs/performance-tuning.md - benchmarking, optimization, caching - docs/troubleshooting.md - common issues and solutions - docs/logging-integrations.md - ELK, Graylog, Datadog, etc. - docs/plugin-development.md - complete plugin development guide - Improve test coverage (84.41% → 85.07%): - ConditionalRuleFactoryInstanceTest (100% coverage) - GdprProcessorBuilderEdgeCasesTest (100% coverage) - StrategyEdgeCasesTest for ReDoS detection and type parsing - 78 new tests, 119 new assertions - Update TODO.md with current statistics: - 141 PHP files, 1,346 tests, 85.07% line coverage * chore: tests, update actions, sonarcloud issues * chore: rector * fix: more sonarcloud fixes * chore: more fixes * refactor: copilot review fix * chore: rector
275 lines
10 KiB
PHP
275 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests;
|
|
|
|
use Tests\TestConstants;
|
|
use Closure;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
|
use Ivuorinen\MonologGdprFilter\RateLimiter;
|
|
|
|
/**
|
|
* Test rate-limited audit logging functionality.
|
|
*
|
|
* @api
|
|
*/
|
|
class RateLimitedAuditLoggerTest extends TestCase
|
|
{
|
|
/** @var array<array{path: string, original: mixed, masked: mixed}> */
|
|
private array $logStorage;
|
|
|
|
#[\Override]
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->logStorage = [];
|
|
RateLimiter::clearAll();
|
|
}
|
|
|
|
#[\Override]
|
|
protected function tearDown(): void
|
|
{
|
|
RateLimiter::clearAll();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* @psalm-return Closure(string, mixed, mixed):void
|
|
*/
|
|
private function createTestAuditLogger(): Closure
|
|
{
|
|
return function (string $path, mixed $original, mixed $masked): void {
|
|
$this->logStorage[] = [
|
|
'path' => $path,
|
|
'original' => $original,
|
|
TestConstants::DATA_MASKED => $masked
|
|
];
|
|
};
|
|
}
|
|
|
|
public function testBasicRateLimiting(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 3, 60); // 3 per minute
|
|
|
|
// First 3 logs should go through
|
|
$rateLimitedLogger('test_operation', 'original1', 'masked1');
|
|
$rateLimitedLogger('test_operation', 'original2', 'masked2');
|
|
$rateLimitedLogger('test_operation', 'original3', 'masked3');
|
|
|
|
$this->assertCount(3, $this->logStorage);
|
|
|
|
// 4th log should be rate limited and generate a warning
|
|
$rateLimitedLogger('test_operation', 'original4', 'masked4');
|
|
|
|
// Should have 3 original logs + 1 rate limit warning = 4 total
|
|
$this->assertCount(4, $this->logStorage);
|
|
|
|
// The last log should be a rate limit warning
|
|
$this->assertEquals('rate_limit_exceeded', $this->logStorage[3]['path']);
|
|
}
|
|
|
|
public function testDifferentOperationTypes(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 2, 60); // 2 per minute per operation type
|
|
|
|
// Different operation types should have separate rate limits
|
|
$rateLimitedLogger('json_masked', 'original1', 'masked1');
|
|
$rateLimitedLogger('json_masked', 'original2', 'masked2');
|
|
$rateLimitedLogger('conditional_skip', 'original3', 'masked3');
|
|
$rateLimitedLogger('conditional_skip', 'original4', 'masked4');
|
|
$rateLimitedLogger('regex_error', 'original5', 'masked5');
|
|
$rateLimitedLogger('regex_error', 'original6', 'masked6');
|
|
|
|
// All should go through because they're different operation types
|
|
$this->assertCount(6, $this->logStorage);
|
|
|
|
// Now exceed the limit for json operations
|
|
$rateLimitedLogger('json_encode_error', 'original7', 'masked7'); // This is json operation type
|
|
|
|
// Should have 6 original logs + 1 rate limit warning = 7 total
|
|
$this->assertCount(7, $this->logStorage);
|
|
|
|
// The last log should be a rate limit warning
|
|
$this->assertEquals('rate_limit_exceeded', $this->logStorage[6]['path']);
|
|
}
|
|
|
|
public function testRateLimitWarnings(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 1, 60); // Very restrictive: 1 per minute
|
|
|
|
// First log goes through
|
|
$rateLimitedLogger('test_operation', 'original1', 'masked1');
|
|
$this->assertCount(1, $this->logStorage);
|
|
|
|
// Second log triggers rate limiting and should generate a warning
|
|
$rateLimitedLogger('test_operation', 'original2', 'masked2');
|
|
|
|
// Should have original log + rate limit warning
|
|
$this->assertCount(2, $this->logStorage);
|
|
$this->assertEquals('rate_limit_exceeded', $this->logStorage[1]['path']);
|
|
}
|
|
|
|
public function testFactoryProfiles(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
|
|
// Test strict profile
|
|
$strictLogger = RateLimitedAuditLogger::create($baseLogger, 'strict');
|
|
$this->assertInstanceOf(RateLimitedAuditLogger::class, $strictLogger);
|
|
|
|
// Test relaxed profile
|
|
$relaxedLogger = RateLimitedAuditLogger::create($baseLogger, 'relaxed');
|
|
$this->assertInstanceOf(RateLimitedAuditLogger::class, $relaxedLogger);
|
|
|
|
// Test testing profile
|
|
$testingLogger = RateLimitedAuditLogger::create($baseLogger, 'testing');
|
|
$this->assertInstanceOf(RateLimitedAuditLogger::class, $testingLogger);
|
|
|
|
// Test default profile
|
|
$defaultLogger = RateLimitedAuditLogger::create($baseLogger, 'default');
|
|
$this->assertInstanceOf(RateLimitedAuditLogger::class, $defaultLogger);
|
|
}
|
|
|
|
public function testStrictProfile(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$strictLogger = RateLimitedAuditLogger::create($baseLogger, 'strict'); // 50 per minute
|
|
|
|
// Should allow 50 operations before rate limiting
|
|
for ($i = 0; $i < 55; $i++) {
|
|
$strictLogger("test_operation", 'original' . $i, TestConstants::DATA_MASKED . $i);
|
|
}
|
|
|
|
// Should have 50 successful logs + some rate limit warnings
|
|
$successfulLogs = array_filter(
|
|
$this->logStorage,
|
|
fn(array $log): bool => $log['path'] !== 'rate_limit_exceeded'
|
|
);
|
|
$this->assertCount(50, $successfulLogs);
|
|
}
|
|
|
|
public function testRelaxedProfile(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$relaxedLogger = RateLimitedAuditLogger::create($baseLogger, 'relaxed'); // 200 per minute
|
|
|
|
// Should allow more operations
|
|
for ($i = 0; $i < 150; $i++) {
|
|
$relaxedLogger("test_operation", 'original' . $i, TestConstants::DATA_MASKED . $i);
|
|
}
|
|
|
|
// All 150 should go through with relaxed profile
|
|
$this->assertCount(150, $this->logStorage);
|
|
}
|
|
|
|
public function testRateLimitStats(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 10, 60);
|
|
|
|
// Make some requests
|
|
$rateLimitedLogger('json_masked', 'original1', 'masked1');
|
|
$rateLimitedLogger('conditional_skip', 'original2', 'masked2');
|
|
$rateLimitedLogger('regex_error', 'original3', 'masked3');
|
|
|
|
$stats = $rateLimitedLogger->getRateLimitStats();
|
|
|
|
$this->assertIsArray($stats);
|
|
$this->assertArrayHasKey('audit:json_operations', $stats);
|
|
$this->assertArrayHasKey('audit:conditional_operations', $stats);
|
|
$this->assertArrayHasKey('audit:regex_operations', $stats);
|
|
|
|
// Check that the used operation types show activity
|
|
$this->assertEquals(1, $stats['audit:json_operations']['current_requests']);
|
|
$this->assertEquals(1, $stats['audit:conditional_operations']['current_requests']);
|
|
$this->assertEquals(1, $stats['audit:regex_operations']['current_requests']);
|
|
}
|
|
|
|
public function testIsOperationAllowed(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 2, 60);
|
|
|
|
// Initially all operations should be allowed
|
|
$this->assertTrue($rateLimitedLogger->isOperationAllowed('json_operations'));
|
|
$this->assertTrue($rateLimitedLogger->isOperationAllowed('regex_operations'));
|
|
|
|
// Use up the limit for json operations
|
|
$rateLimitedLogger('json_masked', 'original1', 'masked1');
|
|
$rateLimitedLogger('json_encode_error', 'original2', 'masked2');
|
|
|
|
// json operations should now be at limit
|
|
$this->assertFalse($rateLimitedLogger->isOperationAllowed('json_operations'));
|
|
// Other operations should still be allowed
|
|
$this->assertTrue($rateLimitedLogger->isOperationAllowed('regex_operations'));
|
|
}
|
|
|
|
public function testClearRateLimitData(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 1, 60);
|
|
|
|
// Use up the limit
|
|
$rateLimitedLogger('test_operation', 'original1', 'masked1');
|
|
$rateLimitedLogger('test_operation', 'original2', 'masked2'); // Should be blocked
|
|
|
|
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
|
|
|
// Clear rate limit data
|
|
$rateLimitedLogger->clearRateLimitData();
|
|
|
|
// Should be able to log again
|
|
$this->logStorage = []; // Clear log storage for clean test
|
|
$rateLimitedLogger('test_operation', 'original3', 'masked3');
|
|
$this->assertCount(1, $this->logStorage);
|
|
}
|
|
|
|
public function testOperationTypeClassification(): void
|
|
{
|
|
$baseLogger = $this->createTestAuditLogger();
|
|
$rateLimitedLogger = new RateLimitedAuditLogger($baseLogger, 1, 60); // Very restrictive
|
|
|
|
// Test that different paths are classified correctly
|
|
$rateLimitedLogger('json_masked', 'original', TestConstants::DATA_MASKED);
|
|
// Should be blocked (same type)
|
|
$rateLimitedLogger('json_encode_error', 'original', TestConstants::DATA_MASKED);
|
|
|
|
// 1 successful + 1 rate limit warning
|
|
$this->assertCount(2, $this->logStorage);
|
|
|
|
$this->logStorage = []; // Reset
|
|
|
|
$rateLimitedLogger('conditional_skip', 'original', TestConstants::DATA_MASKED);
|
|
// Should be blocked (same type)
|
|
$rateLimitedLogger('conditional_error', 'original', TestConstants::DATA_MASKED);
|
|
|
|
// 1 successful + 1 rate limit warning
|
|
$this->assertCount(2, $this->logStorage);
|
|
|
|
$this->logStorage = []; // Reset
|
|
|
|
$rateLimitedLogger('regex_error', 'original', TestConstants::DATA_MASKED);
|
|
// Should be blocked (same type)
|
|
$rateLimitedLogger('preg_replace_error', 'original', TestConstants::DATA_MASKED);
|
|
|
|
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
|
}
|
|
|
|
public function testNonCallableAuditLogger(): void
|
|
{
|
|
// Test with a non-callable audit logger
|
|
$rateLimitedLogger = new RateLimitedAuditLogger('not_callable', 10, 60);
|
|
|
|
// Should not throw an error, just silently handle the non-callable
|
|
$rateLimitedLogger('test_operation', 'original', TestConstants::DATA_MASKED);
|
|
|
|
// No logs should be created since the base logger is not callable
|
|
$this->assertCount(0, $this->logStorage);
|
|
}
|
|
}
|