mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-02-14 19:50:44 +00:00
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:
279
tests/Anonymization/KAnonymizerTest.php
Normal file
279
tests/Anonymization/KAnonymizerTest.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Anonymization;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Anonymization\GeneralizationStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Anonymization\KAnonymizer;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
#[CoversClass(KAnonymizer::class)]
|
||||
#[CoversClass(GeneralizationStrategy::class)]
|
||||
final class KAnonymizerTest extends TestCase
|
||||
{
|
||||
public function testAnonymizeWithAgeStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$record = ['name' => 'John', 'age' => 25];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $result['age']);
|
||||
$this->assertSame('John', $result['name']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithAgeStrategyDifferentRanges(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age', 5);
|
||||
|
||||
$this->assertSame('20-24', $anonymizer->anonymize(['age' => 22])['age']);
|
||||
$this->assertSame('25-29', $anonymizer->anonymize(['age' => 27])['age']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateStrategyMonth(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('created_at', 'month');
|
||||
|
||||
$record = ['created_at' => '2024-03-15'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('2024-03', $result['created_at']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateStrategyYear(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('birth_date', 'year');
|
||||
|
||||
$record = ['birth_date' => '1990-05-20'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('1990', $result['birth_date']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateStrategyQuarter(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('quarter_date', 'quarter');
|
||||
|
||||
$this->assertSame('2024-Q1', $anonymizer->anonymize(['quarter_date' => '2024-02-15'])['quarter_date']);
|
||||
$this->assertSame('2024-Q2', $anonymizer->anonymize(['quarter_date' => '2024-05-15'])['quarter_date']);
|
||||
$this->assertSame('2024-Q3', $anonymizer->anonymize(['quarter_date' => '2024-08-15'])['quarter_date']);
|
||||
$this->assertSame('2024-Q4', $anonymizer->anonymize(['quarter_date' => '2024-11-15'])['quarter_date']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithDateTimeObject(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerDateStrategy('date', 'month');
|
||||
|
||||
$record = ['date' => new \DateTimeImmutable('2024-06-15')];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('2024-06', $result['date']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithLocationStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerLocationStrategy('zip_code', 3);
|
||||
|
||||
$record = ['zip_code' => '12345'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('123**', $result['zip_code']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithLocationStrategyShortValue(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerLocationStrategy('zip', 5);
|
||||
|
||||
$record = ['zip' => '123'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('123', $result['zip']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithNumericRangeStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerNumericRangeStrategy('salary', 1000);
|
||||
|
||||
$record = ['salary' => 52500];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('52000-52999', $result['salary']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithCustomStrategy(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerCustomStrategy('email', fn(mixed $v): string => explode('@', (string) $v)[1] ?? 'unknown');
|
||||
|
||||
$record = ['email' => 'john@example.com'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('example.com', $result['email']);
|
||||
}
|
||||
|
||||
public function testRegisterStrategy(): void
|
||||
{
|
||||
$strategy = new GeneralizationStrategy(fn(mixed $v): string => 'masked', 'test');
|
||||
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerStrategy('field', $strategy);
|
||||
|
||||
$record = ['field' => 'value'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame('masked', $result['field']);
|
||||
}
|
||||
|
||||
public function testAnonymizeIgnoresMissingFields(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$record = ['name' => 'John'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame(['name' => 'John'], $result);
|
||||
}
|
||||
|
||||
public function testAnonymizeBatch(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$records = [
|
||||
['name' => 'John', 'age' => 25],
|
||||
['name' => 'Jane', 'age' => 32],
|
||||
];
|
||||
|
||||
$results = $anonymizer->anonymizeBatch($records);
|
||||
|
||||
$this->assertCount(2, $results);
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $results[0]['age']);
|
||||
$this->assertSame('30-39', $results[1]['age']);
|
||||
}
|
||||
|
||||
public function testAnonymizeWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$anonymizer = new KAnonymizer($auditLogger);
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$anonymizer->anonymize(['age' => 25]);
|
||||
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertSame('k-anonymity.age', $logs[0]['path']);
|
||||
$this->assertSame(25, $logs[0]['original']);
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $logs[0][TestConstants::DATA_MASKED]);
|
||||
}
|
||||
|
||||
public function testSetAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->setAuditLogger($auditLogger);
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
|
||||
$anonymizer->anonymize(['age' => 25]);
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
}
|
||||
|
||||
public function testGetStrategies(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
$anonymizer->registerLocationStrategy('zip', 3);
|
||||
|
||||
$strategies = $anonymizer->getStrategies();
|
||||
|
||||
$this->assertCount(2, $strategies);
|
||||
$this->assertArrayHasKey('age', $strategies);
|
||||
$this->assertArrayHasKey('zip', $strategies);
|
||||
}
|
||||
|
||||
public function testCreateGdprDefault(): void
|
||||
{
|
||||
$anonymizer = KAnonymizer::createGdprDefault();
|
||||
|
||||
$strategies = $anonymizer->getStrategies();
|
||||
|
||||
$this->assertArrayHasKey('age', $strategies);
|
||||
$this->assertArrayHasKey('birth_date', $strategies);
|
||||
$this->assertArrayHasKey('created_at', $strategies);
|
||||
$this->assertArrayHasKey('zip_code', $strategies);
|
||||
$this->assertArrayHasKey('postal_code', $strategies);
|
||||
}
|
||||
|
||||
public function testCreateGdprDefaultWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$anonymizer = KAnonymizer::createGdprDefault($auditLogger);
|
||||
$anonymizer->anonymize(['age' => 35]);
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
}
|
||||
|
||||
public function testGeneralizationStrategyGetType(): void
|
||||
{
|
||||
$strategy = new GeneralizationStrategy(fn(mixed $v): string => (string) $v, 'test_type');
|
||||
|
||||
$this->assertSame('test_type', $strategy->getType());
|
||||
}
|
||||
|
||||
public function testGeneralizationStrategyGeneralize(): void
|
||||
{
|
||||
$strategy = new GeneralizationStrategy(fn(mixed $v): string => strtoupper((string) $v));
|
||||
|
||||
$this->assertSame('HELLO', $strategy->generalize('hello'));
|
||||
}
|
||||
|
||||
public function testMultipleStrategiesOnSameRecord(): void
|
||||
{
|
||||
$anonymizer = new KAnonymizer();
|
||||
$anonymizer->registerAgeStrategy('age');
|
||||
$anonymizer->registerLocationStrategy('zip', 2);
|
||||
$anonymizer->registerDateStrategy('date', 'year');
|
||||
|
||||
$record = ['age' => 28, 'zip' => '12345', 'date' => '2024-06-15', 'name' => 'John'];
|
||||
$result = $anonymizer->anonymize($record);
|
||||
|
||||
$this->assertSame(TestConstants::AGE_RANGE_20_29, $result['age']);
|
||||
$this->assertSame('12***', $result['zip']);
|
||||
$this->assertSame('2024', $result['date']);
|
||||
$this->assertSame('John', $result['name']);
|
||||
}
|
||||
|
||||
public function testFluentInterface(): void
|
||||
{
|
||||
$anonymizer = (new KAnonymizer())
|
||||
->registerAgeStrategy('age')
|
||||
->registerLocationStrategy('zip', 3)
|
||||
->registerDateStrategy('date', 'month');
|
||||
|
||||
$this->assertCount(3, $anonymizer->getStrategies());
|
||||
}
|
||||
}
|
||||
132
tests/ArrayAccessor/ArrayAccessorFactoryTest.php
Normal file
132
tests/ArrayAccessor/ArrayAccessorFactoryTest.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\ArrayAccessor;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\DotArrayAccessor;
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for ArrayAccessorFactory.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class ArrayAccessorFactoryTest extends TestCase
|
||||
{
|
||||
public function testDefaultFactoryCreatesDotAccessor(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create(['test' => 'value']);
|
||||
|
||||
$this->assertInstanceOf(ArrayAccessorInterface::class, $accessor);
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
}
|
||||
|
||||
public function testCreateWithDataPassesDataToAccessor(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create([
|
||||
'user' => ['email' => 'test@example.com'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('user.email'));
|
||||
$this->assertSame('test@example.com', $accessor->get('user.email'));
|
||||
}
|
||||
|
||||
public function testWithClassFactoryMethod(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::withClass(DotArrayAccessor::class);
|
||||
$accessor = $factory->create(['foo' => 'bar']);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
$this->assertSame('bar', $accessor->get('foo'));
|
||||
}
|
||||
|
||||
public function testWithCallableFactoryMethod(): void
|
||||
{
|
||||
$customFactory = (fn(array $data): ArrayAccessorInterface => new DotArrayAccessor(array_merge($data, ['injected' => true])));
|
||||
|
||||
$factory = ArrayAccessorFactory::withCallable($customFactory);
|
||||
$accessor = $factory->create(['original' => 'data']);
|
||||
|
||||
$this->assertTrue($accessor->get('injected'));
|
||||
$this->assertSame('data', $accessor->get('original'));
|
||||
}
|
||||
|
||||
public function testConstructorWithNullUsesDefault(): void
|
||||
{
|
||||
$factory = new ArrayAccessorFactory(null);
|
||||
$accessor = $factory->create(['test' => 'value']);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
}
|
||||
|
||||
public function testConstructorWithClassName(): void
|
||||
{
|
||||
$factory = new ArrayAccessorFactory(DotArrayAccessor::class);
|
||||
$accessor = $factory->create(['key' => 'value']);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
$this->assertSame('value', $accessor->get('key'));
|
||||
}
|
||||
|
||||
public function testConstructorWithCallable(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$customFactory = function (array $data) use (&$callCount): ArrayAccessorInterface {
|
||||
$callCount++;
|
||||
return new DotArrayAccessor($data);
|
||||
};
|
||||
|
||||
$factory = new ArrayAccessorFactory($customFactory);
|
||||
$factory->create([]);
|
||||
$factory->create([]);
|
||||
|
||||
$this->assertSame(2, $callCount);
|
||||
}
|
||||
|
||||
public function testCreateMultipleAccessorsAreIndependent(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
|
||||
$accessor1 = $factory->create(['key' => 'value1']);
|
||||
$accessor2 = $factory->create(['key' => 'value2']);
|
||||
|
||||
$accessor1->set('key', 'modified');
|
||||
|
||||
$this->assertSame('modified', $accessor1->get('key'));
|
||||
$this->assertSame('value2', $accessor2->get('key'));
|
||||
}
|
||||
|
||||
public function testFactoryWithEmptyArray(): void
|
||||
{
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create([]);
|
||||
|
||||
$this->assertSame([], $accessor->all());
|
||||
$this->assertFalse($accessor->has('anything'));
|
||||
}
|
||||
|
||||
public function testFactoryPreservesComplexStructure(): void
|
||||
{
|
||||
$data = [
|
||||
'users' => [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
],
|
||||
'metadata' => [
|
||||
'version' => '1.0',
|
||||
'nested' => ['deep' => ['value' => true]],
|
||||
],
|
||||
];
|
||||
|
||||
$factory = ArrayAccessorFactory::default();
|
||||
$accessor = $factory->create($data);
|
||||
|
||||
$this->assertSame($data, $accessor->all());
|
||||
$this->assertTrue($accessor->get('metadata.nested.deep.value'));
|
||||
}
|
||||
}
|
||||
188
tests/ArrayAccessor/ArrayAccessorInterfaceTest.php
Normal file
188
tests/ArrayAccessor/ArrayAccessorInterfaceTest.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\ArrayAccessor;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\DotArrayAccessor;
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\ArrayAccessorInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for ArrayAccessorInterface implementations.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class ArrayAccessorInterfaceTest extends TestCase
|
||||
{
|
||||
public function testDotArrayAccessorImplementsInterface(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([]);
|
||||
|
||||
$this->assertInstanceOf(ArrayAccessorInterface::class, $accessor);
|
||||
}
|
||||
|
||||
public function testHasReturnsTrueForExistingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('user.email'));
|
||||
}
|
||||
|
||||
public function testHasReturnsFalseForMissingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertFalse($accessor->has('user.name'));
|
||||
$this->assertFalse($accessor->has('nonexistent'));
|
||||
}
|
||||
|
||||
public function testGetReturnsValueForExistingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
'age' => 25,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(TestConstants::EMAIL_TEST, $accessor->get('user.email'));
|
||||
$this->assertSame(25, $accessor->get('user.age'));
|
||||
}
|
||||
|
||||
public function testGetReturnsDefaultForMissingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor(['key' => 'value']);
|
||||
|
||||
$this->assertNull($accessor->get('missing'));
|
||||
$this->assertSame('default', $accessor->get('missing', 'default'));
|
||||
}
|
||||
|
||||
public function testSetCreatesNewPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([]);
|
||||
|
||||
$accessor->set('user.email', TestConstants::EMAIL_NEW);
|
||||
|
||||
$this->assertTrue($accessor->has('user.email'));
|
||||
$this->assertSame(TestConstants::EMAIL_NEW, $accessor->get('user.email'));
|
||||
}
|
||||
|
||||
public function testSetOverwritesExistingPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => ['email' => 'old@example.com'],
|
||||
]);
|
||||
|
||||
$accessor->set('user.email', TestConstants::EMAIL_NEW);
|
||||
|
||||
$this->assertSame(TestConstants::EMAIL_NEW, $accessor->get('user.email'));
|
||||
}
|
||||
|
||||
public function testDeleteRemovesPath(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
'name' => 'Test User',
|
||||
],
|
||||
]);
|
||||
|
||||
$accessor->delete('user.email');
|
||||
|
||||
$this->assertFalse($accessor->has('user.email'));
|
||||
$this->assertTrue($accessor->has('user.name'));
|
||||
}
|
||||
|
||||
public function testAllReturnsCompleteArray(): void
|
||||
{
|
||||
$data = [
|
||||
'user' => [
|
||||
'email' => TestConstants::EMAIL_TEST,
|
||||
'profile' => [
|
||||
'bio' => 'Hello world',
|
||||
],
|
||||
],
|
||||
'settings' => ['theme' => 'dark'],
|
||||
];
|
||||
|
||||
$accessor = new DotArrayAccessor($data);
|
||||
|
||||
$this->assertSame($data, $accessor->all());
|
||||
}
|
||||
|
||||
public function testAllReflectsModifications(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor(['key' => 'original']);
|
||||
|
||||
$accessor->set('key', 'modified');
|
||||
$accessor->set('new', 'value');
|
||||
|
||||
$result = $accessor->all();
|
||||
|
||||
$this->assertSame('modified', $result['key']);
|
||||
$this->assertSame('value', $result['new']);
|
||||
}
|
||||
|
||||
public function testFromArrayFactoryMethod(): void
|
||||
{
|
||||
$data = ['foo' => 'bar'];
|
||||
$accessor = DotArrayAccessor::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(DotArrayAccessor::class, $accessor);
|
||||
$this->assertSame('bar', $accessor->get('foo'));
|
||||
}
|
||||
|
||||
public function testGetDotReturnsUnderlyingInstance(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor(['test' => 'value']);
|
||||
$dot = $accessor->getDot();
|
||||
|
||||
$this->assertInstanceOf(Dot::class, $dot);
|
||||
$this->assertSame('value', $dot->get('test'));
|
||||
}
|
||||
|
||||
public function testDeepNestedAccess(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'level1' => [
|
||||
'level2' => [
|
||||
'level3' => [
|
||||
'level4' => 'deep value',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('level1.level2.level3.level4'));
|
||||
$this->assertSame('deep value', $accessor->get('level1.level2.level3.level4'));
|
||||
|
||||
$accessor->set('level1.level2.level3.level4', 'modified');
|
||||
$this->assertSame('modified', $accessor->get('level1.level2.level3.level4'));
|
||||
}
|
||||
|
||||
public function testNumericKeys(): void
|
||||
{
|
||||
$accessor = new DotArrayAccessor([
|
||||
'items' => [
|
||||
0 => 'first',
|
||||
1 => 'second',
|
||||
2 => 'third',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($accessor->has('items.0'));
|
||||
$this->assertSame('first', $accessor->get('items.0'));
|
||||
$this->assertSame('second', $accessor->get('items.1'));
|
||||
}
|
||||
}
|
||||
190
tests/Audit/AuditContextTest.php
Normal file
190
tests/Audit/AuditContextTest.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Audit;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for AuditContext value object.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class AuditContextTest extends TestCase
|
||||
{
|
||||
public function testSuccessCreation(): void
|
||||
{
|
||||
$context = AuditContext::success(
|
||||
AuditContext::OP_REGEX,
|
||||
12.5,
|
||||
['key' => 'value']
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_REGEX, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_SUCCESS, $context->status);
|
||||
$this->assertSame(1, $context->attemptNumber);
|
||||
$this->assertSame(12.5, $context->durationMs);
|
||||
$this->assertNull($context->error);
|
||||
$this->assertSame(['key' => 'value'], $context->metadata);
|
||||
}
|
||||
|
||||
public function testFailedCreation(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Something went wrong');
|
||||
$context = AuditContext::failed(
|
||||
AuditContext::OP_FIELD_PATH,
|
||||
$error,
|
||||
3,
|
||||
50.0,
|
||||
['retry' => true]
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_FIELD_PATH, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_FAILED, $context->status);
|
||||
$this->assertSame(3, $context->attemptNumber);
|
||||
$this->assertSame(50.0, $context->durationMs);
|
||||
$this->assertSame($error, $context->error);
|
||||
$this->assertArrayHasKey('retry', $context->metadata);
|
||||
}
|
||||
|
||||
public function testRecoveredCreation(): void
|
||||
{
|
||||
$context = AuditContext::recovered(
|
||||
AuditContext::OP_CALLBACK,
|
||||
2,
|
||||
25.0
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_CALLBACK, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_RECOVERED, $context->status);
|
||||
$this->assertSame(2, $context->attemptNumber);
|
||||
$this->assertSame(25.0, $context->durationMs);
|
||||
}
|
||||
|
||||
public function testSkippedCreation(): void
|
||||
{
|
||||
$context = AuditContext::skipped(
|
||||
AuditContext::OP_CONDITIONAL,
|
||||
'Condition not met'
|
||||
);
|
||||
|
||||
$this->assertSame(AuditContext::OP_CONDITIONAL, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_SKIPPED, $context->status);
|
||||
$this->assertArrayHasKey('skip_reason', $context->metadata);
|
||||
$this->assertSame('Condition not met', $context->metadata['skip_reason']);
|
||||
}
|
||||
|
||||
public function testWithCorrelationId(): void
|
||||
{
|
||||
$context = AuditContext::success(AuditContext::OP_REGEX);
|
||||
$this->assertNull($context->correlationId);
|
||||
|
||||
$withId = $context->withCorrelationId('abc123');
|
||||
|
||||
$this->assertNull($context->correlationId);
|
||||
$this->assertSame('abc123', $withId->correlationId);
|
||||
$this->assertSame($context->operationType, $withId->operationType);
|
||||
$this->assertSame($context->status, $withId->status);
|
||||
}
|
||||
|
||||
public function testWithMetadata(): void
|
||||
{
|
||||
$context = AuditContext::success(
|
||||
AuditContext::OP_REGEX,
|
||||
0.0,
|
||||
['original' => 'value']
|
||||
);
|
||||
|
||||
$withMeta = $context->withMetadata(['added' => 'new']);
|
||||
|
||||
$this->assertArrayHasKey('original', $withMeta->metadata);
|
||||
$this->assertArrayHasKey('added', $withMeta->metadata);
|
||||
$this->assertSame('value', $withMeta->metadata['original']);
|
||||
$this->assertSame('new', $withMeta->metadata['added']);
|
||||
}
|
||||
|
||||
public function testIsSuccess(): void
|
||||
{
|
||||
$success = AuditContext::success(AuditContext::OP_REGEX);
|
||||
$recovered = AuditContext::recovered(AuditContext::OP_REGEX, 2);
|
||||
$error = ErrorContext::create('Error', 'msg');
|
||||
$failed = AuditContext::failed(AuditContext::OP_REGEX, $error);
|
||||
$skipped = AuditContext::skipped(AuditContext::OP_REGEX, 'reason');
|
||||
|
||||
$this->assertTrue($success->isSuccess());
|
||||
$this->assertTrue($recovered->isSuccess());
|
||||
$this->assertFalse($failed->isSuccess());
|
||||
$this->assertFalse($skipped->isSuccess());
|
||||
}
|
||||
|
||||
public function testToArray(): void
|
||||
{
|
||||
$context = AuditContext::success(
|
||||
AuditContext::OP_REGEX,
|
||||
15.123456,
|
||||
['key' => 'value']
|
||||
);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('operation_type', $array);
|
||||
$this->assertArrayHasKey('status', $array);
|
||||
$this->assertArrayHasKey('attempt_number', $array);
|
||||
$this->assertArrayHasKey('duration_ms', $array);
|
||||
$this->assertArrayHasKey('metadata', $array);
|
||||
$this->assertSame(15.123, $array['duration_ms']);
|
||||
}
|
||||
|
||||
public function testToArrayWithError(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Message');
|
||||
$context = AuditContext::failed(AuditContext::OP_REGEX, $error);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('error', $array);
|
||||
$this->assertIsArray($array['error']);
|
||||
}
|
||||
|
||||
public function testToArrayWithCorrelationId(): void
|
||||
{
|
||||
$context = AuditContext::success(AuditContext::OP_REGEX)
|
||||
->withCorrelationId('test-id');
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('correlation_id', $array);
|
||||
$this->assertSame('test-id', $array['correlation_id']);
|
||||
}
|
||||
|
||||
public function testGenerateCorrelationId(): void
|
||||
{
|
||||
$id1 = AuditContext::generateCorrelationId();
|
||||
$id2 = AuditContext::generateCorrelationId();
|
||||
|
||||
$this->assertIsString($id1);
|
||||
$this->assertSame(16, strlen($id1));
|
||||
$this->assertNotSame($id1, $id2);
|
||||
}
|
||||
|
||||
public function testOperationTypeConstants(): void
|
||||
{
|
||||
$this->assertSame('regex', AuditContext::OP_REGEX);
|
||||
$this->assertSame('field_path', AuditContext::OP_FIELD_PATH);
|
||||
$this->assertSame('callback', AuditContext::OP_CALLBACK);
|
||||
$this->assertSame('data_type', AuditContext::OP_DATA_TYPE);
|
||||
$this->assertSame('json', AuditContext::OP_JSON);
|
||||
$this->assertSame('conditional', AuditContext::OP_CONDITIONAL);
|
||||
}
|
||||
|
||||
public function testStatusConstants(): void
|
||||
{
|
||||
$this->assertSame('success', AuditContext::STATUS_SUCCESS);
|
||||
$this->assertSame('failed', AuditContext::STATUS_FAILED);
|
||||
$this->assertSame('recovered', AuditContext::STATUS_RECOVERED);
|
||||
$this->assertSame('skipped', AuditContext::STATUS_SKIPPED);
|
||||
}
|
||||
}
|
||||
194
tests/Audit/ErrorContextTest.php
Normal file
194
tests/Audit/ErrorContextTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Audit;
|
||||
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for ErrorContext value object.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class ErrorContextTest extends TestCase
|
||||
{
|
||||
public function testBasicConstruction(): void
|
||||
{
|
||||
$context = new ErrorContext(
|
||||
errorType: 'TestError',
|
||||
message: 'Something went wrong',
|
||||
code: 42,
|
||||
file: '/path/to/file.php',
|
||||
line: 123,
|
||||
metadata: ['key' => 'value']
|
||||
);
|
||||
|
||||
$this->assertSame('TestError', $context->errorType);
|
||||
$this->assertSame('Something went wrong', $context->message);
|
||||
$this->assertSame(42, $context->code);
|
||||
$this->assertSame('/path/to/file.php', $context->file);
|
||||
$this->assertSame(123, $context->line);
|
||||
$this->assertSame(['key' => 'value'], $context->metadata);
|
||||
}
|
||||
|
||||
public function testFromThrowable(): void
|
||||
{
|
||||
$exception = new RuntimeException('Test exception', 500);
|
||||
$context = ErrorContext::fromThrowable($exception);
|
||||
|
||||
$this->assertSame(RuntimeException::class, $context->errorType);
|
||||
$this->assertSame('Test exception', $context->message);
|
||||
$this->assertSame(500, $context->code);
|
||||
$this->assertNull($context->file);
|
||||
$this->assertNull($context->line);
|
||||
}
|
||||
|
||||
public function testFromThrowableWithSensitiveDetails(): void
|
||||
{
|
||||
$exception = new Exception('Error at /home/user/app');
|
||||
$context = ErrorContext::fromThrowable($exception, includeSensitive: true);
|
||||
|
||||
$this->assertNotNull($context->file);
|
||||
$this->assertNotNull($context->line);
|
||||
$this->assertArrayHasKey('trace', $context->metadata);
|
||||
}
|
||||
|
||||
public function testCreate(): void
|
||||
{
|
||||
$context = ErrorContext::create(
|
||||
'CustomError',
|
||||
'Error message',
|
||||
['detail' => 'info']
|
||||
);
|
||||
|
||||
$this->assertSame('CustomError', $context->errorType);
|
||||
$this->assertSame('Error message', $context->message);
|
||||
$this->assertSame(0, $context->code);
|
||||
$this->assertArrayHasKey('detail', $context->metadata);
|
||||
}
|
||||
|
||||
public function testSanitizesPasswordsInMessage(): void
|
||||
{
|
||||
$message = 'Connection failed: password=secret123';
|
||||
$context = ErrorContext::create('DbError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('secret123', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesApiKeysInMessage(): void
|
||||
{
|
||||
$message = 'Auth failed: api_key=sk_live_1234567890';
|
||||
$context = ErrorContext::create('ApiError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('sk_live_1234567890', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesTokensInMessage(): void
|
||||
{
|
||||
$message = 'Auth failed with bearer abc123def456';
|
||||
$context = ErrorContext::create('AuthError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('abc123def456', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesTokenValueInMessage(): void
|
||||
{
|
||||
$message = 'Invalid token=secret_value_here';
|
||||
$context = ErrorContext::create('AuthError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('secret_value_here', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesConnectionStrings(): void
|
||||
{
|
||||
$message = 'Failed: redis://admin:password@localhost:6379';
|
||||
$context = ErrorContext::create('ConnError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('password', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesUserCredentials(): void
|
||||
{
|
||||
$message = 'DB error: user=admin host=secret.internal.com';
|
||||
$context = ErrorContext::create('DbError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('admin', $context->message);
|
||||
$this->assertStringNotContainsString('secret.internal.com', $context->message);
|
||||
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
|
||||
}
|
||||
|
||||
public function testSanitizesFilePaths(): void
|
||||
{
|
||||
$message = 'Cannot read /var/www/secret-app/config/credentials.php';
|
||||
$context = ErrorContext::create('FileError', $message);
|
||||
|
||||
$this->assertStringNotContainsString('/var/www/secret-app', $context->message);
|
||||
$this->assertStringContainsString('[PATH_REDACTED]', $context->message);
|
||||
}
|
||||
|
||||
public function testToArray(): void
|
||||
{
|
||||
$context = new ErrorContext(
|
||||
errorType: 'TestError',
|
||||
message: 'Test message',
|
||||
code: 100,
|
||||
file: '/test/file.php',
|
||||
line: 50,
|
||||
metadata: ['key' => 'value']
|
||||
);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayHasKey('error_type', $array);
|
||||
$this->assertArrayHasKey('message', $array);
|
||||
$this->assertArrayHasKey('code', $array);
|
||||
$this->assertArrayHasKey('file', $array);
|
||||
$this->assertArrayHasKey('line', $array);
|
||||
$this->assertArrayHasKey('metadata', $array);
|
||||
|
||||
$this->assertSame('TestError', $array['error_type']);
|
||||
$this->assertSame('Test message', $array['message']);
|
||||
$this->assertSame(100, $array['code']);
|
||||
}
|
||||
|
||||
public function testToArrayOmitsNullValues(): void
|
||||
{
|
||||
$context = ErrorContext::create('Error', 'Message');
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayNotHasKey('file', $array);
|
||||
$this->assertArrayNotHasKey('line', $array);
|
||||
}
|
||||
|
||||
public function testToArrayOmitsEmptyMetadata(): void
|
||||
{
|
||||
$context = ErrorContext::create('Error', 'Message', []);
|
||||
|
||||
$array = $context->toArray();
|
||||
|
||||
$this->assertArrayNotHasKey('metadata', $array);
|
||||
}
|
||||
|
||||
public function testFromThrowableWithNestedException(): void
|
||||
{
|
||||
$inner = new InvalidArgumentException('Inner error');
|
||||
$outer = new RuntimeException('Outer error', 0, $inner);
|
||||
|
||||
$context = ErrorContext::fromThrowable($outer);
|
||||
|
||||
$this->assertSame(RuntimeException::class, $context->errorType);
|
||||
$this->assertStringContainsString('Outer error', $context->message);
|
||||
}
|
||||
}
|
||||
207
tests/Audit/StructuredAuditLoggerTest.php
Normal file
207
tests/Audit/StructuredAuditLoggerTest.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Audit;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\StructuredAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimiter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for StructuredAuditLogger.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class StructuredAuditLoggerTest extends TestCase
|
||||
{
|
||||
/** @var array<array{path: string, original: mixed, masked: mixed}> */
|
||||
private array $logs;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->logs = [];
|
||||
RateLimiter::clearAll();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RateLimiter::clearAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function createBaseLogger(): callable
|
||||
{
|
||||
return function (string $path, mixed $original, mixed $masked): void {
|
||||
$this->logs[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public function testBasicLogging(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame('user.email', $this->logs[0]['path']);
|
||||
$this->assertSame(TestConstants::EMAIL_JOHN, $this->logs[0]['original']);
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $this->logs[0]['masked']);
|
||||
}
|
||||
|
||||
public function testLogWithContext(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
$context = AuditContext::success(AuditContext::OP_REGEX, 5.0);
|
||||
|
||||
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS, $context);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testLogSuccess(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->logSuccess(
|
||||
'user.ssn',
|
||||
'123-45-6789',
|
||||
'[SSN]',
|
||||
AuditContext::OP_REGEX,
|
||||
10.5
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame('user.ssn', $this->logs[0]['path']);
|
||||
}
|
||||
|
||||
public function testLogFailure(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
$error = ErrorContext::create('RegexError', 'Pattern failed');
|
||||
|
||||
$logger->logFailure(
|
||||
'user.data',
|
||||
'sensitive value',
|
||||
AuditContext::OP_REGEX,
|
||||
$error
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame('[MASKING_FAILED]', $this->logs[0]['masked']);
|
||||
}
|
||||
|
||||
public function testLogRecovery(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->logRecovery(
|
||||
'user.email',
|
||||
TestConstants::EMAIL_JOHN,
|
||||
TestConstants::MASK_MASKED_BRACKETS,
|
||||
AuditContext::OP_REGEX,
|
||||
2,
|
||||
25.0
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testLogSkipped(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$logger->logSkipped(
|
||||
'user.public_name',
|
||||
TestConstants::NAME_FULL,
|
||||
AuditContext::OP_CONDITIONAL,
|
||||
'Field not in mask list'
|
||||
);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
$this->assertSame(TestConstants::NAME_FULL, $this->logs[0]['original']);
|
||||
$this->assertSame(TestConstants::NAME_FULL, $this->logs[0]['masked']);
|
||||
}
|
||||
|
||||
public function testWrapStaticFactory(): void
|
||||
{
|
||||
$logger = StructuredAuditLogger::wrap($this->createBaseLogger());
|
||||
|
||||
$logger->log('test.path', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testWithRateLimitedLogger(): void
|
||||
{
|
||||
$rateLimited = new RateLimitedAuditLogger(
|
||||
$this->createBaseLogger(),
|
||||
100,
|
||||
60
|
||||
);
|
||||
$logger = new StructuredAuditLogger($rateLimited);
|
||||
|
||||
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS);
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testTimerMethods(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger($this->createBaseLogger());
|
||||
|
||||
$start = $logger->startTimer();
|
||||
usleep(10000);
|
||||
$elapsed = $logger->elapsed($start);
|
||||
|
||||
$this->assertGreaterThan(0, $elapsed);
|
||||
$this->assertLessThan(100, $elapsed);
|
||||
}
|
||||
|
||||
public function testGetWrappedLogger(): void
|
||||
{
|
||||
$baseLogger = $this->createBaseLogger();
|
||||
$logger = new StructuredAuditLogger($baseLogger);
|
||||
|
||||
$wrapped = $logger->getWrappedLogger();
|
||||
|
||||
// Verify the wrapped logger works by calling it
|
||||
$wrapped('test.path', 'original', 'masked');
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testDisableTimestamp(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger(
|
||||
$this->createBaseLogger(),
|
||||
includeTimestamp: false
|
||||
);
|
||||
|
||||
$logger->log('test', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
|
||||
public function testDisableDuration(): void
|
||||
{
|
||||
$logger = new StructuredAuditLogger(
|
||||
$this->createBaseLogger(),
|
||||
includeDuration: false
|
||||
);
|
||||
|
||||
$logger->log('test', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $this->logs);
|
||||
}
|
||||
}
|
||||
427
tests/Builder/GdprProcessorBuilderEdgeCasesTest.php
Normal file
427
tests/Builder/GdprProcessorBuilderEdgeCasesTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
365
tests/Builder/GdprProcessorBuilderTest.php
Normal file
365
tests/Builder/GdprProcessorBuilderTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
370
tests/Builder/PluginAwareProcessorTest.php
Normal file
370
tests/Builder/PluginAwareProcessorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,10 @@ class ConditionalMaskingTest extends TestCase
|
||||
|
||||
public function testChannelBasedConditionalMasking(): void
|
||||
{
|
||||
// Create a processor that only masks logs from TestConstants::CHANNEL_SECURITY and TestConstants::CHANNEL_AUDIT channels
|
||||
// Create a processor that only masks logs from security and audit channels
|
||||
$channels = [TestConstants::CHANNEL_SECURITY, TestConstants::CHANNEL_AUDIT];
|
||||
$channelRule = ConditionalRuleFactory::createChannelBasedRule($channels);
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_EMAIL_TEST => MaskConstants::MASK_EMAIL],
|
||||
[],
|
||||
@@ -93,9 +96,7 @@ class ConditionalMaskingTest extends TestCase
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'security_channels_only' => ConditionalRuleFactory::createChannelBasedRule([TestConstants::CHANNEL_SECURITY, TestConstants::CHANNEL_AUDIT])
|
||||
]
|
||||
['security_channels_only' => $channelRule]
|
||||
);
|
||||
|
||||
// Test security channel - should be masked
|
||||
@@ -143,7 +144,9 @@ class ConditionalMaskingTest extends TestCase
|
||||
100,
|
||||
[],
|
||||
[
|
||||
'sensitive_data_present' => ConditionalRuleFactory::createContextFieldRule(TestConstants::CONTEXT_SENSITIVE_DATA)
|
||||
'sensitive_data_present' => ConditionalRuleFactory::createContextFieldRule(
|
||||
TestConstants::CONTEXT_SENSITIVE_DATA
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -306,7 +309,7 @@ class ConditionalMaskingTest extends TestCase
|
||||
{
|
||||
// Create a custom rule that masks only logs with user_id > 1000
|
||||
$customRule = (
|
||||
fn(LogRecord $record): bool => isset($record->context[TestConstants::CONTEXT_USER_ID]) && $record->context[TestConstants::CONTEXT_USER_ID] > 1000
|
||||
fn(LogRecord $record): bool => ($record->context[TestConstants::CONTEXT_USER_ID] ?? 0) > 1000
|
||||
);
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
|
||||
237
tests/ConditionalRuleFactoryInstanceTest.php
Normal file
237
tests/ConditionalRuleFactoryInstanceTest.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\ConditionalRuleFactory;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for ConditionalRuleFactory instance methods.
|
||||
*/
|
||||
#[CoversClass(ConditionalRuleFactory::class)]
|
||||
final class ConditionalRuleFactoryInstanceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function createLogRecord(string $message, array $context = []): LogRecord
|
||||
{
|
||||
return new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: $message,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
public function testConstructorWithDefaultFactory(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$this->assertInstanceOf(ConditionalRuleFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function testConstructorWithCustomFactory(): void
|
||||
{
|
||||
$accessorFactory = ArrayAccessorFactory::default();
|
||||
$factory = new ConditionalRuleFactory($accessorFactory);
|
||||
$this->assertInstanceOf(ConditionalRuleFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithPresentField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('user_id');
|
||||
|
||||
$record = $this->createLogRecord('Test message', ['user_id' => 123]);
|
||||
$this->assertTrue($rule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithMissingField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('user_id');
|
||||
|
||||
$record = $this->createLogRecord('Test message', []);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithNestedField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('user.profile.id');
|
||||
|
||||
$recordWithField = $this->createLogRecord('Test', [
|
||||
'user' => ['profile' => ['id' => 456]],
|
||||
]);
|
||||
$this->assertTrue($rule($recordWithField));
|
||||
|
||||
$recordWithoutField = $this->createLogRecord('Test', [
|
||||
'user' => ['profile' => []],
|
||||
]);
|
||||
$this->assertFalse($rule($recordWithoutField));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithMatchingValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('env', 'production');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['env' => 'production']);
|
||||
$this->assertTrue($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithNonMatchingValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('env', 'production');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['env' => 'development']);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithMissingField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('env', 'production');
|
||||
|
||||
$record = $this->createLogRecord('Test', []);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithNestedField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('config.debug', true);
|
||||
|
||||
$recordMatching = $this->createLogRecord('Test', [
|
||||
'config' => ['debug' => true],
|
||||
]);
|
||||
$this->assertTrue($rule($recordMatching));
|
||||
|
||||
$recordNonMatching = $this->createLogRecord('Test', [
|
||||
'config' => ['debug' => false],
|
||||
]);
|
||||
$this->assertFalse($rule($recordNonMatching));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithNullValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('nullable', null);
|
||||
|
||||
$recordWithNull = $this->createLogRecord('Test', ['nullable' => null]);
|
||||
$this->assertTrue($rule($recordWithNull));
|
||||
|
||||
$recordWithValue = $this->createLogRecord('Test', ['nullable' => 'value']);
|
||||
$this->assertFalse($rule($recordWithValue));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithArrayValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$expectedArray = ['a', 'b', 'c'];
|
||||
$rule = $factory->contextValueRule('tags', $expectedArray);
|
||||
|
||||
$recordMatching = $this->createLogRecord('Test', ['tags' => ['a', 'b', 'c']]);
|
||||
$this->assertTrue($rule($recordMatching));
|
||||
|
||||
$recordNonMatching = $this->createLogRecord('Test', ['tags' => ['a', 'b']]);
|
||||
$this->assertFalse($rule($recordNonMatching));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithIntegerValue(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('count', 42);
|
||||
|
||||
$recordMatching = $this->createLogRecord('Test', ['count' => 42]);
|
||||
$this->assertTrue($rule($recordMatching));
|
||||
|
||||
// Different type (string vs int) should not match
|
||||
$recordDifferentType = $this->createLogRecord('Test', ['count' => '42']);
|
||||
$this->assertFalse($rule($recordDifferentType));
|
||||
}
|
||||
|
||||
public function testCustomAccessorFactoryIsUsed(): void
|
||||
{
|
||||
// Create a custom accessor factory
|
||||
$customFactory = ArrayAccessorFactory::default();
|
||||
$ruleFactory = new ConditionalRuleFactory($customFactory);
|
||||
|
||||
$fieldRule = $ruleFactory->contextFieldRule('test.field');
|
||||
$valueRule = $ruleFactory->contextValueRule('test.value', 'expected');
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'test' => [
|
||||
'field' => 'present',
|
||||
'value' => 'expected',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($fieldRule($record));
|
||||
$this->assertTrue($valueRule($record));
|
||||
}
|
||||
|
||||
public function testInstanceMethodsVsStaticMethods(): void
|
||||
{
|
||||
$instanceFactory = new ConditionalRuleFactory();
|
||||
|
||||
// Create rules using both methods
|
||||
$instanceFieldRule = $instanceFactory->contextFieldRule('user.email');
|
||||
$staticFieldRule = ConditionalRuleFactory::createContextFieldRule('user.email');
|
||||
|
||||
$instanceValueRule = $instanceFactory->contextValueRule('type', 'admin');
|
||||
$staticValueRule = ConditionalRuleFactory::createContextValueRule('type', 'admin');
|
||||
|
||||
$record = $this->createLogRecord('Test', [
|
||||
'user' => ['email' => 'test@example.com'],
|
||||
'type' => 'admin',
|
||||
]);
|
||||
|
||||
// Both should produce the same results
|
||||
$this->assertSame($staticFieldRule($record), $instanceFieldRule($record));
|
||||
$this->assertSame($staticValueRule($record), $instanceValueRule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithEmptyPath(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['key' => 'value']);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextValueRuleWithEmptyPath(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextValueRule('', 'value');
|
||||
|
||||
$record = $this->createLogRecord('Test', ['key' => 'value']);
|
||||
$this->assertFalse($rule($record));
|
||||
}
|
||||
|
||||
public function testContextFieldRuleWithDeeplyNestedField(): void
|
||||
{
|
||||
$factory = new ConditionalRuleFactory();
|
||||
$rule = $factory->contextFieldRule('a.b.c.d.e.f');
|
||||
|
||||
$deepRecord = $this->createLogRecord('Test', [
|
||||
'a' => ['b' => ['c' => ['d' => ['e' => ['f' => 'deep']]]]],
|
||||
]);
|
||||
$this->assertTrue($rule($deepRecord));
|
||||
|
||||
$shallowRecord = $this->createLogRecord('Test', [
|
||||
'a' => ['b' => ['c' => 'shallow']],
|
||||
]);
|
||||
$this->assertFalse($rule($shallowRecord));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Adbar\Dot;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\DotArrayAccessor;
|
||||
use Ivuorinen\MonologGdprFilter\ContextProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RuleExecutionException;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
@@ -23,14 +23,18 @@ final class ContextProcessorTest extends TestCase
|
||||
public function testMaskFieldPathsWithRegexMask(): void
|
||||
{
|
||||
$regexProcessor = fn(string $val): string => str_replace('test', MaskConstants::MASK_GENERIC, $val);
|
||||
$emailConfig = FieldMaskConfig::regexMask(
|
||||
TestConstants::PATTERN_TEST,
|
||||
MaskConstants::MASK_GENERIC
|
||||
);
|
||||
$processor = new ContextProcessor(
|
||||
[TestConstants::CONTEXT_EMAIL => FieldMaskConfig::regexMask(TestConstants::PATTERN_TEST, MaskConstants::MASK_GENERIC)],
|
||||
[TestConstants::CONTEXT_EMAIL => $emailConfig],
|
||||
[],
|
||||
null,
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot([TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]);
|
||||
$accessor = new DotArrayAccessor([TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame([TestConstants::CONTEXT_EMAIL], $processed);
|
||||
@@ -47,7 +51,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['secret' => 'confidential', 'public' => 'data']);
|
||||
$accessor = new DotArrayAccessor(['secret' => 'confidential', 'public' => 'data']);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame(['secret'], $processed);
|
||||
@@ -65,7 +69,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot([TestConstants::CONTEXT_PASSWORD => 'secret123']);
|
||||
$accessor = new DotArrayAccessor([TestConstants::CONTEXT_PASSWORD => 'secret123']);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame([TestConstants::CONTEXT_PASSWORD], $processed);
|
||||
@@ -82,7 +86,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['other' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['other' => 'value']);
|
||||
$processed = $processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertSame([], $processed);
|
||||
@@ -104,7 +108,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'value']);
|
||||
$processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertCount(1, $auditLog);
|
||||
@@ -128,7 +132,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['secret' => 'data']);
|
||||
$accessor = new DotArrayAccessor(['secret' => 'data']);
|
||||
$processor->maskFieldPaths($accessor);
|
||||
|
||||
$this->assertCount(1, $auditLog);
|
||||
@@ -148,7 +152,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['name' => 'john']);
|
||||
$accessor = new DotArrayAccessor(['name' => 'john']);
|
||||
$processed = $processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertSame(['name'], $processed);
|
||||
@@ -167,7 +171,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['other' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['other' => 'value']);
|
||||
$processed = $processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertSame([], $processed);
|
||||
@@ -192,7 +196,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'value']);
|
||||
$processed = $processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertSame(['field'], $processed);
|
||||
@@ -220,7 +224,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'original']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'original']);
|
||||
$processor->processCustomCallbacks($accessor);
|
||||
|
||||
$this->assertCount(1, $auditLog);
|
||||
@@ -332,7 +336,7 @@ final class ContextProcessorTest extends TestCase
|
||||
$regexProcessor
|
||||
);
|
||||
|
||||
$accessor = new Dot(['field' => 'value']);
|
||||
$accessor = new DotArrayAccessor(['field' => 'value']);
|
||||
$processor->processCustomCallbacks($accessor);
|
||||
|
||||
// Should not log when value unchanged
|
||||
|
||||
@@ -142,7 +142,8 @@ class CustomExceptionsTest extends TestCase
|
||||
'Invalid configuration'
|
||||
);
|
||||
|
||||
$this->assertStringContainsString("Field path masking failed for path '" . TestConstants::FIELD_USER_EMAIL . "'", $exception->getMessage());
|
||||
$expectedMsg = "Field path masking failed for path '" . TestConstants::FIELD_USER_EMAIL . "'";
|
||||
$this->assertStringContainsString($expectedMsg, $exception->getMessage());
|
||||
$this->assertStringContainsString('Invalid configuration', $exception->getMessage());
|
||||
$this->assertStringContainsString('operation_type: "field_path_masking"', $exception->getMessage());
|
||||
$this->assertStringContainsString('value_type: "string"', $exception->getMessage());
|
||||
@@ -356,7 +357,12 @@ class CustomExceptionsTest extends TestCase
|
||||
'input',
|
||||
'Failed'
|
||||
);
|
||||
$auditException = AuditLoggingException::callbackFailed('path', 'original', TestConstants::DATA_MASKED, 'Failed');
|
||||
$auditException = AuditLoggingException::callbackFailed(
|
||||
'path',
|
||||
'original',
|
||||
TestConstants::DATA_MASKED,
|
||||
'Failed'
|
||||
);
|
||||
$depthException = RecursionDepthExceededException::depthExceeded(10, 5, 'path');
|
||||
|
||||
// All should inherit from GdprProcessorException
|
||||
|
||||
161
tests/Factory/AuditLoggerFactoryTest.php
Normal file
161
tests/Factory/AuditLoggerFactoryTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Factory;
|
||||
|
||||
use Closure;
|
||||
use Ivuorinen\MonologGdprFilter\Factory\AuditLoggerFactory;
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(AuditLoggerFactory::class)]
|
||||
final class AuditLoggerFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreateReturnsFactoryInstance(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
|
||||
$this->assertInstanceOf(AuditLoggerFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function testCreateRateLimitedReturnsRateLimitedLogger(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$auditLogger = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = $factory->createRateLimited($auditLogger);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateRateLimitedWithProfile(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$auditLogger = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = $factory->createRateLimited($auditLogger, 'strict');
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateArrayLoggerReturnsClosureByDefault(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$result = $factory->createArrayLogger($storage);
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateArrayLoggerWithRateLimiting(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$result = $factory->createArrayLogger($storage, true);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateArrayLoggerStoresLogs(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$logger = $factory->createArrayLogger($storage);
|
||||
$logger('test.path', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $storage);
|
||||
$this->assertSame('test.path', $storage[0]['path']);
|
||||
$this->assertSame('original', $storage[0]['original']);
|
||||
$this->assertSame('masked', $storage[0]['masked']);
|
||||
$this->assertArrayHasKey('timestamp', $storage[0]);
|
||||
}
|
||||
|
||||
public function testCreateNullLoggerReturnsClosure(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
|
||||
$result = $factory->createNullLogger();
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateNullLoggerDoesNothing(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$logger = $factory->createNullLogger();
|
||||
|
||||
// Should not throw
|
||||
$logger('path', 'original', 'masked');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testCreateCallbackLoggerReturnsClosure(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$callback = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = $factory->createCallbackLogger($callback);
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testCreateCallbackLoggerInvokesCallback(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$calls = [];
|
||||
$callback = function (string $path, mixed $original, mixed $masked) use (&$calls): void {
|
||||
$calls[] = ['path' => $path, 'original' => $original, 'masked' => $masked];
|
||||
};
|
||||
|
||||
$logger = $factory->createCallbackLogger($callback);
|
||||
$logger('test.path', 'original', 'masked');
|
||||
|
||||
$this->assertCount(1, $calls);
|
||||
$this->assertSame('test.path', $calls[0]['path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Testing deprecated method
|
||||
*/
|
||||
public function testDeprecatedRateLimitedStaticMethod(): void
|
||||
{
|
||||
$auditLogger = fn(string $path, mixed $original, mixed $masked): mixed => null;
|
||||
|
||||
$result = AuditLoggerFactory::rateLimited($auditLogger);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Testing deprecated method
|
||||
*/
|
||||
public function testDeprecatedArrayLoggerStaticMethod(): void
|
||||
{
|
||||
$storage = [];
|
||||
|
||||
$result = AuditLoggerFactory::arrayLogger($storage);
|
||||
|
||||
$this->assertInstanceOf(Closure::class, $result);
|
||||
}
|
||||
|
||||
public function testMultipleLogEntriesStoredCorrectly(): void
|
||||
{
|
||||
$factory = AuditLoggerFactory::create();
|
||||
$storage = [];
|
||||
|
||||
$logger = $factory->createArrayLogger($storage);
|
||||
$logger('path1', 'orig1', 'mask1');
|
||||
$logger('path2', 'orig2', 'mask2');
|
||||
$logger('path3', 'orig3', 'mask3');
|
||||
|
||||
$this->assertCount(3, $storage);
|
||||
$this->assertSame('path1', $storage[0]['path']);
|
||||
$this->assertSame('path2', $storage[1]['path']);
|
||||
$this->assertSame('path3', $storage[2]['path']);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated audit logger methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
final class GdprProcessorComprehensiveTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated audit logger factory methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
final class GdprProcessorExtendedTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ use Tests\TestHelpers;
|
||||
* Integration tests for GDPR processor with rate-limited audit logging.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated audit logger methods
|
||||
*/
|
||||
class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
{
|
||||
@@ -60,12 +61,18 @@ class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
|
||||
// Process multiple log records
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
// Add context data to be masked
|
||||
$contextData = [
|
||||
'user' => [
|
||||
TestConstants::CONTEXT_EMAIL => sprintf(TestConstants::TEMPLATE_USER_EMAIL, $i)
|
||||
]
|
||||
];
|
||||
$logRecord = new LogRecord(
|
||||
new DateTimeImmutable(),
|
||||
'test',
|
||||
Level::Info,
|
||||
sprintf(TestConstants::TEMPLATE_MESSAGE_EMAIL, $i),
|
||||
['user' => [TestConstants::CONTEXT_EMAIL => sprintf(TestConstants::TEMPLATE_USER_EMAIL, $i)]] // Add context data to be masked
|
||||
$contextData
|
||||
);
|
||||
|
||||
$result = $processor($logRecord);
|
||||
@@ -266,7 +273,12 @@ class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
throw AuditLoggingException::callbackFailed($path, $original, $masked, 'Audit logging failed');
|
||||
}
|
||||
|
||||
$this->auditLogs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked, 'timestamp' => time()];
|
||||
$this->auditLogs[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
TestConstants::DATA_MASKED => $masked,
|
||||
'timestamp' => time()
|
||||
];
|
||||
};
|
||||
|
||||
$rateLimitedLogger = new RateLimitedAuditLogger($failingAuditLogger, 2, 60);
|
||||
@@ -291,9 +303,11 @@ class GdprProcessorRateLimitingIntegrationTest extends TestCase
|
||||
$baseLogger = GdprProcessor::createArrayAuditLogger($this->auditLogs, false);
|
||||
$rateLimitedLogger = RateLimitedAuditLogger::create($baseLogger, 'default');
|
||||
|
||||
// Add field path masking to generate more audit logs
|
||||
$fieldPaths = [TestConstants::FIELD_USER_EMAIL => 'user@masked.com'];
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_EMAIL_TEST => MaskConstants::MASK_EMAIL],
|
||||
[TestConstants::FIELD_USER_EMAIL => 'user@masked.com'], // Add field path masking to generate more audit logs
|
||||
$fieldPaths,
|
||||
[],
|
||||
$rateLimitedLogger
|
||||
);
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\PatternValidationException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
use Tests\TestConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
use Ivuorinen\MonologGdprFilter\DefaultPatterns;
|
||||
@@ -133,7 +136,8 @@ class GdprProcessorTest extends TestCase
|
||||
);
|
||||
$processor($record);
|
||||
$this->assertNotEmpty($auditCalls);
|
||||
$this->assertSame([TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL], $auditCalls[0]);
|
||||
$expected = [TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL];
|
||||
$this->assertSame($expected, $auditCalls[0]);
|
||||
}
|
||||
|
||||
public function testMaskMessage(): void
|
||||
@@ -322,4 +326,140 @@ class GdprProcessorTest extends TestCase
|
||||
$result = $processor->regExpMessage('foo');
|
||||
$this->assertSame('foo', $result, 'Should return original message if preg_replace result is string "0"');
|
||||
}
|
||||
|
||||
public function testCreateRateLimitedAuditLoggerReturnsRateLimitedLogger(): void
|
||||
{
|
||||
$auditLog = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLog): void {
|
||||
$auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
/** @psalm-suppress DeprecatedMethod */
|
||||
$rateLimitedLogger = GdprProcessor::createRateLimitedAuditLogger($auditLogger, 'testing');
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $rateLimitedLogger);
|
||||
}
|
||||
|
||||
public function testCreateArrayAuditLoggerReturnsCallable(): void
|
||||
{
|
||||
$logStorage = [];
|
||||
|
||||
/** @psalm-suppress DeprecatedMethod */
|
||||
$logger = GdprProcessor::createArrayAuditLogger($logStorage, false);
|
||||
|
||||
// Logger is a Closure which is callable
|
||||
$this->assertInstanceOf(\Closure::class, $logger);
|
||||
}
|
||||
|
||||
public function testCreateArrayAuditLoggerWithRateLimiting(): void
|
||||
{
|
||||
$logStorage = [];
|
||||
|
||||
/** @psalm-suppress DeprecatedMethod */
|
||||
$logger = GdprProcessor::createArrayAuditLogger($logStorage, true);
|
||||
|
||||
$this->assertInstanceOf(RateLimitedAuditLogger::class, $logger);
|
||||
}
|
||||
|
||||
public function testValidatePatternsArraySucceedsWithValidPatterns(): void
|
||||
{
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_EMAIL_FULL => Mask::MASK_EMAIL,
|
||||
TestConstants::PATTERN_SSN_FORMAT => Mask::MASK_SSN,
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
GdprProcessor::validatePatternsArray($patterns);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testValidatePatternsArrayThrowsForInvalidPattern(): void
|
||||
{
|
||||
$this->expectException(PatternValidationException::class);
|
||||
|
||||
$patterns = [
|
||||
'/[invalid/' => 'MASKED',
|
||||
];
|
||||
|
||||
GdprProcessor::validatePatternsArray($patterns);
|
||||
}
|
||||
|
||||
public function testGetOrchestratorReturnsOrchestrator(): void
|
||||
{
|
||||
$processor = $this->createProcessor([TestConstants::PATTERN_TEST => Mask::MASK_GENERIC]);
|
||||
|
||||
$orchestrator = $processor->getOrchestrator();
|
||||
|
||||
$this->assertInstanceOf(MaskingOrchestrator::class, $orchestrator);
|
||||
}
|
||||
|
||||
public function testSetAuditLoggerUpdatesLogger(): void
|
||||
{
|
||||
$auditLog = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLog): void {
|
||||
$auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_SECRET => Mask::MASK_MASKED],
|
||||
[TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::replace(Mask::MASK_MASKED)]
|
||||
);
|
||||
|
||||
// Initially no audit logger
|
||||
$processor->setAuditLogger($auditLogger);
|
||||
|
||||
$record = $this->createLogRecord(
|
||||
context: ['user' => [TestConstants::CONTEXT_PASSWORD => TestConstants::PASSWORD]]
|
||||
);
|
||||
$processor($record);
|
||||
|
||||
$this->assertNotEmpty($auditLog);
|
||||
}
|
||||
|
||||
public function testSetAuditLoggerToNull(): void
|
||||
{
|
||||
$auditLog = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$auditLog): void {
|
||||
$auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor(
|
||||
[TestConstants::PATTERN_SECRET => Mask::MASK_MASKED],
|
||||
[TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::replace(Mask::MASK_MASKED)],
|
||||
[],
|
||||
$auditLogger
|
||||
);
|
||||
|
||||
// Set to null
|
||||
$processor->setAuditLogger(null);
|
||||
|
||||
$record = $this->createLogRecord(
|
||||
context: ['user' => [TestConstants::CONTEXT_PASSWORD => TestConstants::PASSWORD]]
|
||||
);
|
||||
$processor($record);
|
||||
|
||||
// Audit log should be empty because logger was set to null
|
||||
$this->assertEmpty($auditLog);
|
||||
}
|
||||
|
||||
public function testMaskMessageHandlesEmptyPatterns(): void
|
||||
{
|
||||
$processor = $this->createProcessor([]);
|
||||
|
||||
$result = $processor->maskMessage('test message with nothing to mask');
|
||||
|
||||
$this->assertSame('test message with nothing to mask', $result);
|
||||
}
|
||||
|
||||
public function testMaskMessageAppliesAllPatterns(): void
|
||||
{
|
||||
$processor = $this->createProcessor([
|
||||
'/foo/' => 'bar',
|
||||
'/baz/' => 'qux',
|
||||
]);
|
||||
|
||||
$result = $processor->maskMessage('foo and baz');
|
||||
|
||||
$this->assertSame('bar and qux', $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,25 @@ class ConfigValidationTest extends TestCase
|
||||
*
|
||||
* @return ((bool|int|string)[]|bool|int)[]
|
||||
*
|
||||
* @psalm-return array{auto_register: bool, channels: list{'single', 'daily', 'stack'}, patterns: array<never, never>, field_paths: array<never, never>, custom_callbacks: array<never, never>, max_depth: int<1, 1000>, audit_logging: array{enabled: bool, channel: string}, performance: array{chunk_size: int<100, 10000>, garbage_collection_threshold: int<1000, 100000>}, validation: array{max_pattern_length: int<10, 1000>, max_field_path_length: int<5, 500>, allow_empty_patterns: bool, strict_regex_validation: bool}}
|
||||
* @psalm-return array{
|
||||
* auto_register: bool,
|
||||
* channels: list{'single', 'daily', 'stack'},
|
||||
* patterns: array<never, never>,
|
||||
* field_paths: array<never, never>,
|
||||
* custom_callbacks: array<never, never>,
|
||||
* max_depth: int<1, 1000>,
|
||||
* audit_logging: array{enabled: bool, channel: string},
|
||||
* performance: array{
|
||||
* chunk_size: int<100, 10000>,
|
||||
* garbage_collection_threshold: int<1000, 100000>
|
||||
* },
|
||||
* validation: array{
|
||||
* max_pattern_length: int<10, 1000>,
|
||||
* max_field_path_length: int<5, 500>,
|
||||
* allow_empty_patterns: bool,
|
||||
* strict_regex_validation: bool
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
private function getTestConfig(): array
|
||||
{
|
||||
@@ -42,10 +60,22 @@ class ConfigValidationTest extends TestCase
|
||||
'garbage_collection_threshold' => max(1000, min(100000, (int) ($_ENV['GDPR_GC_THRESHOLD'] ?? 10000))),
|
||||
],
|
||||
'validation' => [
|
||||
'max_pattern_length' => max(10, min(1000, (int) ($_ENV['GDPR_MAX_PATTERN_LENGTH'] ?? 500))),
|
||||
'max_field_path_length' => max(5, min(500, (int) ($_ENV['GDPR_MAX_FIELD_PATH_LENGTH'] ?? 100))),
|
||||
'allow_empty_patterns' => filter_var($_ENV['GDPR_ALLOW_EMPTY_PATTERNS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'strict_regex_validation' => filter_var($_ENV['GDPR_STRICT_REGEX_VALIDATION'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||
'max_pattern_length' => max(
|
||||
10,
|
||||
min(1000, (int) ($_ENV['GDPR_MAX_PATTERN_LENGTH'] ?? 500))
|
||||
),
|
||||
'max_field_path_length' => max(
|
||||
5,
|
||||
min(500, (int) ($_ENV['GDPR_MAX_FIELD_PATH_LENGTH'] ?? 100))
|
||||
),
|
||||
'allow_empty_patterns' => filter_var(
|
||||
$_ENV['GDPR_ALLOW_EMPTY_PATTERNS'] ?? false,
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
),
|
||||
'strict_regex_validation' => filter_var(
|
||||
$_ENV['GDPR_STRICT_REGEX_VALIDATION'] ?? true,
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -426,9 +456,18 @@ class ConfigValidationTest extends TestCase
|
||||
|
||||
// Security-focused defaults
|
||||
$this->assertFalse($config['auto_register'], 'auto_register should default to false');
|
||||
$this->assertFalse($config['audit_logging']['enabled'], 'audit logging should default to false');
|
||||
$this->assertFalse($config['validation']['allow_empty_patterns'], 'empty patterns should not be allowed by default');
|
||||
$this->assertTrue($config['validation']['strict_regex_validation'], 'strict regex validation should be enabled by default');
|
||||
$this->assertFalse(
|
||||
$config['audit_logging']['enabled'],
|
||||
'audit logging should default to false'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$config['validation']['allow_empty_patterns'],
|
||||
'empty patterns should not be allowed by default'
|
||||
);
|
||||
$this->assertTrue(
|
||||
$config['validation']['strict_regex_validation'],
|
||||
'strict regex validation should be enabled by default'
|
||||
);
|
||||
|
||||
// Restore environment variables
|
||||
foreach ($oldValues as $var => $value) {
|
||||
|
||||
@@ -21,6 +21,7 @@ use Tests\TestConstants;
|
||||
* Tests for the GdprProcessor class.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated validation methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
class GdprProcessorValidationTest extends TestCase
|
||||
@@ -272,7 +273,8 @@ class GdprProcessorValidationTest extends TestCase
|
||||
public function constructorThrowsExceptionForInvalidDataTypeMaskKey(): void
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
$this->expectExceptionMessage("Must be one of: integer, double, string, boolean, NULL, array, object, resource");
|
||||
$expectedMsg = 'Must be one of: integer, double, string, boolean, NULL, array, object, resource';
|
||||
$this->expectExceptionMessage($expectedMsg);
|
||||
|
||||
new GdprProcessor([], [], [], null, 100, ['invalid_type' => MaskConstants::MASK_MASKED]);
|
||||
}
|
||||
@@ -408,8 +410,12 @@ class GdprProcessorValidationTest extends TestCase
|
||||
#[Test]
|
||||
public function constructorHandlesComplexValidRegexPatterns(): void
|
||||
{
|
||||
// Complex IP address pattern (IPv4 octet validation)
|
||||
$ipPattern = '/(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}'
|
||||
. '(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/';
|
||||
|
||||
$complexPatterns = [
|
||||
'/(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/' => MaskConstants::MASK_IP,
|
||||
$ipPattern => MaskConstants::MASK_IP,
|
||||
TestConstants::PATTERN_EMAIL_FULL => MaskConstants::MASK_EMAIL,
|
||||
'/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/' => MaskConstants::MASK_CARD
|
||||
];
|
||||
|
||||
@@ -132,10 +132,15 @@ final class InputValidatorTest extends TestCase
|
||||
#[Test]
|
||||
public function validateFieldPathsPassesForValidPaths(): void
|
||||
{
|
||||
$ssnConfig = FieldMaskConfig::regexMask(
|
||||
TestConstants::PATTERN_SSN_FORMAT,
|
||||
MaskConstants::MASK_SSN_PATTERN
|
||||
);
|
||||
|
||||
InputValidator::validateFieldPaths([
|
||||
TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::remove(),
|
||||
'user.ssn' => FieldMaskConfig::regexMask(TestConstants::PATTERN_SSN_FORMAT, MaskConstants::MASK_SSN_PATTERN),
|
||||
'user.ssn' => $ssnConfig,
|
||||
]);
|
||||
|
||||
$this->assertTrue(true);
|
||||
|
||||
@@ -319,9 +319,10 @@ class JsonMaskingTest extends TestCase
|
||||
$extractedJson = $this->extractJsonFromMessage($result);
|
||||
$this->assertNotNull($extractedJson);
|
||||
$this->assertCount(2, $extractedJson['users']);
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $extractedJson['users'][0][TestConstants::CONTEXT_EMAIL]);
|
||||
$this->assertEquals(MaskConstants::MASK_PHONE, $extractedJson['users'][0]['contacts']['phone']);
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $extractedJson['users'][0]['contacts']['emergency'][TestConstants::CONTEXT_EMAIL]);
|
||||
$user0 = $extractedJson['users'][0];
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $user0[TestConstants::CONTEXT_EMAIL]);
|
||||
$this->assertEquals(MaskConstants::MASK_PHONE, $user0['contacts']['phone']);
|
||||
$this->assertEquals(MaskConstants::MASK_EMAIL, $user0['contacts']['emergency'][TestConstants::CONTEXT_EMAIL]);
|
||||
}
|
||||
|
||||
public function testJsonMaskingErrorHandling(): void
|
||||
@@ -346,7 +347,7 @@ class JsonMaskingTest extends TestCase
|
||||
$this->assertStringContainsString('{"valid":true}', $result);
|
||||
|
||||
// No error logs should be generated for valid JSON
|
||||
$errorLogs = array_filter($auditLogs, fn(array $log): bool => str_contains($log['path'], 'error'));
|
||||
$errorLogs = array_filter($auditLogs, fn(array $log): bool => str_contains((string) $log['path'], 'error'));
|
||||
$this->assertEmpty($errorLogs);
|
||||
}
|
||||
|
||||
|
||||
258
tests/MaskingOrchestratorTest.php
Normal file
258
tests/MaskingOrchestratorTest.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
|
||||
use Ivuorinen\MonologGdprFilter\ArrayAccessor\ArrayAccessorFactory;
|
||||
use Ivuorinen\MonologGdprFilter\ContextProcessor;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\MaskingOrchestrator;
|
||||
use Ivuorinen\MonologGdprFilter\RecursiveProcessor;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(MaskingOrchestrator::class)]
|
||||
final class MaskingOrchestratorTest extends TestCase
|
||||
{
|
||||
public function testProcessMasksMessage(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('This is a test message', []);
|
||||
|
||||
$this->assertSame('This is a ' . MaskConstants::MASK_GENERIC . ' message', $result['message']);
|
||||
$this->assertSame([], $result['context']);
|
||||
}
|
||||
|
||||
public function testProcessMasksContext(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('message', ['key' => TestConstants::VALUE_TEST]);
|
||||
|
||||
$this->assertSame('message', $result['message']);
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . TestConstants::VALUE_SUFFIX, $result['context']['key']);
|
||||
}
|
||||
|
||||
public function testProcessMasksFieldPaths(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[TestConstants::CONTEXT_EMAIL => FieldMaskConfig::replace(TestConstants::MASK_EMAIL_BRACKETS)]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('message', [TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST]);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_EMAIL_BRACKETS, $result['context'][TestConstants::CONTEXT_EMAIL]);
|
||||
}
|
||||
|
||||
public function testProcessExecutesCustomCallbacks(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[],
|
||||
['name' => fn(mixed $val): string => strtoupper((string) $val)]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process('message', ['name' => 'john']);
|
||||
|
||||
$this->assertSame('JOHN', $result['context']['name']);
|
||||
}
|
||||
|
||||
public function testProcessContextDirectly(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->processContext(['key' => TestConstants::VALUE_TEST]);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . TestConstants::VALUE_SUFFIX, $result['key']);
|
||||
}
|
||||
|
||||
public function testRegExpMessageMasksPatterns(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_SSN_FORMAT => '[SSN]']
|
||||
);
|
||||
|
||||
$result = $orchestrator->regExpMessage('SSN: 123-45-6789');
|
||||
|
||||
$this->assertSame('SSN: [SSN]', $result);
|
||||
}
|
||||
|
||||
public function testRegExpMessagePreservesEmptyString(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]);
|
||||
|
||||
$result = $orchestrator->regExpMessage('');
|
||||
|
||||
$this->assertSame('', $result);
|
||||
}
|
||||
|
||||
public function testRecursiveMaskMasksNestedArrays(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->recursiveMask(['level1' => ['level2' => TestConstants::VALUE_TEST]]);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . TestConstants::VALUE_SUFFIX, $result['level1']['level2']);
|
||||
}
|
||||
|
||||
public function testRecursiveMaskMasksString(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC]
|
||||
);
|
||||
|
||||
$result = $orchestrator->recursiveMask('test string');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' string', $result);
|
||||
}
|
||||
|
||||
public function testCreateValidatesParameters(): void
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
|
||||
MaskingOrchestrator::create(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
-1 // Invalid depth
|
||||
);
|
||||
}
|
||||
|
||||
public function testCreateWithValidParameters(): void
|
||||
{
|
||||
$orchestrator = MaskingOrchestrator::create(
|
||||
[TestConstants::PATTERN_DIGITS => '[DIGITS]'],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
50
|
||||
);
|
||||
|
||||
$result = $orchestrator->regExpMessage('Number: 12345');
|
||||
|
||||
$this->assertSame('Number: [DIGITS]', $result);
|
||||
}
|
||||
|
||||
public function testGetContextProcessor(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([]);
|
||||
|
||||
$this->assertInstanceOf(ContextProcessor::class, $orchestrator->getContextProcessor());
|
||||
}
|
||||
|
||||
public function testGetRecursiveProcessor(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([]);
|
||||
|
||||
$this->assertInstanceOf(RecursiveProcessor::class, $orchestrator->getRecursiveProcessor());
|
||||
}
|
||||
|
||||
public function testGetArrayAccessorFactory(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator([]);
|
||||
|
||||
$this->assertInstanceOf(ArrayAccessorFactory::class, $orchestrator->getArrayAccessorFactory());
|
||||
}
|
||||
|
||||
public function testSetAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
['field' => FieldMaskConfig::replace('[MASKED]')]
|
||||
);
|
||||
|
||||
$orchestrator->setAuditLogger($auditLogger);
|
||||
$orchestrator->processContext(['field' => 'value']);
|
||||
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertSame('field', $logs[0]['path']);
|
||||
}
|
||||
|
||||
public function testWithCustomArrayAccessorFactory(): void
|
||||
{
|
||||
$customFactory = ArrayAccessorFactory::default();
|
||||
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
[],
|
||||
$customFactory
|
||||
);
|
||||
|
||||
$this->assertSame($customFactory, $orchestrator->getArrayAccessorFactory());
|
||||
}
|
||||
|
||||
public function testProcessWithFieldPathsAndCustomCallbacksCombined(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC],
|
||||
[TestConstants::CONTEXT_EMAIL => FieldMaskConfig::replace(TestConstants::MASK_EMAIL_BRACKETS)],
|
||||
['name' => fn(mixed $val): string => strtoupper((string) $val)]
|
||||
);
|
||||
|
||||
$result = $orchestrator->process(
|
||||
'Hello test',
|
||||
[
|
||||
TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST,
|
||||
'name' => 'john',
|
||||
'message' => 'test'
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame('Hello ' . MaskConstants::MASK_GENERIC, $result['message']);
|
||||
$this->assertSame(TestConstants::MASK_EMAIL_BRACKETS, $result['context'][TestConstants::CONTEXT_EMAIL]);
|
||||
$this->assertSame('JOHN', $result['context']['name']);
|
||||
}
|
||||
|
||||
public function testProcessWithDataTypeMasks(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
100,
|
||||
['integer' => '[INT]']
|
||||
);
|
||||
|
||||
$result = $orchestrator->processContext(['count' => 42]);
|
||||
|
||||
$this->assertSame('[INT]', $result['count']);
|
||||
}
|
||||
|
||||
public function testProcessContextWithRemoveConfig(): void
|
||||
{
|
||||
$orchestrator = new MaskingOrchestrator(
|
||||
[],
|
||||
['secret' => FieldMaskConfig::remove()]
|
||||
);
|
||||
|
||||
$result = $orchestrator->processContext(['secret' => 'value', 'public' => 'data']);
|
||||
|
||||
$this->assertArrayNotHasKey('secret', $result);
|
||||
$this->assertSame('data', $result['public']);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase;
|
||||
* Test PatternValidator functionality.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated static API
|
||||
*/
|
||||
#[CoversClass(PatternValidator::class)]
|
||||
class PatternValidatorTest extends TestCase
|
||||
@@ -41,14 +42,14 @@ class PatternValidatorTest extends TestCase
|
||||
{
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_DIGITS));
|
||||
$this->assertTrue(PatternValidator::isValid('/[a-z]+/i'));
|
||||
$this->assertTrue(PatternValidator::isValid('/^test$/'));
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_VALID_SIMPLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isValidReturnsFalseForInvalidPattern(): void
|
||||
{
|
||||
$this->assertFalse(PatternValidator::isValid('invalid'));
|
||||
$this->assertFalse(PatternValidator::isValid('/unclosed'));
|
||||
$this->assertFalse(PatternValidator::isValid(TestConstants::PATTERN_INVALID_UNCLOSED));
|
||||
$this->assertFalse(PatternValidator::isValid('//'));
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ class PatternValidatorTest extends TestCase
|
||||
public function isValidDetectsNestedQuantifiers(): void
|
||||
{
|
||||
// hasDangerousPattern is private, test via isValid
|
||||
$this->assertFalse(PatternValidator::isValid('/^(a+)+$/'));
|
||||
$this->assertFalse(PatternValidator::isValid(TestConstants::PATTERN_REDOS_VULNERABLE));
|
||||
$this->assertFalse(PatternValidator::isValid('/(a*)*/'));
|
||||
$this->assertFalse(PatternValidator::isValid('/([a-zA-Z]+)*/'));
|
||||
}
|
||||
@@ -82,8 +83,8 @@ class PatternValidatorTest extends TestCase
|
||||
{
|
||||
// hasDangerousPattern is private, test via isValid
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_SSN_FORMAT));
|
||||
$this->assertTrue(PatternValidator::isValid('/[a-z]+/'));
|
||||
$this->assertTrue(PatternValidator::isValid('/^test$/'));
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_SAFE));
|
||||
$this->assertTrue(PatternValidator::isValid(TestConstants::PATTERN_VALID_SIMPLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -91,16 +92,16 @@ class PatternValidatorTest extends TestCase
|
||||
{
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_DIGITS => 'mask1',
|
||||
'/[a-z]+/' => 'mask2',
|
||||
TestConstants::PATTERN_SAFE => 'mask2',
|
||||
];
|
||||
|
||||
PatternValidator::cachePatterns($patterns);
|
||||
$cache = PatternValidator::getCache();
|
||||
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_DIGITS, $cache);
|
||||
$this->assertArrayHasKey('/[a-z]+/', $cache);
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_SAFE, $cache);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_DIGITS]);
|
||||
$this->assertTrue($cache['/[a-z]+/']);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_SAFE]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -195,7 +196,16 @@ class PatternValidatorTest extends TestCase
|
||||
/**
|
||||
* @return string[][]
|
||||
*
|
||||
* @psalm-return array{'simple digits': array{pattern: TestConstants::PATTERN_DIGITS}, 'email pattern': array{pattern: '/[a-z]+@[a-z]+\.[a-z]+/'}, 'phone pattern': array{pattern: '/\+?\d{1,3}[\s-]?\d{3}[\s-]?\d{4}/'}, 'ssn pattern': array{pattern: TestConstants::PATTERN_SSN_FORMAT}, 'word boundary': array{pattern: '/\b\w+\b/'}, 'case insensitive': array{pattern: '/test/i'}, multiline: array{pattern: '/^test$/m'}, unicode: array{pattern: '/\p{L}+/u'}}
|
||||
* @psalm-return array{
|
||||
* 'simple digits': array{pattern: TestConstants::PATTERN_DIGITS},
|
||||
* 'email pattern': array{pattern: '/[a-z]+@[a-z]+\.[a-z]+/'},
|
||||
* 'phone pattern': array{pattern: '/\+?\d{1,3}[\s-]?\d{3}[\s-]?\d{4}/'},
|
||||
* 'ssn pattern': array{pattern: TestConstants::PATTERN_SSN_FORMAT},
|
||||
* 'word boundary': array{pattern: '/\b\w+\b/'},
|
||||
* 'case insensitive': array{pattern: '/test/i'},
|
||||
* multiline: array{pattern: '/^test$/m'},
|
||||
* unicode: array{pattern: '/\p{L}+/u'}
|
||||
* }
|
||||
*/
|
||||
public static function validPatternProvider(): array
|
||||
{
|
||||
@@ -221,19 +231,208 @@ class PatternValidatorTest extends TestCase
|
||||
/**
|
||||
* @return string[][]
|
||||
*
|
||||
* @psalm-return array{'no delimiters': array{pattern: 'test'}, unclosed: array{pattern: '/unclosed'}, empty: array{pattern: '//'}, 'invalid bracket': array{pattern: '/[invalid/'}, recursive: array{pattern: TestConstants::PATTERN_RECURSIVE}, 'named recursion': array{pattern: TestConstants::PATTERN_NAMED_RECURSION}, 'nested quantifiers': array{pattern: '/^(a+)+$/'}, 'invalid unicode': array{pattern: '/\x{10000000}/'}}
|
||||
* @psalm-return array{
|
||||
* 'no delimiters': array{pattern: 'test'},
|
||||
* unclosed: array{pattern: TestConstants::PATTERN_INVALID_UNCLOSED},
|
||||
* empty: array{pattern: '//'},
|
||||
* 'invalid bracket': array{pattern: '/[invalid/'},
|
||||
* recursive: array{pattern: TestConstants::PATTERN_RECURSIVE},
|
||||
* 'named recursion': array{pattern: TestConstants::PATTERN_NAMED_RECURSION},
|
||||
* 'nested quantifiers': array{pattern: TestConstants::PATTERN_REDOS_VULNERABLE},
|
||||
* 'invalid unicode': array{pattern: '/\x{10000000}/'}
|
||||
* }
|
||||
*/
|
||||
public static function invalidPatternProvider(): array
|
||||
{
|
||||
return [
|
||||
'no delimiters' => ['pattern' => 'test'],
|
||||
'unclosed' => ['pattern' => '/unclosed'],
|
||||
'unclosed' => ['pattern' => TestConstants::PATTERN_INVALID_UNCLOSED],
|
||||
'empty' => ['pattern' => '//'],
|
||||
'invalid bracket' => ['pattern' => '/[invalid/'],
|
||||
'recursive' => ['pattern' => TestConstants::PATTERN_RECURSIVE],
|
||||
'named recursion' => ['pattern' => TestConstants::PATTERN_NAMED_RECURSION],
|
||||
'nested quantifiers' => ['pattern' => '/^(a+)+$/'],
|
||||
'nested quantifiers' => ['pattern' => TestConstants::PATTERN_REDOS_VULNERABLE],
|
||||
'invalid unicode' => ['pattern' => '/\x{10000000}/'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INSTANCE METHOD TESTS
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function createReturnsNewInstance(): void
|
||||
{
|
||||
$validator = PatternValidator::create();
|
||||
|
||||
$this->assertInstanceOf(PatternValidator::class, $validator);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateReturnsTrueForValidPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertTrue($validator->validate(TestConstants::PATTERN_DIGITS));
|
||||
$this->assertTrue($validator->validate('/[a-z]+/i'));
|
||||
$this->assertTrue($validator->validate(TestConstants::PATTERN_VALID_SIMPLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateReturnsFalseForInvalidPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertFalse($validator->validate('invalid'));
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_INVALID_UNCLOSED));
|
||||
$this->assertFalse($validator->validate('//'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateReturnsFalseForDangerousPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_RECURSIVE));
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_NAMED_RECURSION));
|
||||
$this->assertFalse($validator->validate(TestConstants::PATTERN_REDOS_VULNERABLE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUsesCacheOnSecondCall(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$pattern = TestConstants::PATTERN_DIGITS;
|
||||
|
||||
// First call should cache
|
||||
$result1 = $validator->validate($pattern);
|
||||
|
||||
// Second call should use cache
|
||||
$result2 = $validator->validate($pattern);
|
||||
|
||||
$this->assertTrue($result1);
|
||||
$this->assertTrue($result2);
|
||||
$this->assertArrayHasKey($pattern, $validator->getInstanceCache());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clearInstanceCacheRemovesAllCachedPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$validator->validate(TestConstants::PATTERN_DIGITS);
|
||||
$this->assertNotEmpty($validator->getInstanceCache());
|
||||
|
||||
$validator->clearInstanceCache();
|
||||
$this->assertEmpty($validator->getInstanceCache());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheAllPatternsCachesValidPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_DIGITS => 'mask1',
|
||||
TestConstants::PATTERN_SAFE => 'mask2',
|
||||
];
|
||||
|
||||
$validator->cacheAllPatterns($patterns);
|
||||
$cache = $validator->getInstanceCache();
|
||||
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_DIGITS, $cache);
|
||||
$this->assertArrayHasKey(TestConstants::PATTERN_SAFE, $cache);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_DIGITS]);
|
||||
$this->assertTrue($cache[TestConstants::PATTERN_SAFE]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheAllPatternsCachesBothValidAndInvalid(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$patterns = [
|
||||
'/valid/' => 'mask1',
|
||||
'invalid' => 'mask2',
|
||||
];
|
||||
|
||||
$validator->cacheAllPatterns($patterns);
|
||||
$cache = $validator->getInstanceCache();
|
||||
|
||||
$this->assertTrue($cache['/valid/']);
|
||||
$this->assertFalse($cache['invalid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateAllPatternsThrowsForInvalidPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->expectException(InvalidRegexPatternException::class);
|
||||
$this->expectExceptionMessage('Pattern failed validation or is potentially unsafe');
|
||||
|
||||
$validator->validateAllPatterns(['invalid_pattern' => 'mask']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateAllPatternsPassesForValidPatterns(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_SSN_FORMAT => 'SSN',
|
||||
'/[a-z]+@[a-z]+\.[a-z]+/' => 'Email',
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
$validator->validateAllPatterns($patterns);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateAllPatternsThrowsForDangerousPattern(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->expectException(InvalidRegexPatternException::class);
|
||||
|
||||
$validator->validateAllPatterns([TestConstants::PATTERN_RECURSIVE => 'mask']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getInstanceCacheReturnsEmptyArrayInitially(): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
$cache = $validator->getInstanceCache();
|
||||
|
||||
$this->assertIsArray($cache);
|
||||
$this->assertEmpty($cache);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function instanceCachesAreIndependent(): void
|
||||
{
|
||||
$validator1 = new PatternValidator();
|
||||
$validator2 = new PatternValidator();
|
||||
|
||||
$validator1->validate(TestConstants::PATTERN_DIGITS);
|
||||
|
||||
$this->assertNotEmpty($validator1->getInstanceCache());
|
||||
$this->assertEmpty($validator2->getInstanceCache());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validPatternProvider')]
|
||||
public function validateAcceptsVariousValidPatterns(string $pattern): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertTrue($validator->validate($pattern));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidPatternProvider')]
|
||||
public function validateRejectsVariousInvalidPatterns(string $pattern): void
|
||||
{
|
||||
$validator = new PatternValidator();
|
||||
|
||||
$this->assertFalse($validator->validate($pattern));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use Ivuorinen\MonologGdprFilter\PatternValidator;
|
||||
* These tests measure and validate the performance improvements.
|
||||
*
|
||||
* @api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated PatternValidator API
|
||||
*/
|
||||
class PerformanceBenchmarkTest extends TestCase
|
||||
{
|
||||
@@ -170,7 +171,8 @@ class PerformanceBenchmarkTest extends TestCase
|
||||
// Verify processing worked
|
||||
$this->assertInstanceOf(LogRecord::class, $result);
|
||||
$this->assertCount($size, $result->context);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL]);
|
||||
$emailValue = (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL];
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $emailValue);
|
||||
|
||||
// Performance should scale reasonably
|
||||
$timePerItem = $duration / (float) $size;
|
||||
@@ -267,7 +269,8 @@ class PerformanceBenchmarkTest extends TestCase
|
||||
// Verify processing worked
|
||||
$this->assertInstanceOf(LogRecord::class, $result);
|
||||
$this->assertCount(2000, $result->context);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL]);
|
||||
$emailValue = (string) $result->context['item_0'][TestConstants::CONTEXT_EMAIL];
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $emailValue);
|
||||
|
||||
// Memory usage should be reasonable even for large datasets
|
||||
$this->assertLessThan(50, $memoryUsed, 'Memory usage should be under 50MB for dataset');
|
||||
|
||||
197
tests/Plugins/AbstractMaskingPluginTest.php
Normal file
197
tests/Plugins/AbstractMaskingPluginTest.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Plugins;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Contracts\MaskingPluginInterface;
|
||||
use Ivuorinen\MonologGdprFilter\Plugins\AbstractMaskingPlugin;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(AbstractMaskingPlugin::class)]
|
||||
final class AbstractMaskingPluginTest extends TestCase
|
||||
{
|
||||
public function testImplementsInterface(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertInstanceOf(MaskingPluginInterface::class, $plugin);
|
||||
}
|
||||
|
||||
public function testDefaultPriority(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame(100, $plugin->getPriority());
|
||||
}
|
||||
|
||||
public function testCustomPriority(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(50);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame(50, $plugin->getPriority());
|
||||
}
|
||||
|
||||
public function testPreProcessContextReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$context = ['key' => 'value'];
|
||||
$result = $plugin->preProcessContext($context);
|
||||
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
public function testPostProcessContextReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$context = ['key' => 'value'];
|
||||
$result = $plugin->postProcessContext($context);
|
||||
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
public function testPreProcessMessageReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$message = 'test message';
|
||||
$result = $plugin->preProcessMessage($message);
|
||||
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testPostProcessMessageReturnsUnchanged(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$message = 'test message';
|
||||
$result = $plugin->postProcessMessage($message);
|
||||
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testGetPatternsReturnsEmptyArray(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame([], $plugin->getPatterns());
|
||||
}
|
||||
|
||||
public function testGetFieldPathsReturnsEmptyArray(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame([], $plugin->getFieldPaths());
|
||||
}
|
||||
|
||||
public function testCanOverridePreProcessContext(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function preProcessContext(array $context): array
|
||||
{
|
||||
$context['added'] = true;
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $plugin->preProcessContext(['original' => 'value']);
|
||||
|
||||
$this->assertTrue($result['added']);
|
||||
$this->assertSame('value', $result['original']);
|
||||
}
|
||||
|
||||
public function testCanOverridePreProcessMessage(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function preProcessMessage(string $message): string
|
||||
{
|
||||
return strtoupper($message);
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame('HELLO', $plugin->preProcessMessage('hello'));
|
||||
}
|
||||
|
||||
public function testCanOverrideGetPatterns(): void
|
||||
{
|
||||
$plugin = new class extends AbstractMaskingPlugin {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'test-plugin';
|
||||
}
|
||||
|
||||
public function getPatterns(): array
|
||||
{
|
||||
return ['/secret/' => '[REDACTED]'];
|
||||
}
|
||||
};
|
||||
|
||||
$patterns = $plugin->getPatterns();
|
||||
|
||||
$this->assertArrayHasKey('/secret/', $patterns);
|
||||
$this->assertSame('[REDACTED]', $patterns['/secret/']);
|
||||
}
|
||||
}
|
||||
@@ -236,21 +236,26 @@ class RateLimitedAuditLoggerTest extends TestCase
|
||||
|
||||
// Test that different paths are classified correctly
|
||||
$rateLimitedLogger('json_masked', 'original', TestConstants::DATA_MASKED);
|
||||
$rateLimitedLogger('json_encode_error', 'original', TestConstants::DATA_MASKED); // Should be blocked (same type)
|
||||
// Should be blocked (same type)
|
||||
$rateLimitedLogger('json_encode_error', 'original', TestConstants::DATA_MASKED);
|
||||
|
||||
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
||||
// 1 successful + 1 rate limit warning
|
||||
$this->assertCount(2, $this->logStorage);
|
||||
|
||||
$this->logStorage = []; // Reset
|
||||
|
||||
$rateLimitedLogger('conditional_skip', 'original', TestConstants::DATA_MASKED);
|
||||
$rateLimitedLogger('conditional_error', 'original', TestConstants::DATA_MASKED); // Should be blocked (same type)
|
||||
// Should be blocked (same type)
|
||||
$rateLimitedLogger('conditional_error', 'original', TestConstants::DATA_MASKED);
|
||||
|
||||
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
||||
// 1 successful + 1 rate limit warning
|
||||
$this->assertCount(2, $this->logStorage);
|
||||
|
||||
$this->logStorage = []; // Reset
|
||||
|
||||
$rateLimitedLogger('regex_error', 'original', TestConstants::DATA_MASKED);
|
||||
$rateLimitedLogger('preg_replace_error', 'original', TestConstants::DATA_MASKED); // Should be blocked (same type)
|
||||
// Should be blocked (same type)
|
||||
$rateLimitedLogger('preg_replace_error', 'original', TestConstants::DATA_MASKED);
|
||||
|
||||
$this->assertCount(2, $this->logStorage); // 1 successful + 1 rate limit warning
|
||||
}
|
||||
|
||||
56
tests/Recovery/FailureModeTest.php
Normal file
56
tests/Recovery/FailureModeTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for FailureMode enum.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FailureModeTest extends TestCase
|
||||
{
|
||||
public function testEnumValues(): void
|
||||
{
|
||||
$this->assertSame('fail_open', FailureMode::FAIL_OPEN->value);
|
||||
$this->assertSame('fail_closed', FailureMode::FAIL_CLOSED->value);
|
||||
$this->assertSame('fail_safe', FailureMode::FAIL_SAFE->value);
|
||||
}
|
||||
|
||||
public function testGetDescription(): void
|
||||
{
|
||||
$openDesc = FailureMode::FAIL_OPEN->getDescription();
|
||||
$closedDesc = FailureMode::FAIL_CLOSED->getDescription();
|
||||
$safeDesc = FailureMode::FAIL_SAFE->getDescription();
|
||||
|
||||
$this->assertStringContainsString('original', $openDesc);
|
||||
$this->assertStringContainsString('risky', $openDesc);
|
||||
|
||||
$this->assertStringContainsString('redacted', $closedDesc);
|
||||
$this->assertStringContainsString('strict', $closedDesc);
|
||||
|
||||
$this->assertStringContainsString('fallback', $safeDesc);
|
||||
$this->assertStringContainsString('balanced', $safeDesc);
|
||||
}
|
||||
|
||||
public function testRecommended(): void
|
||||
{
|
||||
$recommended = FailureMode::recommended();
|
||||
|
||||
$this->assertSame(FailureMode::FAIL_SAFE, $recommended);
|
||||
}
|
||||
|
||||
public function testAllCasesExist(): void
|
||||
{
|
||||
$cases = FailureMode::cases();
|
||||
|
||||
$this->assertCount(3, $cases);
|
||||
$this->assertContains(FailureMode::FAIL_OPEN, $cases);
|
||||
$this->assertContains(FailureMode::FAIL_CLOSED, $cases);
|
||||
$this->assertContains(FailureMode::FAIL_SAFE, $cases);
|
||||
}
|
||||
}
|
||||
215
tests/Recovery/FallbackMaskStrategyTest.php
Normal file
215
tests/Recovery/FallbackMaskStrategyTest.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FallbackMaskStrategy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for FallbackMaskStrategy.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FallbackMaskStrategyTest extends TestCase
|
||||
{
|
||||
public function testDefaultFactory(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$this->assertInstanceOf(FallbackMaskStrategy::class, $strategy);
|
||||
}
|
||||
|
||||
public function testStrictFactory(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::strict();
|
||||
|
||||
$this->assertSame(
|
||||
MaskConstants::MASK_REDACTED,
|
||||
$strategy->getFallback('test', FailureMode::FAIL_SAFE)
|
||||
);
|
||||
}
|
||||
|
||||
public function testStrictFactoryWithCustomMask(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::strict('[REMOVED]');
|
||||
|
||||
$this->assertSame(
|
||||
'[REMOVED]',
|
||||
$strategy->getFallback('test', FailureMode::FAIL_SAFE)
|
||||
);
|
||||
}
|
||||
|
||||
public function testWithMappingsFactory(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::withMappings([
|
||||
'string' => '[CUSTOM_STRING]',
|
||||
'integer' => '[CUSTOM_INT]',
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
'[CUSTOM_STRING]',
|
||||
$strategy->getFallback('test', FailureMode::FAIL_SAFE)
|
||||
);
|
||||
$this->assertSame(
|
||||
'[CUSTOM_INT]',
|
||||
$strategy->getFallback(42, FailureMode::FAIL_SAFE)
|
||||
);
|
||||
}
|
||||
|
||||
public function testFailOpenReturnsOriginal(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$this->assertSame('original', $strategy->getFallback('original', FailureMode::FAIL_OPEN));
|
||||
$this->assertSame(42, $strategy->getFallback(42, FailureMode::FAIL_OPEN));
|
||||
$this->assertSame(['key' => 'value'], $strategy->getFallback(['key' => 'value'], FailureMode::FAIL_OPEN));
|
||||
}
|
||||
|
||||
public function testFailClosedReturnsRedacted(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_REDACTED, $strategy->getFallback('test', FailureMode::FAIL_CLOSED));
|
||||
$this->assertSame(MaskConstants::MASK_REDACTED, $strategy->getFallback(42, FailureMode::FAIL_CLOSED));
|
||||
}
|
||||
|
||||
public function testFailSafeForString(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$shortResult = $strategy->getFallback('short', FailureMode::FAIL_SAFE);
|
||||
$this->assertSame(MaskConstants::MASK_STRING, $shortResult);
|
||||
|
||||
$longString = str_repeat('a', 50);
|
||||
$longResult = $strategy->getFallback($longString, FailureMode::FAIL_SAFE);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_STRING, $longResult);
|
||||
$this->assertStringContainsString('50 chars', $longResult);
|
||||
}
|
||||
|
||||
public function testFailSafeForInteger(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(42, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_INT, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForFloat(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(3.14, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_FLOAT, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForBoolean(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(true, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_BOOL, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForNull(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(null, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_NULL, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForEmptyArray(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback([], FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_ARRAY, $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForNonEmptyArray(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
|
||||
$result = $strategy->getFallback(['a', 'b', 'c'], FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_ARRAY, $result);
|
||||
$this->assertStringContainsString('3 items', $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForObject(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
$obj = new stdClass();
|
||||
|
||||
$result = $strategy->getFallback($obj, FailureMode::FAIL_SAFE);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_OBJECT, $result);
|
||||
$this->assertStringContainsString('stdClass', $result);
|
||||
}
|
||||
|
||||
public function testFailSafeForResource(): void
|
||||
{
|
||||
$strategy = FallbackMaskStrategy::default();
|
||||
$resource = fopen('php://memory', 'r');
|
||||
$this->assertNotFalse($resource, 'Failed to open memory stream');
|
||||
|
||||
$result = $strategy->getFallback($resource, FailureMode::FAIL_SAFE);
|
||||
|
||||
fclose($resource);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_RESOURCE, $result);
|
||||
}
|
||||
|
||||
public function testGetConfiguration(): void
|
||||
{
|
||||
$strategy = new FallbackMaskStrategy(
|
||||
customFallbacks: ['string' => '[CUSTOM]'],
|
||||
defaultFallback: '[DEFAULT]',
|
||||
preserveType: false
|
||||
);
|
||||
|
||||
$config = $strategy->getConfiguration();
|
||||
|
||||
$this->assertArrayHasKey('custom_fallbacks', $config);
|
||||
$this->assertArrayHasKey('default_fallback', $config);
|
||||
$this->assertArrayHasKey('preserve_type', $config);
|
||||
|
||||
$this->assertSame(['string' => '[CUSTOM]'], $config['custom_fallbacks']);
|
||||
$this->assertSame('[DEFAULT]', $config['default_fallback']);
|
||||
$this->assertFalse($config['preserve_type']);
|
||||
}
|
||||
|
||||
public function testPreserveTypeFalseUsesDefault(): void
|
||||
{
|
||||
$strategy = new FallbackMaskStrategy(
|
||||
defaultFallback: TestConstants::MASK_ALWAYS_THIS,
|
||||
preserveType: false
|
||||
);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_ALWAYS_THIS, $strategy->getFallback('string', FailureMode::FAIL_SAFE));
|
||||
$this->assertSame(TestConstants::MASK_ALWAYS_THIS, $strategy->getFallback(42, FailureMode::FAIL_SAFE));
|
||||
$this->assertSame(TestConstants::MASK_ALWAYS_THIS, $strategy->getFallback(['array'], FailureMode::FAIL_SAFE));
|
||||
}
|
||||
|
||||
public function testCustomClosedFallback(): void
|
||||
{
|
||||
$strategy = new FallbackMaskStrategy(
|
||||
customFallbacks: ['closed' => '[CUSTOM_CLOSED]']
|
||||
);
|
||||
|
||||
$result = $strategy->getFallback('test', FailureMode::FAIL_CLOSED);
|
||||
|
||||
$this->assertSame('[CUSTOM_CLOSED]', $result);
|
||||
}
|
||||
}
|
||||
172
tests/Recovery/RecoveryResultTest.php
Normal file
172
tests/Recovery/RecoveryResultTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\RecoveryResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for RecoveryResult value object.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RecoveryResultTest extends TestCase
|
||||
{
|
||||
public function testSuccessCreation(): void
|
||||
{
|
||||
$result = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS, 5.5);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_SUCCESS, $result->outcome);
|
||||
$this->assertSame(1, $result->attempts);
|
||||
$this->assertSame(5.5, $result->totalDurationMs);
|
||||
$this->assertNull($result->lastError);
|
||||
}
|
||||
|
||||
public function testRecoveredCreation(): void
|
||||
{
|
||||
$result = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 3, 25.0);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_RECOVERED, $result->outcome);
|
||||
$this->assertSame(3, $result->attempts);
|
||||
$this->assertSame(25.0, $result->totalDurationMs);
|
||||
}
|
||||
|
||||
public function testFallbackCreation(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Failed to mask');
|
||||
$result = RecoveryResult::fallback('[REDACTED]', 3, $error, 50.0);
|
||||
|
||||
$this->assertSame('[REDACTED]', $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_FALLBACK, $result->outcome);
|
||||
$this->assertSame(3, $result->attempts);
|
||||
$this->assertSame($error, $result->lastError);
|
||||
$this->assertSame(50.0, $result->totalDurationMs);
|
||||
}
|
||||
|
||||
public function testFailedCreation(): void
|
||||
{
|
||||
$error = ErrorContext::create('FatalError', 'Cannot recover');
|
||||
$result = RecoveryResult::failed('original', 5, $error, 100.0);
|
||||
|
||||
$this->assertSame('original', $result->value);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_FAILED, $result->outcome);
|
||||
$this->assertSame(5, $result->attempts);
|
||||
$this->assertSame($error, $result->lastError);
|
||||
}
|
||||
|
||||
public function testIsSuccess(): void
|
||||
{
|
||||
$success = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$recovered = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 2);
|
||||
$error = ErrorContext::create('E', 'M');
|
||||
$fallback = RecoveryResult::fallback('[X]', 3, $error);
|
||||
$failed = RecoveryResult::failed('orig', 3, $error);
|
||||
|
||||
$this->assertTrue($success->isSuccess());
|
||||
$this->assertTrue($recovered->isSuccess());
|
||||
$this->assertFalse($fallback->isSuccess());
|
||||
$this->assertFalse($failed->isSuccess());
|
||||
}
|
||||
|
||||
public function testUsedFallback(): void
|
||||
{
|
||||
$success = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$error = ErrorContext::create('E', 'M');
|
||||
$fallback = RecoveryResult::fallback('[X]', 3, $error);
|
||||
$failed = RecoveryResult::failed('orig', 3, $error);
|
||||
|
||||
$this->assertFalse($success->usedFallback());
|
||||
$this->assertTrue($fallback->usedFallback());
|
||||
$this->assertFalse($failed->usedFallback());
|
||||
}
|
||||
|
||||
public function testIsFailed(): void
|
||||
{
|
||||
$success = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$error = ErrorContext::create('E', 'M');
|
||||
$fallback = RecoveryResult::fallback('[X]', 3, $error);
|
||||
$failed = RecoveryResult::failed('orig', 3, $error);
|
||||
|
||||
$this->assertFalse($success->isFailed());
|
||||
$this->assertFalse($fallback->isFailed());
|
||||
$this->assertTrue($failed->isFailed());
|
||||
}
|
||||
|
||||
public function testNeededRetry(): void
|
||||
{
|
||||
$firstTry = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS);
|
||||
$secondTry = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 2);
|
||||
|
||||
$this->assertFalse($firstTry->neededRetry());
|
||||
$this->assertTrue($secondTry->neededRetry());
|
||||
}
|
||||
|
||||
public function testToAuditContextSuccess(): void
|
||||
{
|
||||
$result = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS, 10.0);
|
||||
$context = $result->toAuditContext(AuditContext::OP_REGEX);
|
||||
|
||||
$this->assertSame(AuditContext::OP_REGEX, $context->operationType);
|
||||
$this->assertSame(AuditContext::STATUS_SUCCESS, $context->status);
|
||||
$this->assertSame(10.0, $context->durationMs);
|
||||
}
|
||||
|
||||
public function testToAuditContextRecovered(): void
|
||||
{
|
||||
$result = RecoveryResult::recovered(TestConstants::MASK_MASKED_BRACKETS, 3, 30.0);
|
||||
$context = $result->toAuditContext(AuditContext::OP_FIELD_PATH);
|
||||
|
||||
$this->assertSame(AuditContext::STATUS_RECOVERED, $context->status);
|
||||
$this->assertSame(3, $context->attemptNumber);
|
||||
}
|
||||
|
||||
public function testToAuditContextFailed(): void
|
||||
{
|
||||
$error = ErrorContext::create('Error', 'Message');
|
||||
$result = RecoveryResult::failed('orig', 3, $error, 50.0);
|
||||
$context = $result->toAuditContext(AuditContext::OP_CALLBACK);
|
||||
|
||||
$this->assertSame(AuditContext::STATUS_FAILED, $context->status);
|
||||
$this->assertSame($error, $context->error);
|
||||
}
|
||||
|
||||
public function testToArray(): void
|
||||
{
|
||||
$result = RecoveryResult::success(TestConstants::MASK_MASKED_BRACKETS, 15.123456);
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertArrayHasKey('outcome', $array);
|
||||
$this->assertArrayHasKey('attempts', $array);
|
||||
$this->assertArrayHasKey('duration_ms', $array);
|
||||
$this->assertArrayNotHasKey('error', $array);
|
||||
|
||||
$this->assertSame('success', $array['outcome']);
|
||||
$this->assertSame(1, $array['attempts']);
|
||||
$this->assertSame(15.123, $array['duration_ms']);
|
||||
}
|
||||
|
||||
public function testToArrayWithError(): void
|
||||
{
|
||||
$error = ErrorContext::create('TestError', 'Message');
|
||||
$result = RecoveryResult::failed('orig', 3, $error);
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertArrayHasKey('error', $array);
|
||||
$this->assertIsArray($array['error']);
|
||||
}
|
||||
|
||||
public function testOutcomeConstants(): void
|
||||
{
|
||||
$this->assertSame('success', RecoveryResult::OUTCOME_SUCCESS);
|
||||
$this->assertSame('recovered', RecoveryResult::OUTCOME_RECOVERED);
|
||||
$this->assertSame('fallback', RecoveryResult::OUTCOME_FALLBACK);
|
||||
$this->assertSame('failed', RecoveryResult::OUTCOME_FAILED);
|
||||
}
|
||||
}
|
||||
308
tests/Recovery/RetryStrategyTest.php
Normal file
308
tests/Recovery/RetryStrategyTest.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RecursionDepthExceededException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\FailureMode;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\RecoveryResult;
|
||||
use Ivuorinen\MonologGdprFilter\Recovery\RetryStrategy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
/**
|
||||
* Tests for RetryStrategy.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RetryStrategyTest extends TestCase
|
||||
{
|
||||
public function testDefaultFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::default();
|
||||
|
||||
$this->assertSame(3, $strategy->getMaxAttempts());
|
||||
$this->assertSame(FailureMode::FAIL_SAFE, $strategy->getFailureMode());
|
||||
}
|
||||
|
||||
public function testNoRetryFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::noRetry();
|
||||
|
||||
$this->assertSame(1, $strategy->getMaxAttempts());
|
||||
}
|
||||
|
||||
public function testFastFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::fast();
|
||||
|
||||
$this->assertSame(2, $strategy->getMaxAttempts());
|
||||
$this->assertSame(FailureMode::FAIL_SAFE, $strategy->getFailureMode());
|
||||
}
|
||||
|
||||
public function testThoroughFactory(): void
|
||||
{
|
||||
$strategy = RetryStrategy::thorough();
|
||||
|
||||
$this->assertSame(5, $strategy->getMaxAttempts());
|
||||
$this->assertSame(FailureMode::FAIL_CLOSED, $strategy->getFailureMode());
|
||||
}
|
||||
|
||||
public function testSuccessfulExecution(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(maxAttempts: 3);
|
||||
$operation = fn(): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertTrue($result->isSuccess());
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(1, $result->attempts);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_SUCCESS, $result->outcome);
|
||||
}
|
||||
|
||||
public function testRecoveryAfterRetry(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 5
|
||||
);
|
||||
|
||||
$attemptCount = 0;
|
||||
$operation = function () use (&$attemptCount): string {
|
||||
$attemptCount++;
|
||||
if ($attemptCount < 3) {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Temporary failure', TestConstants::PATTERN_TEST, 'test');
|
||||
}
|
||||
return TestConstants::MASK_MASKED_BRACKETS;
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertTrue($result->isSuccess());
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result->value);
|
||||
$this->assertSame(3, $result->attempts);
|
||||
$this->assertSame(RecoveryResult::OUTCOME_RECOVERED, $result->outcome);
|
||||
}
|
||||
|
||||
public function testFallbackAfterAllFailures(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 2,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 5,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Permanent failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original string', 'test.path');
|
||||
|
||||
$this->assertTrue($result->usedFallback());
|
||||
$this->assertSame(2, $result->attempts);
|
||||
$this->assertNotNull($result->lastError);
|
||||
$this->assertSame(MaskConstants::MASK_STRING, $result->value);
|
||||
}
|
||||
|
||||
public function testFailOpenMode(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_OPEN
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame('original', $result->value);
|
||||
}
|
||||
|
||||
public function testFailClosedMode(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_CLOSED
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_REDACTED, $result->value);
|
||||
}
|
||||
|
||||
public function testCustomFallbackMask(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
fallbackMask: '[CUSTOM_FALLBACK]'
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame('[CUSTOM_FALLBACK]', $result->value);
|
||||
}
|
||||
|
||||
public function testNonRecoverableErrorSkipsRetry(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 5
|
||||
);
|
||||
|
||||
$attemptCount = 0;
|
||||
$operation = function () use (&$attemptCount): never {
|
||||
$attemptCount++;
|
||||
throw RecursionDepthExceededException::depthExceeded(100, 50, 'path');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 'original', 'test.path');
|
||||
|
||||
$this->assertSame(1, $attemptCount);
|
||||
$this->assertTrue($result->usedFallback());
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithRecursionDepthException(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = RecursionDepthExceededException::depthExceeded(100, 50, 'path');
|
||||
|
||||
$this->assertFalse($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithPatternCompilationError(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = MaskingOperationFailedException::regexMaskingFailed(
|
||||
TestConstants::PATTERN_TEST,
|
||||
'input',
|
||||
'Pattern compilation failed'
|
||||
);
|
||||
|
||||
$this->assertFalse($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithReDoSError(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = MaskingOperationFailedException::regexMaskingFailed(
|
||||
TestConstants::PATTERN_TEST,
|
||||
'input',
|
||||
'Potential ReDoS vulnerability detected'
|
||||
);
|
||||
|
||||
$this->assertFalse($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testIsRecoverableWithTransientError(): void
|
||||
{
|
||||
$strategy = new RetryStrategy();
|
||||
$exception = MaskingOperationFailedException::regexMaskingFailed('Temporary failure', TestConstants::PATTERN_TEST, 'test');
|
||||
|
||||
$this->assertTrue($strategy->isRecoverable($exception));
|
||||
}
|
||||
|
||||
public function testGetConfiguration(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 20,
|
||||
maxDelayMs: 200,
|
||||
failureMode: FailureMode::FAIL_CLOSED,
|
||||
fallbackMask: '[CUSTOM]'
|
||||
);
|
||||
|
||||
$config = $strategy->getConfiguration();
|
||||
|
||||
$this->assertArrayHasKey('max_attempts', $config);
|
||||
$this->assertArrayHasKey('base_delay_ms', $config);
|
||||
$this->assertArrayHasKey('max_delay_ms', $config);
|
||||
$this->assertArrayHasKey('failure_mode', $config);
|
||||
$this->assertArrayHasKey('fallback_mask', $config);
|
||||
|
||||
$this->assertSame(5, $config['max_attempts']);
|
||||
$this->assertSame(20, $config['base_delay_ms']);
|
||||
$this->assertSame(200, $config['max_delay_ms']);
|
||||
$this->assertSame('fail_closed', $config['failure_mode']);
|
||||
$this->assertSame('[CUSTOM]', $config['fallback_mask']);
|
||||
}
|
||||
|
||||
public function testTypeFallbackForInteger(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, 42, 'test.path');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_INT, $result->value);
|
||||
}
|
||||
|
||||
public function testTypeFallbackForArray(): void
|
||||
{
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 1,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$result = $strategy->execute($operation, ['key' => 'value'], 'test.path');
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_ARRAY, $result->value);
|
||||
}
|
||||
|
||||
public function testAuditLoggerCalledOnRetry(): void
|
||||
{
|
||||
$auditLogs = [];
|
||||
$auditLogger = function (
|
||||
string $path,
|
||||
mixed $original,
|
||||
mixed $masked
|
||||
) use (&$auditLogs): void {
|
||||
$auditLogs[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
'masked' => $masked
|
||||
];
|
||||
};
|
||||
|
||||
$strategy = new RetryStrategy(
|
||||
maxAttempts: 2,
|
||||
baseDelayMs: 1,
|
||||
maxDelayMs: 2
|
||||
);
|
||||
|
||||
$operation = function (): never {
|
||||
throw MaskingOperationFailedException::regexMaskingFailed('Failure', TestConstants::PATTERN_TEST, 'test');
|
||||
};
|
||||
|
||||
$strategy->execute($operation, 'original', 'test.path', $auditLogger);
|
||||
|
||||
$this->assertNotEmpty($auditLogs);
|
||||
$this->assertStringContainsString('recovery', $auditLogs[0]['path']);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,8 @@ class RegexMaskProcessorTest extends TestCase
|
||||
);
|
||||
$processor($record);
|
||||
$this->assertNotEmpty($auditCalls);
|
||||
$this->assertSame([TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL], $auditCalls[0]);
|
||||
$expected = [TestConstants::FIELD_USER_EMAIL, TestConstants::EMAIL_JOHN_DOE, Mask::MASK_EMAIL];
|
||||
$this->assertSame($expected, $auditCalls[0]);
|
||||
}
|
||||
|
||||
public function testInvalidRegexPatternThrowsExceptionOnConstruction(): void
|
||||
|
||||
@@ -38,6 +38,7 @@ use Tests\TestConstants;
|
||||
* 5. Laravel Integration - Fixed undefined variables and imports
|
||||
*
|
||||
* @psalm-api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated validation methods
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
#[CoversClass(RateLimiter::class)]
|
||||
@@ -296,7 +297,11 @@ class ComprehensiveValidationTest extends TestCase
|
||||
fieldPaths: [],
|
||||
customCallbacks: [],
|
||||
auditLogger: function (string $path, mixed $original, mixed $masked): void {
|
||||
$this->auditLog[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
$this->auditLog[] = [
|
||||
'path' => $path,
|
||||
'original' => $original,
|
||||
TestConstants::DATA_MASKED => $masked
|
||||
];
|
||||
},
|
||||
maxDepth: 100,
|
||||
dataTypeMasks: [],
|
||||
|
||||
@@ -32,6 +32,7 @@ use stdClass;
|
||||
* Each test method corresponds to a specific bug that was identified and fixed.
|
||||
*
|
||||
* @psalm-api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated PatternValidator API
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
#[CoversClass(RateLimiter::class)]
|
||||
@@ -124,7 +125,10 @@ class CriticalBugRegressionTest extends TestCase
|
||||
/**
|
||||
* Data provider for PHP type testing
|
||||
*
|
||||
* @psalm-return Generator<string, list{'hello world'|123|bool|float|list{'a', 'b', 'c'}|null|resource|stdClass, string}, mixed, void>
|
||||
* @psalm-return Generator<string, list{
|
||||
* 'hello world'|123|bool|float|list{'a', 'b', 'c'}|null|resource|stdClass,
|
||||
* string
|
||||
* }, mixed, void>
|
||||
*/
|
||||
public static function phpTypesDataProvider(): Generator
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ use InvalidArgumentException;
|
||||
* - Concurrent access safety
|
||||
*
|
||||
* @psalm-api
|
||||
* @psalm-suppress DeprecatedMethod - Tests for deprecated PatternValidator API
|
||||
*/
|
||||
#[CoversClass(GdprProcessor::class)]
|
||||
#[CoversClass(RateLimiter::class)]
|
||||
|
||||
136
tests/Retention/RetentionPolicyTest.php
Normal file
136
tests/Retention/RetentionPolicyTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Retention;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Retention\RetentionPolicy;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(RetentionPolicy::class)]
|
||||
final class RetentionPolicyTest extends TestCase
|
||||
{
|
||||
public function testGetName(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test_policy', 30);
|
||||
|
||||
$this->assertSame('test_policy', $policy->getName());
|
||||
}
|
||||
|
||||
public function testGetRetentionDays(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 90);
|
||||
|
||||
$this->assertSame(90, $policy->getRetentionDays());
|
||||
}
|
||||
|
||||
public function testGetActionDefaultsToDelete(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
|
||||
$this->assertSame(RetentionPolicy::ACTION_DELETE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testGetActionCustom(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30, RetentionPolicy::ACTION_ANONYMIZE);
|
||||
|
||||
$this->assertSame(RetentionPolicy::ACTION_ANONYMIZE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testGetFieldsDefaultsToEmpty(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
|
||||
$this->assertSame([], $policy->getFields());
|
||||
}
|
||||
|
||||
public function testGetFieldsCustom(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30, RetentionPolicy::ACTION_DELETE, ['email', 'phone']);
|
||||
|
||||
$this->assertSame(['email', 'phone'], $policy->getFields());
|
||||
}
|
||||
|
||||
public function testIsWithinRetentionRecent(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$recentDate = new \DateTimeImmutable('-10 days');
|
||||
|
||||
$this->assertTrue($policy->isWithinRetention($recentDate));
|
||||
}
|
||||
|
||||
public function testIsWithinRetentionExpired(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$oldDate = new \DateTimeImmutable('-60 days');
|
||||
|
||||
$this->assertFalse($policy->isWithinRetention($oldDate));
|
||||
}
|
||||
|
||||
public function testIsWithinRetentionBoundary(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$boundaryDate = new \DateTimeImmutable('-29 days');
|
||||
|
||||
$this->assertTrue($policy->isWithinRetention($boundaryDate));
|
||||
}
|
||||
|
||||
public function testGetCutoffDate(): void
|
||||
{
|
||||
$policy = new RetentionPolicy('test', 30);
|
||||
$cutoff = $policy->getCutoffDate();
|
||||
|
||||
$expected = (new \DateTimeImmutable())->modify('-30 days');
|
||||
|
||||
// Allow 1 second tolerance for test execution time
|
||||
$this->assertEqualsWithDelta(
|
||||
$expected->getTimestamp(),
|
||||
$cutoff->getTimestamp(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
public function testGdpr30DaysFactory(): void
|
||||
{
|
||||
$policy = RetentionPolicy::gdpr30Days();
|
||||
|
||||
$this->assertSame('gdpr_standard', $policy->getName());
|
||||
$this->assertSame(30, $policy->getRetentionDays());
|
||||
$this->assertSame(RetentionPolicy::ACTION_DELETE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testGdpr30DaysFactoryCustomName(): void
|
||||
{
|
||||
$policy = RetentionPolicy::gdpr30Days('custom_gdpr');
|
||||
|
||||
$this->assertSame('custom_gdpr', $policy->getName());
|
||||
}
|
||||
|
||||
public function testArchivalFactory(): void
|
||||
{
|
||||
$policy = RetentionPolicy::archival();
|
||||
|
||||
$this->assertSame('archival', $policy->getName());
|
||||
$this->assertSame(2555, $policy->getRetentionDays()); // ~7 years
|
||||
$this->assertSame(RetentionPolicy::ACTION_ARCHIVE, $policy->getAction());
|
||||
}
|
||||
|
||||
public function testAnonymizeFactory(): void
|
||||
{
|
||||
$policy = RetentionPolicy::anonymize('user_data', 90, ['email', 'name']);
|
||||
|
||||
$this->assertSame('user_data', $policy->getName());
|
||||
$this->assertSame(90, $policy->getRetentionDays());
|
||||
$this->assertSame(RetentionPolicy::ACTION_ANONYMIZE, $policy->getAction());
|
||||
$this->assertSame(['email', 'name'], $policy->getFields());
|
||||
}
|
||||
|
||||
public function testActionConstants(): void
|
||||
{
|
||||
$this->assertSame('delete', RetentionPolicy::ACTION_DELETE);
|
||||
$this->assertSame('anonymize', RetentionPolicy::ACTION_ANONYMIZE);
|
||||
$this->assertSame('archive', RetentionPolicy::ACTION_ARCHIVE);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,36 @@ class SecuritySanitizerTest extends TestCase
|
||||
/**
|
||||
* @return string[][]
|
||||
*
|
||||
* @psalm-return array{'password with equals': array{input: 'Error: password=secretpass', shouldNotContain: 'secretpass'}, 'api key with underscore': array{input: 'Failed: api_key=key123456', shouldNotContain: 'key123456'}, 'api key with dash': array{input: 'Failed: api-key: key123456', shouldNotContain: 'key123456'}, 'token in header': array{input: 'Request failed: Authorization: Bearer token123abc', shouldNotContain: 'token123abc'}, 'mysql connection string': array{input: 'DB error: mysql://user:pass@localhost:3306', shouldNotContain: 'user:pass'}, 'secret key': array{input: 'Config: secret_key=my-secret-123', shouldNotContain: 'my-secret-123'}, 'private key': array{input: 'Error: private_key=pk_test_12345', shouldNotContain: 'pk_test_12345'}}
|
||||
* @psalm-return array{
|
||||
* 'password with equals': array{
|
||||
* input: 'Error: password=secretpass',
|
||||
* shouldNotContain: 'secretpass'
|
||||
* },
|
||||
* 'api key with underscore': array{
|
||||
* input: 'Failed: api_key=key123456',
|
||||
* shouldNotContain: 'key123456'
|
||||
* },
|
||||
* 'api key with dash': array{
|
||||
* input: 'Failed: api-key: key123456',
|
||||
* shouldNotContain: 'key123456'
|
||||
* },
|
||||
* 'token in header': array{
|
||||
* input: 'Request failed: Authorization: Bearer token123abc',
|
||||
* shouldNotContain: 'token123abc'
|
||||
* },
|
||||
* 'mysql connection string': array{
|
||||
* input: 'DB error: mysql://user:pass@localhost:3306',
|
||||
* shouldNotContain: 'user:pass'
|
||||
* },
|
||||
* 'secret key': array{
|
||||
* input: 'Config: secret_key=my-secret-123',
|
||||
* shouldNotContain: 'my-secret-123'
|
||||
* },
|
||||
* 'private key': array{
|
||||
* input: 'Error: private_key=pk_test_12345',
|
||||
* shouldNotContain: 'pk_test_12345'
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public static function sensitivePatternProvider(): array
|
||||
{
|
||||
@@ -167,4 +196,192 @@ class SecuritySanitizerTest extends TestCase
|
||||
$this->assertStringContainsString(TestConstants::ERROR_TRUNCATED_SECURITY, $sanitized);
|
||||
$this->assertLessThanOrEqual(550, strlen($sanitized)); // 500 + truncation message
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesPwdAndPassVariations(): void
|
||||
{
|
||||
$message = 'Connection failed: pwd=mypassword pass=anotherpass';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('pwd=***', $sanitized);
|
||||
$this->assertStringContainsString('pass=***', $sanitized);
|
||||
$this->assertStringNotContainsString('mypassword', $sanitized);
|
||||
$this->assertStringNotContainsString('anotherpass', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesHostServerHostnamePatterns(): void
|
||||
{
|
||||
$message = 'Error: host=db.example.com server=192.168.1.1 hostname=internal.local';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('host=***', $sanitized);
|
||||
$this->assertStringContainsString('server=***', $sanitized);
|
||||
$this->assertStringContainsString('hostname=***', $sanitized);
|
||||
$this->assertStringNotContainsString('db.example.com', $sanitized);
|
||||
$this->assertStringNotContainsString('internal.local', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesUsernameAndUidPatterns(): void
|
||||
{
|
||||
$message = 'Auth error: username=admin uid=12345';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('username=***', $sanitized);
|
||||
$this->assertStringContainsString('uid=***', $sanitized);
|
||||
$this->assertStringNotContainsString('=admin', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesStripeStyleKeys(): void
|
||||
{
|
||||
$message = 'Stripe error: sk_live_abc123def456 pk_test_xyz789ghi012';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('sk_***', $sanitized);
|
||||
$this->assertStringContainsString('pk_***', $sanitized);
|
||||
$this->assertStringNotContainsString('sk_live_abc123def456', $sanitized);
|
||||
$this->assertStringNotContainsString('pk_test_xyz789ghi012', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesRedisConnectionString(): void
|
||||
{
|
||||
$message = 'Redis error: redis://user:pass@localhost:6379';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('redis://***:***@***:***', $sanitized);
|
||||
$this->assertStringNotContainsString('user:pass@', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesPostgresqlConnectionString(): void
|
||||
{
|
||||
$message = 'Connection error: postgresql://admin:password@pg.server:5432';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('postgresql://***:***@***:***', $sanitized);
|
||||
$this->assertStringNotContainsString('admin:password@', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesJwtSecretPatterns(): void
|
||||
{
|
||||
$message = 'JWT error: jwt_secret=mysupersecret123';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('jwt_secret=***', $sanitized);
|
||||
$this->assertStringNotContainsString('mysupersecret123', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesSuperSecretPattern(): void
|
||||
{
|
||||
$message = 'Config contains super_secret_value';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_SECRET, $sanitized);
|
||||
$this->assertStringNotContainsString('super_secret_value', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('internalIpRangesProvider')]
|
||||
public function sanitizesInternalIpAddresses(string $ip, string $range): void
|
||||
{
|
||||
$message = 'Cannot reach server at ' . $ip;
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringNotContainsString($ip, $sanitized, "Internal IP in $range range should be masked");
|
||||
$this->assertStringContainsString('***.***.***', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{ip: string, range: string}>
|
||||
*/
|
||||
public static function internalIpRangesProvider(): array
|
||||
{
|
||||
return [
|
||||
'10.0.0.1' => ['ip' => '10.0.0.1', 'range' => '10.x.x.x'],
|
||||
'10.255.255.255' => ['ip' => '10.255.255.255', 'range' => '10.x.x.x'],
|
||||
'172.16.0.1' => ['ip' => '172.16.0.1', 'range' => '172.16-31.x.x'],
|
||||
'172.31.255.255' => ['ip' => '172.31.255.255', 'range' => '172.16-31.x.x'],
|
||||
'192.168.0.1' => ['ip' => '192.168.0.1', 'range' => '192.168.x.x'],
|
||||
'192.168.255.255' => ['ip' => '192.168.255.255', 'range' => '192.168.x.x'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preservesPublicIpAddresses(): void
|
||||
{
|
||||
$message = 'External server at 8.8.8.8 responded';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('8.8.8.8', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesSensitiveFilePaths(): void
|
||||
{
|
||||
$message = 'Error reading /var/www/config/secrets.json';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringNotContainsString('/var/www/config', $sanitized);
|
||||
$this->assertStringContainsString('/***/', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesWindowsSensitiveFilePaths(): void
|
||||
{
|
||||
$message = 'Error reading C:\\Users\\admin\\config\\secrets.txt';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
// Windows paths with sensitive keywords are masked
|
||||
$this->assertStringNotContainsString('admin', $sanitized);
|
||||
$this->assertStringNotContainsString('secrets.txt', $sanitized);
|
||||
$this->assertStringContainsString('C:\\***', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesGenericSecretPatterns(): void
|
||||
{
|
||||
$message = 'Config: app_secret=abcd1234567890 encryption_key: xyz9876543210abc';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringNotContainsString('abcd1234567890', $sanitized);
|
||||
$this->assertStringNotContainsString('xyz9876543210abc', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classCannotBeInstantiated(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(SecuritySanitizer::class);
|
||||
$constructor = $reflection->getConstructor();
|
||||
|
||||
$this->assertNotNull($constructor);
|
||||
$this->assertTrue($constructor->isPrivate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sanitizesUserPattern(): void
|
||||
{
|
||||
$message = 'Connection failed: user=dbadmin';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
$this->assertStringContainsString('user=***', $sanitized);
|
||||
$this->assertStringNotContainsString('dbadmin', $sanitized);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function caseInsensitivePatternMatching(): void
|
||||
{
|
||||
$message = 'Error: PASSWORD=secret HOST=server.local TOKEN=abc123token';
|
||||
$sanitized = SecuritySanitizer::sanitizeErrorMessage($message);
|
||||
|
||||
// Case-insensitive matching means uppercase is matched but replaced with lowercase pattern
|
||||
$this->assertStringNotContainsString('secret', $sanitized);
|
||||
$this->assertStringNotContainsString('server.local', $sanitized);
|
||||
$this->assertStringNotContainsString('abc123token', $sanitized);
|
||||
$this->assertStringContainsString('=***', $sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
270
tests/SerializedDataProcessorTest.php
Normal file
270
tests/SerializedDataProcessorTest.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\SerializedDataProcessor;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
|
||||
#[CoversClass(SerializedDataProcessor::class)]
|
||||
final class SerializedDataProcessorTest extends TestCase
|
||||
{
|
||||
private function createProcessor(?callable $auditLogger = null): SerializedDataProcessor
|
||||
{
|
||||
$stringMasker = fn(string $value): string => preg_replace(
|
||||
'/\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/',
|
||||
MaskConstants::MASK_EMAIL,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
return new SerializedDataProcessor($stringMasker, $auditLogger);
|
||||
}
|
||||
|
||||
public function testProcessEmptyMessage(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->assertSame('', $processor->process(''));
|
||||
}
|
||||
|
||||
public function testProcessPlainTextUnchanged(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'This is a plain text message without serialized data';
|
||||
$this->assertSame($message, $processor->process($message));
|
||||
}
|
||||
|
||||
public function testProcessEmbeddedJsonMasksEmail(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'User data: {"email":"' . TestConstants::EMAIL_JOHN . '","name":"John"}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_JOHN, $result);
|
||||
}
|
||||
|
||||
public function testProcessEmbeddedJsonPreservesStructure(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'Data: {"id":123,"email":"' . TestConstants::EMAIL_TEST . '"}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
// Should still be valid JSON in the message
|
||||
preg_match('/\{[^}]+\}/', $result, $matches);
|
||||
$this->assertNotEmpty($matches);
|
||||
|
||||
$decoded = json_decode($matches[0], true);
|
||||
$this->assertNotNull($decoded);
|
||||
$this->assertSame(123, $decoded['id']);
|
||||
}
|
||||
|
||||
public function testProcessNestedJson(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'User: {"user":{"contact":{"email":"' . TestConstants::EMAIL_TEST . '"}}}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_TEST, $result);
|
||||
}
|
||||
|
||||
public function testProcessPrintROutput(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$printROutput = 'Array
|
||||
(
|
||||
[name] => John Doe
|
||||
[email] => ' . TestConstants::EMAIL_JOHN . '
|
||||
[age] => 30
|
||||
)';
|
||||
|
||||
$result = $processor->process($printROutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_JOHN, $result);
|
||||
$this->assertStringContainsString('John Doe', $result); // Name not masked
|
||||
}
|
||||
|
||||
public function testProcessPrintROutputWithNestedArrays(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$printROutput = <<<'PRINT_R'
|
||||
Array
|
||||
(
|
||||
[user] => Array
|
||||
(
|
||||
[email] => user@example.com
|
||||
)
|
||||
)
|
||||
PRINT_R;
|
||||
|
||||
$result = $processor->process($printROutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessVarExportOutput(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$varExportOutput = "array (
|
||||
'name' => 'John Doe',
|
||||
'email' => '" . TestConstants::EMAIL_JOHN . "',
|
||||
'active' => true,
|
||||
)";
|
||||
|
||||
$result = $processor->process($varExportOutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_JOHN, $result);
|
||||
}
|
||||
|
||||
public function testProcessSerializeOutput(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = ['email' => TestConstants::EMAIL_TEST, 'name' => 'Test'];
|
||||
$serialized = serialize($data);
|
||||
|
||||
$result = $processor->process($serialized);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
$this->assertStringNotContainsString(TestConstants::EMAIL_TEST, $result);
|
||||
}
|
||||
|
||||
public function testProcessSerializeOutputUpdatesLength(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
// test@example.com is 16 characters, so s:16:"test@example.com";
|
||||
$email = TestConstants::EMAIL_TEST;
|
||||
$serialized = 's:' . strlen($email) . ':"' . $email . '";';
|
||||
$result = $processor->process($serialized);
|
||||
|
||||
// Should update the length prefix to match the mask
|
||||
$maskLength = strlen(MaskConstants::MASK_EMAIL);
|
||||
$this->assertStringContainsString("s:{$maskLength}:", $result);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessMixedContent(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'Log entry: User {"email":"' . TestConstants::EMAIL_TEST . '"} performed action';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString('Log entry: User', $result);
|
||||
$this->assertStringContainsString('performed action', $result);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessWithAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path, mixed $original, mixed $masked) use (&$logs): void {
|
||||
$logs[] = ['path' => $path, 'original' => $original, TestConstants::DATA_MASKED => $masked];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor($auditLogger);
|
||||
|
||||
$processor->process('{"email":"' . TestConstants::EMAIL_TEST . '"}');
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
$this->assertStringContainsString('json', $logs[0]['path']);
|
||||
}
|
||||
|
||||
public function testSetAuditLogger(): void
|
||||
{
|
||||
$logs = [];
|
||||
$auditLogger = function (string $path) use (&$logs): void {
|
||||
$logs[] = ['path' => $path];
|
||||
};
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
$processor->setAuditLogger($auditLogger);
|
||||
|
||||
$processor->process('{"email":"' . TestConstants::EMAIL_TEST . '"}');
|
||||
|
||||
$this->assertNotEmpty($logs);
|
||||
}
|
||||
|
||||
public function testProcessInvalidJsonNotModified(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = '{invalid json here}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testProcessJsonArray(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'Users: [{"email":"a@example.com"},{"email":"b@example.com"}]';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringNotContainsString('a@example.com', $result);
|
||||
$this->assertStringNotContainsString('b@example.com', $result);
|
||||
}
|
||||
|
||||
public function testProcessDoesNotMaskNonSensitiveData(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = '{"status":"ok","count":42}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
// Should remain unchanged since no sensitive data
|
||||
$this->assertSame($message, $result);
|
||||
}
|
||||
|
||||
public function testProcessWithDoubleQuotesInVarExport(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$varExportOutput = 'array (
|
||||
"email" => "' . TestConstants::EMAIL_JOHN . '",
|
||||
)';
|
||||
|
||||
$result = $processor->process($varExportOutput);
|
||||
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
|
||||
public function testProcessMultipleFormatsInSameMessage(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$message = 'JSON: {"email":"a@example.com"} and serialized: s:16:"b@example.com";';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringNotContainsString('a@example.com', $result);
|
||||
// Note: b@example.com length is 13, not 16, so serialize won't match
|
||||
}
|
||||
|
||||
public function testProcessWithCustomMasker(): void
|
||||
{
|
||||
$customMasker = fn(string $value): string => str_replace('secret', '[REDACTED]', $value);
|
||||
$processor = new SerializedDataProcessor($customMasker);
|
||||
|
||||
$message = '{"data":"this is secret information"}';
|
||||
$result = $processor->process($message);
|
||||
|
||||
$this->assertStringContainsString('[REDACTED]', $result);
|
||||
$this->assertStringNotContainsString('secret', $result);
|
||||
}
|
||||
}
|
||||
@@ -196,20 +196,32 @@ final class AbstractMaskingStrategyTest extends TestCase
|
||||
#[Test]
|
||||
public function pathMatchesReturnsTrueForExactMatch(): void
|
||||
{
|
||||
$this->assertTrue($this->strategy->testPathMatches(TestConstants::FIELD_USER_EMAIL, TestConstants::FIELD_USER_EMAIL));
|
||||
$this->assertTrue($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
TestConstants::FIELD_USER_EMAIL
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pathMatchesReturnsFalseForNonMatch(): void
|
||||
{
|
||||
$this->assertFalse($this->strategy->testPathMatches(TestConstants::FIELD_USER_EMAIL, TestConstants::FIELD_USER_PASSWORD));
|
||||
$this->assertFalse($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
TestConstants::FIELD_USER_PASSWORD
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pathMatchesSupportsWildcardAtEnd(): void
|
||||
{
|
||||
$this->assertTrue($this->strategy->testPathMatches(TestConstants::FIELD_USER_EMAIL, TestConstants::PATH_USER_WILDCARD));
|
||||
$this->assertTrue($this->strategy->testPathMatches(TestConstants::FIELD_USER_PASSWORD, TestConstants::PATH_USER_WILDCARD));
|
||||
$this->assertTrue($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
TestConstants::PATH_USER_WILDCARD
|
||||
));
|
||||
$this->assertTrue($this->strategy->testPathMatches(
|
||||
TestConstants::FIELD_USER_PASSWORD,
|
||||
TestConstants::PATH_USER_WILDCARD
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
253
tests/Strategies/CallbackMaskingStrategyTest.php
Normal file
253
tests/Strategies/CallbackMaskingStrategyTest.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Strategies;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RuleExecutionException;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\CallbackMaskingStrategy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\TestConstants;
|
||||
use Tests\TestHelpers;
|
||||
|
||||
/**
|
||||
* Tests for CallbackMaskingStrategy.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class CallbackMaskingStrategyTest extends TestCase
|
||||
{
|
||||
use TestHelpers;
|
||||
|
||||
public function testBasicConstruction(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy('user.email', $callback);
|
||||
|
||||
$this->assertSame('user.email', $strategy->getFieldPath());
|
||||
$this->assertTrue($strategy->isExactMatch());
|
||||
$this->assertSame(50, $strategy->getPriority());
|
||||
}
|
||||
|
||||
public function testMaskWithSimpleCallback(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy('user.email', $callback);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('john@example.com', 'user.email', $record);
|
||||
|
||||
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $result);
|
||||
}
|
||||
|
||||
public function testMaskWithTransformingCallback(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => strtoupper((string) $value);
|
||||
$strategy = new CallbackMaskingStrategy('user.name', $callback);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('john', 'user.name', $record);
|
||||
|
||||
$this->assertSame('JOHN', $result);
|
||||
}
|
||||
|
||||
public function testMaskThrowsOnCallbackException(): void
|
||||
{
|
||||
$callback = function (): never {
|
||||
throw new RuleExecutionException('Callback failed');
|
||||
};
|
||||
$strategy = new CallbackMaskingStrategy('user.data', $callback);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$this->expectException(MaskingOperationFailedException::class);
|
||||
$this->expectExceptionMessage('Callback threw exception');
|
||||
|
||||
$strategy->mask('value', 'user.data', $record);
|
||||
}
|
||||
|
||||
public function testShouldApplyWithExactMatch(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy(
|
||||
'user.email',
|
||||
$callback,
|
||||
exactMatch: true
|
||||
);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$this->assertTrue($strategy->shouldApply('value', 'user.email', $record));
|
||||
$this->assertFalse($strategy->shouldApply('value', 'user.name', $record));
|
||||
$this->assertFalse($strategy->shouldApply('value', 'user.email.work', $record));
|
||||
}
|
||||
|
||||
public function testShouldApplyWithWildcardMatch(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy(
|
||||
'user.*',
|
||||
$callback,
|
||||
exactMatch: false
|
||||
);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$this->assertTrue($strategy->shouldApply('value', 'user.email', $record));
|
||||
$this->assertTrue($strategy->shouldApply('value', 'user.name', $record));
|
||||
$this->assertFalse($strategy->shouldApply('value', 'admin.email', $record));
|
||||
}
|
||||
|
||||
public function testGetName(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy('user.email', $callback);
|
||||
|
||||
$name = $strategy->getName();
|
||||
|
||||
$this->assertStringContainsString('Callback Masking', $name);
|
||||
$this->assertStringContainsString('user.email', $name);
|
||||
}
|
||||
|
||||
public function testValidate(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy('user.email', $callback);
|
||||
|
||||
$this->assertTrue($strategy->validate());
|
||||
}
|
||||
|
||||
public function testGetConfiguration(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy(
|
||||
'user.email',
|
||||
$callback,
|
||||
75,
|
||||
false
|
||||
);
|
||||
|
||||
$config = $strategy->getConfiguration();
|
||||
|
||||
$this->assertArrayHasKey('field_path', $config);
|
||||
$this->assertArrayHasKey('exact_match', $config);
|
||||
$this->assertArrayHasKey('priority', $config);
|
||||
$this->assertSame('user.email', $config['field_path']);
|
||||
$this->assertFalse($config['exact_match']);
|
||||
$this->assertSame(75, $config['priority']);
|
||||
}
|
||||
|
||||
public function testForPathsFactoryMethod(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$paths = ['user.email', 'admin.email', 'contact.email'];
|
||||
|
||||
$strategies = CallbackMaskingStrategy::forPaths($paths, $callback);
|
||||
|
||||
$this->assertCount(3, $strategies);
|
||||
|
||||
foreach ($strategies as $index => $strategy) {
|
||||
$this->assertInstanceOf(CallbackMaskingStrategy::class, $strategy);
|
||||
$this->assertSame($paths[$index], $strategy->getFieldPath());
|
||||
}
|
||||
}
|
||||
|
||||
public function testConstantFactoryMethod(): void
|
||||
{
|
||||
$strategy = CallbackMaskingStrategy::constant('user.ssn', '***-**-****');
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('123-45-6789', 'user.ssn', $record);
|
||||
|
||||
$this->assertSame('***-**-****', $result);
|
||||
}
|
||||
|
||||
public function testHashFactoryMethod(): void
|
||||
{
|
||||
$strategy = CallbackMaskingStrategy::hash('user.password', 'sha256', 8);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('secret123', 'user.password', $record);
|
||||
|
||||
$this->assertIsString($result);
|
||||
$this->assertSame(11, strlen($result));
|
||||
$this->assertStringEndsWith('...', $result);
|
||||
}
|
||||
|
||||
public function testHashWithNoTruncation(): void
|
||||
{
|
||||
$strategy = CallbackMaskingStrategy::hash('user.password', 'md5', 0);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('test', 'user.password', $record);
|
||||
|
||||
$this->assertSame(32, strlen((string) $result));
|
||||
}
|
||||
|
||||
public function testPartialFactoryMethod(): void
|
||||
{
|
||||
$strategy = CallbackMaskingStrategy::partial('user.email', 2, 4);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('john@example.com', 'user.email', $record);
|
||||
|
||||
$this->assertStringStartsWith('jo', $result);
|
||||
$this->assertStringEndsWith('.com', $result);
|
||||
$this->assertStringContainsString('***', $result);
|
||||
}
|
||||
|
||||
public function testPartialWithShortString(): void
|
||||
{
|
||||
$strategy = CallbackMaskingStrategy::partial('user.code', 2, 2);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('abc', 'user.code', $record);
|
||||
|
||||
$this->assertSame('***', $result);
|
||||
}
|
||||
|
||||
public function testPartialWithCustomMaskChar(): void
|
||||
{
|
||||
$strategy = CallbackMaskingStrategy::partial('user.card', 4, 4, '#');
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask('1234567890123456', 'user.card', $record);
|
||||
|
||||
$this->assertStringStartsWith('1234', $result);
|
||||
$this->assertStringEndsWith('3456', $result);
|
||||
$this->assertStringContainsString('########', $result);
|
||||
}
|
||||
|
||||
public function testCallbackReceivesOriginalValue(): void
|
||||
{
|
||||
$receivedValue = null;
|
||||
$callback = function (mixed $value) use (&$receivedValue): string {
|
||||
$receivedValue = $value;
|
||||
return TestConstants::MASK_MASKED_BRACKETS;
|
||||
};
|
||||
|
||||
$strategy = new CallbackMaskingStrategy('user.data', $callback);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$strategy->mask(['key' => 'value'], 'user.data', $record);
|
||||
|
||||
$this->assertSame($receivedValue, ['key' => 'value']);
|
||||
}
|
||||
|
||||
public function testCallbackCanReturnNonString(): void
|
||||
{
|
||||
$callback = fn(mixed $value): array => ['masked' => true];
|
||||
$strategy = new CallbackMaskingStrategy('user.data', $callback);
|
||||
$record = $this->createLogRecord();
|
||||
|
||||
$result = $strategy->mask(['key' => 'value'], 'user.data', $record);
|
||||
|
||||
$this->assertSame(['masked' => true], $result);
|
||||
}
|
||||
|
||||
public function testCustomPriority(): void
|
||||
{
|
||||
$callback = fn(mixed $value): string => TestConstants::MASK_MASKED_BRACKETS;
|
||||
$strategy = new CallbackMaskingStrategy('user.email', $callback, 100);
|
||||
|
||||
$this->assertSame(100, $strategy->getPriority());
|
||||
}
|
||||
}
|
||||
@@ -170,9 +170,24 @@ final class ConditionalMaskingStrategyEnhancedTest extends TestCase
|
||||
[TestConstants::CHANNEL_SECURITY, TestConstants::CHANNEL_AUDIT, 'admin']
|
||||
);
|
||||
|
||||
$securityRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, [], Level::Info, TestConstants::CHANNEL_SECURITY);
|
||||
$auditRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, [], Level::Info, TestConstants::CHANNEL_AUDIT);
|
||||
$testRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, [], Level::Info, 'test');
|
||||
$securityRecord = $this->createLogRecord(
|
||||
TestConstants::MESSAGE_DEFAULT,
|
||||
[],
|
||||
Level::Info,
|
||||
TestConstants::CHANNEL_SECURITY
|
||||
);
|
||||
$auditRecord = $this->createLogRecord(
|
||||
TestConstants::MESSAGE_DEFAULT,
|
||||
[],
|
||||
Level::Info,
|
||||
TestConstants::CHANNEL_AUDIT
|
||||
);
|
||||
$testRecord = $this->createLogRecord(
|
||||
TestConstants::MESSAGE_DEFAULT,
|
||||
[],
|
||||
Level::Info,
|
||||
'test'
|
||||
);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply('secret', TestConstants::FIELD_MESSAGE, $securityRecord));
|
||||
$this->assertTrue($strategy->shouldApply('secret', TestConstants::FIELD_MESSAGE, $auditRecord));
|
||||
@@ -188,8 +203,16 @@ final class ConditionalMaskingStrategyEnhancedTest extends TestCase
|
||||
['env' => 'production', 'sensitive' => true]
|
||||
);
|
||||
|
||||
$prodRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, ['env' => 'production', 'sensitive' => true]);
|
||||
$devRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT, ['env' => 'development', 'sensitive' => true]);
|
||||
$prodContext = ['env' => 'production', 'sensitive' => true];
|
||||
$prodRecord = $this->createLogRecord(
|
||||
TestConstants::MESSAGE_DEFAULT,
|
||||
$prodContext
|
||||
);
|
||||
$devContext = ['env' => 'development', 'sensitive' => true];
|
||||
$devRecord = $this->createLogRecord(
|
||||
TestConstants::MESSAGE_DEFAULT,
|
||||
$devContext
|
||||
);
|
||||
$noContextRecord = $this->createLogRecord(TestConstants::MESSAGE_DEFAULT);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply('secret', TestConstants::FIELD_MESSAGE, $prodRecord));
|
||||
|
||||
@@ -69,32 +69,52 @@ final class FieldPathMaskingStrategyTest extends TestCase
|
||||
#[Test]
|
||||
public function shouldApplyReturnsTrueForExactPathMatch(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC]);
|
||||
$config = [TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC];
|
||||
$strategy = new FieldPathMaskingStrategy($config);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::EMAIL_TEST, TestConstants::FIELD_USER_EMAIL, $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::EMAIL_TEST,
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
$this->logRecord
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function shouldApplyReturnsFalseForNonMatchingPath(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC]);
|
||||
$config = [TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_GENERIC];
|
||||
$strategy = new FieldPathMaskingStrategy($config);
|
||||
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::CONTEXT_PASSWORD, TestConstants::FIELD_USER_PASSWORD, $this->logRecord));
|
||||
$this->assertFalse($strategy->shouldApply(
|
||||
TestConstants::CONTEXT_PASSWORD,
|
||||
TestConstants::FIELD_USER_PASSWORD,
|
||||
$this->logRecord
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function shouldApplySupportsWildcardPatterns(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([TestConstants::PATH_USER_WILDCARD => MaskConstants::MASK_GENERIC]);
|
||||
$config = [TestConstants::PATH_USER_WILDCARD => MaskConstants::MASK_GENERIC];
|
||||
$strategy = new FieldPathMaskingStrategy($config);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::EMAIL_TEST, TestConstants::FIELD_USER_EMAIL, $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::CONTEXT_PASSWORD, TestConstants::FIELD_USER_PASSWORD, $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::EMAIL_TEST,
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
$this->logRecord
|
||||
));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::CONTEXT_PASSWORD,
|
||||
TestConstants::FIELD_USER_PASSWORD,
|
||||
$this->logRecord
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function maskAppliesStringReplacement(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN]);
|
||||
$config = [TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN];
|
||||
$strategy = new FieldPathMaskingStrategy($config);
|
||||
|
||||
$result = $strategy->mask(TestConstants::EMAIL_TEST, TestConstants::FIELD_USER_EMAIL, $this->logRecord);
|
||||
|
||||
@@ -114,9 +134,11 @@ final class FieldPathMaskingStrategyTest extends TestCase
|
||||
#[Test]
|
||||
public function maskAppliesRegexReplacement(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.ssn' => FieldMaskConfig::regexMask(TestConstants::PATTERN_SSN_FORMAT, MaskConstants::MASK_SSN_PATTERN),
|
||||
]);
|
||||
$ssnConfig = FieldMaskConfig::regexMask(
|
||||
TestConstants::PATTERN_SSN_FORMAT,
|
||||
MaskConstants::MASK_SSN_PATTERN
|
||||
);
|
||||
$strategy = new FieldPathMaskingStrategy(['user.ssn' => $ssnConfig]);
|
||||
|
||||
$result = $strategy->mask(TestConstants::SSN_US, 'user.ssn', $this->logRecord);
|
||||
|
||||
@@ -208,10 +230,15 @@ final class FieldPathMaskingStrategyTest extends TestCase
|
||||
#[Test]
|
||||
public function validateReturnsTrueForValidConfiguration(): void
|
||||
{
|
||||
$ssnConfig = FieldMaskConfig::regexMask(
|
||||
TestConstants::PATTERN_SSN_FORMAT,
|
||||
MaskConstants::MASK_SSN_PATTERN
|
||||
);
|
||||
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
TestConstants::FIELD_USER_EMAIL => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
TestConstants::FIELD_USER_PASSWORD => FieldMaskConfig::remove(),
|
||||
'user.ssn' => FieldMaskConfig::regexMask(TestConstants::PATTERN_SSN_FORMAT, MaskConstants::MASK_SSN_PATTERN),
|
||||
'user.ssn' => $ssnConfig,
|
||||
]);
|
||||
|
||||
$this->assertTrue($strategy->validate());
|
||||
|
||||
@@ -69,7 +69,10 @@ class MaskingStrategiesTest extends TestCase
|
||||
public function testRegexMaskingStrategyWithInvalidPattern(): void
|
||||
{
|
||||
$this->expectException(InvalidRegexPatternException::class);
|
||||
$strategy = new RegexMaskingStrategy([TestConstants::PATTERN_INVALID_UNCLOSED_BRACKET => TestConstants::DATA_MASKED]);
|
||||
$patterns = [
|
||||
TestConstants::PATTERN_INVALID_UNCLOSED_BRACKET => TestConstants::DATA_MASKED
|
||||
];
|
||||
$strategy = new RegexMaskingStrategy($patterns);
|
||||
unset($strategy); // Satisfy SonarQube - this line won't be reached if exception is thrown
|
||||
$this->fail(TestConstants::ERROR_EXCEPTION_NOT_THROWN);
|
||||
}
|
||||
@@ -390,7 +393,10 @@ class MaskingStrategiesTest extends TestCase
|
||||
$this->assertFalse($strategy->testRecordMatches($logRecord, ['level' => 'Info']));
|
||||
|
||||
// Test preserveValueType
|
||||
$this->assertEquals(TestConstants::DATA_MASKED, $strategy->testPreserveValueType('original', TestConstants::DATA_MASKED));
|
||||
$this->assertEquals(
|
||||
TestConstants::DATA_MASKED,
|
||||
$strategy->testPreserveValueType('original', TestConstants::DATA_MASKED)
|
||||
);
|
||||
$this->assertEquals(123, $strategy->testPreserveValueType(456, '123'));
|
||||
$this->assertEqualsWithDelta(12.5, $strategy->testPreserveValueType(45.6, '12.5'), PHP_FLOAT_EPSILON);
|
||||
$this->assertTrue($strategy->testPreserveValueType(false, 'true'));
|
||||
|
||||
@@ -205,7 +205,12 @@ final class RegexMaskingStrategyComprehensiveTest extends TestCase
|
||||
$record = $this->createLogRecord('Test');
|
||||
|
||||
// Should apply to included path
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, TestConstants::FIELD_USER_PASSWORD, $record));
|
||||
$shouldApply = $strategy->shouldApply(
|
||||
TestConstants::MESSAGE_SECRET_DATA,
|
||||
TestConstants::FIELD_USER_PASSWORD,
|
||||
$record
|
||||
);
|
||||
$this->assertTrue($shouldApply);
|
||||
|
||||
// Should not apply to non-included path
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, 'other.field', $record));
|
||||
@@ -238,10 +243,20 @@ final class RegexMaskingStrategyComprehensiveTest extends TestCase
|
||||
$record = $this->createLogRecord('Test');
|
||||
|
||||
// Should not apply to excluded path even if in include list
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, TestConstants::FIELD_USER_PUBLIC, $record));
|
||||
$shouldNotApply = $strategy->shouldApply(
|
||||
TestConstants::MESSAGE_SECRET_DATA,
|
||||
TestConstants::FIELD_USER_PUBLIC,
|
||||
$record
|
||||
);
|
||||
$this->assertFalse($shouldNotApply);
|
||||
|
||||
// Should apply to included path not in exclude list
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::MESSAGE_SECRET_DATA, TestConstants::FIELD_USER_PASSWORD, $record));
|
||||
$shouldApply = $strategy->shouldApply(
|
||||
TestConstants::MESSAGE_SECRET_DATA,
|
||||
TestConstants::FIELD_USER_PASSWORD,
|
||||
$record
|
||||
);
|
||||
$this->assertTrue($shouldApply);
|
||||
}
|
||||
|
||||
public function testShouldApplyCatchesMaskingException(): void
|
||||
|
||||
@@ -83,7 +83,8 @@ final class RegexMaskingStrategyEnhancedTest extends TestCase
|
||||
$logRecord = $this->createLogRecord();
|
||||
|
||||
// Should successfully apply all valid patterns
|
||||
$result = $strategy->mask('SSN: 123-45-6789, Email: emailtest@example.com', TestConstants::FIELD_MESSAGE, $logRecord);
|
||||
$input = 'SSN: 123-45-6789, Email: emailtest@example.com';
|
||||
$result = $strategy->mask($input, TestConstants::FIELD_MESSAGE, $logRecord);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_SSN, $result);
|
||||
$this->assertStringContainsString(MaskConstants::MASK_EMAIL, $result);
|
||||
}
|
||||
@@ -126,25 +127,44 @@ final class RegexMaskingStrategyEnhancedTest extends TestCase
|
||||
public function testShouldApplyWithIncludePathsOnly(): void
|
||||
{
|
||||
$patterns = [TestConstants::PATTERN_TEST => MaskConstants::MASK_MASKED];
|
||||
$strategy = new RegexMaskingStrategy($patterns, [TestConstants::PATH_USER_WILDCARD, 'admin.log']);
|
||||
$includePaths = [TestConstants::PATH_USER_WILDCARD, 'admin.log'];
|
||||
$strategy = new RegexMaskingStrategy($patterns, $includePaths);
|
||||
$logRecord = $this->createLogRecord();
|
||||
|
||||
// Should apply to matching content in included paths
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::DATA_TEST_DATA, TestConstants::FIELD_USER_EMAIL, $logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::DATA_TEST_DATA, 'admin.log', $logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::DATA_TEST_DATA,
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
$logRecord
|
||||
));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::DATA_TEST_DATA,
|
||||
'admin.log',
|
||||
$logRecord
|
||||
));
|
||||
|
||||
// Should not apply to non-included paths even if content matches
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::DATA_TEST_DATA, 'system.info', $logRecord));
|
||||
$this->assertFalse($strategy->shouldApply(
|
||||
TestConstants::DATA_TEST_DATA,
|
||||
'system.info',
|
||||
$logRecord
|
||||
));
|
||||
}
|
||||
|
||||
public function testShouldApplyWithExcludePathsPrecedence(): void
|
||||
{
|
||||
$patterns = [TestConstants::PATTERN_TEST => MaskConstants::MASK_MASKED];
|
||||
$strategy = new RegexMaskingStrategy($patterns, [TestConstants::PATH_USER_WILDCARD], ['user.id', 'user.created_at']);
|
||||
$includePaths = [TestConstants::PATH_USER_WILDCARD];
|
||||
$excludePaths = ['user.id', 'user.created_at'];
|
||||
$strategy = new RegexMaskingStrategy($patterns, $includePaths, $excludePaths);
|
||||
$logRecord = $this->createLogRecord();
|
||||
|
||||
// Should apply to included but not excluded
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::DATA_TEST_DATA, TestConstants::FIELD_USER_EMAIL, $logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::DATA_TEST_DATA,
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
$logRecord
|
||||
));
|
||||
|
||||
// Should not apply to excluded paths
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::DATA_TEST_DATA, 'user.id', $logRecord));
|
||||
@@ -158,8 +178,16 @@ final class RegexMaskingStrategyEnhancedTest extends TestCase
|
||||
$logRecord = $this->createLogRecord();
|
||||
|
||||
// Should return false when content doesn't match patterns
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::DATA_PUBLIC, TestConstants::FIELD_MESSAGE, $logRecord));
|
||||
$this->assertFalse($strategy->shouldApply('no sensitive info', 'context.field', $logRecord));
|
||||
$this->assertFalse($strategy->shouldApply(
|
||||
TestConstants::DATA_PUBLIC,
|
||||
TestConstants::FIELD_MESSAGE,
|
||||
$logRecord
|
||||
));
|
||||
$this->assertFalse($strategy->shouldApply(
|
||||
'no sensitive info',
|
||||
'context.field',
|
||||
$logRecord
|
||||
));
|
||||
}
|
||||
|
||||
public function testShouldApplyForNonStringValuesWhenPatternMatches(): void
|
||||
|
||||
@@ -127,7 +127,8 @@ final class RegexMaskingStrategyTest extends TestCase
|
||||
'/"email":"[^"]+"/' => '"email":"' . MaskConstants::MASK_EMAIL_PATTERN . '"',
|
||||
]);
|
||||
|
||||
$result = $strategy->mask([TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST], 'field', $this->logRecord);
|
||||
$input = [TestConstants::CONTEXT_EMAIL => TestConstants::EMAIL_TEST];
|
||||
$result = $strategy->mask($input, 'field', $this->logRecord);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(MaskConstants::MASK_EMAIL_PATTERN, $result[TestConstants::CONTEXT_EMAIL]);
|
||||
@@ -180,7 +181,11 @@ final class RegexMaskingStrategyTest extends TestCase
|
||||
excludePaths: ['excluded.field']
|
||||
);
|
||||
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'excluded.field', $this->logRecord));
|
||||
$this->assertFalse($strategy->shouldApply(
|
||||
TestConstants::DATA_NUMBER_STRING,
|
||||
'excluded.field',
|
||||
$this->logRecord
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -191,7 +196,11 @@ final class RegexMaskingStrategyTest extends TestCase
|
||||
excludePaths: ['excluded.field']
|
||||
);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'included.field', $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::DATA_NUMBER_STRING,
|
||||
'included.field',
|
||||
$this->logRecord
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -202,9 +211,21 @@ final class RegexMaskingStrategyTest extends TestCase
|
||||
includePaths: ['user.ssn', 'user.phone']
|
||||
);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'user.ssn', $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, 'user.phone', $this->logRecord));
|
||||
$this->assertFalse($strategy->shouldApply(TestConstants::DATA_NUMBER_STRING, TestConstants::FIELD_USER_EMAIL, $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::DATA_NUMBER_STRING,
|
||||
'user.ssn',
|
||||
$this->logRecord
|
||||
));
|
||||
$this->assertTrue($strategy->shouldApply(
|
||||
TestConstants::DATA_NUMBER_STRING,
|
||||
'user.phone',
|
||||
$this->logRecord
|
||||
));
|
||||
$this->assertFalse($strategy->shouldApply(
|
||||
TestConstants::DATA_NUMBER_STRING,
|
||||
TestConstants::FIELD_USER_EMAIL,
|
||||
$this->logRecord
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -337,7 +358,9 @@ final class RegexMaskingStrategyTest extends TestCase
|
||||
'/password/i' => MaskConstants::MASK_GENERIC,
|
||||
]);
|
||||
|
||||
$this->assertSame(MaskConstants::MASK_GENERIC . ' ' . MaskConstants::MASK_GENERIC, $strategy->mask('password PASSWORD', 'field', $this->logRecord));
|
||||
$expected = MaskConstants::MASK_GENERIC . ' ' . MaskConstants::MASK_GENERIC;
|
||||
$result = $strategy->mask('password PASSWORD', 'field', $this->logRecord);
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
484
tests/Strategies/StrategyEdgeCasesTest.php
Normal file
484
tests/Strategies/StrategyEdgeCasesTest.php
Normal file
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Strategies;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
|
||||
use Ivuorinen\MonologGdprFilter\FieldMaskConfig;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\DataTypeMaskingStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\FieldPathMaskingStrategy;
|
||||
use Ivuorinen\MonologGdprFilter\Strategies\RegexMaskingStrategy;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Edge case tests for masking strategies to improve coverage.
|
||||
*/
|
||||
#[CoversClass(RegexMaskingStrategy::class)]
|
||||
#[CoversClass(DataTypeMaskingStrategy::class)]
|
||||
#[CoversClass(FieldPathMaskingStrategy::class)]
|
||||
final class StrategyEdgeCasesTest extends TestCase
|
||||
{
|
||||
private LogRecord $logRecord;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->logRecord = new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: 'Test message',
|
||||
context: [],
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// RegexMaskingStrategy ReDoS Detection
|
||||
// ========================================
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('redosPatternProvider')]
|
||||
public function regexStrategyDetectsReDoSPatterns(string $pattern): void
|
||||
{
|
||||
$this->expectException(InvalidRegexPatternException::class);
|
||||
$this->expectExceptionMessage('catastrophic backtracking');
|
||||
|
||||
$strategy = new RegexMaskingStrategy([$pattern => MaskConstants::MASK_GENERIC]);
|
||||
$this->assertInstanceOf(RegexMaskingStrategy::class, $strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string}>
|
||||
*/
|
||||
public static function redosPatternProvider(): array
|
||||
{
|
||||
return [
|
||||
'nested plus quantifier' => ['/^(a+)+$/'],
|
||||
'nested star quantifier' => ['/^(a*)*$/'],
|
||||
'plus with repetition' => ['/^(a+){1,10}$/'],
|
||||
'star with repetition' => ['/^(a*){1,10}$/'],
|
||||
'identical alternation with star' => ['/(.*|.*)x/'],
|
||||
'identical alternation with plus' => ['/(.+|.+)x/'],
|
||||
'multiple overlapping alternations with star' => ['/(ab|bc|cd)*y/'],
|
||||
'multiple overlapping alternations with plus' => ['/(ab|bc|cd)+y/'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function regexStrategySafePatternsPasses(): void
|
||||
{
|
||||
$strategy = new RegexMaskingStrategy([
|
||||
'/\d{3}-\d{2}-\d{4}/' => '[SSN]',
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/' => '[EMAIL]',
|
||||
'/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/' => '[CARD]',
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(RegexMaskingStrategy::class, $strategy);
|
||||
$this->assertTrue($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function regexStrategyHandlesErrorInHasPatternMatches(): void
|
||||
{
|
||||
$strategy = new RegexMaskingStrategy([
|
||||
'/simple/' => MaskConstants::MASK_GENERIC,
|
||||
]);
|
||||
|
||||
$result = $strategy->shouldApply('no match here', 'field', $this->logRecord);
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// DataTypeMaskingStrategy Edge Cases
|
||||
// ========================================
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyParseArrayMaskWithEmptyString(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['array' => '']);
|
||||
|
||||
$result = $strategy->mask(['original'], 'field', $this->logRecord);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyParseArrayMaskWithInvalidJson(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['array' => '[invalid json']);
|
||||
|
||||
$result = $strategy->mask(['original'], 'field', $this->logRecord);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(['invalid json'], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyParseArrayMaskWithNonArrayJson(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['array' => '["test"]']);
|
||||
|
||||
$result = $strategy->mask(['original'], 'field', $this->logRecord);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(['test'], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyParseObjectMaskWithEmptyString(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['object' => '']);
|
||||
|
||||
$obj = (object) ['key' => 'value'];
|
||||
$result = $strategy->mask($obj, 'field', $this->logRecord);
|
||||
|
||||
$this->assertIsObject($result);
|
||||
$this->assertEquals((object) [], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyParseObjectMaskWithInvalidJson(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['object' => '{invalid json']);
|
||||
|
||||
$obj = (object) ['key' => 'value'];
|
||||
$result = $strategy->mask($obj, 'field', $this->logRecord);
|
||||
|
||||
$this->assertIsObject($result);
|
||||
$this->assertEquals((object) ['masked' => '{invalid json'], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyParseObjectMaskWithNonObjectJson(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['object' => '["array"]']);
|
||||
|
||||
$obj = (object) ['key' => 'value'];
|
||||
$result = $strategy->mask($obj, 'field', $this->logRecord);
|
||||
|
||||
$this->assertIsObject($result);
|
||||
$this->assertEquals((object) ['masked' => '["array"]'], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyHandlesResourceTypeUnmapped(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['string' => 'MASKED']);
|
||||
|
||||
$resource = fopen('php://memory', 'r');
|
||||
$this->assertIsResource($resource);
|
||||
|
||||
$result = $strategy->shouldApply($resource, 'field', $this->logRecord);
|
||||
$this->assertFalse($result);
|
||||
|
||||
fclose($resource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyHandlesResourceTypeMapped(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['resource' => 'RESOURCE_MASKED']);
|
||||
|
||||
$resource = fopen('php://memory', 'r');
|
||||
$this->assertIsResource($resource);
|
||||
|
||||
$result = $strategy->shouldApply($resource, 'field', $this->logRecord);
|
||||
$this->assertTrue($result);
|
||||
|
||||
fclose($resource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyValidateWithResourceType(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['resource' => 'MASKED']);
|
||||
|
||||
$this->assertTrue($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyHandlesDoubleNonNumericMask(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['double' => 'NOT_A_NUMBER']);
|
||||
|
||||
$result = $strategy->mask(123.45, 'field', $this->logRecord);
|
||||
|
||||
$this->assertSame('NOT_A_NUMBER', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyHandlesIntegerNonNumericMask(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['integer' => 'NOT_A_NUMBER']);
|
||||
|
||||
$result = $strategy->mask(123, 'field', $this->logRecord);
|
||||
|
||||
$this->assertSame('NOT_A_NUMBER', $result);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FieldPathMaskingStrategy Edge Cases
|
||||
// ========================================
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyValidateWithEmptyConfigs(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([]);
|
||||
|
||||
$this->assertFalse($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyValidateWithEmptyPath(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy(['' => MaskConstants::MASK_GENERIC]);
|
||||
|
||||
$this->assertFalse($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyValidateWithZeroPath(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy(['0' => MaskConstants::MASK_GENERIC]);
|
||||
|
||||
$this->assertFalse($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyValidateWithValidStringConfig(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.email' => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
]);
|
||||
|
||||
$this->assertTrue($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyValidateWithFieldMaskConfig(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.email' => FieldMaskConfig::replace(MaskConstants::MASK_EMAIL_PATTERN),
|
||||
'user.ssn' => FieldMaskConfig::remove(),
|
||||
]);
|
||||
|
||||
$this->assertTrue($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyValidateWithValidRegexConfig(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.data' => FieldMaskConfig::regexMask('/\d+/', '[MASKED]'),
|
||||
]);
|
||||
|
||||
$this->assertTrue($strategy->validate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyApplyStaticReplacementPreservesIntType(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.age' => FieldMaskConfig::replace('999'),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(25, 'user.age', $this->logRecord);
|
||||
|
||||
$this->assertSame(999, $result);
|
||||
$this->assertIsInt($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyApplyStaticReplacementPreservesFloatType(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'price' => FieldMaskConfig::replace('99.99'),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(123.45, 'price', $this->logRecord);
|
||||
|
||||
$this->assertSame(99.99, $result);
|
||||
$this->assertIsFloat($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyApplyStaticReplacementPreservesBoolType(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'active' => FieldMaskConfig::replace('false'),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(true, 'active', $this->logRecord);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertIsBool($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyApplyStaticReplacementWithNonNumericForInt(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.count' => FieldMaskConfig::replace('NOT_NUMERIC'),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(42, 'user.count', $this->logRecord);
|
||||
|
||||
$this->assertSame('NOT_NUMERIC', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyShouldApplyReturnsFalseForMissingPath(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.email' => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
]);
|
||||
|
||||
$this->assertFalse($strategy->shouldApply('value', 'other.path', $this->logRecord));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyShouldApplyReturnsTrueForWildcardMatch(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.*' => MaskConstants::MASK_GENERIC,
|
||||
]);
|
||||
|
||||
$this->assertTrue($strategy->shouldApply('value', 'user.email', $this->logRecord));
|
||||
$this->assertTrue($strategy->shouldApply('value', 'user.name', $this->logRecord));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyMaskAppliesRemoveConfig(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'secret.key' => FieldMaskConfig::remove(),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask('sensitive', 'secret.key', $this->logRecord);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyMaskAppliesRegexConfig(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.ssn' => FieldMaskConfig::regexMask('/\d{3}-\d{2}-\d{4}/', '[SSN]'),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask('SSN: 123-45-6789', 'user.ssn', $this->logRecord);
|
||||
|
||||
$this->assertSame('SSN: [SSN]', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyMaskHandlesArrayValue(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'data' => FieldMaskConfig::regexMask('/\d+/', '[NUM]'),
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(['count' => '123 items'], 'data', $this->logRecord);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame('[NUM] items', $result['count']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyMaskReturnsValueWhenNoConfigMatch(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.email' => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
]);
|
||||
|
||||
$result = $strategy->mask('original', 'other.field', $this->logRecord);
|
||||
|
||||
$this->assertSame('original', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyGetNameReturnsCorrectFormat(): void
|
||||
{
|
||||
$strategy = new FieldPathMaskingStrategy([
|
||||
'user.email' => MaskConstants::MASK_EMAIL_PATTERN,
|
||||
'user.phone' => MaskConstants::MASK_PHONE,
|
||||
]);
|
||||
|
||||
$name = $strategy->getName();
|
||||
|
||||
$this->assertSame('Field Path Masking (2 fields)', $name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fieldPathStrategyGetConfigurationReturnsAllSettings(): void
|
||||
{
|
||||
$config = [
|
||||
'user.email' => FieldMaskConfig::replace('[EMAIL]'),
|
||||
];
|
||||
$strategy = new FieldPathMaskingStrategy($config);
|
||||
|
||||
$result = $strategy->getConfiguration();
|
||||
|
||||
$this->assertArrayHasKey('field_configs', $result);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Integration Edge Cases
|
||||
// ========================================
|
||||
|
||||
#[Test]
|
||||
public function regexStrategyMaskHandlesBooleanValue(): void
|
||||
{
|
||||
$strategy = new RegexMaskingStrategy([
|
||||
'/true/' => 'MASKED',
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(true, 'field', $this->logRecord);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function regexStrategyMaskHandlesNullValue(): void
|
||||
{
|
||||
$strategy = new RegexMaskingStrategy([
|
||||
'/.*/' => 'MASKED',
|
||||
]);
|
||||
|
||||
$result = $strategy->mask(null, 'field', $this->logRecord);
|
||||
|
||||
// Null converts to empty string, which matches .* and gets masked
|
||||
// preserveValueType doesn't specifically handle null, so returns masked string
|
||||
$this->assertSame('MASKED', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function regexStrategyMaskHandlesEmptyString(): void
|
||||
{
|
||||
$strategy = new RegexMaskingStrategy([
|
||||
'/.+/' => 'MASKED',
|
||||
]);
|
||||
|
||||
$result = $strategy->mask('', 'field', $this->logRecord);
|
||||
|
||||
$this->assertSame('', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataTypeStrategyMaskHandlesDefaultCase(): void
|
||||
{
|
||||
$strategy = new DataTypeMaskingStrategy(['string' => 'MASKED']);
|
||||
|
||||
$result = $strategy->mask('test', 'field', $this->logRecord);
|
||||
|
||||
$this->assertSame('MASKED', $result);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,11 @@ final class StrategyManagerEnhancedTest extends TestCase
|
||||
$logRecord = $this->createLogRecord();
|
||||
|
||||
// Value doesn't match pattern
|
||||
$applicable = $manager->getApplicableStrategies(TestConstants::DATA_PUBLIC, TestConstants::FIELD_MESSAGE, $logRecord);
|
||||
$applicable = $manager->getApplicableStrategies(
|
||||
TestConstants::DATA_PUBLIC,
|
||||
TestConstants::FIELD_MESSAGE,
|
||||
$logRecord
|
||||
);
|
||||
|
||||
$this->assertEmpty($applicable);
|
||||
}
|
||||
@@ -90,19 +94,22 @@ final class StrategyManagerEnhancedTest extends TestCase
|
||||
{
|
||||
$manager = new StrategyManager();
|
||||
|
||||
// Add strategies with edge priority values
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 0)); // Lowest
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 19)); // High edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 20)); // Medium-high boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 39)); // Medium-high edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 40)); // Medium boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 59)); // Medium edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 60)); // Medium-low boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 79)); // Medium-low edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 80)); // Low boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 89)); // Low edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 90)); // Lowest boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy([TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC], [], [], 100)); // Highest
|
||||
// Common pattern for all strategies
|
||||
$patterns = [TestConstants::PATTERN_TEST => MaskConstants::MASK_GENERIC];
|
||||
|
||||
// Add strategies with edge priority values across all priority ranges
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 0)); // Lowest
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 19)); // High edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 20)); // Medium-high boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 39)); // Medium-high edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 40)); // Medium boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 59)); // Medium edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 60)); // Medium-low boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 79)); // Medium-low edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 80)); // Low boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 89)); // Low edge
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 90)); // Lowest boundary
|
||||
$manager->addStrategy(new RegexMaskingStrategy($patterns, [], [], 100)); // Highest
|
||||
|
||||
$stats = $manager->getStatistics();
|
||||
|
||||
|
||||
249
tests/Streaming/StreamingProcessorTest.php
Normal file
249
tests/Streaming/StreamingProcessorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,31 @@ final class TestConstants
|
||||
// Replacement Values
|
||||
public const REPLACEMENT_TEST = '[TEST]';
|
||||
|
||||
// Age range values
|
||||
public const AGE_RANGE_20_29 = '20-29';
|
||||
|
||||
// Additional email variations
|
||||
public const EMAIL_NEW = 'new@example.com';
|
||||
public const EMAIL_JOHN = 'john@example.com';
|
||||
|
||||
// Mask placeholders used in tests (bracketed format)
|
||||
public const MASK_REDACTED_BRACKETS = '[REDACTED]';
|
||||
public const MASK_MASKED_BRACKETS = '[MASKED]';
|
||||
public const MASK_EMAIL_BRACKETS = '[EMAIL]';
|
||||
public const MASK_DIGITS_BRACKETS = '[DIGITS]';
|
||||
public const MASK_INT_BRACKETS = '[INT]';
|
||||
public const MASK_ALWAYS_THIS = '[ALWAYS_THIS]';
|
||||
|
||||
// Test values
|
||||
public const VALUE_TEST = 'test value';
|
||||
public const VALUE_SUFFIX = ' value';
|
||||
|
||||
// Additional pattern constants
|
||||
public const PATTERN_VALID_SIMPLE = '/^test$/';
|
||||
public const PATTERN_INVALID_UNCLOSED = '/unclosed';
|
||||
public const PATTERN_REDOS_VULNERABLE = '/^(a+)+$/';
|
||||
public const PATTERN_SAFE = '/[a-z]+/';
|
||||
|
||||
/**
|
||||
* Prevent instantiation.
|
||||
*
|
||||
|
||||
@@ -199,6 +199,7 @@ trait TestHelpers
|
||||
*/
|
||||
protected function clearPatternCache(): void
|
||||
{
|
||||
/** @psalm-suppress DeprecatedMethod - Test helper for deprecated cache API */
|
||||
PatternValidator::clearCache();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user