$this->paymentId, 'card_number' => $this->cardNumber, 'cvv' => $this->cvv, 'amount' => $this->amount, 'currency' => $this->currency, ]; } public static function fromArray(array $data): self { return new self( paymentId: $data['payment_id'], cardNumber: $data['card_number'], cvv: $data['cvv'], amount: $data['amount'], currency: $data['currency'] ); } } describe('State Encryption Integration', function () { beforeEach(function () { // Setup encryption infrastructure $this->random = new SecureRandomGenerator(); $this->crypto = new CryptographicUtilities($this->random); $this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); $this->encryptor = new StateEncryptor( $this->encryptionKey, $this->crypto, $this->random ); $this->serializer = new EncryptedStateSerializer($this->encryptor); $this->cache = new InMemoryCache(); // Test payment state $this->paymentState = new PaymentState( paymentId: 'pay_12345', cardNumber: '4111111111111111', cvv: '123', amount: 99.99, currency: 'USD' ); }); describe('End-to-End Encryption Flow', function () { it('encrypts state and stores in cache', function () { // 1. Encrypt state $encrypted = $this->serializer->serialize($this->paymentState); // 2. Store in cache $cacheKey = CacheKey::fromString('payment:session:abc123'); $cacheItem = CacheItem::forSet( key: $cacheKey, value: $encrypted, ttl: Duration::fromMinutes(15) ); $this->cache->set($cacheItem); // 3. Retrieve from cache $retrieved = $this->cache->get($cacheKey); expect($retrieved->isHit)->toBeTrue(); expect($retrieved->value)->toBeString(); // 4. Verify encrypted (no plaintext) expect($retrieved->value)->not->toContain('4111111111111111'); expect($retrieved->value)->not->toContain('123'); }); it('decrypts state correctly from cache', function () { // Encrypt and store $encrypted = $this->serializer->serialize($this->paymentState); $cacheKey = CacheKey::fromString('payment:session:xyz789'); $this->cache->set(CacheItem::forSet( key: $cacheKey, value: $encrypted )); // Retrieve and decrypt $cached = $this->cache->get($cacheKey); $decrypted = $this->serializer->deserialize( $cached->value, PaymentState::class ); // Verify decryption expect($decrypted)->toBeInstanceOf(PaymentState::class); expect($decrypted->paymentId)->toBe('pay_12345'); expect($decrypted->cardNumber)->toBe('4111111111111111'); expect($decrypted->cvv)->toBe('123'); expect($decrypted->amount)->toBe(99.99); }); it('handles cache expiration correctly', function () { // Store with short TTL $encrypted = $this->serializer->serialize($this->paymentState); $cacheKey = CacheKey::fromString('payment:session:expired'); // Manually expire by not setting (or setting expired) // InMemoryCache doesn't auto-expire, so we simulate by not storing $retrieved = $this->cache->get($cacheKey); expect($retrieved->isHit)->toBeFalse(); }); }); describe('Security Properties', function () { it('prevents tampering with MAC verification', function () { // Encrypt and store $encrypted = $this->serializer->serialize($this->paymentState); $cacheKey = CacheKey::fromString('payment:tamper-test'); $this->cache->set(CacheItem::forSet( key: $cacheKey, value: $encrypted )); // Retrieve and tamper $cached = $this->cache->get($cacheKey); $tampered = substr($cached->value, 0, -5) . 'XXXXX'; // Store tampered data $this->cache->set(CacheItem::forSet( key: $cacheKey, value: $tampered )); // Attempt to decrypt should fail $tamperedData = $this->cache->get($cacheKey); expect(fn() => $this->serializer->deserialize( $tamperedData->value, PaymentState::class ))->toThrow(StateEncryptionException::class); }); it('uses unique nonces for each encryption', function () { // Encrypt same state twice $encrypted1 = $this->serializer->serialize($this->paymentState); $encrypted2 = $this->serializer->serialize($this->paymentState); // Should be different due to unique nonces expect($encrypted1)->not->toBe($encrypted2); // Both should decrypt to same state $decrypted1 = $this->serializer->deserialize($encrypted1, PaymentState::class); $decrypted2 = $this->serializer->deserialize($encrypted2, PaymentState::class); expect($decrypted1->cardNumber)->toBe($decrypted2->cardNumber); }); it('does not leak sensitive data in cache storage', function () { // Store multiple encrypted states for ($i = 1; $i <= 5; $i++) { $state = new PaymentState( paymentId: "pay_{$i}", cardNumber: '4111111111111111', cvv: '123', amount: 100.00 * $i, currency: 'USD' ); $encrypted = $this->serializer->serialize($state); $cacheKey = CacheKey::fromString("payment:session:{$i}"); $this->cache->set(CacheItem::forSet( key: $cacheKey, value: $encrypted )); } // Scan all cache entries $allKeys = $this->cache->scan('payment:session:*'); foreach ($allKeys as $keyString) { $cacheKey = CacheKey::fromString($keyString); $item = $this->cache->get($cacheKey); // No plaintext card data should be visible expect($item->value)->not->toContain('4111111111111111'); expect($item->value)->not->toContain('123'); } }); }); describe('Performance Characteristics', function () { it('maintains acceptable encryption overhead', function () { $startTime = microtime(true); for ($i = 0; $i < 100; $i++) { $encrypted = $this->serializer->serialize($this->paymentState); $this->serializer->deserialize($encrypted, PaymentState::class); } $duration = (microtime(true) - $startTime) * 1000; $avgTimeMs = $duration / 100; // Should be < 2ms per encrypt+decrypt cycle (as per docs) expect($avgTimeMs)->toBeLessThan(2.0); }); it('handles concurrent cache operations', function () { // Simulate concurrent writes $operations = []; for ($i = 0; $i < 10; $i++) { $state = new PaymentState( paymentId: "concurrent_{$i}", cardNumber: '4111111111111111', cvv: '123', amount: 50.00, currency: 'USD' ); $encrypted = $this->serializer->serialize($state); $cacheKey = CacheKey::fromString("payment:concurrent:{$i}"); $this->cache->set(CacheItem::forSet( key: $cacheKey, value: $encrypted )); $operations[] = ['key' => $cacheKey, 'paymentId' => "concurrent_{$i}"]; } // Verify all operations succeeded foreach ($operations as $op) { $cached = $this->cache->get($op['key']); $decrypted = $this->serializer->deserialize( $cached->value, PaymentState::class ); expect($decrypted->paymentId)->toBe($op['paymentId']); } }); }); describe('Error Handling', function () { it('throws StateEncryptionException on decryption failure', function () { // Store corrupted data $cacheKey = CacheKey::fromString('payment:corrupted'); $this->cache->set(CacheItem::forSet( key: $cacheKey, value: 'corrupted-encrypted-data' )); $cached = $this->cache->get($cacheKey); expect(fn() => $this->serializer->deserialize( $cached->value, PaymentState::class ))->toThrow(StateEncryptionException::class); }); it('does not leak encryption key in error messages', function () { try { $this->serializer->deserialize( 'invalid-data', PaymentState::class ); } catch (StateEncryptionException $e) { // Error message should not contain encryption key expect($e->getMessage())->not->toContain(bin2hex($this->encryptionKey)); } }); it('handles invalid state class gracefully', function () { $encrypted = $this->serializer->serialize($this->paymentState); expect(fn() => $this->serializer->deserialize( $encrypted, \stdClass::class ))->toThrow(StateEncryptionException::class); }); }); describe('Multi-Component Isolation', function () { it('maintains separate encrypted states per component', function () { $component1Key = CacheKey::fromString('payment:component1'); $component2Key = CacheKey::fromString('payment:component2'); $state1 = new PaymentState( paymentId: 'pay_comp1', cardNumber: '4111111111111111', cvv: '111', amount: 50.00, currency: 'USD' ); $state2 = new PaymentState( paymentId: 'pay_comp2', cardNumber: '5555555555554444', cvv: '222', amount: 75.00, currency: 'EUR' ); // Encrypt and store both $encrypted1 = $this->serializer->serialize($state1); $encrypted2 = $this->serializer->serialize($state2); $this->cache->set(CacheItem::forSet(key: $component1Key, value: $encrypted1)); $this->cache->set(CacheItem::forSet(key: $component2Key, value: $encrypted2)); // Verify different encrypted data $cached1 = $this->cache->get($component1Key); $cached2 = $this->cache->get($component2Key); expect($cached1->value)->not->toBe($cached2->value); // Decrypt and verify correct isolation $decrypted1 = $this->serializer->deserialize($cached1->value, PaymentState::class); $decrypted2 = $this->serializer->deserialize($cached2->value, PaymentState::class); expect($decrypted1->paymentId)->toBe('pay_comp1'); expect($decrypted1->currency)->toBe('USD'); expect($decrypted2->paymentId)->toBe('pay_comp2'); expect($decrypted2->currency)->toBe('EUR'); }); }); });