assertSame($plain, $dec); $this->assertNotSame($plain, $enc, 'Ciphertext must differ from plaintext'); } public function testEncryptDecryptWithUnicode(): void { $plain = 'émoji 🎉 — français & 日本語'; $enc = Crypto::encrypt($plain); $dec = Crypto::decrypt($enc); $this->assertSame($plain, $dec); } public function testEncryptDecryptMultiline(): void { $plain = "Line 1\nLine 2\r\nLine 3\n\n"; $enc = Crypto::encrypt($plain); $dec = Crypto::decrypt($enc); $this->assertSame($plain, $dec); } // ── Different plaintexts → different ciphertexts ────────────────────────── public function testDifferentPlaintextsProduceDifferentCiphertexts(): void { $a = Crypto::encrypt('alpha'); $b = Crypto::encrypt('beta'); $this->assertNotSame($a, $b); } public function testSamePlaintextProducesDifferentCiphertexts(): void { // IV is random every time — encrypting the same value twice should // never produce identical ciphertext (except in astronomically rare // collisions of a 12-byte IV). $a = Crypto::encrypt('same'); $b = Crypto::encrypt('same'); $this->assertNotSame($a, $b, 'IV randomness → ciphertexts must differ'); } // ── isEncrypted() ───────────────────────────────────────────────────────── public function testIsEncryptedRecognizesEncryptedValue(): void { $enc = Crypto::encrypt('secret'); $this->assertTrue(Crypto::isEncrypted($enc)); } public function testIsEncryptedRejectsPlaintext(): void { $this->assertFalse(Crypto::isEncrypted('plaintext password')); } public function testIsEncryptedReturnsFalseForEmptyString(): void { $this->assertFalse(Crypto::isEncrypted('')); } public function testIsEncryptedRejectsInvalidBase64(): void { $this->assertFalse(Crypto::isEncrypted('not valid base64!!!')); } // ── Empty string ────────────────────────────────────────────────────────── public function testEncryptEmptyStringProducesCiphertext(): void { $enc = Crypto::encrypt(''); $this->assertNotEmpty($enc, 'Encrypting empty string should still produce ciphertext'); // Note: isEncrypted() returns false for the empty-string ciphertext // because the raw blob (IV+TAG+empty ciphertext) is exactly 28 bytes, // which is < IV_LEN + TAG_LEN + 1 (29). This is a known edge case. $raw = base64_decode($enc, true); $this->assertIsString($raw); $this->assertEquals(28, strlen($raw), 'IV(12) + TAG(16) + empty ciphertext(0)'); } public function testDecryptEmptyStringReturnsEmpty(): void { $this->assertSame('', Crypto::decrypt('')); } // ── Invalid base64 input ────────────────────────────────────────────────── public function testDecryptInvalidBase64ReturnsInputGracefully(): void { // Legacy fallback: non-base64 strings are returned as-is $input = 'plaintext-legacy-password'; $result = Crypto::decrypt($input); $this->assertSame($input, $result); } public function testDecryptTooShortBlobReturnsInputGracefully(): void { // Base64 blob that decodes to fewer bytes than IV + TAG + 1 $tooShort = base64_encode('abc'); $result = Crypto::decrypt($tooShort); $this->assertSame($tooShort, $result); } // ── Wrong key / tampering ───────────────────────────────────────────────── public function testDecryptWithTamperedCiphertextReturnsEmpty(): void { $enc = Crypto::encrypt('tamper me'); $raw = base64_decode($enc, true); // Flip a byte inside the ciphertext portion (after IV + tag) $tamperOffset = 12 + 16; // after 12-byte IV + 16-byte tag $raw[$tamperOffset] = chr(ord($raw[$tamperOffset]) ^ 0xFF); $tampered = base64_encode($raw); $result = Crypto::decrypt($tampered); $this->assertSame('', $result, 'Tampered ciphertext must return empty string'); } public function testDecryptValidBlobTamperedTagReturnsEmpty(): void { $enc = Crypto::encrypt('test'); $raw = base64_decode($enc, true); // Tamper with the authentication tag (bytes 13-28, after the 12-byte IV) $raw[13] = chr(ord($raw[13]) ^ 0xFF); $tampered = base64_encode($raw); $result = Crypto::decrypt($tampered); $this->assertSame('', $result, 'Tag mismatch must return empty'); } }