<?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",
];
}
}