Files
michaelschiemer/tests/Unit/Framework/LiveComponents/Serialization/EncryptedStateSerializerTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

387 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Serialization\EncryptedStateSerializer;
use App\Framework\LiveComponents\Serialization\StateEncryptor;
use App\Framework\LiveComponents\Exceptions\StateEncryptionException;
use App\Framework\StateManagement\SerializableState;
use App\Framework\Cryptography\CryptographicUtilities;
use App\Framework\Random\SecureRandomGenerator;
// Test State Value Object
final readonly class TestEncryptableState implements SerializableState
{
public function __construct(
public int $count,
public string $message,
public ?string $optional = null
) {
}
public function toArray(): array
{
return [
'count' => $this->count,
'message' => $this->message,
'optional' => $this->optional,
];
}
public static function fromArray(array $data): self
{
return new self(
count: $data['count'] ?? 0,
message: $data['message'] ?? '',
optional: $data['optional'] ?? null
);
}
}
describe('EncryptedStateSerializer', function () {
beforeEach(function () {
// Use framework's RandomGenerator for cryptographically secure key
$this->random = new SecureRandomGenerator();
$this->crypto = new CryptographicUtilities($this->random);
// Generate valid 32-byte encryption key using framework's RandomGenerator
$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->testState = new TestEncryptableState(
count: 42,
message: 'Hello, encrypted world!',
optional: 'optional data'
);
});
it('serializes and encrypts state to string', function () {
$encrypted = $this->serializer->serialize($this->testState);
expect($encrypted)->toBeString();
expect($encrypted)->not->toBeEmpty();
// Encrypted data should be base64-encoded
$decoded = base64_decode($encrypted, strict: true);
expect($decoded)->not->toBeFalse();
// Should be longer than original due to nonce and MAC
expect(strlen($encrypted))->toBeGreaterThan(50);
});
it('deserializes and decrypts state correctly', function () {
$encrypted = $this->serializer->serialize($this->testState);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted)->toBeInstanceOf(TestEncryptableState::class);
expect($decrypted->count)->toBe(42);
expect($decrypted->message)->toBe('Hello, encrypted world!');
expect($decrypted->optional)->toBe('optional data');
});
it('produces different ciphertext for same state due to unique nonces', function () {
$encrypted1 = $this->serializer->serialize($this->testState);
$encrypted2 = $this->serializer->serialize($this->testState);
// Same plaintext should produce different ciphertext (unique nonces)
expect($encrypted1)->not->toBe($encrypted2);
// But both should decrypt to same state
$decrypted1 = $this->serializer->deserialize($encrypted1, TestEncryptableState::class);
$decrypted2 = $this->serializer->deserialize($encrypted2, TestEncryptableState::class);
expect($decrypted1->count)->toBe($decrypted2->count);
expect($decrypted1->message)->toBe($decrypted2->message);
});
it('handles state with null optional field', function () {
$state = new TestEncryptableState(
count: 10,
message: 'test',
optional: null
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->optional)->toBeNull();
});
it('handles state with special characters', function () {
$state = new TestEncryptableState(
count: 123,
message: "Special chars: 🔐 <script>alert('xss')</script> '; DROP TABLE users;--",
optional: "Unicode: 你好世界 مرحبا العالم"
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->message)->toBe($state->message);
expect($decrypted->optional)->toBe($state->optional);
});
it('throws exception for invalid encrypted data', function () {
$this->serializer->deserialize('invalid-base64-data', TestEncryptableState::class);
})->throws(StateEncryptionException::class);
it('throws exception for tampered ciphertext', function () {
$encrypted = $this->serializer->serialize($this->testState);
// Tamper with encrypted data
$tamperedEncrypted = substr($encrypted, 0, -5) . 'XXXXX';
$this->serializer->deserialize($tamperedEncrypted, TestEncryptableState::class);
})->throws(StateEncryptionException::class);
it('throws exception for invalid state class', function () {
$encrypted = $this->serializer->serialize($this->testState);
// Try to deserialize to wrong class
$this->serializer->deserialize($encrypted, \stdClass::class);
})->throws(StateEncryptionException::class, 'must implement fromArray');
it('throws exception for state without toArray method', function () {
$invalidState = new class {
public int $value = 42;
};
$this->serializer->serialize($invalidState);
})->throws(StateEncryptionException::class, 'must implement toArray');
it('throws exception for corrupted JSON in ciphertext', function () {
// Create manually corrupted encrypted data with valid structure but invalid JSON
$nonce = random_bytes(24);
$invalidJson = '{invalid json}';
$ciphertext = sodium_crypto_secretbox($invalidJson, $nonce, $this->encryptionKey);
$versionByte = chr(1);
$encrypted = base64_encode($versionByte . $nonce . $ciphertext);
$this->serializer->deserialize($encrypted, TestEncryptableState::class);
})->throws(StateEncryptionException::class);
it('preserves data types through encryption cycle', function () {
$state = new TestEncryptableState(
count: 0, // Zero value
message: '', // Empty string
optional: null
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->count)->toBe(0);
expect($decrypted->count)->toBeInt();
expect($decrypted->message)->toBe('');
expect($decrypted->message)->toBeString();
expect($decrypted->optional)->toBeNull();
});
it('handles large state objects', function () {
$largeMessage = str_repeat('A', 10000); // 10KB string
$state = new TestEncryptableState(
count: 999999,
message: $largeMessage,
optional: str_repeat('B', 5000)
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->message)->toBe($largeMessage);
expect(strlen($decrypted->message))->toBe(10000);
});
});
describe('StateEncryptor', function () {
beforeEach(function () {
$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
);
});
it('encrypts and decrypts plaintext correctly', function () {
$plaintext = 'Secret message';
$encrypted = $this->encryptor->encrypt($plaintext);
$decrypted = $this->encryptor->decrypt($encrypted);
expect($decrypted)->toBe($plaintext);
});
it('produces different ciphertext for same plaintext', function () {
$plaintext = 'Same message';
$encrypted1 = $this->encryptor->encrypt($plaintext);
$encrypted2 = $this->encryptor->encrypt($plaintext);
expect($encrypted1)->not->toBe($encrypted2);
expect($this->encryptor->decrypt($encrypted1))->toBe($plaintext);
expect($this->encryptor->decrypt($encrypted2))->toBe($plaintext);
});
it('throws exception for invalid encryption key length', function () {
new StateEncryptor(
'too-short-key', // Invalid key length
$this->crypto,
$this->random
);
})->throws(\InvalidArgumentException::class, 'Encryption key must be');
it('throws exception for weak encryption key', function () {
$random = new SecureRandomGenerator();
$crypto = new CryptographicUtilities($random);
// All zeros - weak key (insufficient entropy)
$weakKey = str_repeat("\0", SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
new StateEncryptor(
$weakKey,
$crypto,
$random
);
})->throws(\InvalidArgumentException::class, 'insufficient entropy');
it('detects MAC tampering', function () {
$encrypted = $this->encryptor->encrypt('original message');
// Tamper with MAC by modifying last bytes
$decoded = base64_decode($encrypted, strict: true);
$tampered = substr($decoded, 0, -5) . 'XXXXX';
$tamperedEncrypted = base64_encode($tampered);
$this->encryptor->decrypt($tamperedEncrypted);
})->throws(StateEncryptionException::class, 'MAC verification failed');
it('throws exception for corrupted base64', function () {
$this->encryptor->decrypt('!!!invalid-base64!!!');
})->throws(StateEncryptionException::class);
it('throws exception for invalid encryption version', function () {
// Create encrypted data with invalid version byte
$nonce = random_bytes(24);
$plaintext = 'test';
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->encryptionKey);
$invalidVersion = chr(99); // Invalid version
$encrypted = base64_encode($invalidVersion . $nonce . $ciphertext);
$this->encryptor->decrypt($encrypted);
})->throws(StateEncryptionException::class, 'Unsupported encryption version');
it('handles empty plaintext', function () {
$encrypted = $this->encryptor->encrypt('');
$decrypted = $this->encryptor->decrypt($encrypted);
expect($decrypted)->toBe('');
});
it('handles unicode plaintext', function () {
$plaintext = '🔐 Encrypted Unicode: 你好世界 مرحبا العالم';
$encrypted = $this->encryptor->encrypt($plaintext);
$decrypted = $this->encryptor->decrypt($encrypted);
expect($decrypted)->toBe($plaintext);
});
it('validates encrypted data format', function () {
$encrypted = $this->encryptor->encrypt('test');
expect($this->encryptor->isEncrypted($encrypted))->toBeTrue();
expect($this->encryptor->isEncrypted('not-encrypted'))->toBeFalse();
expect($this->encryptor->isEncrypted(''))->toBeFalse();
});
});
describe('StateEncryptor Security', function () {
beforeEach(function () {
$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
);
});
it('uses different nonce for each encryption', function () {
$plaintext = 'test';
$nonces = [];
for ($i = 0; $i < 100; $i++) {
$encrypted = $this->encryptor->encrypt($plaintext);
$decoded = base64_decode($encrypted, strict: true);
// Minimum length check: 1 (version) + 24 (nonce) + 16 (min ciphertext from sodium_crypto_secretbox)
expect(strlen($decoded))->toBeGreaterThanOrEqual(41);
// Extract nonce (after version byte, 24 bytes)
$nonce = substr($decoded, 1, 24);
expect(strlen($nonce))->toBe(24);
expect($nonces)->not->toContain($nonce);
$nonces[] = $nonce;
}
// All nonces should be unique
expect(count($nonces))->toBe(100);
expect(count(array_unique($nonces, SORT_REGULAR)))->toBe(100);
});
it('prevents key reuse across different encryptors', function () {
$plaintext = 'secret';
$random = new SecureRandomGenerator();
$crypto = new CryptographicUtilities($random);
$encryptor1 = new StateEncryptor(
$this->encryptionKey,
$crypto,
$random
);
// Different key using framework's RandomGenerator
$differentKey = $random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$encryptor2 = new StateEncryptor(
$differentKey,
$crypto,
$random
);
$encrypted1 = $encryptor1->encrypt($plaintext);
// Should fail with wrong key
$encryptor2->decrypt($encrypted1);
})->throws(StateEncryptionException::class);
it('includes version byte for future compatibility', function () {
$encrypted = $this->encryptor->encrypt('test');
$decoded = base64_decode($encrypted, strict: true);
// First byte should be version byte (value 1)
$version = ord($decoded[0]);
expect($version)->toBe(1);
});
});