Files
monolog-gdpr-filter/tests/RateLimitedAuditLoggerTest.php
Ismo Vuorinen 8866daaf33 feat: add advanced architecture, documentation, and coverage improvements (#65)
* 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
2025-12-22 13:38:18 +02:00

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);
}
}