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,427 @@
<?php
declare(strict_types=1);
namespace Tests\Builder;
use DateTimeImmutable;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
use Ivuorinen\MonologGdprFilter\Builder\PluginAwareProcessor;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
/**
* Edge case tests for GdprProcessorBuilder.
*/
#[CoversClass(GdprProcessorBuilder::class)]
#[CoversClass(PluginAwareProcessor::class)]
final class GdprProcessorBuilderEdgeCasesTest extends TestCase
{
/**
* @param array<string, mixed> $context
*/
private function createLogRecord(string $message = 'Test', array $context = []): LogRecord
{
return new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: $message,
context: $context,
);
}
#[Test]
public function setPatternsReplacesExisting(): void
{
$builder = GdprProcessorBuilder::create()
->addPattern('/old/', 'OLD_MASK')
->setPatterns(['/new/' => 'NEW_MASK']);
$patterns = $builder->getPatterns();
$this->assertArrayNotHasKey('/old/', $patterns);
$this->assertArrayHasKey('/new/', $patterns);
$this->assertSame('NEW_MASK', $patterns['/new/']);
}
#[Test]
public function setFieldPathsReplacesExisting(): void
{
$builder = GdprProcessorBuilder::create()
->addFieldPath('old.path', '[OLD]')
->setFieldPaths(['new.path' => '[NEW]']);
$paths = $builder->getFieldPaths();
$this->assertArrayNotHasKey('old.path', $paths);
$this->assertArrayHasKey('new.path', $paths);
$this->assertSame('[NEW]', $paths['new.path']);
}
#[Test]
public function addCallbacksAddsMultipleCallbacks(): void
{
$callback1 = fn(mixed $value): string => 'CALLBACK1';
$callback2 = fn(mixed $value): string => 'CALLBACK2';
$builder = GdprProcessorBuilder::create()
->addCallbacks([
'path.one' => $callback1,
'path.two' => $callback2,
]);
$processor = $builder->build();
$record = $this->createLogRecord('Test', [
'path' => [
'one' => 'value1',
'two' => 'value2',
],
]);
$processed = $processor($record);
$this->assertSame('CALLBACK1', $processed->context['path']['one']);
$this->assertSame('CALLBACK2', $processed->context['path']['two']);
}
#[Test]
public function addDataTypeMasksAddsMultipleMasks(): void
{
$builder = GdprProcessorBuilder::create()
->addDataTypeMasks([
'integer' => '999',
'boolean' => 'false',
]);
$processor = $builder->build();
$record = $this->createLogRecord('Test', [
'count' => 42,
'active' => true,
]);
$processed = $processor($record);
$this->assertSame(999, $processed->context['count']);
$this->assertFalse($processed->context['active']);
}
#[Test]
public function addConditionalRulesAddsMultipleRules(): void
{
$rule1Called = false;
$rule2Called = false;
$builder = GdprProcessorBuilder::create()
->addPattern('/sensitive/', TestConstants::MASK_MASKED_BRACKETS)
->addConditionalRules([
'rule1' => function (LogRecord $record) use (&$rule1Called): bool {
$rule1Called = true;
return $record->channel === 'test';
},
'rule2' => function () use (&$rule2Called): bool {
$rule2Called = true;
return true;
},
]);
$processor = $builder->build();
$record = $this->createLogRecord('Contains sensitive data');
$processed = $processor($record);
$this->assertTrue($rule1Called);
$this->assertTrue($rule2Called);
$this->assertStringContainsString(TestConstants::MASK_MASKED_BRACKETS, $processed->message);
}
#[Test]
public function getPluginsReturnsRegisteredPlugins(): void
{
$plugin1 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin-1';
}
};
$plugin2 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin-2';
}
};
$builder = GdprProcessorBuilder::create()
->addPlugin($plugin1)
->addPlugin($plugin2);
$plugins = $builder->getPlugins();
$this->assertCount(2, $plugins);
$this->assertSame('plugin-1', $plugins[0]->getName());
$this->assertSame('plugin-2', $plugins[1]->getName());
}
#[Test]
public function addPluginsAddsMultiplePlugins(): void
{
$plugin1 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin-1';
}
};
$plugin2 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin-2';
}
};
$builder = GdprProcessorBuilder::create()
->addPlugins([$plugin1, $plugin2]);
$plugins = $builder->getPlugins();
$this->assertCount(2, $plugins);
}
#[Test]
public function buildWithPluginsReturnsGdprProcessorWhenNoPlugins(): void
{
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->buildWithPlugins();
// buildWithPlugins returns GdprProcessor when no plugins are registered
// We can't use assertNotInstanceOf due to PHPStan's static analysis
// Instead we verify the actual return type
$this->assertSame(GdprProcessor::class, $processor::class);
}
#[Test]
public function buildWithPluginsReturnsPluginAwareProcessorWithPlugins(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
}
#[Test]
public function buildWithPluginsSortsPluginsByPriority(): void
{
$lowPriority = new class (200) extends AbstractMaskingPlugin {
public function getName(): string
{
return 'low-priority';
}
};
$highPriority = new class (10) extends AbstractMaskingPlugin {
public function getName(): string
{
return 'high-priority';
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($lowPriority)
->addPlugin($highPriority)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
}
#[Test]
public function pluginContributesPatterns(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'pattern-plugin';
}
#[\Override]
public function getPatterns(): array
{
return ['/PLUGIN-\d+/' => '[PLUGIN-ID]'];
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
$record = $this->createLogRecord('Reference: PLUGIN-12345');
$processed = $processor($record);
$this->assertSame('Reference: [PLUGIN-ID]', $processed->message);
}
#[Test]
public function pluginContributesFieldPaths(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'field-plugin';
}
#[\Override]
public function getFieldPaths(): array
{
return ['secret.key' => FieldMaskConfig::replace('[REDACTED]')];
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
$record = $this->createLogRecord('Test', ['secret' => ['key' => 'sensitive-value']]);
$processed = $processor($record);
$this->assertSame('[REDACTED]', $processed->context['secret']['key']);
}
#[Test]
public function withArrayAccessorFactoryConfiguresProcessor(): void
{
$factory = ArrayAccessorFactory::default();
$processor = GdprProcessorBuilder::create()
->withArrayAccessorFactory($factory)
->addFieldPath('user.email', MaskConstants::MASK_EMAIL)
->build();
$record = $this->createLogRecord('Test', [
'user' => ['email' => 'test@example.com'],
]);
$processed = $processor($record);
$this->assertSame(MaskConstants::MASK_EMAIL, $processed->context['user']['email']);
}
#[Test]
public function withMaxDepthLimitsRecursion(): void
{
$processor = GdprProcessorBuilder::create()
->withMaxDepth(2)
->addPattern('/secret/', TestConstants::MASK_MASKED_BRACKETS)
->build();
$record = $this->createLogRecord('Test', [
'level1' => [
'level2' => [
'level3' => 'secret data',
],
],
]);
$processed = $processor($record);
// Processor should handle the record without throwing
$this->assertIsArray($processed->context);
}
#[Test]
public function withAuditLoggerConfiguresLogging(): void
{
$auditLogs = [];
$processor = GdprProcessorBuilder::create()
->addFieldPath('password', MaskConstants::MASK_REDACTED)
->withAuditLogger(function ($path, $original, $masked) use (&$auditLogs): void {
$auditLogs[] = ['path' => $path, 'original' => $original, 'masked' => $masked];
})
->build();
$record = $this->createLogRecord('Test', ['password' => 'secret123']);
$processor($record);
$this->assertNotEmpty($auditLogs);
}
#[Test]
public function addConditionalRuleConfiguresProcessor(): void
{
$ruleExecuted = false;
$processor = GdprProcessorBuilder::create()
->addPattern('/data/', TestConstants::MASK_MASKED_BRACKETS)
->addConditionalRule('track-execution', function () use (&$ruleExecuted): bool {
$ruleExecuted = true;
return true;
})
->build();
$record = $this->createLogRecord('Contains data value');
$processor($record);
// Verify the conditional rule was executed
$this->assertTrue($ruleExecuted);
}
#[Test]
public function addFieldPathsAddsMultiplePaths(): void
{
$processor = GdprProcessorBuilder::create()
->addFieldPaths([
'user.email' => MaskConstants::MASK_EMAIL,
'user.phone' => MaskConstants::MASK_PHONE,
])
->build();
$record = $this->createLogRecord('Test', [
'user' => [
'email' => 'test@example.com',
'phone' => '555-1234',
],
]);
$processed = $processor($record);
$this->assertSame(MaskConstants::MASK_EMAIL, $processed->context['user']['email']);
$this->assertSame(MaskConstants::MASK_PHONE, $processed->context['user']['phone']);
}
#[Test]
public function addPatternsAddsMultiplePatterns(): void
{
$processor = GdprProcessorBuilder::create()
->addPatterns([
'/\d{3}-\d{2}-\d{4}/' => '[SSN]',
'/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/' => '[EMAIL]',
])
->build();
$record = $this->createLogRecord('SSN: 123-45-6789, Email: user@example.com');
$processed = $processor($record);
$this->assertStringContainsString('[SSN]', $processed->message);
$this->assertStringContainsString('[EMAIL]', $processed->message);
}
}

View File

@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace Tests\Builder;
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
use Ivuorinen\MonologGdprFilter\Builder\PluginAwareProcessor;
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
#[CoversClass(GdprProcessorBuilder::class)]
final class GdprProcessorBuilderTest extends TestCase
{
public function testCreateReturnsBuilder(): void
{
$builder = GdprProcessorBuilder::create();
$this->assertInstanceOf(GdprProcessorBuilder::class, $builder);
}
public function testBuildReturnsGdprProcessor(): void
{
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->build();
$this->assertInstanceOf(GdprProcessor::class, $processor);
}
public function testWithDefaultPatterns(): void
{
$builder = GdprProcessorBuilder::create()->withDefaultPatterns();
$patterns = $builder->getPatterns();
$this->assertNotEmpty($patterns);
$this->assertSame(DefaultPatterns::get(), $patterns);
}
public function testAddPattern(): void
{
$builder = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_DIGITS, TestConstants::MASK_DIGITS_BRACKETS);
$this->assertArrayHasKey(TestConstants::PATTERN_DIGITS, $builder->getPatterns());
}
public function testAddPatterns(): void
{
$patterns = [
TestConstants::PATTERN_DIGITS => TestConstants::MASK_DIGITS_BRACKETS,
TestConstants::PATTERN_TEST => '[TEST]',
];
$builder = GdprProcessorBuilder::create()->addPatterns($patterns);
$this->assertSame($patterns, $builder->getPatterns());
}
public function testSetPatternsReplacesExisting(): void
{
$builder = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_DIGITS, TestConstants::MASK_DIGITS_BRACKETS)
->setPatterns([TestConstants::PATTERN_TEST => '[TEST]']);
$patterns = $builder->getPatterns();
$this->assertCount(1, $patterns);
$this->assertArrayHasKey(TestConstants::PATTERN_TEST, $patterns);
$this->assertArrayNotHasKey(TestConstants::PATTERN_DIGITS, $patterns);
}
public function testAddFieldPath(): void
{
$builder = GdprProcessorBuilder::create()
->addFieldPath(TestConstants::CONTEXT_EMAIL, FieldMaskConfig::replace('[EMAIL]'));
$this->assertArrayHasKey(TestConstants::CONTEXT_EMAIL, $builder->getFieldPaths());
}
public function testAddFieldPaths(): void
{
$fieldPaths = [
TestConstants::CONTEXT_EMAIL => FieldMaskConfig::replace('[EMAIL]'),
TestConstants::CONTEXT_PASSWORD => FieldMaskConfig::remove(),
];
$builder = GdprProcessorBuilder::create()->addFieldPaths($fieldPaths);
$this->assertCount(2, $builder->getFieldPaths());
}
public function testAddCallback(): void
{
$callback = fn(mixed $val): string => strtoupper((string) $val);
$processor = GdprProcessorBuilder::create()
->addCallback('name', $callback)
->build();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_DEFAULT,
context: ['name' => 'john']
);
$result = $processor($record);
$this->assertSame('JOHN', $result->context['name']);
}
public function testWithAuditLogger(): void
{
$logs = [];
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
};
$processor = GdprProcessorBuilder::create()
->addFieldPath('field', FieldMaskConfig::replace('[MASKED]'))
->withAuditLogger($auditLogger)
->build();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_DEFAULT,
context: ['field' => 'value']
);
$processor($record);
$this->assertCount(1, $logs);
}
public function testWithMaxDepth(): void
{
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->withMaxDepth(50)
->build();
// The processor should still work
$result = $processor->regExpMessage(TestConstants::MESSAGE_TEST_LOWERCASE);
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $result);
}
public function testAddDataTypeMask(): void
{
$processor = GdprProcessorBuilder::create()
->addDataTypeMask('integer', TestConstants::MASK_INT_BRACKETS)
->build();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_DEFAULT,
context: ['count' => 42]
);
$result = $processor($record);
$this->assertSame(TestConstants::MASK_INT_BRACKETS, $result->context['count']);
}
public function testAddConditionalRule(): void
{
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->addConditionalRule('skip_debug', fn(LogRecord $r): bool => $r->level !== Level::Debug)
->build();
$debugRecord = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Debug,
message: TestConstants::MESSAGE_TEST_LOWERCASE,
context: []
);
$infoRecord = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_TEST_LOWERCASE,
context: []
);
// Debug should not be masked
$this->assertSame(TestConstants::MESSAGE_TEST_LOWERCASE, $processor($debugRecord)->message);
// Info should be masked
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $processor($infoRecord)->message);
}
public function testWithArrayAccessorFactory(): void
{
$factory = ArrayAccessorFactory::default();
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->withArrayAccessorFactory($factory)
->build();
$this->assertInstanceOf(GdprProcessor::class, $processor);
}
public function testAddPlugin(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
public function getPatterns(): array
{
return ['/secret/' => '[SECRET]'];
}
};
$builder = GdprProcessorBuilder::create()->addPlugin($plugin);
$this->assertCount(1, $builder->getPlugins());
}
public function testAddPlugins(): void
{
$plugin1 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin1';
}
};
$plugin2 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin2';
}
};
$builder = GdprProcessorBuilder::create()->addPlugins([$plugin1, $plugin2]);
$this->assertCount(2, $builder->getPlugins());
}
public function testBuildWithPluginsReturnsGdprProcessorWhenNoPlugins(): void
{
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->buildWithPlugins();
$this->assertInstanceOf(GdprProcessor::class, $processor);
// When no plugins, it returns GdprProcessor directly, not PluginAwareProcessor
$this->assertSame(GdprProcessor::class, $processor::class);
}
public function testBuildWithPluginsReturnsPluginAwareProcessor(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
};
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->addPlugin($plugin)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
}
public function testPluginPatternsAreApplied(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'secret-plugin';
}
public function getPatterns(): array
{
return ['/secret/' => '[SECRET]'];
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->build();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: 'This is secret data',
context: []
);
$result = $processor($record);
$this->assertSame('This is [SECRET] data', $result->message);
}
public function testPluginFieldPathsAreApplied(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'field-plugin';
}
public function getFieldPaths(): array
{
return ['api_key' => FieldMaskConfig::replace('[API_KEY]')];
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->build();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_DEFAULT,
context: ['api_key' => 'abc123']
);
$result = $processor($record);
$this->assertSame('[API_KEY]', $result->context['api_key']);
}
public function testFluentChaining(): void
{
$processor = GdprProcessorBuilder::create()
->withDefaultPatterns()
->addPattern('/custom/', '[CUSTOM]')
->addFieldPath('secret', FieldMaskConfig::remove())
->addCallback('name', fn(mixed $v): string => strtoupper((string) $v))
->withMaxDepth(50)
->addDataTypeMask('integer', TestConstants::MASK_INT_BRACKETS)
->build();
$this->assertInstanceOf(GdprProcessor::class, $processor);
}
}

