mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-02-13 10:50:11 +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:
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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user