/ tests / CredentialCipherTest.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\library\CredentialCipher;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests for CredentialCipher, now a pure-crypto helper. Each test
 * mints a fresh random master key in setUp and exercises encrypt /
 * decrypt against it; no database is involved, since key storage now
 * lives in MailAccountModel.
 *
 * @author Chris Pollett
 */
class CredentialCipherTest extends UnitTest
{
    /**
     * Master key minted fresh in setUp; the round-trip tests encrypt
     * and decrypt against it.
     * @var string
     */
    public $key;
    /**
     * Mints a fresh random master key. CredentialCipher is pure crypto
     * now, so no database is involved.
     */
    public function setUp()
    {
        $this->key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
    }
    /**
     * Nothing to tear down; the cipher keeps no state between tests.
     */
    public function tearDown()
    {
    }
    /**
     * encrypt then decrypt returns the original plaintext for both
     * ASCII and binary payloads, including the empty string.
     */
    public function roundTripTestCase()
    {
        foreach (["hello", "", "with spaces and \"quotes\"",
            str_repeat("\x00\x01\x02", 50),
            "unicode: café résumé naïve"] as $plaintext) {
            $pair = CredentialCipher::encrypt($this->key, $plaintext);
            $back = CredentialCipher::decrypt($this->key,
                $pair['nonce'], $pair['ciphertext']);
            $this->assertEqual($plaintext, $back,
                "Round trip preserves " . strlen($plaintext) .
                "-byte plaintext");
        }
    }
    /**
     * Two successive encrypts of the same plaintext produce different
     * ciphertexts because each gets a fresh random nonce.
     */
    public function nonceUniquenessTestCase()
    {
        $first = CredentialCipher::encrypt($this->key, "same input");
        $second = CredentialCipher::encrypt($this->key, "same input");
        $this->assertTrue($first['nonce'] !== $second['nonce'],
            "Nonces differ between successive encrypts");
        $this->assertTrue(
            $first['ciphertext'] !== $second['ciphertext'],
            "Ciphertexts differ between successive encrypts");
    }
    /**
     * Decrypting a ciphertext whose payload has been altered by a
     * single byte should throw \Exception.
     */
    public function tamperDetectionTestCase()
    {
        $pair = CredentialCipher::encrypt($this->key, "secret");
        $cipher_raw = base64_decode($pair['ciphertext']);
        $cipher_raw[5] = chr(ord($cipher_raw[5]) ^ 0x01);
        $tampered = base64_encode($cipher_raw);
        try {
            CredentialCipher::decrypt($this->key,
                $pair['nonce'], $tampered);
            $this->assertTrue(false,
                "Tampered ciphertext should have thrown");
        } catch (\Exception $e) {
            $this->assertTrue(
                str_contains($e->getMessage(), "authentication"),
                "Exception explains the auth failure");
        }
    }
    /**
     * Decrypting with a wrong-length nonce should throw before
     * sodium_crypto_secretbox_open gets a chance to.
     */
    public function badNonceLengthTestCase()
    {
        $pair = CredentialCipher::encrypt($this->key, "secret");
        $short_nonce = base64_encode("too short");
        try {
            CredentialCipher::decrypt($this->key,
                $short_nonce, $pair['ciphertext']);
            $this->assertTrue(false,
                "Wrong-length nonce should have thrown");
        } catch (\Exception $e) {
            $this->assertTrue(
                str_contains($e->getMessage(), "nonce"),
                "Exception calls out the nonce length");
        }
    }
    /**
     * Garbage base64 in either input should throw rather than crash
     * sodium_crypto_secretbox_open.
     */
    public function malformedBase64TestCase()
    {
        try {
            CredentialCipher::decrypt($this->key,
                "***not base64***", "alsonot");
            $this->assertTrue(false,
                "Malformed base64 should have thrown");
        } catch (\Exception $e) {
            $this->assertTrue(
                str_contains($e->getMessage(), "base64"),
                "Exception calls out the bad base64");
        }
    }
}
X