View File

@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace Tests\Builder;
use Ivuorinen\MonologGdprFilter\Builder\GdprProcessorBuilder;
use Ivuorinen\MonologGdprFilter\Builder\PluginAwareProcessor;
use Ivuorinen\MonologGdprFilter\GdprProcessor;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
#[CoversClass(PluginAwareProcessor::class)]
final class PluginAwareProcessorTest extends TestCase
{
public function testInvokeAppliesPreProcessing(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'uppercase-plugin';
}
public function preProcessMessage(string $message): string
{
return strtoupper($message);
}
};
$processor = GdprProcessorBuilder::create()
->addPattern('/TEST/', '[MASKED]')
->addPlugin($plugin)
->buildWithPlugins();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: 'This is a test message',
context: []
);
$result = $processor($record);
// Message should be uppercased, then 'TEST' should be masked
$this->assertStringContainsString('[MASKED]', $result->message);
}
public function testInvokeAppliesPostProcessing(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'suffix-plugin';
}
public function postProcessMessage(string $message): string
{
return $message . ' [processed]';
}
};
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->addPlugin($plugin)
->buildWithPlugins();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: 'test',
context: []
);
$result = $processor($record);
$this->assertStringEndsWith('[processed]', $result->message);
}
public function testInvokeAppliesPreProcessContext(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'add-field-plugin';
}
public function preProcessContext(array $context): array
{
$context['added_by_plugin'] = 'true';
return $context;
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_DEFAULT,
context: ['original' => 'data']
);
$result = $processor($record);
$this->assertArrayHasKey('added_by_plugin', $result->context);
}
public function testInvokeAppliesPostProcessContext(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'remove-field-plugin';
}
public function postProcessContext(array $context): array
{
unset($context['to_remove']);
return $context;
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: TestConstants::MESSAGE_DEFAULT,
context: ['to_remove' => 'value', 'keep' => 'data']
);
$result = $processor($record);
$this->assertArrayNotHasKey('to_remove', $result->context);
$this->assertArrayHasKey('keep', $result->context);
}
public function testPostProcessingRunsInReverseOrder(): void
{
// Test that post-processing happens by verifying the message is modified
$plugin1 = new class extends AbstractMaskingPlugin {
public function __construct()
{
parent::__construct(10);
}
public function getName(): string
{
return 'plugin1';
}
public function postProcessMessage(string $message): string
{
return $message . '-plugin1';
}
};
$plugin2 = new class extends AbstractMaskingPlugin {
public function __construct()
{
parent::__construct(20);
}
public function getName(): string
{
return 'plugin2';
}
public function postProcessMessage(string $message): string
{
return $message . '-plugin2';
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin1)
->addPlugin($plugin2)
->buildWithPlugins();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: 'base',
context: []
);
$result = $processor($record);
// Post-processing runs in reverse priority order (higher priority last)
// plugin2 (priority 20) runs first in post-processing, then plugin1 (priority 10)
$this->assertSame('base-plugin2-plugin1', $result->message);
}
public function testGetProcessor(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
};
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->addPlugin($plugin)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
$this->assertInstanceOf(GdprProcessor::class, $processor->getProcessor());
}
public function testGetPlugins(): void
{
$plugin1 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin1';
}
};
$plugin2 = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'plugin2';
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin1)
->addPlugin($plugin2)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
$this->assertCount(2, $processor->getPlugins());
}
public function testRegExpMessageDelegates(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
};
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->addPlugin($plugin)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
$this->assertSame(MaskConstants::MASK_GENERIC . ' message', $processor->regExpMessage('test message'));
}
public function testRecursiveMaskDelegates(): void
{
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
};
$processor = GdprProcessorBuilder::create()
->addPattern(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)
->addPlugin($plugin)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
$result = $processor->recursiveMask(['key' => 'test value']);
$this->assertSame(MaskConstants::MASK_GENERIC . ' value', $result['key']);
}
public function testSetAuditLoggerDelegates(): void
{
$logs = [];
$auditLogger = function (string $path) use (&$logs): void {
$logs[] = ['path' => $path];
};
$plugin = new class extends AbstractMaskingPlugin {
public function getName(): string
{
return 'test-plugin';
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin)
->buildWithPlugins();
$this->assertInstanceOf(PluginAwareProcessor::class, $processor);
$processor->setAuditLogger($auditLogger);
// Audit logger should be set on underlying processor
$this->assertTrue(true); // No exception means it worked
}
public function testMultiplePluginsProcessInPriorityOrder(): void
{
// Test that pre-processing runs in priority order (lower number first)
$plugin1 = new class extends AbstractMaskingPlugin {
public function __construct()
{
parent::__construct(20); // Lower priority (runs second)
}
public function getName(): string
{
return 'plugin1';
}
public function preProcessMessage(string $message): string
{
return $message . '-plugin1';
}
};
$plugin2 = new class extends AbstractMaskingPlugin {
public function __construct()
{
parent::__construct(10); // Higher priority (runs first)
}
public function getName(): string
{
return 'plugin2';
}
public function preProcessMessage(string $message): string
{
return $message . '-plugin2';
}
};
$processor = GdprProcessorBuilder::create()
->addPlugin($plugin1)
->addPlugin($plugin2)
->buildWithPlugins();
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: TestConstants::CHANNEL_TEST,
level: Level::Info,
message: 'base',
context: []
);
$result = $processor($record);
// plugin2 (priority 10) runs first in pre-processing, then plugin1 (priority 20)
$this->assertSame('base-plugin2-plugin1', $result->message);
}
}