/ tests / MailAccountModelTest.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\configs as C;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\MailAccountModel;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;

/**
 * Tests for MailAccountModel CRUD + user-scoping + the
 * leave-password-alone behavior of updateAccount. Each test runs
 * against a fresh pair of throwaway sqlite files (one public, one
 * private) so the access-control boundary can be exercised with
 * multiple distinct USER_IDs.
 *
 * @author Chris Pollett
 */
class MailAccountModelTest extends UnitTest
{
    /**
     * Filesystem path for the throwaway public DB
     * @var string
     */
    public $public_db_path;
    /**
     * Filesystem path for the throwaway private DB
     * @var string
     */
    public $private_db_path;
    /**
     * Model under test
     * @var MailAccountModel
     */
    public $model;
    /**
     * Sets up empty public + private DBs and hands them to a fresh
     * model instance. Clears any cached master key.
     */
    public function setUp()
    {
        $tag = getmypid() . "_" . random_int(1000, 9999);
        $this->public_db_path = C\WORK_DIRECTORY . "/temp/" .
            "mail_account_test_public_$tag.db";
        $this->private_db_path = C\WORK_DIRECTORY . "/temp/" .
            "mail_account_test_private_$tag.db";
        $public_db = new Sqlite3Manager();
        $public_db->connect("", "", "", $this->public_db_path);
        /* Build MAIL_ACCOUNT from ProfileModel's definition, the same one the
           live database uses, so this test tracks any schema change rather
           than a hand-copied CREATE TABLE that could drift. MAIL_SECRET below
           is not a ProfileModel table, so it stays defined here. */
        $dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($public_db, $dbinfo);
        $public_db->execute($profile->create_statements['MAIL_ACCOUNT']);
        $private_db = new Sqlite3Manager();
        $private_db->connect("", "", "", $this->private_db_path);
        $private_db->execute("CREATE TABLE MAIL_SECRET (" .
            "KEY_ID INTEGER PRIMARY KEY, KEY_VALUE VARCHAR(" .
            C\MAIL_SECRET_KEY_LEN . "))");
        $this->model = new MailAccountModel(C\DB_NAME, false);
        $this->model->db = $public_db;
        $this->model->private_db = $private_db;
    }
    /**
     * Disconnects + removes the throwaway DBs.
     */
    public function tearDown()
    {
        if ($this->model) {
            if ($this->model->db) {
                $this->model->db->disconnect();
                unset(Sqlite3Manager::$active_connections[
                    $this->model->db->connect_string]);
            }
            if ($this->model->private_db) {
                $this->model->private_db->disconnect();
                unset(Sqlite3Manager::$active_connections[
                    $this->model->private_db->connect_string]);
            }
        }
        /* Evicting this test's own handles above (keyed by file path)
           keeps a reused scratch-file name in a later test from being
           handed this test's still-open connection. A plain disconnect
           does not evict; shared handles are deliberately left alone. */
        foreach ([$this->public_db_path,
            $this->private_db_path] as $p) {
            if ($p && file_exists($p)) {
                unlink($p);
            }
        }
    }
    /**
     * addAccount inserts a row and returns the new id; the row is
     * then retrievable by both the password-free and the
     * with-password readers, with the password round-tripping
     * cleanly through encryption.
     */
    public function addAndReadTestCase()
    {
        $fields = $this->sampleFields();
        $id = $this->model->addAccount(42, $fields);
        $this->assertTrue($id > 0, "addAccount returns positive id");
        $row = $this->model->getAccount($id, 42);
        $this->assertTrue(is_array($row),
            "getAccount returns the row");
        $this->assertEqual("Personal Gmail", $row['DISPLAY_NAME'],
            "DISPLAY_NAME persisted");
        $this->assertEqual("imap.gmail.com", $row['HOST'],
            "HOST persisted");
        $this->assertTrue(!isset($row['PASSWORD']),
            "getAccount does not surface PASSWORD");
        $with_pw = $this->model->getAccountWithPassword($id, 42);
        $this->assertEqual("hunter2", $with_pw['PASSWORD'],
            "Password round-trips through encrypt/decrypt");
        $this->assertTrue(!isset($with_pw['PASSWORD_NONCE']),
            "Nonce stripped from getAccountWithPassword result");
    }
    /**
     * Adding an account with a password makes the model mint and store
     * a master key in MAIL_SECRET (KEY_ID = 1, a 32-byte value) the
     * first time it needs to encrypt, with the table starting empty.
     */
    public function masterKeyPersistenceTestCase()
    {
        $before = $this->model->private_db->execute(
            "SELECT * FROM MAIL_SECRET WHERE KEY_ID = 1");
        $row_before = $before ?
            $this->model->private_db->fetchArray($before) : false;
        $this->assertTrue($row_before === false,
            "MAIL_SECRET starts empty");
        $this->model->addAccount(42, $this->sampleFields());
        $after = $this->model->private_db->execute(
            "SELECT * FROM MAIL_SECRET WHERE KEY_ID = 1");
        $row_after = $this->model->private_db->fetchArray($after);
        $this->assertTrue(is_array($row_after),
            "Encrypting an account persists a key row");
        $decoded = base64_decode($row_after['KEY_VALUE'], true);
        $this->assertEqual(SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
            strlen($decoded), "Stored key is the right size");
    }
    /**
     * getAccount must return false when the account_id belongs to a
     * different user, even if the id itself exists.
     */
    public function userScopingTestCase()
    {
        $a_id = $this->model->addAccount(1, $this->sampleFields());
        $b_id = $this->model->addAccount(2, $this->sampleFields());
        $this->assertTrue($this->model->getAccount($a_id, 1) !== false,
            "Owner reads own account");
        $this->assertEqual(false, $this->model->getAccount($a_id, 2),
            "Other user cannot read account A");
        $this->assertEqual(false, $this->model->getAccount($b_id, 1),
            "User 1 cannot read account B");
    }
    /**
     * getAccountsForUser returns rows in insertion order and does
     * not leak rows belonging to other users.
     */
    public function listAccountsScopedTestCase()
    {
        $fields_a = $this->sampleFields();
        $fields_a['DISPLAY_NAME'] = "First";
        $fields_b = $this->sampleFields();
        $fields_b['DISPLAY_NAME'] = "Second";
        $fields_c = $this->sampleFields();
        $fields_c['DISPLAY_NAME'] = "Other user's";
        $this->model->addAccount(1, $fields_a);
        sleep(0); // keep CREATED_AT predictable across both rows
        $this->model->addAccount(1, $fields_b);
        $this->model->addAccount(2, $fields_c);
        $rows = $this->model->getAccountsForUser(1);
        $this->assertEqual(2, count($rows),
            "User 1 sees only their two accounts");
        $names = array_map(fn($r) => $r['DISPLAY_NAME'], $rows);
        $this->assertTrue(in_array("First", $names),
            "User 1's first account is in the list");
        $this->assertTrue(in_array("Second", $names),
            "User 1's second account is in the list");
        $this->assertTrue(!in_array("Other user's", $names),
            "User 2's account is NOT in user 1's list");
    }
    /**
     * updateAccount with $leave_password = true preserves the
     * stored ciphertext while updating other fields. The password
     * round-trips the original value, not the empty string the
     * caller passed.
     */
    public function updatePreservesPasswordTestCase()
    {
        $id = $this->model->addAccount(1, $this->sampleFields());
        $update = $this->sampleFields();
        $update['DISPLAY_NAME'] = "Renamed";
        $update['PASSWORD'] = "";
        $ok = $this->model->updateAccount($id, 1, $update, true);
        $this->assertTrue($ok, "updateAccount returns true on success");
        $row = $this->model->getAccount($id, 1);
        $this->assertEqual("Renamed", $row['DISPLAY_NAME'],
            "DISPLAY_NAME was updated");
        $with_pw = $this->model->getAccountWithPassword($id, 1);
        $this->assertEqual("hunter2", $with_pw['PASSWORD'],
            "Original password still readable after leave_password update");
    }
    /**
     * updateAccount with $leave_password = false re-encrypts and
     * stores the new password supplied in $fields.
     */
    public function updateReplacesPasswordTestCase()
    {
        $id = $this->model->addAccount(1, $this->sampleFields());
        $update = $this->sampleFields();
        $update['PASSWORD'] = "new-password-789";
        $this->model->updateAccount($id, 1, $update, false);
        $with_pw = $this->model->getAccountWithPassword($id, 1);
        $this->assertEqual("new-password-789", $with_pw['PASSWORD'],
            "New password is now stored");
    }
    /**
     * updateAccount called with the wrong owner does nothing (returns
     * false because the existence check fails first).
     */
    public function updateRejectsCrossUserTestCase()
    {
        $id = $this->model->addAccount(1, $this->sampleFields());
        $update = $this->sampleFields();
        $update['DISPLAY_NAME'] = "Hijacked";
        $ok = $this->model->updateAccount($id, 2, $update, true);
        $this->assertEqual(false, $ok,
            "updateAccount returns false for non-owner");
        $row = $this->model->getAccount($id, 1);
        $this->assertEqual("Personal Gmail", $row['DISPLAY_NAME'],
            "Row was not touched");
    }
    /**
     * deleteAccount removes a row the user owns; getAccount returns
     * false afterward. Cross-user delete is a no-op.
     */
    public function deleteAccountTestCase()
    {
        $id = $this->model->addAccount(1, $this->sampleFields());
        $other_id = $this->model->addAccount(2, $this->sampleFields());
        $this->model->deleteAccount($id, 1);
        $this->assertEqual(false, $this->model->getAccount($id, 1),
            "Deleted row no longer exists");
        $this->model->deleteAccount($other_id, 1);
        $this->assertTrue(
            $this->model->getAccount($other_id, 2) !== false,
            "Cross-user delete did not touch the row");
    }
    /**
     * The SMTP password round-trips through its own encrypt/decrypt
     * pair independently of the IMAP password, the ALLOW_SELF_SIGNED
     * flag persists, and updateAccount with $leave_smtp_password
     * true keeps the stored SMTP ciphertext while still updating the
     * IMAP password when $leave_password is false.
     */
    public function smtpFieldsRoundTripTestCase()
    {
        $id = $this->model->addAccount(7, $this->sampleFields());
        $row = $this->model->getAccount($id, 7);
        $this->assertEqual(1, (int) $row['ALLOW_SELF_SIGNED'],
            "ALLOW_SELF_SIGNED flag persisted");
        $this->assertEqual("smtp.example.com", $row['SMTP_HOST'],
            "SMTP_HOST persisted");
        $with_smtp = $this->model->getAccountWithSmtpPassword($id, 7);
        $this->assertEqual("smtp-secret-1", $with_smtp['SMTP_PASSWORD'],
            "SMTP password round-trips through encrypt/decrypt");
        $this->assertTrue(!isset($with_smtp['SMTP_PASSWORD_NONCE']),
            "SMTP nonce stripped from getAccountWithSmtpPassword result");
        $update = $this->sampleFields();
        $update['PASSWORD'] = "imap-changed";
        $update['SMTP_PASSWORD'] = "";
        $this->model->updateAccount($id, 7, $update, false, true);
        $imap = $this->model->getAccountWithPassword($id, 7);
        $this->assertEqual("imap-changed", $imap['PASSWORD'],
            "IMAP password updated when leave_password false");
        $smtp = $this->model->getAccountWithSmtpPassword($id, 7);
        $this->assertEqual("smtp-secret-1", $smtp['SMTP_PASSWORD'],
            "SMTP password preserved when leave_smtp_password true");
    }
    /**
     * getAccount() reports whether each section has a stored
     * credential via the derived HAS_PASSWORD / HAS_SMTP_PASSWORD
     * columns, without surfacing ciphertext. An account created with
     * a non-empty IMAP password but an empty SMTP password has
     * HAS_PASSWORD = 1 and HAS_SMTP_PASSWORD = 0; the empty SMTP
     * password is stored as genuinely empty columns, so
     * getAccountWithSmtpPassword yields the empty string. Emptying
     * the IMAP password through updateAccount then flips
     * HAS_PASSWORD to 0 as well.
     */
    public function hasPasswordFlagsTestCase()
    {
        $fields = $this->sampleFields();
        $fields['SMTP_PASSWORD'] = "";
        $id = $this->model->addAccount(5, $fields);
        $row = $this->model->getAccount($id, 5);
        $this->assertEqual(1, (int) $row['HAS_PASSWORD'],
            "HAS_PASSWORD is 1 when an IMAP password was stored");
        $this->assertEqual(0, (int) $row['HAS_SMTP_PASSWORD'],
            "HAS_SMTP_PASSWORD is 0 when the SMTP password was empty");
        $this->assertTrue(!isset($row['PASSWORD_CIPHERTEXT']),
            "getAccount still does not surface ciphertext columns");
        $with_smtp = $this->model->getAccountWithSmtpPassword($id, 5);
        $this->assertEqual("", $with_smtp['SMTP_PASSWORD'],
            "An empty SMTP password round-trips as the empty string");
        $update = $this->sampleFields();
        $update['PASSWORD'] = "";
        $this->model->updateAccount($id, 5, $update, false, true);
        $row2 = $this->model->getAccount($id, 5);
        $this->assertEqual(0, (int) $row2['HAS_PASSWORD'],
            "Emptying the IMAP password flips HAS_PASSWORD to 0");
        $imap = $this->model->getAccountWithPassword($id, 5);
        $this->assertEqual("", $imap['PASSWORD'],
            "A cleared IMAP password reads back as the empty string");
    }
    /**
     * Helper: returns a fresh sample fields array for addAccount.
     *
     * @return array
     */
    /**
     * updateAccountOrder writes each account's position so a later
     * getAccountsForUser returns them in the chosen order; ids of
     * another user are ignored, and accounts default to creation
     * order until reordered.
     */
    public function updateAccountOrderTestCase()
    {
        $fields = $this->sampleFields();
        $fields['DISPLAY_NAME'] = 'First';
        $first = $this->model->addAccount(7, $fields);
        $fields['DISPLAY_NAME'] = 'Second';
        $second = $this->model->addAccount(7, $fields);
        $fields['DISPLAY_NAME'] = 'Third';
        $third = $this->model->addAccount(7, $fields);
        /* default order is creation order */
        $accounts = $this->model->getAccountsForUser(7);
        $this->assertEqual(['First', 'Second', 'Third'],
            array_column($accounts, 'DISPLAY_NAME'),
            'defaults to creation order before reorder');
        /* reorder to Third, First, Second */
        $this->model->updateAccountOrder(7,
            [$third, $first, $second]);
        $accounts = $this->model->getAccountsForUser(7);
        $this->assertEqual(['Third', 'First', 'Second'],
            array_column($accounts, 'DISPLAY_NAME'),
            'getAccountsForUser reflects the new order');
        /* an id from another user is ignored, not applied */
        $other = $this->model->addAccount(8, $fields);
        $this->model->updateAccountOrder(7, [$other, $first]);
        $other_rows = $this->model->getAccountsForUser(8);
        $this->assertEqual(1, count($other_rows),
            "other user's account untouched by cross-user order");
    }
    /**
     * Returns a representative set of account fields for tests.
     * @return array field map suitable for addAccount
     */
    protected function sampleFields()
    {
        return [
            "DISPLAY_NAME" => "Personal Gmail",
            "HOST" => "imap.gmail.com",
            "PORT" => 993,
            "USERNAME" => "alice@example.com",
            "PASSWORD" => "hunter2",
            "TLS_MODE" => "imaps",
            "DEFAULT_FOLDER" => "INBOX",
            "ALLOW_SELF_SIGNED" => 1,
            "SMTP_HOST" => "smtp.example.com",
            "SMTP_PORT" => 587,
            "SMTP_USERNAME" => "alice@example.com",
            "SMTP_PASSWORD" => "smtp-secret-1",
            "SMTP_TLS_MODE" => "starttls",
        ];
    }
}
X