feat: performance, integrations, advanced features (#2)

* feat: performance, integrations, advanced features

* chore: fix linting problems

* chore: suppressions and linting

* chore(lint): pre-commit linting, fixes

* feat: comprehensive input validation, security hardening, and regression testing

- Add extensive input validation throughout codebase with proper error handling
- Implement comprehensive security hardening with ReDoS protection and bounds checking
- Add 3 new regression test suites covering critical bugs, security, and validation scenarios
- Enhance rate limiting with memory management and configurable cleanup intervals
- Update configuration security settings and improve Laravel integration
- Fix TODO.md timestamps to reflect actual development timeline
- Strengthen static analysis configuration and improve code quality standards

* feat: configure static analysis tools and enhance development workflow

- Complete configuration of Psalm, PHPStan, and Rector for harmonious static analysis.
- Fix invalid configurations and tool conflicts that prevented proper code quality analysis.
- Add comprehensive safe analysis script with interactive workflow, backup/restore
  capabilities, and dry-run modes. Update documentation with linting policy
  requiring issue resolution over suppression.
- Clean completed items from TODO to focus on actionable improvements.
- All static analysis tools now work together seamlessly to provide
  code quality insights without breaking existing functionality.

* fix(test): update Invalid regex pattern expectation

* chore: phpstan, psalm fixes

* chore: phpstan, psalm fixes, more tests

* chore: tooling tweaks, cleanup

* chore: tweaks to get the tests pass

* fix(lint): rector config tweaks and successful run

* feat: refactoring, more tests, fixes, cleanup

* chore: deduplication, use constants

* chore: psalm fixes

* chore: ignore phpstan deliberate errors in tests

* chore: improve codebase, deduplicate code

* fix: lint

* chore: deduplication, codebase simplification, sonarqube fixes

* fix: resolve SonarQube reliability rating issues

Fix useless object instantiation warnings in test files by assigning
instantiated objects to variables. This resolves the SonarQube reliability
rating issue (was C, now targeting A).

Changes:
- tests/Strategies/MaskingStrategiesTest.php: Fix 3 instances
- tests/Strategies/FieldPathMaskingStrategyTest.php: Fix 1 instance

The tests use expectException() to verify that constructors throw
exceptions for invalid input. SonarQube flagged standalone `new`
statements as useless. Fixed by assigning to variables with explicit
unset() and fail() calls.

All tests pass (623/623) and static analysis tools pass.

* fix: resolve more SonarQube detected issues

* fix: resolve psalm detected issues

* fix: resolve more SonarQube detected issues

* fix: resolve psalm detected issues

* fix: duplications

* fix: resolve SonarQube reliability rating issues

* fix: resolve psalm and phpstan detected issues
This commit is contained in:
2025-10-31 13:59:01 +02:00
committed by GitHub
parent 63637900c8
commit 00c6f76c97
126 changed files with 30815 additions and 921 deletions

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
namespace Tests\Exceptions;
use Ivuorinen\MonologGdprFilter\Exceptions\AuditLoggingException;
use Ivuorinen\MonologGdprFilter\MaskConstants;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
#[CoversClass(AuditLoggingException::class)]
final class AuditLoggingExceptionComprehensiveTest extends TestCase
{
public function testCallbackFailedCreatesException(): void
{
$exception = AuditLoggingException::callbackFailed(
TestConstants::FIELD_USER_EMAIL,
TestConstants::EMAIL_TEST,
MaskConstants::MASK_EMAIL_PATTERN,
'Callback threw exception'
);
$this->assertInstanceOf(AuditLoggingException::class, $exception);
$message = $exception->getMessage();
$this->assertStringContainsString(TestConstants::FIELD_USER_EMAIL, $message);
$this->assertStringContainsString('Callback threw exception', $message);
$this->assertStringContainsString('callback_failure', $message);
}
public function testCallbackFailedWithPreviousException(): void
{
$previous = new \RuntimeException('Original error');
$exception = AuditLoggingException::callbackFailed(
'field',
'value',
TestConstants::DATA_MASKED,
'Error',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testCallbackFailedWithArrayValues(): void
{
$original = ['key1' => 'value1', 'key2' => 'value2'];
$masked = ['key1' => 'MASKED'];
$exception = AuditLoggingException::callbackFailed(
'data',
$original,
$masked,
'Processing failed'
);
$message = $exception->getMessage();
$this->assertStringContainsString('data', $message);
$this->assertStringContainsString('array', $message);
$this->assertStringContainsString('Processing failed', $message);
}
public function testCallbackFailedWithLongString(): void
{
$longString = str_repeat('a', 150);
$exception = AuditLoggingException::callbackFailed(
'field',
$longString,
TestConstants::DATA_MASKED,
'error'
);
$message = $exception->getMessage();
// Should contain truncated preview with '...'
$this->assertStringContainsString('...', $message);
}
public function testCallbackFailedWithObject(): void
{
$object = (object)['property' => 'value'];
$exception = AuditLoggingException::callbackFailed(
'field',
$object,
TestConstants::DATA_MASKED,
'error'
);
$message = $exception->getMessage();
$this->assertStringContainsString('object', $message);
}
public function testSerializationFailedCreatesException(): void
{
$value = ['data' => 'test'];
$exception = AuditLoggingException::serializationFailed(
'user.data',
$value,
'JSON encoding failed'
);
$this->assertInstanceOf(AuditLoggingException::class, $exception);
$message = $exception->getMessage();
$this->assertStringContainsString('user.data', $message);
$this->assertStringContainsString('JSON encoding failed', $message);
$this->assertStringContainsString('serialization_failure', $message);
}
public function testSerializationFailedWithPrevious(): void
{
$previous = new \Exception('Encoding error');
$exception = AuditLoggingException::serializationFailed(
'path',
'value',
'Failed',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testRateLimitingFailedCreatesException(): void
{
$exception = AuditLoggingException::rateLimitingFailed(
'audit_log',
150,
100,
'Rate limit exceeded'
);
$this->assertInstanceOf(AuditLoggingException::class, $exception);
$message = $exception->getMessage();
$this->assertStringContainsString('audit_log', $message);
$this->assertStringContainsString('Rate limit exceeded', $message);
$this->assertStringContainsString('rate_limiting_failure', $message);
$this->assertStringContainsString('150', $message);
$this->assertStringContainsString('100', $message);
}
public function testRateLimitingFailedWithPrevious(): void
{
$previous = new \RuntimeException('Limiter error');
$exception = AuditLoggingException::rateLimitingFailed(
'operation',
10,
5,
'Exceeded',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testInvalidConfigurationCreatesException(): void
{
$config = ['profile' => 'invalid', 'max_requests' => -1];
$exception = AuditLoggingException::invalidConfiguration(
'Profile not found',
$config
);
$this->assertInstanceOf(AuditLoggingException::class, $exception);
$message = $exception->getMessage();
$this->assertStringContainsString('Profile not found', $message);
$this->assertStringContainsString('configuration_error', $message);
$this->assertStringContainsString('invalid', $message);
}
public function testInvalidConfigurationWithPrevious(): void
{
$previous = new \InvalidArgumentException('Bad config');
$exception = AuditLoggingException::invalidConfiguration(
'Issue',
[],
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testLoggerCreationFailedCreatesException(): void
{
$exception = AuditLoggingException::loggerCreationFailed(
'RateLimitedLogger',
'Invalid callback provided'
);
$this->assertInstanceOf(AuditLoggingException::class, $exception);
$message = $exception->getMessage();
$this->assertStringContainsString('RateLimitedLogger', $message);
$this->assertStringContainsString('Invalid callback provided', $message);
$this->assertStringContainsString('logger_creation_failure', $message);
}
public function testLoggerCreationFailedWithPrevious(): void
{
$previous = new \TypeError('Wrong type');
$exception = AuditLoggingException::loggerCreationFailed(
'Logger',
'Failed',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testAllExceptionTypesHaveCorrectCode(): void
{
$callback = AuditLoggingException::callbackFailed('p', 'o', 'm', 'r');
$serialization = AuditLoggingException::serializationFailed('p', 'v', 'r');
$rateLimit = AuditLoggingException::rateLimitingFailed('t', 1, 2, 'r');
$config = AuditLoggingException::invalidConfiguration('i', []);
$creation = AuditLoggingException::loggerCreationFailed('t', 'r');
// All should have code 0 as specified in the method calls
$this->assertSame(0, $callback->getCode());
$this->assertSame(0, $serialization->getCode());
$this->assertSame(0, $rateLimit->getCode());
$this->assertSame(0, $config->getCode());
$this->assertSame(0, $creation->getCode());
}
public function testValuePreviewWithResource(): void
{
// Create a resource which cannot be JSON encoded
$resource = fopen('php://memory', 'r');
$this->assertIsResource($resource);
$exception = AuditLoggingException::callbackFailed(
'field',
$resource,
TestConstants::DATA_MASKED,
'error'
);
if (is_resource($resource)) {
fclose($resource);
}
$message = $exception->getMessage();
// Resource should be converted to string representation
$this->assertStringContainsString('Resource', $message);
}
public function testValuePreviewWithInteger(): void
{
$exception = AuditLoggingException::callbackFailed(
'field',
12345,
99999,
'error'
);
$message = $exception->getMessage();
$this->assertStringContainsString(TestConstants::DATA_NUMBER_STRING, $message);
$this->assertStringContainsString('99999', $message);
}
public function testValuePreviewWithFloat(): void
{
$exception = AuditLoggingException::callbackFailed(
'field',
3.14159,
0.0,
'error'
);
$message = $exception->getMessage();
$this->assertStringContainsString('3.14159', $message);
}
public function testValuePreviewWithBoolean(): void
{
$exception = AuditLoggingException::callbackFailed(
'field',
true,
false,
'error'
);
$message = $exception->getMessage();
$this->assertStringContainsString('boolean', $message);
}
public function testValuePreviewWithNull(): void
{
$exception = AuditLoggingException::callbackFailed(
'field',
null,
null,
'error'
);
$message = $exception->getMessage();
$this->assertStringContainsString('NULL', $message);
}
public function testValuePreviewWithLargeArray(): void
{
$largeArray = array_fill(0, 100, 'value');
$exception = AuditLoggingException::callbackFailed(
'field',
$largeArray,
TestConstants::DATA_MASKED,
'error'
);
$message = $exception->getMessage();
// Large JSON should be truncated
$this->assertStringContainsString('...', $message);
}
}

View File

@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace Tests\Exceptions;
use Tests\TestConstants;
use Exception;
use PHPUnit\Framework\TestCase;
use Ivuorinen\MonologGdprFilter\Exceptions\GdprProcessorException;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRegexPatternException;
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
use Ivuorinen\MonologGdprFilter\Exceptions\AuditLoggingException;
use Ivuorinen\MonologGdprFilter\Exceptions\RecursionDepthExceededException;
use RuntimeException;
/**
* Tests for custom GDPR processor exceptions.
* @api
*/
class CustomExceptionsTest extends TestCase
{
public function testGdprProcessorExceptionBasicUsage(): void
{
$exception = new GdprProcessorException(TestConstants::MESSAGE_DEFAULT, 123);
$this->assertSame(TestConstants::MESSAGE_DEFAULT, $exception->getMessage());
$this->assertEquals(123, $exception->getCode());
}
public function testGdprProcessorExceptionWithContext(): void
{
$context = ['field' => TestConstants::CONTEXT_EMAIL, 'value' => TestConstants::EMAIL_TEST];
$exception = GdprProcessorException::withContext(TestConstants::MESSAGE_BASE, $context);
$this->assertStringContainsString(TestConstants::MESSAGE_BASE, $exception->getMessage());
$this->assertStringContainsString('field: "' . TestConstants::CONTEXT_EMAIL . '"', $exception->getMessage());
$this->assertStringContainsString('value: "' . TestConstants::EMAIL_TEST . '"', $exception->getMessage());
}
public function testGdprProcessorExceptionWithEmptyContext(): void
{
$exception = GdprProcessorException::withContext(TestConstants::MESSAGE_BASE, []);
$this->assertSame(TestConstants::MESSAGE_BASE, $exception->getMessage());
}
public function testInvalidRegexPatternExceptionForPattern(): void
{
$exception = InvalidRegexPatternException::forPattern(
TestConstants::PATTERN_INVALID_UNCLOSED_BRACKET,
'Unclosed bracket',
PREG_INTERNAL_ERROR
);
$this->assertStringContainsString(
"Invalid regex pattern '" . TestConstants::PATTERN_INVALID_UNCLOSED_BRACKET . "'",
$exception->getMessage()
);
$this->assertStringContainsString(
'Unclosed bracket',
$exception->getMessage()
);
$this->assertStringContainsString(
'PCRE Error: Internal PCRE error',
$exception->getMessage()
);
$this->assertEquals(
PREG_INTERNAL_ERROR,
$exception->getCode()
);
}
public function testInvalidRegexPatternExceptionCompilationFailed(): void
{
$exception = InvalidRegexPatternException::compilationFailed('/test[/', PREG_INTERNAL_ERROR);
$this->assertStringContainsString("Invalid regex pattern '/test[/'", $exception->getMessage());
$this->assertStringContainsString('Pattern compilation failed', $exception->getMessage());
$this->assertEquals(PREG_INTERNAL_ERROR, $exception->getCode());
}
public function testInvalidRegexPatternExceptionRedosVulnerable(): void
{
$exception = InvalidRegexPatternException::redosVulnerable('/(a+)+$/', 'Catastrophic backtracking');
$this->assertStringContainsString("Invalid regex pattern '/(a+)+$/'", $exception->getMessage());
$this->assertStringContainsString(
'Potential ReDoS vulnerability: Catastrophic backtracking',
$exception->getMessage()
);
}
public function testInvalidRegexPatternExceptionPcreErrorMessages(): void
{
$testCases = [
PREG_INTERNAL_ERROR => 'Internal PCRE error',
PREG_BACKTRACK_LIMIT_ERROR => 'Backtrack limit exceeded',
PREG_RECURSION_LIMIT_ERROR => 'Recursion limit exceeded',
PREG_BAD_UTF8_ERROR => 'Invalid UTF-8 data',
PREG_BAD_UTF8_OFFSET_ERROR => 'Invalid UTF-8 offset',
PREG_JIT_STACKLIMIT_ERROR => 'JIT stack limit exceeded',
99999 => 'Unknown PCRE error (code: 99999)',
];
foreach ($testCases as $errorCode => $expectedMessage) {
$exception = InvalidRegexPatternException::forPattern(TestConstants::PATTERN_TEST, 'Test', $errorCode);
$this->assertStringContainsString($expectedMessage, $exception->getMessage());
}
// Test case where no error is provided (should not include PCRE error message)
$noErrorException = InvalidRegexPatternException::forPattern(
TestConstants::PATTERN_TEST,
'Test',
PREG_NO_ERROR
);
$this->assertStringNotContainsString('PCRE Error:', $noErrorException->getMessage());
}
public function testMaskingOperationFailedExceptionRegexMasking(): void
{
$exception = MaskingOperationFailedException::regexMaskingFailed(
TestConstants::PATTERN_TEST,
'input string',
'PCRE error'
);
$this->assertStringContainsString(
"Regex masking failed for pattern '" . TestConstants::PATTERN_TEST . "'",
$exception->getMessage()
);
$this->assertStringContainsString('PCRE error', $exception->getMessage());
$this->assertStringContainsString('operation_type: "regex_masking"', $exception->getMessage());
$this->assertStringContainsString('input_length: 12', $exception->getMessage());
}
public function testMaskingOperationFailedExceptionFieldPathMasking(): void
{
$exception = MaskingOperationFailedException::fieldPathMaskingFailed(
TestConstants::FIELD_USER_EMAIL,
TestConstants::EMAIL_TEST,
'Invalid configuration'
);
$this->assertStringContainsString("Field path masking failed for path '" . TestConstants::FIELD_USER_EMAIL . "'", $exception->getMessage());
$this->assertStringContainsString('Invalid configuration', $exception->getMessage());
$this->assertStringContainsString('operation_type: "field_path_masking"', $exception->getMessage());
$this->assertStringContainsString('value_type: "string"', $exception->getMessage());
}
public function testMaskingOperationFailedExceptionCustomCallback(): void
{
$exception = MaskingOperationFailedException::customCallbackFailed(
TestConstants::FIELD_USER_NAME,
[TestConstants::NAME_FIRST, TestConstants::NAME_LAST],
'Callback threw exception'
);
$this->assertStringContainsString(
"Custom callback masking failed for path '" . TestConstants::FIELD_USER_NAME . "'",
$exception->getMessage()
);
$this->assertStringContainsString('Callback threw exception', $exception->getMessage());
$this->assertStringContainsString('operation_type: "custom_callback"', $exception->getMessage());
$this->assertStringContainsString('value_type: "array"', $exception->getMessage());
}
public function testMaskingOperationFailedExceptionDataTypeMasking(): void
{
$exception = MaskingOperationFailedException::dataTypeMaskingFailed(
'integer',
'not an integer',
'Type mismatch'
);
$this->assertStringContainsString("Data type masking failed for type 'integer'", $exception->getMessage());
$this->assertStringContainsString('Type mismatch', $exception->getMessage());
$this->assertStringContainsString('expected_type: "integer"', $exception->getMessage());
$this->assertStringContainsString('actual_type: "string"', $exception->getMessage());
}
public function testMaskingOperationFailedExceptionJsonMasking(): void
{
$exception = MaskingOperationFailedException::jsonMaskingFailed(
'{"invalid": json}',
'Malformed JSON',
JSON_ERROR_SYNTAX
);
$this->assertStringContainsString('JSON masking failed: Malformed JSON', $exception->getMessage());
$this->assertStringContainsString('operation_type: "json_masking"', $exception->getMessage());
$this->assertStringContainsString('json_error: ' . JSON_ERROR_SYNTAX, $exception->getMessage());
}
public function testMaskingOperationFailedExceptionValuePreview(): void
{
// Test long string truncation
$longString = str_repeat('a', 150);
$exception = MaskingOperationFailedException::fieldPathMaskingFailed('test.field', $longString, 'Test');
$this->assertStringContainsString('...', $exception->getMessage());
// Test object serialization
$object = (object) ['property' => 'value'];
$exception = MaskingOperationFailedException::fieldPathMaskingFailed('test.field', $object, 'Test');
$this->assertStringContainsString('\"property\":\"value\"', $exception->getMessage());
}
public function testAuditLoggingExceptionCallbackFailed(): void
{
$exception = AuditLoggingException::callbackFailed(
TestConstants::FIELD_USER_EMAIL,
'original@example.com',
'masked@example.com',
'Logger unavailable'
);
$this->assertStringContainsString(
"Audit logging callback failed for path '" . TestConstants::FIELD_USER_EMAIL . "'",
$exception->getMessage()
);
$this->assertStringContainsString('Logger unavailable', $exception->getMessage());
$this->assertStringContainsString('audit_type: "callback_failure"', $exception->getMessage());
$this->assertStringContainsString('original_type: "string"', $exception->getMessage());
$this->assertStringContainsString('masked_type: "string"', $exception->getMessage());
}
public function testAuditLoggingExceptionSerializationFailed(): void
{
$exception = AuditLoggingException::serializationFailed(
'user.data',
['circular' => 'reference'],
'Circular reference detected'
);
$this->assertStringContainsString(
"Audit data serialization failed for path 'user.data'",
$exception->getMessage()
);
$this->assertStringContainsString('Circular reference detected', $exception->getMessage());
$this->assertStringContainsString('audit_type: "serialization_failure"', $exception->getMessage());
}
public function testAuditLoggingExceptionRateLimitingFailed(): void
{
$exception = AuditLoggingException::rateLimitingFailed('general_operations', 55, 50, 'Rate limit exceeded');
$this->assertStringContainsString(
"Rate-limited audit logging failed for operation 'general_operations'",
$exception->getMessage()
);
$this->assertStringContainsString('Rate limit exceeded', $exception->getMessage());
$this->assertStringContainsString('current_requests: 55', $exception->getMessage());
$this->assertStringContainsString('max_requests: 50', $exception->getMessage());
}
public function testAuditLoggingExceptionInvalidConfiguration(): void
{
$config = ['invalid_key' => 'invalid_value'];
$exception = AuditLoggingException::invalidConfiguration('Missing required key', $config);
$this->assertStringContainsString(
'Invalid audit logger configuration: Missing required key',
$exception->getMessage()
);
$this->assertStringContainsString(
'audit_type: "configuration_error"',
$exception->getMessage()
);
$this->assertStringContainsString('config:', $exception->getMessage());
}
public function testAuditLoggingExceptionLoggerCreationFailed(): void
{
$exception = AuditLoggingException::loggerCreationFailed('file_logger', 'Directory not writable');
$this->assertStringContainsString(
"Audit logger creation failed for type 'file_logger'",
$exception->getMessage()
);
$this->assertStringContainsString('Directory not writable', $exception->getMessage());
$this->assertStringContainsString('audit_type: "logger_creation_failure"', $exception->getMessage());
}
public function testRecursionDepthExceededExceptionDepthExceeded(): void
{
$exception = RecursionDepthExceededException::depthExceeded(105, 100, 'user.deep.nested.field');
$this->assertStringContainsString(
'Maximum recursion depth of 100 exceeded (current: 105)',
$exception->getMessage()
);
$this->assertStringContainsString("at path 'user.deep.nested.field'", $exception->getMessage());
$this->assertStringContainsString('error_type: "depth_exceeded"', $exception->getMessage());
$this->assertStringContainsString('current_depth: 105', $exception->getMessage());
$this->assertStringContainsString('max_depth: 100', $exception->getMessage());
}
public function testRecursionDepthExceededExceptionCircularReference(): void
{
$exception = RecursionDepthExceededException::circularReferenceDetected('user.self_reference', 50, 100);
$this->assertStringContainsString(
"Potential circular reference detected at path 'user.self_reference'",
$exception->getMessage()
);
$this->assertStringContainsString('depth: 50/100', $exception->getMessage());
$this->assertStringContainsString('error_type: "circular_reference"', $exception->getMessage());
}
public function testRecursionDepthExceededExceptionExtremeNesting(): void
{
$exception = RecursionDepthExceededException::extremeNesting('array', 95, 100, 'data.nested.array');
$this->assertStringContainsString(
"Extremely deep nesting detected in array at path 'data.nested.array'",
$exception->getMessage()
);
$this->assertStringContainsString('depth: 95/100', $exception->getMessage());
$this->assertStringContainsString('error_type: "extreme_nesting"', $exception->getMessage());
$this->assertStringContainsString('data_type: "array"', $exception->getMessage());
}
public function testRecursionDepthExceededExceptionInvalidConfiguration(): void
{
$exception = RecursionDepthExceededException::invalidDepthConfiguration(-5, 'Depth cannot be negative');
$this->assertStringContainsString(
'Invalid recursion depth configuration: -5 (Depth cannot be negative)',
$exception->getMessage()
);
$this->assertStringContainsString('error_type: "invalid_configuration"', $exception->getMessage());
$this->assertStringContainsString('invalid_depth: -5', $exception->getMessage());
}
public function testRecursionDepthExceededExceptionWithRecommendations(): void
{
$recommendations = [
'Increase maxDepth parameter',
'Flatten data structure',
'Use pagination for large datasets'
];
$exception = RecursionDepthExceededException::withRecommendations(100, 100, 'data.path', $recommendations);
$this->assertStringContainsString('Recursion depth limit reached', $exception->getMessage());
$this->assertStringContainsString('error_type: "depth_with_recommendations"', $exception->getMessage());
$this->assertStringContainsString('recommendations:', $exception->getMessage());
$this->assertStringContainsString('Increase maxDepth parameter', $exception->getMessage());
}
public function testExceptionHierarchy(): void
{
$baseException = new GdprProcessorException('Base exception');
$regexException = InvalidRegexPatternException::forPattern(TestConstants::PATTERN_TEST, 'Invalid');
$maskingException = MaskingOperationFailedException::regexMaskingFailed(
TestConstants::PATTERN_TEST,
'input',
'Failed'
);
$auditException = AuditLoggingException::callbackFailed('path', 'original', TestConstants::DATA_MASKED, 'Failed');
$depthException = RecursionDepthExceededException::depthExceeded(10, 5, 'path');
// All should inherit from GdprProcessorException
$this->assertInstanceOf(GdprProcessorException::class, $baseException);
$this->assertInstanceOf(GdprProcessorException::class, $regexException);
$this->assertInstanceOf(GdprProcessorException::class, $maskingException);
$this->assertInstanceOf(GdprProcessorException::class, $auditException);
$this->assertInstanceOf(GdprProcessorException::class, $depthException);
// All should inherit from \Exception
$this->assertInstanceOf(Exception::class, $baseException);
$this->assertInstanceOf(Exception::class, $regexException);
$this->assertInstanceOf(Exception::class, $maskingException);
$this->assertInstanceOf(Exception::class, $auditException);
$this->assertInstanceOf(Exception::class, $depthException);
}
public function testExceptionChaining(): void
{
$originalException = new RuntimeException('Original error');
$gdprException = InvalidRegexPatternException::forPattern(
TestConstants::PATTERN_TEST,
'Invalid pattern',
0,
$originalException
);
$this->assertSame($originalException, $gdprException->getPrevious());
$this->assertSame('Original error', $gdprException->getPrevious()->getMessage());
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Tests\Exceptions;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidConfigurationException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Test InvalidConfigurationException factory methods.
*
* @api
*/
#[CoversClass(InvalidConfigurationException::class)]
class InvalidConfigurationExceptionTest extends TestCase
{
#[Test]
public function forFieldPathCreatesException(): void
{
$exception = InvalidConfigurationException::forFieldPath(
'user.invalid',
'Field path is malformed'
);
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString("Invalid field path 'user.invalid'", $exception->getMessage());
$this->assertStringContainsString('Field path is malformed', $exception->getMessage());
}
#[Test]
public function forDataTypeMaskCreatesException(): void
{
$exception = InvalidConfigurationException::forDataTypeMask(
'unknown_type',
'mask_value',
'Type is not supported'
);
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString("Invalid data type mask for 'unknown_type'", $exception->getMessage());
$this->assertStringContainsString('Type is not supported', $exception->getMessage());
}
#[Test]
public function forConditionalRuleCreatesException(): void
{
$exception = InvalidConfigurationException::forConditionalRule(
'invalid_rule',
'Rule callback is not callable'
);
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString("Invalid conditional rule 'invalid_rule'", $exception->getMessage());
$this->assertStringContainsString('Rule callback is not callable', $exception->getMessage());
}
#[Test]
public function forParameterCreatesException(): void
{
$exception = InvalidConfigurationException::forParameter(
'max_depth',
10000,
'Value exceeds maximum allowed'
);
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString("Invalid configuration parameter 'max_depth'", $exception->getMessage());
$this->assertStringContainsString('Value exceeds maximum allowed', $exception->getMessage());
}
#[Test]
public function emptyValueCreatesException(): void
{
$exception = InvalidConfigurationException::emptyValue('pattern');
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString('Pattern cannot be empty', $exception->getMessage());
}
#[Test]
public function exceedsMaxLengthCreatesException(): void
{
$exception = InvalidConfigurationException::exceedsMaxLength(
'field_path',
500,
255
);
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString('Field_path length (500) exceeds maximum', $exception->getMessage());
$this->assertStringContainsString('255', $exception->getMessage());
}
#[Test]
public function invalidTypeCreatesException(): void
{
$exception = InvalidConfigurationException::invalidType(
'callback',
'callable',
'string'
);
$this->assertInstanceOf(InvalidConfigurationException::class, $exception);
$this->assertStringContainsString('Callback must be of type callable', $exception->getMessage());
$this->assertStringContainsString('got string', $exception->getMessage());
}
#[Test]
public function exceptionsIncludeContextInformation(): void
{
$exception = InvalidConfigurationException::forParameter(
'test_param',
['key' => 'value'],
'Test reason'
);
// Verify context is included
$message = $exception->getMessage();
$this->assertStringContainsString('Context:', $message);
$this->assertStringContainsString('parameter', $message);
$this->assertStringContainsString('value', $message);
$this->assertStringContainsString('reason', $message);
}
#[Test]
public function withContextAddsContextToMessage(): void
{
$exception = InvalidConfigurationException::withContext(
'Base error message',
['custom_key' => 'custom_value', 'another_key' => 123]
);
$message = $exception->getMessage();
$this->assertStringContainsString('Base error message', $message);
$this->assertStringContainsString('Context:', $message);
$this->assertStringContainsString('custom_key', $message);
$this->assertStringContainsString('custom_value', $message);
$this->assertStringContainsString('another_key', $message);
$this->assertStringContainsString('123', $message);
}
#[Test]
public function withContextHandlesEmptyContext(): void
{
$exception = InvalidConfigurationException::withContext('Error message', []);
$this->assertSame('Error message', $exception->getMessage());
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Tests\Exceptions;
use Ivuorinen\MonologGdprFilter\Exceptions\InvalidRateLimitConfigurationException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tests\TestConstants;
/**
* Test InvalidRateLimitConfigurationException factory methods.
*
* @api
*/
#[CoversClass(InvalidRateLimitConfigurationException::class)]
class InvalidRateLimitConfigurationExceptionTest extends TestCase
{
#[Test]
public function invalidMaxRequestsCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::invalidMaxRequests(0);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Maximum requests must be a positive integer', $exception->getMessage());
$this->assertStringContainsString('max_requests', $exception->getMessage());
}
#[Test]
public function invalidTimeWindowCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::invalidTimeWindow(-5);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Time window must be a positive integer', $exception->getMessage());
$this->assertStringContainsString('time_window', $exception->getMessage());
}
#[Test]
public function invalidCleanupIntervalCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::invalidCleanupInterval(0);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Cleanup interval must be a positive integer', $exception->getMessage());
$this->assertStringContainsString('cleanup_interval', $exception->getMessage());
}
#[Test]
public function timeWindowTooShortCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::timeWindowTooShort(5, 10);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Time window (5 seconds) is too short', $exception->getMessage());
$this->assertStringContainsString('minimum is 10 seconds', $exception->getMessage());
}
#[Test]
public function cleanupIntervalTooShortCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::cleanupIntervalTooShort(30, 60);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Cleanup interval (30 seconds) is too short', $exception->getMessage());
$this->assertStringContainsString('minimum is 60 seconds', $exception->getMessage());
}
#[Test]
public function emptyKeyCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::emptyKey();
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString(TestConstants::ERROR_RATE_LIMIT_KEY_EMPTY, $exception->getMessage());
$this->assertStringContainsString('key', $exception->getMessage());
}
#[Test]
public function keyTooLongCreatesException(): void
{
$longKey = str_repeat('a', 300);
$exception = InvalidRateLimitConfigurationException::keyTooLong($longKey, 250);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Rate limiting key length (300) exceeds maximum', $exception->getMessage());
$this->assertStringContainsString('250 characters', $exception->getMessage());
}
#[Test]
public function invalidKeyFormatCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::invalidKeyFormat(
'Key contains invalid characters'
);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Key contains invalid characters', $exception->getMessage());
$this->assertStringContainsString('key', $exception->getMessage());
}
#[Test]
public function forParameterCreatesException(): void
{
$exception = InvalidRateLimitConfigurationException::forParameter(
'custom_param',
'invalid_value',
'Must meet specific criteria'
);
$this->assertInstanceOf(InvalidRateLimitConfigurationException::class, $exception);
$this->assertStringContainsString('Invalid rate limit parameter', $exception->getMessage());
$this->assertStringContainsString('custom_param', $exception->getMessage());
$this->assertStringContainsString('Must meet specific criteria', $exception->getMessage());
}
#[Test]
public function exceptionsIncludeContextInformation(): void
{
$exception = InvalidRateLimitConfigurationException::invalidMaxRequests(1000000);
// Verify context is included
$message = $exception->getMessage();
$this->assertStringContainsString('Context:', $message);
$this->assertStringContainsString('parameter', $message);
$this->assertStringContainsString('value', $message);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Tests\Exceptions;
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(MaskingOperationFailedException::class)]
final class MaskingOperationFailedExceptionTest extends TestCase
{
public function testJsonMaskingFailedWithJsonError(): void
{
$exception = MaskingOperationFailedException::jsonMaskingFailed(
'{"invalid": json}',
'Malformed JSON',
JSON_ERROR_SYNTAX
);
$message = $exception->getMessage();
$this->assertStringContainsString('JSON masking failed', $message);
$this->assertStringContainsString('Malformed JSON', $message);
$this->assertStringContainsString('JSON Error:', $message);
$this->assertStringContainsString('json_masking', $message);
}
public function testJsonMaskingFailedWithoutJsonError(): void
{
$exception = MaskingOperationFailedException::jsonMaskingFailed(
'{"valid": "json"}',
'Processing failed',
0 // No JSON error
);
$message = $exception->getMessage();
$this->assertStringContainsString('JSON masking failed', $message);
$this->assertStringContainsString('Processing failed', $message);
$this->assertStringNotContainsString('JSON Error:', $message);
}
public function testJsonMaskingFailedWithLongString(): void
{
$longJson = str_repeat('{"key": "value"},', 100);
$exception = MaskingOperationFailedException::jsonMaskingFailed(
$longJson,
'Too large',
0
);
$message = $exception->getMessage();
// Should be truncated
$this->assertStringContainsString('...', $message);
$this->assertStringContainsString('json_length:', $message);
}
public function testRegexMaskingFailedWithLongInput(): void
{
$longInput = str_repeat('test ', 50);
$exception = MaskingOperationFailedException::regexMaskingFailed(
'/pattern/',
$longInput,
'PCRE error'
);
$message = $exception->getMessage();
$this->assertStringContainsString('Regex masking failed', $message);
$this->assertStringContainsString('/pattern/', $message);
$this->assertStringContainsString('...', $message); // Truncated preview
}
public function testFieldPathMaskingFailedWithPrevious(): void
{
$previous = new \RuntimeException('Inner error');
$exception = MaskingOperationFailedException::fieldPathMaskingFailed(
'user.data',
['complex' => 'value'],
'Failed',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testCustomCallbackFailedWithAllTypes(): void
{
// Test with resource
$resource = fopen('php://memory', 'r');
$this->assertIsResource($resource);
$exception = MaskingOperationFailedException::customCallbackFailed(
'field',
$resource,
'Callback error'
);
if (is_resource($resource)) {
fclose($resource);
}
$message = $exception->getMessage();
$this->assertStringContainsString('Custom callback masking failed', $message);
$this->assertStringContainsString('resource', $message);
}
public function testDataTypeMaskingFailedShowsTypes(): void
{
$exception = MaskingOperationFailedException::dataTypeMaskingFailed(
'string',
12345, // Integer value when string expected
'Type mismatch'
);
$message = $exception->getMessage();
$this->assertStringContainsString('Data type masking failed', $message);
$this->assertStringContainsString('string', $message);
$this->assertStringContainsString('integer', $message); // actual_type
}
public function testDataTypeMaskingFailedWithObjectValue(): void
{
$obj = (object) ['key' => 'value'];
$exception = MaskingOperationFailedException::dataTypeMaskingFailed(
'string',
$obj,
'Cannot convert object'
);
$message = $exception->getMessage();
$this->assertStringContainsString('Data type masking failed', $message);
$this->assertStringContainsString('object', $message);
$this->assertStringContainsString('key', $message); // JSON preview
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Tests\Exceptions;
use Ivuorinen\MonologGdprFilter\Exceptions\RuleExecutionException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Tests\TestConstants;
#[CoversClass(RuleExecutionException::class)]
final class RuleExecutionExceptionTest extends TestCase
{
public function testForConditionalRuleCreatesException(): void
{
$exception = RuleExecutionException::forConditionalRule(
'test_rule',
'Rule validation failed',
['field' => 'value']
);
$this->assertInstanceOf(RuleExecutionException::class, $exception);
$this->assertStringContainsString('test_rule', $exception->getMessage());
$this->assertStringContainsString('Rule validation failed', $exception->getMessage());
$this->assertStringContainsString('rule_name', $exception->getMessage());
$this->assertStringContainsString('conditional_rule', $exception->getMessage());
}
public function testForConditionalRuleWithoutContext(): void
{
$exception = RuleExecutionException::forConditionalRule(
'simple_rule',
'Failed'
);
$this->assertStringContainsString('simple_rule', $exception->getMessage());
$this->assertStringContainsString('Failed', $exception->getMessage());
}
public function testForConditionalRuleWithPreviousException(): void
{
$previous = new RuntimeException('Original error');
$exception = RuleExecutionException::forConditionalRule(
'test_rule',
'Wrapped failure',
null,
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testForCallbackCreatesException(): void
{
$exception = RuleExecutionException::forCallback(
'custom_callback',
TestConstants::FIELD_USER_EMAIL,
'Callback threw exception'
);
$this->assertInstanceOf(RuleExecutionException::class, $exception);
$this->assertStringContainsString('custom_callback', $exception->getMessage());
$this->assertStringContainsString(TestConstants::FIELD_USER_EMAIL, $exception->getMessage());
$this->assertStringContainsString('Callback threw exception', $exception->getMessage());
$this->assertStringContainsString('callback_execution', $exception->getMessage());
}
public function testForCallbackWithPreviousException(): void
{
$previous = new RuntimeException('Callback error');
$exception = RuleExecutionException::forCallback(
'test_callback',
'field.path',
'Error',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testForTimeoutCreatesException(): void
{
$exception = RuleExecutionException::forTimeout(
'slow_rule',
1.0,
1.5
);
$this->assertInstanceOf(RuleExecutionException::class, $exception);
$this->assertStringContainsString('slow_rule', $exception->getMessage());
$this->assertStringContainsString('1.500', $exception->getMessage());
$this->assertStringContainsString('1.000', $exception->getMessage());
$this->assertStringContainsString('timed out', $exception->getMessage());
$this->assertStringContainsString('timeout', $exception->getMessage());
}
public function testForTimeoutWithPreviousException(): void
{
$previous = new RuntimeException('Timeout error');
$exception = RuleExecutionException::forTimeout(
'rule',
2.0,
3.0,
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
public function testForEvaluationCreatesException(): void
{
$inputData = ['user' => TestConstants::EMAIL_TEST];
$exception = RuleExecutionException::forEvaluation(
'validation_rule',
$inputData,
'Invalid input format'
);
$this->assertInstanceOf(RuleExecutionException::class, $exception);
$this->assertStringContainsString('validation_rule', $exception->getMessage());
$this->assertStringContainsString('Invalid input format', $exception->getMessage());
$this->assertStringContainsString('evaluation', $exception->getMessage());
}
public function testForEvaluationWithPreviousException(): void
{
$previous = new RuntimeException('Evaluation error');
$exception = RuleExecutionException::forEvaluation(
'rule',
['data'],
'Failed',
$previous
);
$this->assertSame($previous, $exception->getPrevious());
}
}