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

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

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

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

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

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

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

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

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

* docs: add framework integration guides and examples

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

* chore(docker): add Docker development environment

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

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

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

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

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

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

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

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

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

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

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

* chore: tests, update actions, sonarcloud issues

* chore: rector

* fix: more sonarcloud fixes

* chore: more fixes

* refactor: copilot review fix

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

View File

@@ -0,0 +1,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);
}
}

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

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

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