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
This commit is contained in:
2025-12-22 13:38:18 +02:00
committed by GitHub
parent b1eb567b92
commit 8866daaf33
112 changed files with 15391 additions and 607 deletions

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Tests\Streaming;
use Ivuorinen\MonologGdprFilter\Exceptions\StreamingOperationFailedException;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
use Ivuorinen\MonologGdprFilter\Streaming\StreamingProcessor;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
#[CoversClass(StreamingProcessor::class)]
final class StreamingProcessorTest extends TestCase
{
private function createOrchestrator(): MaskingOrchestrator
{
return new MaskingOrchestrator([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]);
}
public function testProcessStreamSingleRecord(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$records = [
['message' => 'test message', 'context' => []],
];
$results = iterator_to_array($processor->processStream($records));
$this->assertCount(1, $results);
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $results[0]['message']);
}
public function testProcessStreamMultipleRecords(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$records = [
['message' => 'test one', 'context' => []],
['message' => 'test two', 'context' => []],
['message' => 'test three', 'context' => []],
];
$results = iterator_to_array($processor->processStream($records));
$this->assertCount(3, $results);
}
public function testProcessStreamChunking(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 2);
$records = [
['message' => 'test 1', 'context' => []],
['message' => 'test 2', 'context' => []],
['message' => 'test 3', 'context' => []],
['message' => 'test 4', 'context' => []],
['message' => 'test 5', 'context' => []],
];
$results = iterator_to_array($processor->processStream($records));
$this->assertCount(5, $results);
}
public function testProcessStreamWithContext(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$records = [
['message' => 'message', 'context' => ['key' => 'test value']],
];
$results = iterator_to_array($processor->processStream($records));
$this->assertSame(MaskConstants::MASK_GENERIC . ' value', $results[0]['context']['key']);
}
public function testProcessStreamWithGenerator(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 2);
$generator = (function () {
yield ['message' => 'test a', 'context' => []];
yield ['message' => 'test b', 'context' => []];
yield ['message' => 'test c', 'context' => []];
})();
$results = iterator_to_array($processor->processStream($generator));
$this->assertCount(3, $results);
}
public function testProcessFileWithTempFile(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 2);
// Create temp file with test data containing 'test' to be masked
$tempFile = tempnam(sys_get_temp_dir(), 'gdpr_test_');
$this->assertIsString($tempFile, 'Failed to create temp file');
file_put_contents($tempFile, "test line 1\ntest line 2\ntest line 3\n");
try {
$lineParser = fn(string $line): array => ['message' => $line, 'context' => []];
$results = [];
foreach ($processor->processFile($tempFile, $lineParser) as $result) {
$results[] = $result;
}
$this->assertCount(3, $results);
$this->assertStringContainsString(MaskConstants::MASK_GENERIC, $results[0]['message']);
} finally {
unlink($tempFile);
}
}
public function testProcessFileSkipsEmptyLines(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$tempFile = tempnam(sys_get_temp_dir(), 'gdpr_test_');
$this->assertIsString($tempFile, 'Failed to create temp file');
file_put_contents($tempFile, "test line 1\n\n\ntest line 2\n");
try {
$lineParser = fn(string $line): array => ['message' => $line, 'context' => []];
$results = iterator_to_array($processor->processFile($tempFile, $lineParser));
$this->assertCount(2, $results);
} finally {
unlink($tempFile);
}
}
public function testProcessFileThrowsOnInvalidPath(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$this->expectException(StreamingOperationFailedException::class);
$this->expectExceptionMessage('Cannot open input file for streaming:');
iterator_to_array($processor->processFile('/nonexistent/path/file.log', fn(string $l): array => ['message' => $l, 'context' => []]));
}
public function testProcessToFile(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$records = [
['message' => 'test line 1', 'context' => []],
['message' => 'test line 2', 'context' => []],
];
$outputFile = tempnam(sys_get_temp_dir(), 'gdpr_output_');
$this->assertIsString($outputFile, 'Failed to create temp file');
try {
$formatter = fn(array $record): string => $record['message'];
$count = $processor->processToFile($records, $outputFile, $formatter);
$this->assertSame(2, $count);
$output = file_get_contents($outputFile);
$this->assertNotFalse($output);
$this->assertStringContainsString(MaskConstants::MASK_GENERIC, $output);
} finally {
unlink($outputFile);
}
}
public function testProcessToFileThrowsOnInvalidPath(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$this->expectException(StreamingOperationFailedException::class);
$this->expectExceptionMessage('Cannot open output file for streaming:');
$processor->processToFile([], '/nonexistent/path/output.log', fn(array $r): string => '');
}
public function testGetStatistics(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 10);
$records = [
['message' => 'test masked', 'context' => []],
['message' => 'no sensitive data', 'context' => []],
['message' => 'another test here', 'context' => []],
];
$stats = $processor->getStatistics($records);
$this->assertSame(3, $stats['processed']);
$this->assertGreaterThan(0, $stats['masked']); // At least some should be masked
}
public function testSetAuditLogger(): void
{
$logs = [];
$auditLogger = function (string $path) use (&$logs): void {
$logs[] = ['path' => $path];
};
$processor = new StreamingProcessor($this->createOrchestrator(), 1);
$processor->setAuditLogger($auditLogger);
$records = [['message' => 'test', 'context' => []]];
iterator_to_array($processor->processStream($records));
$this->assertNotEmpty($logs);
}
public function testGetChunkSize(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 500);
$this->assertSame(500, $processor->getChunkSize());
}
public function testDefaultChunkSize(): void
{
$processor = new StreamingProcessor($this->createOrchestrator());
$this->assertSame(1000, $processor->getChunkSize());
}
public function testLargeDataSet(): void
{
$processor = new StreamingProcessor($this->createOrchestrator(), 100);
$records = [];
for ($i = 1; $i <= 500; $i++) {
$records[] = ['message' => "test record {$i}", 'context' => []];
}
$count = 0;
foreach ($processor->processStream($records) as $record) {
$count++;
$this->assertIsArray($record);
}
$this->assertSame(500, $count);
}
}