From 79eecd7cfe73ed3e0b42e48c5d419503229d6e3a Mon Sep 17 00:00:00 2001 From: Chris Arter Date: Fri, 28 Nov 2025 10:53:50 -0500 Subject: [PATCH] code cleanup --- .github/workflows/tests.yml | 10 +- composer.json | 12 +- composer.lock | 71 +++++- pint.json | 6 + src/Cloak.php | 233 ++++++++++++++++---- src/Concerns/HasLifecycleCallbacks.php | 125 +++++++++++ src/Contracts/EncryptorInterface.php | 28 +++ src/Detector.php | 4 +- src/Detectors/Phone.php | 2 - src/Encryptors/NullEncryptor.php | 24 ++ src/Encryptors/OpenSslEncryptor.php | 185 ++++++++++++++++ tests/BuilderTest.php | 118 ++++++++++ tests/EncryptionTest.php | 256 ++++++++++++++++++++++ tests/Encryptors/NullEncryptorTest.php | 52 +++++ tests/Encryptors/OpenSslEncryptorTest.php | 218 ++++++++++++++++++ tests/LifecycleCallbacksTest.php | 230 +++++++++++++++++++ 16 files changed, 1521 insertions(+), 53 deletions(-) create mode 100644 pint.json create mode 100644 src/Concerns/HasLifecycleCallbacks.php create mode 100644 src/Contracts/EncryptorInterface.php create mode 100644 src/Encryptors/NullEncryptor.php create mode 100644 src/Encryptors/OpenSslEncryptor.php create mode 100644 tests/BuilderTest.php create mode 100644 tests/EncryptionTest.php create mode 100644 tests/Encryptors/NullEncryptorTest.php create mode 100644 tests/Encryptors/OpenSslEncryptorTest.php create mode 100644 tests/LifecycleCallbacksTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fb77fb9..f4ed999 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main, develop ] + branches: [main, develop] jobs: phpstan: @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4'] + php: ["8.2", "8.3", "8.4"] steps: - name: Checkout code @@ -52,5 +52,5 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress --no-interaction - - name: Run tests - run: ./vendor/bin/pest + - name: Run Tests + run: composer ci:test diff --git a/composer.json b/composer.json index 44e6beb..ce648fb 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ }, "require-dev": { "pestphp/pest": "^3.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0", + "laravel/pint": "^1.26" }, "config": { "allow-plugins": { @@ -28,6 +29,13 @@ }, "scripts": { "test": "pest", - "analyse": "phpstan analyse" + "analyse": "phpstan analyse", + "format": "./vendor/bin/pint", + "format:test": "./vendor/bin/pint --test", + "ci:test": [ + "@format:check", + "@analyse", + "@test" + ] } } \ No newline at end of file diff --git a/composer.lock b/composer.lock index 55a9132..04a890a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "672c96c85f6337482f91892ae0713d91", + "content-hash": "1e545d52399995293f5226e49772a398", "packages": [ { "name": "giggsey/libphonenumber-for-php", @@ -558,6 +558,73 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "laravel/pint", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-11-25T21:15:52+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -4114,7 +4181,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.1" + "php": "^8.2" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..4c94624 --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "psr12", + "notPath": [ + "vendor" + ] +} \ No newline at end of file diff --git a/src/Cloak.php b/src/Cloak.php index 3d71c10..8114e42 100644 --- a/src/Cloak.php +++ b/src/Cloak.php @@ -4,8 +4,12 @@ namespace DynamikDev\Cloak; +use DynamikDev\Cloak\Concerns\HasLifecycleCallbacks; use DynamikDev\Cloak\Contracts\DetectorInterface; +use DynamikDev\Cloak\Contracts\EncryptorInterface; use DynamikDev\Cloak\Contracts\StoreInterface; +use DynamikDev\Cloak\Encryptors\NullEncryptor; +use DynamikDev\Cloak\Encryptors\OpenSslEncryptor; use DynamikDev\Cloak\Stores\ArrayStore; /** @@ -13,12 +17,27 @@ */ class Cloak { + use HasLifecycleCallbacks; + protected const PLACEHOLDER_PATTERN = '/\{\{([A-Z_]+)_([a-zA-Z0-9]{6})_(\d+)\}\}/'; protected const KEY_LENGTH = 6; protected const KEY_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; protected static ?StoreInterface $defaultStore = null; + /** @var array|null */ + protected ?array $defaultDetectors = null; + + protected int $ttl = 3600; + + /** @var array */ + protected array $filters = []; + + protected ?EncryptorInterface $encryptor = null; + + /** @var callable|null */ + protected $encryptorCallback = null; + protected function __construct( protected readonly StoreInterface $store ) { @@ -43,61 +62,135 @@ protected static function getDefaultStore(): StoreInterface return self::$defaultStore; } + /** + * Set the default detectors to use when none are specified. + * + * @param array $detectors + * @return $this + */ + public function withDetectors(array $detectors): self + { + $this->defaultDetectors = $detectors; + + return $this; + } + + /** + * Set the TTL (time to live) for stored mappings in seconds. + * + * @param int $ttl Time to live in seconds + * @return $this + */ + public function withTtl(int $ttl): self + { + $this->ttl = $ttl; + + return $this; + } + + /** + * Add a filter to exclude certain detections. + * Multiple filters can be added - all must return true for a detection to be included. + * + * @param callable(array{match: string, type: string}): bool $callback Return false to exclude the detection + * @return $this + */ + public function filter(callable $callback): self + { + $this->filters[] = $callback; + + return $this; + } + + /** + * Enable encryption using the default OpenSslEncryptor. + * If no key is provided, it will attempt to read from CLOAK_PRIVATE_KEY environment variable. + * + * @param string|null $key The encryption key (32 bytes raw or base64-encoded) + * @return $this + * @throws \RuntimeException If the key is invalid or not found + */ + public function encrypt(?string $key = null): self + { + $this->encryptor = new OpenSslEncryptor($key); + $this->encryptorCallback = null; + + return $this; + } + + /** + * Set a custom encryptor instance or callback. + * + * @param EncryptorInterface|callable(): EncryptorInterface $encryptor + * @return $this + */ + public function encryptUsing(EncryptorInterface|callable $encryptor): self + { + if (is_callable($encryptor)) { + $this->encryptorCallback = $encryptor; + $this->encryptor = null; + } else { + $this->encryptor = $encryptor; + $this->encryptorCallback = null; + } + + return $this; + } + /** * @param array|null $detectors */ public function cloak(string $text, ?array $detectors = null): string { - $detectors ??= Detector::all(); + $processedText = $this->executeBeforeCloakCallbacks($text); - // Collect all detections - $detections = $this->runDetectors($text, $detectors); + $detections = $this->applyFilters( + $this->runDetectors($processedText, $detectors ?? $this->defaultDetectors ?? Detector::all()) + ); if ($detections === []) { - return $text; + $this->executeAfterCloakCallbacks($text, $processedText); + + return $processedText; } - // Generate unique key for this cloak operation $key = $this->generateKey(); - - // Build placeholder map $map = $this->buildPlaceholderMap($detections, $key); - // Store the mapping - $this->store->put('cloak:' . $key, $map); + $this->store->put('cloak:' . $key, $this->encryptMap($map), $this->ttl); + + $result = $this->replaceWithPlaceholders($processedText, $map); + + $this->executeAfterCloakCallbacks($text, $result); - // Replace original values with placeholders - return $this->replaceWithPlaceholders($text, $map); + return $result; } public function uncloak(string $text): string { - // Find all placeholders in the text + $text = $this->executeBeforeUncloakCallbacks($text); + preg_match_all(self::PLACEHOLDER_PATTERN, $text, $matches, PREG_SET_ORDER); if ($matches === []) { return $text; } - // Group placeholders by key - $keyGroups = $this->groupPlaceholdersByKey($matches); - - // Fetch mappings and replace - foreach ($keyGroups as $key => $placeholders) { - $map = $this->store->get('cloak:' . $key); + foreach ($this->groupPlaceholdersByKey($matches) as $key => $placeholders) { + $encryptedMap = $this->store->get('cloak:' . $key); - if ($map === null) { + if ($encryptedMap === null) { continue; } - foreach ($placeholders as $placeholder) { - if (isset($map[$placeholder])) { - $text = str_replace($placeholder, $map[$placeholder], $text); + foreach ($this->decryptMap($encryptedMap) as $placeholder => $value) { + if (in_array($placeholder, $placeholders, true)) { + $text = str_replace($placeholder, $value, $text); } } } - return $text; + return $this->executeAfterUncloakCallbacks($text); } /** @@ -109,8 +202,7 @@ protected function runDetectors(string $text, array $detectors): array $detections = []; foreach ($detectors as $detector) { - $results = $detector->detect($text); - foreach ($results as $result) { + foreach ($detector->detect($text) as $result) { $detections[] = $result; } } @@ -118,6 +210,26 @@ protected function runDetectors(string $text, array $detectors): array return $detections; } + /** + * Apply all registered filters to the detections. + * All filters must return true for a detection to be included. + * + * @param array $detections + * @return array + */ + protected function applyFilters(array $detections): array + { + if ($this->filters === []) { + return $detections; + } + + foreach ($this->filters as $filter) { + $detections = array_filter($detections, $filter); + } + + return array_values($detections); + } + protected function generateKey(): string { $key = ''; @@ -143,15 +255,12 @@ protected function buildPlaceholderMap(array $detections, string $key): array $valueToPlaceholder = []; foreach ($detections as $detection) { - $match = $detection['match']; - $type = strtoupper($detection['type']); - - // Reuse placeholder for same value - if (isset($valueToPlaceholder[$match])) { + if (isset($valueToPlaceholder[$detection['match']])) { continue; } - // Initialize counter for this type + $type = strtoupper($detection['type']); + if (!isset($typeCounters[$type])) { $typeCounters[$type] = 0; } @@ -159,8 +268,8 @@ protected function buildPlaceholderMap(array $detections, string $key): array $typeCounters[$type]++; $placeholder = '{{' . $type . '_' . $key . '_' . $typeCounters[$type] . '}}'; - $map[$placeholder] = $match; - $valueToPlaceholder[$match] = $placeholder; + $map[$placeholder] = $detection['match']; + $valueToPlaceholder[$detection['match']] = $placeholder; } return $map; @@ -187,16 +296,62 @@ protected function groupPlaceholdersByKey(array $matches): array $groups = []; foreach ($matches as $match) { - $placeholder = $match[0]; - $key = $match[2]; - - if (!isset($groups[$key])) { - $groups[$key] = []; + if (!isset($groups[$match[2]])) { + $groups[$match[2]] = []; } - $groups[$key][] = $placeholder; + $groups[$match[2]][] = $match[0]; } return $groups; } + + /** + * Get the encryptor instance, initializing from callback if needed. + */ + protected function getEncryptor(): EncryptorInterface + { + if ($this->encryptorCallback !== null && $this->encryptor === null) { + $result = ($this->encryptorCallback)(); + assert($result instanceof EncryptorInterface); + $this->encryptor = $result; + $this->encryptorCallback = null; + } + + return $this->encryptor ?? new NullEncryptor(); + } + + /** + * Encrypt the values in the placeholder map. + * + * @param array $map + * @return array + */ + protected function encryptMap(array $map): array + { + $encrypted = []; + + foreach ($map as $placeholder => $value) { + $encrypted[$placeholder] = $this->getEncryptor()->encrypt($value); + } + + return $encrypted; + } + + /** + * Decrypt the values in the placeholder map. + * + * @param array $map + * @return array + */ + protected function decryptMap(array $map): array + { + $decrypted = []; + + foreach ($map as $placeholder => $value) { + $decrypted[$placeholder] = $this->getEncryptor()->decrypt($value); + } + + return $decrypted; + } } diff --git a/src/Concerns/HasLifecycleCallbacks.php b/src/Concerns/HasLifecycleCallbacks.php new file mode 100644 index 0000000..313c355 --- /dev/null +++ b/src/Concerns/HasLifecycleCallbacks.php @@ -0,0 +1,125 @@ + */ + protected array $beforeCloakCallbacks = []; + + /** @var array */ + protected array $afterCloakCallbacks = []; + + /** @var array */ + protected array $beforeUncloakCallbacks = []; + + /** @var array */ + protected array $afterUncloakCallbacks = []; + + /** + * Register a callback to execute before cloaking. + * Can be used to pre-process or normalize input text. + * + * @param callable(string): string $callback + * @return $this + */ + public function beforeCloak(callable $callback): self + { + $this->beforeCloakCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to execute after cloaking. + * Useful for logging, metrics, or auditing. + * + * @param callable(string, string): void $callback Receives original and cloaked text + * @return $this + */ + public function afterCloak(callable $callback): self + { + $this->afterCloakCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to execute before uncloaking. + * Can be used for validation or authorization checks. + * + * @param callable(string): string $callback + * @return $this + */ + public function beforeUncloak(callable $callback): self + { + $this->beforeUncloakCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to execute after uncloaking. + * Can be used for post-processing or audit trails. + * + * @param callable(string): string $callback + * @return $this + */ + public function afterUncloak(callable $callback): self + { + $this->afterUncloakCallbacks[] = $callback; + + return $this; + } + + /** + * Execute all beforeCloak callbacks in sequence. + */ + protected function executeBeforeCloakCallbacks(string $text): string + { + foreach ($this->beforeCloakCallbacks as $callback) { + $text = $callback($text); + } + + return $text; + } + + /** + * Execute all afterCloak callbacks in sequence. + */ + protected function executeAfterCloakCallbacks(string $originalText, string $cloakedText): void + { + foreach ($this->afterCloakCallbacks as $callback) { + $callback($originalText, $cloakedText); + } + } + + /** + * Execute all beforeUncloak callbacks in sequence. + */ + protected function executeBeforeUncloakCallbacks(string $text): string + { + foreach ($this->beforeUncloakCallbacks as $callback) { + $text = $callback($text); + } + + return $text; + } + + /** + * Execute all afterUncloak callbacks in sequence. + */ + protected function executeAfterUncloakCallbacks(string $text): string + { + foreach ($this->afterUncloakCallbacks as $callback) { + $text = $callback($text); + } + + return $text; + } +} diff --git a/src/Contracts/EncryptorInterface.php b/src/Contracts/EncryptorInterface.php new file mode 100644 index 0000000..19b7a0f --- /dev/null +++ b/src/Contracts/EncryptorInterface.php @@ -0,0 +1,28 @@ +words as $word) { - $lowerWord = strtolower($word); - $pos = strpos($lowerText, $lowerWord); - if ($pos !== false) { + if (($pos = strpos($lowerText, strtolower($word))) !== false) { $matches[] = [ 'match' => substr($text, $pos, strlen($word)), 'type' => $this->type, diff --git a/src/Detectors/Phone.php b/src/Detectors/Phone.php index af14354..0468139 100644 --- a/src/Detectors/Phone.php +++ b/src/Detectors/Phone.php @@ -35,7 +35,6 @@ public function detect(string $text): array $results = []; try { - // Use PhoneNumberMatcher to find all phone numbers in text $matcher = $this->phoneUtil->findNumbers($text, $this->defaultRegion); foreach ($matcher as $match) { @@ -47,7 +46,6 @@ public function detect(string $text): array } } } catch (NumberParseException $e) { - // If parsing fails, return empty array return []; } diff --git a/src/Encryptors/NullEncryptor.php b/src/Encryptors/NullEncryptor.php new file mode 100644 index 0000000..ad40038 --- /dev/null +++ b/src/Encryptors/NullEncryptor.php @@ -0,0 +1,24 @@ +envKeyName = $envKeyName; + $key ??= $this->getKeyFromEnvironment(); + $this->key = $this->prepareKey($key); + } + + /** + * Generate a secure encryption key. + * + * @return string Base64-encoded 32-byte key + * @throws RuntimeException If random bytes generation fails + */ + public static function generateKey(): string + { + try { + return base64_encode(random_bytes(self::KEY_LENGTH)); + } catch (\Exception $e) { + throw new RuntimeException('Failed to generate encryption key: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Encrypt a value using AES-256-GCM. + * + * Format: base64(iv || ciphertext || tag) + * + * @param string $value The plaintext value to encrypt + * @return string The encrypted value (base64-encoded) + * @throws RuntimeException If encryption fails + */ + public function encrypt(string $value): string + { + try { + $iv = random_bytes(self::IV_LENGTH); + } catch (\Exception $e) { + throw new RuntimeException('Failed to generate IV: ' . $e->getMessage(), 0, $e); + } + + $tag = ''; + + $ciphertext = openssl_encrypt( + $value, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $iv, + $tag, + '', + self::TAG_LENGTH + ); + + if ($ciphertext === false) { + throw new RuntimeException('Encryption failed: ' . openssl_error_string()); + } + + return base64_encode($iv . $ciphertext . $tag); + } + + /** + * Decrypt a previously encrypted value. + * + * @param string $encrypted The encrypted value (base64-encoded) + * @return string The decrypted plaintext value + * @throws RuntimeException If decryption fails or data is invalid + */ + public function decrypt(string $encrypted): string + { + $data = base64_decode($encrypted, true); + + if ($data === false) { + throw new RuntimeException('Decryption failed: invalid base64 encoding'); + } + + $dataLength = strlen($data); + $minLength = self::IV_LENGTH + self::TAG_LENGTH; + + if ($dataLength < $minLength) { + throw new RuntimeException('Decryption failed: encrypted data is too short'); + } + + $iv = substr($data, 0, self::IV_LENGTH); + $tag = substr($data, -self::TAG_LENGTH); + $ciphertext = substr($data, self::IV_LENGTH, -self::TAG_LENGTH); + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if ($plaintext === false) { + throw new RuntimeException('Decryption failed: authentication tag mismatch or corrupted data'); + } + + return $plaintext; + } + + /** + * Get encryption key from environment variable. + * + * @return string The encryption key from the configured environment variable + * @throws RuntimeException If the environment variable is not set + */ + protected function getKeyFromEnvironment(): string + { + if (($key = getenv($this->envKeyName)) !== false && $key !== '') { + return $key; + } + + $envValue = $_ENV[$this->envKeyName] ?? null; + + if (is_string($envValue) && $envValue !== '') { + return $envValue; + } + + throw new RuntimeException( + sprintf( + 'Encryption key not provided and %s environment variable is not set', + $this->envKeyName + ) + ); + } + + /** + * Prepare and validate the encryption key. + * + * @param string $key The raw or base64-encoded key + * @return string The raw 32-byte key + * @throws RuntimeException If the key is invalid + */ + protected function prepareKey(string $key): string + { + $decoded = base64_decode($key, true); + + if ($decoded !== false && strlen($decoded) === self::KEY_LENGTH) { + return $decoded; + } + + if (strlen($key) === self::KEY_LENGTH) { + return $key; + } + + throw new RuntimeException( + sprintf( + 'Invalid encryption key: must be %d bytes raw or base64-encoded', + self::KEY_LENGTH + ) + ); + } +} diff --git a/tests/BuilderTest.php b/tests/BuilderTest.php new file mode 100644 index 0000000..6248743 --- /dev/null +++ b/tests/BuilderTest.php @@ -0,0 +1,118 @@ +withDetectors([Detector::email()]); + + expect($cloak)->toBeInstanceOf(Cloak::class); +}); + +it('uses default detectors when set via withDetectors', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->withDetectors([Detector::email()]); + + $result = $cloak->cloak('test@example.com 123-45-6789'); + + // Should only cloak email, not SSN + expect($result)->toMatch('/\{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($result)->toContain('123-45-6789'); +}); + +it('can chain withTtl method', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->withTtl(7200); + + expect($cloak)->toBeInstanceOf(Cloak::class); +}); + +it('can chain multiple builder methods', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->withDetectors([Detector::email()]) + ->withTtl(7200) + ->filter(fn ($d) => strlen($d['match']) > 5); + + expect($cloak)->toBeInstanceOf(Cloak::class); +}); + +it('filters out detections based on filter callback', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->filter(function ($detection) { + // Exclude test.local emails + if ($detection['type'] === 'email' && str_ends_with($detection['match'], '.local')) { + return false; + } + + return true; + }); + + $result = $cloak->cloak('Email: test@example.com and local@test.local', [Detector::email()]); + + // Should cloak first email but not second + expect($result)->toMatch('/Email: \{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($result)->toContain('local@test.local'); +}); + +it('applies multiple filters in sequence', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->filter(fn ($d) => strlen($d['match']) > 10) // Must be longer than 10 chars + ->filter(fn ($d) => !str_contains($d['match'], 'test')); // Must not contain 'test' + + $result = $cloak->cloak('a@b.com test@example.com admin@company.com', [Detector::email()]); + + // a@b.com filtered out by length (7 chars, stays visible) + // test@example.com filtered out by 'test' check (stays visible) + // admin@company.com passes both filters (17 chars, gets cloaked) + expect($result)->toContain('a@b.com'); + expect($result)->toContain('test@example.com'); + expect($result)->toMatch('/\{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($result)->not->toContain('admin@company.com'); +}); + +it('can filter by type', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->filter(fn ($d) => $d['type'] !== 'ssn'); + + $result = $cloak->cloak('Email: test@example.com SSN: 123-45-6789', [ + Detector::email(), + Detector::ssn(), + ]); + + expect($result)->toMatch('/Email: \{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($result)->toContain('123-45-6789'); +}); + +it('returns original text when all detections are filtered', function () { + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->filter(fn () => false); // Filter out everything + + $result = $cloak->cloak('test@example.com', [Detector::email()]); + + expect($result)->toBe('test@example.com'); +}); + +it('filters with whitelist pattern', function () { + $whitelist = ['support@company.com', 'public@company.com']; + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->filter(fn ($d) => !in_array($d['match'], $whitelist)); + + $result = $cloak->cloak('Contact: support@company.com or admin@company.com', [Detector::email()]); + + expect($result)->toContain('support@company.com'); + expect($result)->toMatch('/\{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($result)->not->toContain('admin@company.com'); +}); diff --git a/tests/EncryptionTest.php b/tests/EncryptionTest.php new file mode 100644 index 0000000..3062483 --- /dev/null +++ b/tests/EncryptionTest.php @@ -0,0 +1,256 @@ +cloak('test@example.com', [Detector::email()]); + preg_match('/\{\{EMAIL_([a-zA-Z0-9]{6})_1\}\}/', $cloaked, $matches); + $key = 'cloak:' . $matches[1]; + + // Value stored in plaintext + $stored = $store->get($key); + expect($stored)->toBeArray(); + expect($stored[$cloaked])->toBe('test@example.com'); +}); + +it('encrypts values when encrypt() is called', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encrypt($encryptionKey); + + $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); + preg_match('/\{\{EMAIL_([a-zA-Z0-9]{6})_1\}\}/', $cloaked, $matches); + $key = 'cloak:' . $matches[1]; + + // Value stored encrypted + $stored = $store->get($key); + expect($stored)->toBeArray(); + expect($stored[$cloaked])->not->toBe('test@example.com'); +}); + +it('decrypts values correctly on uncloak', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encrypt($encryptionKey); + + $original = 'Contact: test@example.com Phone: 555-123-4567'; + $cloaked = $cloak->cloak($original, [Detector::email(), Detector::phone()]); + $uncloaked = $cloak->uncloak($cloaked); + + expect($uncloaked)->toBe($original); +}); + +it('uses custom encryptor via encryptUsing()', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $customEncryptor = new OpenSslEncryptor($encryptionKey); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encryptUsing($customEncryptor); + + $original = 'test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + $uncloaked = $cloak->uncloak($cloaked); + + expect($uncloaked)->toBe($original); +}); + +it('uses callback-based encryptor via encryptUsing()', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $callbackCalled = false; + + $cloak = Cloak::using($store) + ->encryptUsing(function () use ($encryptionKey, &$callbackCalled) { + $callbackCalled = true; + + return new OpenSslEncryptor($encryptionKey); + }); + + // Callback not called yet + expect($callbackCalled)->toBe(false); + + $original = 'test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + + // Callback called during cloak + expect($callbackCalled)->toBe(true); + + $uncloaked = $cloak->uncloak($cloaked); + expect($uncloaked)->toBe($original); +}); + +it('reads encryption key from environment variable', function () { + $key = OpenSslEncryptor::generateKey(); + putenv("CLOAK_TEST_PRIVATE_KEY={$key}"); + + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encryptUsing(new OpenSslEncryptor(null, 'CLOAK_TEST_PRIVATE_KEY')); + + $original = 'test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + $uncloaked = $cloak->uncloak($cloaked); + + expect($uncloaked)->toBe($original); +}); + +it('encrypts multiple values independently', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encrypt($encryptionKey); + + $original = 'Email: test@example.com, Phone: 555-123-4567, SSN: 123-45-6789'; + $cloaked = $cloak->cloak($original, [ + Detector::email(), + Detector::phone(), + Detector::ssn(), + ]); + + $uncloaked = $cloak->uncloak($cloaked); + expect($uncloaked)->toBe($original); +}); + +it('handles same value appearing multiple times with encryption', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encrypt($encryptionKey); + + $original = 'test@example.com and test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + + // Should use same placeholder + preg_match_all('/\{\{EMAIL_[a-zA-Z0-9]{6}_\d+\}\}/', $cloaked, $matches); + expect($matches[0][0])->toBe($matches[0][1]); + + $uncloaked = $cloak->uncloak($cloaked); + expect($uncloaked)->toBe($original); +}); + +it('combines encryption with filters', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encrypt($encryptionKey) + ->filter(fn ($d) => !str_ends_with($d['match'], '.local')); + + $text = 'prod@company.com test@test.local'; + $cloaked = $cloak->cloak($text, [Detector::email()]); + + // test@test.local should not be cloaked (filtered out) + expect($cloaked)->toMatch('/\{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($cloaked)->toContain('test@test.local'); + + $uncloaked = $cloak->uncloak($cloaked); + expect($uncloaked)->toContain('prod@company.com'); + expect($uncloaked)->toContain('test@test.local'); +}); + +it('combines encryption with lifecycle callbacks', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $log = []; + + $cloak = Cloak::using($store) + ->encrypt($encryptionKey) + ->beforeCloak(function ($text) use (&$log) { + $log[] = 'before'; + + return $text; + }) + ->afterCloak(function ($original, $cloaked) use (&$log) { + $log[] = 'after'; + }); + + $original = 'test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + $uncloaked = $cloak->uncloak($cloaked); + + expect($log)->toBe(['before', 'after']); + expect($uncloaked)->toBe($original); +}); + +it('cannot decrypt with wrong key', function () { + $key1 = OpenSslEncryptor::generateKey(); + $key2 = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + + $cloak1 = Cloak::using($store)->encrypt($key1); + $cloaked = $cloak1->cloak('test@example.com', [Detector::email()]); + + $cloak2 = Cloak::using($store)->encrypt($key2); + $cloak2->uncloak($cloaked); +})->throws(RuntimeException::class); + +it('handles encryption with withTtl()', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->withTtl(7200) + ->encrypt($encryptionKey); + + $original = 'test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + $uncloaked = $cloak->uncloak($cloaked); + + expect($uncloaked)->toBe($original); +}); + +it('handles encryption with withDetectors()', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->withDetectors([Detector::email()]) + ->encrypt($encryptionKey); + + $text = 'Email: test@example.com Phone: 555-123-4567'; + $cloaked = $cloak->cloak($text); + $uncloaked = $cloak->uncloak($cloaked); + + // Only email should be cloaked + expect($uncloaked)->toContain('test@example.com'); + expect($uncloaked)->toContain('555-123-4567'); +}); + +it('switching from encrypt() to encryptUsing() replaces encryptor', function () { + $key1 = OpenSslEncryptor::generateKey(); + $key2 = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + + $cloak = Cloak::using($store) + ->encrypt($key1) + ->encryptUsing(new OpenSslEncryptor($key2)); + + $original = 'test@example.com'; + $cloaked = $cloak->cloak($original, [Detector::email()]); + $uncloaked = $cloak->uncloak($cloaked); + + expect($uncloaked)->toBe($original); +}); + +it('handles empty encryption results', function () { + $encryptionKey = OpenSslEncryptor::generateKey(); + $store = new ArrayStore(); + $cloak = Cloak::using($store) + ->encrypt($encryptionKey); + + $result = $cloak->cloak('No sensitive data here', [Detector::email()]); + + expect($result)->toBe('No sensitive data here'); +}); diff --git a/tests/Encryptors/NullEncryptorTest.php b/tests/Encryptors/NullEncryptorTest.php new file mode 100644 index 0000000..af08ae5 --- /dev/null +++ b/tests/Encryptors/NullEncryptorTest.php @@ -0,0 +1,52 @@ +toBeInstanceOf(DynamikDev\Cloak\Contracts\EncryptorInterface::class); +}); + +it('returns value unchanged when encrypting', function () { + $encryptor = new NullEncryptor(); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + + expect($encrypted)->toBe($value); +}); + +it('returns value unchanged when decrypting', function () { + $encryptor = new NullEncryptor(); + $value = 'encrypted-data'; + + $decrypted = $encryptor->decrypt($value); + + expect($decrypted)->toBe($value); +}); + +it('handles empty strings', function () { + $encryptor = new NullEncryptor(); + + expect($encryptor->encrypt(''))->toBe(''); + expect($encryptor->decrypt(''))->toBe(''); +}); + +it('handles special characters', function () { + $encryptor = new NullEncryptor(); + $value = '!@#$%^&*(){}[]|\\:;"\'<>,.?/~`'; + + expect($encryptor->encrypt($value))->toBe($value); + expect($encryptor->decrypt($value))->toBe($value); +}); + +it('handles unicode characters', function () { + $encryptor = new NullEncryptor(); + $value = '你好世界 🌍 مرحبا'; + + expect($encryptor->encrypt($value))->toBe($value); + expect($encryptor->decrypt($value))->toBe($value); +}); diff --git a/tests/Encryptors/OpenSslEncryptorTest.php b/tests/Encryptors/OpenSslEncryptorTest.php new file mode 100644 index 0000000..0dcc72d --- /dev/null +++ b/tests/Encryptors/OpenSslEncryptorTest.php @@ -0,0 +1,218 @@ +toBeInstanceOf(DynamikDev\Cloak\Contracts\EncryptorInterface::class); +}); + +it('generates a valid base64 key', function () { + $key = OpenSslEncryptor::generateKey(); + + expect($key)->toBeString(); + expect(base64_decode($key, true))->not->toBe(false); + expect(strlen(base64_decode($key, true)))->toBe(32); +}); + +it('encrypts and decrypts successfully', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); + expect($encrypted)->not->toBe($value); +}); + +it('produces different output for same input', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + $value = 'test@example.com'; + + $encrypted1 = $encryptor->encrypt($value); + $encrypted2 = $encryptor->encrypt($value); + + // Different due to random IV + expect($encrypted1)->not->toBe($encrypted2); + + // But both decrypt to same value + expect($encryptor->decrypt($encrypted1))->toBe($value); + expect($encryptor->decrypt($encrypted2))->toBe($value); +}); + +it('accepts base64-encoded key', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('accepts raw 32-byte key', function () { + $rawKey = random_bytes(32); + $encryptor = new OpenSslEncryptor($rawKey); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('throws exception for invalid key length', function () { + new OpenSslEncryptor('short-key'); +})->throws(RuntimeException::class, 'Invalid encryption key'); + +it('throws exception for tampered ciphertext', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + + $encrypted = $encryptor->encrypt('test'); + $tampered = substr($encrypted, 0, -5) . 'XXXXX'; + + $encryptor->decrypt($tampered); +})->throws(RuntimeException::class); + +it('throws exception for invalid base64', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + + $encryptor->decrypt('not-valid-base64!!!'); +})->throws(RuntimeException::class, 'invalid base64'); + +it('throws exception for data too short', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + + // Valid base64 but too short + $encryptor->decrypt(base64_encode('short')); +})->throws(RuntimeException::class, 'too short'); + +it('handles empty strings', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + + $encrypted = $encryptor->encrypt(''); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe(''); +}); + +it('handles long strings', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + $value = str_repeat('test@example.com ', 1000); + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('handles special characters', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + $value = '!@#$%^&*(){}[]|\\:;"\'<>,.?/~`'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('handles unicode characters', function () { + $key = OpenSslEncryptor::generateKey(); + $encryptor = new OpenSslEncryptor($key); + $value = '你好世界 🌍 مرحبا עברית'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('different keys produce different ciphertext', function () { + $key1 = OpenSslEncryptor::generateKey(); + $key2 = OpenSslEncryptor::generateKey(); + $encryptor1 = new OpenSslEncryptor($key1); + $encryptor2 = new OpenSslEncryptor($key2); + $value = 'test@example.com'; + + $encrypted1 = $encryptor1->encrypt($value); + $encrypted2 = $encryptor2->encrypt($value); + + expect($encrypted1)->not->toBe($encrypted2); +}); + +it('cannot decrypt with wrong key', function () { + $key1 = OpenSslEncryptor::generateKey(); + $key2 = OpenSslEncryptor::generateKey(); + $encryptor1 = new OpenSslEncryptor($key1); + $encryptor2 = new OpenSslEncryptor($key2); + + $encrypted = $encryptor1->encrypt('test@example.com'); + + $encryptor2->decrypt($encrypted); +})->throws(RuntimeException::class, 'authentication tag mismatch'); + +it('reads key from CLOAK_PRIVATE_KEY environment variable', function () { + $key = OpenSslEncryptor::generateKey(); + putenv("CLOAK_TEST_PRIVATE_KEY={$key}"); + + $encryptor = new OpenSslEncryptor(null, 'CLOAK_TEST_PRIVATE_KEY'); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('reads key from $_ENV array', function () { + $key = OpenSslEncryptor::generateKey(); + $_ENV['CLOAK_TEST_PRIVATE_KEY'] = $key; + + $encryptor = new OpenSslEncryptor(null, 'CLOAK_TEST_PRIVATE_KEY'); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + $decrypted = $encryptor->decrypt($encrypted); + + expect($decrypted)->toBe($value); +}); + +it('throws exception when env var not set and no key provided', function () { + new OpenSslEncryptor(null, 'CLOAK_TEST_PRIVATE_KEY'); +})->throws(RuntimeException::class, 'CLOAK_TEST_PRIVATE_KEY environment variable is not set'); + +it('prefers provided key over environment variable', function () { + $envKey = OpenSslEncryptor::generateKey(); + $providedKey = OpenSslEncryptor::generateKey(); + + putenv("CLOAK_TEST_PRIVATE_KEY={$envKey}"); + + $encryptor = new OpenSslEncryptor($providedKey, 'CLOAK_TEST_PRIVATE_KEY'); + $value = 'test@example.com'; + + $encrypted = $encryptor->encrypt($value); + + $envEncryptor = new OpenSslEncryptor($envKey, 'CLOAK_TEST_PRIVATE_KEY'); + + expect(fn () => $envEncryptor->decrypt($encrypted)) + ->toThrow(RuntimeException::class); +}); diff --git a/tests/LifecycleCallbacksTest.php b/tests/LifecycleCallbacksTest.php new file mode 100644 index 0000000..feae18f --- /dev/null +++ b/tests/LifecycleCallbacksTest.php @@ -0,0 +1,230 @@ +beforeCloak(function ($text) use (&$called) { + $called = true; + + return $text; + }); + + $cloak->cloak('test@example.com', [Detector::email()]); + + expect($called)->toBeTrue(); +}); + +it('beforeCloak callback can modify text', function () { + $store = new ArrayStore(); + + $cloak = Cloak::using($store) + ->beforeCloak(fn ($text) => strtoupper($text)); + + $result = $cloak->cloak('test@example.com', [Detector::email()]); + $uncloaked = $cloak->uncloak($result); + + // Email was uppercased before detection + expect($uncloaked)->toBe('TEST@EXAMPLE.COM'); +}); + +it('executes afterCloak callback', function () { + $store = new ArrayStore(); + $called = false; + $receivedOriginal = ''; + $receivedCloaked = ''; + + $cloak = Cloak::using($store) + ->afterCloak(function ($original, $cloaked) use (&$called, &$receivedOriginal, &$receivedCloaked) { + $called = true; + $receivedOriginal = $original; + $receivedCloaked = $cloaked; + }); + + $result = $cloak->cloak('test@example.com', [Detector::email()]); + + expect($called)->toBeTrue(); + expect($receivedOriginal)->toBe('test@example.com'); + expect($receivedCloaked)->toBe($result); +}); + +it('executes beforeUncloak callback', function () { + $store = new ArrayStore(); + $called = false; + + $cloak = Cloak::using($store) + ->beforeUncloak(function ($text) use (&$called) { + $called = true; + + return $text; + }); + + $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); + $cloak->uncloak($cloaked); + + expect($called)->toBeTrue(); +}); + +it('beforeUncloak callback can modify text', function () { + $store = new ArrayStore(); + + $cloak = Cloak::using($store) + ->beforeUncloak(fn ($text) => trim($text)); + + $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); + $result = $cloak->uncloak(' ' . $cloaked . ' '); + + expect($result)->toBe('test@example.com'); +}); + +it('executes afterUncloak callback', function () { + $store = new ArrayStore(); + $called = false; + + $cloak = Cloak::using($store) + ->afterUncloak(function ($text) use (&$called) { + $called = true; + + return $text; + }); + + $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); + $cloak->uncloak($cloaked); + + expect($called)->toBeTrue(); +}); + +it('afterUncloak callback can modify result', function () { + $store = new ArrayStore(); + + $cloak = Cloak::using($store) + ->afterUncloak(fn ($text) => strtoupper($text)); + + $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); + $result = $cloak->uncloak($cloaked); + + expect($result)->toBe('TEST@EXAMPLE.COM'); +}); + +it('executes multiple callbacks in registration order', function () { + $store = new ArrayStore(); + $order = []; + + $cloak = Cloak::using($store) + ->beforeCloak(function ($text) use (&$order) { + $order[] = 'before1'; + + return $text; + }) + ->beforeCloak(function ($text) use (&$order) { + $order[] = 'before2'; + + return $text; + }); + + $cloak->cloak('test@example.com', [Detector::email()]); + + expect($order)->toBe(['before1', 'before2']); +}); + +it('chains beforeCloak transformations', function () { + $store = new ArrayStore(); + + $cloak = Cloak::using($store) + ->beforeCloak(fn ($text) => strtoupper($text)) + ->beforeCloak(fn ($text) => str_replace('@', '[AT]', $text)); + + $result = $cloak->cloak('test@example.com', [Detector::email()]); + $uncloaked = $cloak->uncloak($result); + + expect($uncloaked)->toBe('TEST[AT]EXAMPLE.COM'); +}); + +it('chains afterUncloak transformations', function () { + $store = new ArrayStore(); + + $cloak = Cloak::using($store) + ->afterUncloak(fn ($text) => str_replace('@', ' [at] ', $text)) + ->afterUncloak(fn ($text) => strtoupper($text)); + + $cloaked = $cloak->cloak('test@example.com', [Detector::email()]); + $result = $cloak->uncloak($cloaked); + + expect($result)->toBe('TEST [AT] EXAMPLE.COM'); +}); + +it('combines builder methods with callbacks', function () { + $store = new ArrayStore(); + $log = []; + + $cloak = Cloak::using($store) + ->withDetectors([Detector::email()]) + ->withTtl(7200) + ->filter(fn ($d) => !str_ends_with($d['match'], '.local')) + ->beforeCloak(function ($text) use (&$log) { + $log[] = 'before'; + + return $text; + }) + ->afterCloak(function ($original, $cloaked) use (&$log) { + $log[] = 'after'; + }); + + $result = $cloak->cloak('test@example.com local@test.local'); + + expect($log)->toBe(['before', 'after']); + expect($result)->toMatch('/\{\{EMAIL_[a-zA-Z0-9]{6}_1\}\}/'); + expect($result)->toContain('local@test.local'); +}); + +it('executes callbacks even when no detections found', function () { + $store = new ArrayStore(); + $beforeCalled = false; + $afterCalled = false; + + $cloak = Cloak::using($store) + ->beforeCloak(function ($text) use (&$beforeCalled) { + $beforeCalled = true; + + return $text; + }) + ->afterCloak(function ($original, $cloaked) use (&$afterCalled) { + $afterCalled = true; + }); + + $result = $cloak->cloak('No sensitive data', [Detector::email()]); + + expect($beforeCalled)->toBeTrue(); + expect($afterCalled)->toBeTrue(); + expect($result)->toBe('No sensitive data'); +}); + +it('executes callbacks even when all detections filtered', function () { + $store = new ArrayStore(); + $beforeCalled = false; + $afterCalled = false; + + $cloak = Cloak::using($store) + ->filter(fn () => false) + ->beforeCloak(function ($text) use (&$beforeCalled) { + $beforeCalled = true; + + return $text; + }) + ->afterCloak(function ($original, $cloaked) use (&$afterCalled) { + $afterCalled = true; + }); + + $result = $cloak->cloak('test@example.com', [Detector::email()]); + + expect($beforeCalled)->toBeTrue(); + expect($afterCalled)->toBeTrue(); + expect($result)->toBe('test@example.com'); +});