feat: add advanced architecture, documentation, and coverage improvements (#65)

* fix(style): resolve PHPCS line-length warnings in source files

* fix(style): resolve PHPCS line-length warnings in test files

* feat(audit): add structured audit logging with ErrorContext and AuditContext

- ErrorContext: standardized error information with sensitive data sanitization
- AuditContext: structured context for audit entries with operation types
- StructuredAuditLogger: enhanced audit logger wrapper with timing support

* feat(recovery): add recovery mechanism for failed masking operations

- FailureMode enum: FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE modes
- RecoveryStrategy interface and RecoveryResult value object
- RetryStrategy: exponential backoff with configurable attempts
- FallbackMaskStrategy: type-aware fallback values

* feat(strategies): add CallbackMaskingStrategy for custom masking logic

- Wraps custom callbacks as MaskingStrategy implementations
- Factory methods: constant(), hash(), partial() for common use cases
- Supports exact match and prefix match for field paths

* docs: add framework integration guides and examples

- symfony-integration.md: Symfony service configuration and Monolog setup
- psr3-decorator.md: PSR-3 logger decorator pattern implementation
- framework-examples.md: CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15
- docker-development.md: Docker development environment guide

* chore(docker): add Docker development environment

- Dockerfile: PHP 8.2-cli-alpine with Xdebug for coverage
- docker-compose.yml: development services with volume mounts

* feat(demo): add interactive GDPR pattern tester playground

- PatternTester.php: pattern testing utility with strategy support
- index.php: web API endpoint with JSON response handling
- playground.html: interactive web interface for testing patterns

* docs(todo): update with completed medium priority items

- Mark all PHPCS warnings as fixed (81 → 0)
- Document new Audit and Recovery features
- Update test count to 1,068 tests with 2,953 assertions
- Move remaining items to low priority

* feat: add advanced architecture, documentation, and coverage improvements

- Add architecture improvements:
  - ArrayAccessorInterface and DotArrayAccessor for decoupled array access
  - MaskingOrchestrator for single-responsibility masking coordination
  - GdprProcessorBuilder for fluent configuration
  - MaskingPluginInterface and AbstractMaskingPlugin for plugin architecture
  - PluginAwareProcessor for plugin hook execution
  - AuditLoggerFactory for instance-based audit logger creation

- Add advanced features:
  - SerializedDataProcessor for handling print_r/var_export/serialize output
  - KAnonymizer with GeneralizationStrategy for GDPR k-anonymity
  - RetentionPolicy for configurable data retention periods
  - StreamingProcessor for memory-efficient large log processing

- Add comprehensive documentation:
  - docs/performance-tuning.md - benchmarking, optimization, caching
  - docs/troubleshooting.md - common issues and solutions
  - docs/logging-integrations.md - ELK, Graylog, Datadog, etc.
  - docs/plugin-development.md - complete plugin development guide

- Improve test coverage (84.41% → 85.07%):
  - ConditionalRuleFactoryInstanceTest (100% coverage)
  - GdprProcessorBuilderEdgeCasesTest (100% coverage)
  - StrategyEdgeCasesTest for ReDoS detection and type parsing
  - 78 new tests, 119 new assertions

- Update TODO.md with current statistics:
  - 141 PHP files, 1,346 tests, 85.07% line coverage

* chore: tests, update actions, sonarcloud issues

* chore: rector

* fix: more sonarcloud fixes

* chore: more fixes

* refactor: copilot review fix

* chore: rector
This commit is contained in:
2025-12-22 13:38:18 +02:00
committed by GitHub
parent b1eb567b92
commit 8866daaf33
112 changed files with 15391 additions and 607 deletions

View File

@@ -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]

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

View File

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

View File

@@ -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());

View File

@@ -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'));

View File

@@ -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

View File

@@ -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

View File

@@ -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]

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

View File

@@ -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();