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,190 @@
<?php
declare(strict_types=1);
namespace Tests\Audit;
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
use PHPUnit\Framework\TestCase;
/**
* Tests for AuditContext value object.
*
* @api
*/
final class AuditContextTest extends TestCase
{
public function testSuccessCreation(): void
{
$context = AuditContext::success(
AuditContext::OP_REGEX,
12.5,
['key' => 'value']
);
$this->assertSame(AuditContext::OP_REGEX, $context->operationType);
$this->assertSame(AuditContext::STATUS_SUCCESS, $context->status);
$this->assertSame(1, $context->attemptNumber);
$this->assertSame(12.5, $context->durationMs);
$this->assertNull($context->error);
$this->assertSame(['key' => 'value'], $context->metadata);
}
public function testFailedCreation(): void
{
$error = ErrorContext::create('TestError', 'Something went wrong');
$context = AuditContext::failed(
AuditContext::OP_FIELD_PATH,
$error,
3,
50.0,
['retry' => true]
);
$this->assertSame(AuditContext::OP_FIELD_PATH, $context->operationType);
$this->assertSame(AuditContext::STATUS_FAILED, $context->status);
$this->assertSame(3, $context->attemptNumber);
$this->assertSame(50.0, $context->durationMs);
$this->assertSame($error, $context->error);
$this->assertArrayHasKey('retry', $context->metadata);
}
public function testRecoveredCreation(): void
{
$context = AuditContext::recovered(
AuditContext::OP_CALLBACK,
2,
25.0
);
$this->assertSame(AuditContext::OP_CALLBACK, $context->operationType);
$this->assertSame(AuditContext::STATUS_RECOVERED, $context->status);
$this->assertSame(2, $context->attemptNumber);
$this->assertSame(25.0, $context->durationMs);
}
public function testSkippedCreation(): void
{
$context = AuditContext::skipped(
AuditContext::OP_CONDITIONAL,
'Condition not met'
);
$this->assertSame(AuditContext::OP_CONDITIONAL, $context->operationType);
$this->assertSame(AuditContext::STATUS_SKIPPED, $context->status);
$this->assertArrayHasKey('skip_reason', $context->metadata);
$this->assertSame('Condition not met', $context->metadata['skip_reason']);
}
public function testWithCorrelationId(): void
{
$context = AuditContext::success(AuditContext::OP_REGEX);
$this->assertNull($context->correlationId);
$withId = $context->withCorrelationId('abc123');
$this->assertNull($context->correlationId);
$this->assertSame('abc123', $withId->correlationId);
$this->assertSame($context->operationType, $withId->operationType);
$this->assertSame($context->status, $withId->status);
}
public function testWithMetadata(): void
{
$context = AuditContext::success(
AuditContext::OP_REGEX,
0.0,
['original' => 'value']
);
$withMeta = $context->withMetadata(['added' => 'new']);
$this->assertArrayHasKey('original', $withMeta->metadata);
$this->assertArrayHasKey('added', $withMeta->metadata);
$this->assertSame('value', $withMeta->metadata['original']);
$this->assertSame('new', $withMeta->metadata['added']);
}
public function testIsSuccess(): void
{
$success = AuditContext::success(AuditContext::OP_REGEX);
$recovered = AuditContext::recovered(AuditContext::OP_REGEX, 2);
$error = ErrorContext::create('Error', 'msg');
$failed = AuditContext::failed(AuditContext::OP_REGEX, $error);
$skipped = AuditContext::skipped(AuditContext::OP_REGEX, 'reason');
$this->assertTrue($success->isSuccess());
$this->assertTrue($recovered->isSuccess());
$this->assertFalse($failed->isSuccess());
$this->assertFalse($skipped->isSuccess());
}
public function testToArray(): void
{
$context = AuditContext::success(
AuditContext::OP_REGEX,
15.123456,
['key' => 'value']
);
$array = $context->toArray();
$this->assertArrayHasKey('operation_type', $array);
$this->assertArrayHasKey('status', $array);
$this->assertArrayHasKey('attempt_number', $array);
$this->assertArrayHasKey('duration_ms', $array);
$this->assertArrayHasKey('metadata', $array);
$this->assertSame(15.123, $array['duration_ms']);
}
public function testToArrayWithError(): void
{
$error = ErrorContext::create('TestError', 'Message');
$context = AuditContext::failed(AuditContext::OP_REGEX, $error);
$array = $context->toArray();
$this->assertArrayHasKey('error', $array);
$this->assertIsArray($array['error']);
}
public function testToArrayWithCorrelationId(): void
{
$context = AuditContext::success(AuditContext::OP_REGEX)
->withCorrelationId('test-id');
$array = $context->toArray();
$this->assertArrayHasKey('correlation_id', $array);
$this->assertSame('test-id', $array['correlation_id']);
}
public function testGenerateCorrelationId(): void
{
$id1 = AuditContext::generateCorrelationId();
$id2 = AuditContext::generateCorrelationId();
$this->assertIsString($id1);
$this->assertSame(16, strlen($id1));
$this->assertNotSame($id1, $id2);
}
public function testOperationTypeConstants(): void
{
$this->assertSame('regex', AuditContext::OP_REGEX);
$this->assertSame('field_path', AuditContext::OP_FIELD_PATH);
$this->assertSame('callback', AuditContext::OP_CALLBACK);
$this->assertSame('data_type', AuditContext::OP_DATA_TYPE);
$this->assertSame('json', AuditContext::OP_JSON);
$this->assertSame('conditional', AuditContext::OP_CONDITIONAL);
}
public function testStatusConstants(): void
{
$this->assertSame('success', AuditContext::STATUS_SUCCESS);
$this->assertSame('failed', AuditContext::STATUS_FAILED);
$this->assertSame('recovered', AuditContext::STATUS_RECOVERED);
$this->assertSame('skipped', AuditContext::STATUS_SKIPPED);
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Tests\Audit;
use Exception;
use InvalidArgumentException;
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Tests\TestConstants;
/**
* Tests for ErrorContext value object.
*
* @api
*/
final class ErrorContextTest extends TestCase
{
public function testBasicConstruction(): void
{
$context = new ErrorContext(
errorType: 'TestError',
message: 'Something went wrong',
code: 42,
file: '/path/to/file.php',
line: 123,
metadata: ['key' => 'value']
);
$this->assertSame('TestError', $context->errorType);
$this->assertSame('Something went wrong', $context->message);
$this->assertSame(42, $context->code);
$this->assertSame('/path/to/file.php', $context->file);
$this->assertSame(123, $context->line);
$this->assertSame(['key' => 'value'], $context->metadata);
}
public function testFromThrowable(): void
{
$exception = new RuntimeException('Test exception', 500);
$context = ErrorContext::fromThrowable($exception);
$this->assertSame(RuntimeException::class, $context->errorType);
$this->assertSame('Test exception', $context->message);
$this->assertSame(500, $context->code);
$this->assertNull($context->file);
$this->assertNull($context->line);
}
public function testFromThrowableWithSensitiveDetails(): void
{
$exception = new Exception('Error at /home/user/app');
$context = ErrorContext::fromThrowable($exception, includeSensitive: true);
$this->assertNotNull($context->file);
$this->assertNotNull($context->line);
$this->assertArrayHasKey('trace', $context->metadata);
}
public function testCreate(): void
{
$context = ErrorContext::create(
'CustomError',
'Error message',
['detail' => 'info']
);
$this->assertSame('CustomError', $context->errorType);
$this->assertSame('Error message', $context->message);
$this->assertSame(0, $context->code);
$this->assertArrayHasKey('detail', $context->metadata);
}
public function testSanitizesPasswordsInMessage(): void
{
$message = 'Connection failed: password=secret123';
$context = ErrorContext::create('DbError', $message);
$this->assertStringNotContainsString('secret123', $context->message);
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
}
public function testSanitizesApiKeysInMessage(): void
{
$message = 'Auth failed: api_key=sk_live_1234567890';
$context = ErrorContext::create('ApiError', $message);
$this->assertStringNotContainsString('sk_live_1234567890', $context->message);
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
}
public function testSanitizesTokensInMessage(): void
{
$message = 'Auth failed with bearer abc123def456';
$context = ErrorContext::create('AuthError', $message);
$this->assertStringNotContainsString('abc123def456', $context->message);
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
}
public function testSanitizesTokenValueInMessage(): void
{
$message = 'Invalid token=secret_value_here';
$context = ErrorContext::create('AuthError', $message);
$this->assertStringNotContainsString('secret_value_here', $context->message);
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
}
public function testSanitizesConnectionStrings(): void
{
$message = 'Failed: redis://admin:password@localhost:6379';
$context = ErrorContext::create('ConnError', $message);
$this->assertStringNotContainsString('password', $context->message);
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
}
public function testSanitizesUserCredentials(): void
{
$message = 'DB error: user=admin host=secret.internal.com';
$context = ErrorContext::create('DbError', $message);
$this->assertStringNotContainsString('admin', $context->message);
$this->assertStringNotContainsString('secret.internal.com', $context->message);
$this->assertStringContainsString(TestConstants::MASK_REDACTED_BRACKETS, $context->message);
}
public function testSanitizesFilePaths(): void
{
$message = 'Cannot read /var/www/secret-app/config/credentials.php';
$context = ErrorContext::create('FileError', $message);
$this->assertStringNotContainsString('/var/www/secret-app', $context->message);
$this->assertStringContainsString('[PATH_REDACTED]', $context->message);
}
public function testToArray(): void
{
$context = new ErrorContext(
errorType: 'TestError',
message: 'Test message',
code: 100,
file: '/test/file.php',
line: 50,
metadata: ['key' => 'value']
);
$array = $context->toArray();
$this->assertArrayHasKey('error_type', $array);
$this->assertArrayHasKey('message', $array);
$this->assertArrayHasKey('code', $array);
$this->assertArrayHasKey('file', $array);
$this->assertArrayHasKey('line', $array);
$this->assertArrayHasKey('metadata', $array);
$this->assertSame('TestError', $array['error_type']);
$this->assertSame('Test message', $array['message']);
$this->assertSame(100, $array['code']);
}
public function testToArrayOmitsNullValues(): void
{
$context = ErrorContext::create('Error', 'Message');
$array = $context->toArray();
$this->assertArrayNotHasKey('file', $array);
$this->assertArrayNotHasKey('line', $array);
}
public function testToArrayOmitsEmptyMetadata(): void
{
$context = ErrorContext::create('Error', 'Message', []);
$array = $context->toArray();
$this->assertArrayNotHasKey('metadata', $array);
}
public function testFromThrowableWithNestedException(): void
{
$inner = new InvalidArgumentException('Inner error');
$outer = new RuntimeException('Outer error', 0, $inner);
$context = ErrorContext::fromThrowable($outer);
$this->assertSame(RuntimeException::class, $context->errorType);
$this->assertStringContainsString('Outer error', $context->message);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Tests\Audit;
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
use Ivuorinen\MonologGdprFilter\Audit\StructuredAuditLogger;
use Ivuorinen\MonologGdprFilter\RateLimitedAuditLogger;
use Ivuorinen\MonologGdprFilter\RateLimiter;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
/**
* Tests for StructuredAuditLogger.
*
* @api
*/
final class StructuredAuditLoggerTest extends TestCase
{
/** @var array<array{path: string, original: mixed, masked: mixed}> */
private array $logs;
#[\Override]
protected function setUp(): void
{
parent::setUp();
$this->logs = [];
RateLimiter::clearAll();
}
#[\Override]
protected function tearDown(): void
{
RateLimiter::clearAll();
parent::tearDown();
}
private function createBaseLogger(): callable
{
return function (string $path, mixed $original, mixed $masked): void {
$this->logs[] = [
'path' => $path,
'original' => $original,
'masked' => $masked
];
};
}
public function testBasicLogging(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS);
$this->assertCount(1, $this->logs);
$this->assertSame('user.email', $this->logs[0]['path']);
$this->assertSame(TestConstants::EMAIL_JOHN, $this->logs[0]['original']);
$this->assertSame(TestConstants::MASK_MASKED_BRACKETS, $this->logs[0]['masked']);
}
public function testLogWithContext(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$context = AuditContext::success(AuditContext::OP_REGEX, 5.0);
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS, $context);
$this->assertCount(1, $this->logs);
}
public function testLogSuccess(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$logger->logSuccess(
'user.ssn',
'123-45-6789',
'[SSN]',
AuditContext::OP_REGEX,
10.5
);
$this->assertCount(1, $this->logs);
$this->assertSame('user.ssn', $this->logs[0]['path']);
}
public function testLogFailure(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$error = ErrorContext::create('RegexError', 'Pattern failed');
$logger->logFailure(
'user.data',
'sensitive value',
AuditContext::OP_REGEX,
$error
);
$this->assertCount(1, $this->logs);
$this->assertSame('[MASKING_FAILED]', $this->logs[0]['masked']);
}
public function testLogRecovery(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$logger->logRecovery(
'user.email',
TestConstants::EMAIL_JOHN,
TestConstants::MASK_MASKED_BRACKETS,
AuditContext::OP_REGEX,
2,
25.0
);
$this->assertCount(1, $this->logs);
}
public function testLogSkipped(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$logger->logSkipped(
'user.public_name',
TestConstants::NAME_FULL,
AuditContext::OP_CONDITIONAL,
'Field not in mask list'
);
$this->assertCount(1, $this->logs);
$this->assertSame(TestConstants::NAME_FULL, $this->logs[0]['original']);
$this->assertSame(TestConstants::NAME_FULL, $this->logs[0]['masked']);
}
public function testWrapStaticFactory(): void
{
$logger = StructuredAuditLogger::wrap($this->createBaseLogger());
$logger->log('test.path', 'original', 'masked');
$this->assertCount(1, $this->logs);
}
public function testWithRateLimitedLogger(): void
{
$rateLimited = new RateLimitedAuditLogger(
$this->createBaseLogger(),
100,
60
);
$logger = new StructuredAuditLogger($rateLimited);
$logger->log('user.email', TestConstants::EMAIL_JOHN, TestConstants::MASK_MASKED_BRACKETS);
$this->assertCount(1, $this->logs);
}
public function testTimerMethods(): void
{
$logger = new StructuredAuditLogger($this->createBaseLogger());
$start = $logger->startTimer();
usleep(10000);
$elapsed = $logger->elapsed($start);
$this->assertGreaterThan(0, $elapsed);
$this->assertLessThan(100, $elapsed);
}
public function testGetWrappedLogger(): void
{
$baseLogger = $this->createBaseLogger();
$logger = new StructuredAuditLogger($baseLogger);
$wrapped = $logger->getWrappedLogger();
// Verify the wrapped logger works by calling it
$wrapped('test.path', 'original', 'masked');
$this->assertCount(1, $this->logs);
}
public function testDisableTimestamp(): void
{
$logger = new StructuredAuditLogger(
$this->createBaseLogger(),
includeTimestamp: false
);
$logger->log('test', 'original', 'masked');
$this->assertCount(1, $this->logs);
}
public function testDisableDuration(): void
{
$logger = new StructuredAuditLogger(
$this->createBaseLogger(),
includeDuration: false
);
$logger->log('test', 'original', 'masked');
$this->assertCount(1, $this->logs);
}
}