/ tests / UserModelTest.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 as L;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\UserModel;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;

/**
 * Tests the look-up of an account by its email address, used to stop the
 * site's bot address from being pointed at an address some member
 * already uses. Each test runs against a fresh throwaway sqlite file
 * holding a small USERS table.
 *
 * @author Chris Pollett
 */
class UserModelTest extends UnitTest
{
    /**
     * Filesystem path for the throwaway test DB
     * @var string
     */
    public $db_path;
    /**
     * Model under test
     * @var UserModel
     */
    public $model;
    /**
     * Sets up a small USERS table holding one account and hands it to a
     * fresh model instance.
     */
    public function setUp()
    {
        $tag = getmypid() . "_" . random_int(1000, 9999);
        $this->db_path = C\WORK_DIRECTORY . "/temp/" .
            "user_model_email_test_$tag.db";
        $db = new Sqlite3Manager();
        $db->connect("", "", "", $this->db_path);
        /* Build the table 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. */
        $dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($db, $dbinfo);
        /* addUser puts the user in a group, gives them a role and
           the caller reads their bot settings, so those tables are
           made too. Taken from the same statements the live database
           is made from, so a change to the schema reaches this test
           rather than being missed by a copy written here. */
        foreach (["USERS", "USER_GROUP", "USER_ROLE", "ROLE",
            "CHAT_BOT"] as $table) {
            $db->execute($profile->create_statements[$table]);
        }
        $db->execute(
            "INSERT INTO USERS (USER_ID, USER_NAME, EMAIL) VALUES (?, ?, ?)",
            [11, "alice", "Alice@Example.com"]);
        $this->model = new UserModel(C\DB_NAME, false);
        $this->model->db = $db;
    }
    /**
     * Disconnects and removes the throwaway DB.
     */
    public function tearDown()
    {
        if ($this->model && $this->model->db) {
            $this->model->db->disconnect();
            unset(Sqlite3Manager::$active_connections[
                $this->model->db->connect_string]);
        }
        if ($this->db_path && file_exists($this->db_path)) {
            unlink($this->db_path);
        }
    }
    /**
     * Saving a record that was read back leaves the password as it was,
     * while the field that did change is saved. Somebody whose last
     * name was edited through Manage Users could no longer sign in with
     * the password they had, since what is stored is already scrambled
     * and saving scrambled it a second time.
     */
    public function editingAUserKeepsTheirPasswordTestCase()
    {
        $this->model->addUser("bob", "test123A", "Bob", "Jones",
            "bob@example.org", C\ACTIVE_STATUS);
        $bob = $this->model->getUser("bob");
        $this->assertTrue($bob['PASSWORD'] === L\crawlCrypt("test123A",
            $bob['PASSWORD']),
            "the password bob was given is the one stored");
        $bob['LAST_NAME'] = "bob2";
        $this->model->updateUser($bob);
        $after = $this->model->getUser("bob");
        $this->assertEqual("bob2", $after['LAST_NAME'],
            "the name that was edited is saved");
        $this->assertTrue($after['PASSWORD'] === L\crawlCrypt("test123A",
            $after['PASSWORD']),
            "and bob can still sign in with the password they had");
    }
    /**
     * Setting a password still sets it, so leaving the stored one alone
     * does not stop anybody changing a password.
     */
    public function settingAPasswordStillSetsItTestCase()
    {
        $this->model->addUser("carol", "test123A", "Carol", "Ng",
            "carol@example.org", C\ACTIVE_STATUS);
        $carol = $this->model->getUser("carol");
        $carol['PASSWORD'] = "Newpass1A";
        $this->model->updateUser($carol);
        $after = $this->model->getUser("carol");
        $this->assertTrue($after['PASSWORD'] === L\crawlCrypt("Newpass1A",
            $after['PASSWORD']), "the new password is the one stored");
        $this->assertTrue($after['PASSWORD'] !== L\crawlCrypt("test123A",
            $after['PASSWORD']),
            "and the old one no longer opens the account");
    }
    /**
     * What is stored for a user is handed back for the account asked
     * for, and nothing for an account that is not there.
     */
    public function storedPasswordIsReadBackTestCase()
    {
        $this->model->addUser("dana", "test123A", "Dana", "Poe",
            "dana@example.org", C\ACTIVE_STATUS);
        $dana = $this->model->getUser("dana");
        $this->assertEqual($dana['PASSWORD'],
            $this->model->storedPassword($dana['USER_ID']),
            "what is stored is what the record shows");
        $this->assertEqual("", $this->model->storedPassword(99999),
            "an account that is not there has nothing stored");
    }
    /**
     * An account is found by its email address even when the lookup uses
     * different letter case from how the address was stored.
     */
    public function foundCaseInsensitiveTestCase()
    {
        $row = $this->model->getUserByEmail("alice@example.com");
        $this->assertTrue(is_array($row) && isset($row['USER_ID']),
            "getUserByEmail finds the account regardless of case");
        $this->assertEqual(11, intval($row['USER_ID']),
            "the matching account is returned");
    }
    /**
     * An address no account uses returns false.
     */
    public function notFoundTestCase()
    {
        $row = $this->model->getUserByEmail("nobody@example.com");
        $this->assertFalse(!empty($row),
            "an unused address matches no account");
    }
    /**
     * A member's recovery question is stored as plain text while their
     * answer is kept only as a hash; the correct answer then verifies and
     * a wrong one does not.
     */
    public function recoveryQuestionRoundTripTestCase()
    {
        $this->model->setRecoveryQuestion(11, "First pet's name?", "Rex");
        $result = $this->model->db->execute(
            "SELECT * FROM USERS WHERE USER_ID = ?", [11]);
        $user = $this->model->db->fetchArray($result);
        $this->assertEqual("First pet's name?",
            $user['RECOVERY_QUESTION'],
            "the question is stored back as plain text");
        $this->assertNotEqual("Rex", $user['RECOVERY_ANSWER'],
            "the answer is not stored in the clear");
        $this->assertTrue(
            $this->model->checkRecoveryAnswer($user, "Rex"),
            "the answer that was set opens the account");
        $this->assertFalse(
            $this->model->checkRecoveryAnswer($user, "rex"),
            "a wrong answer does not verify");
    }
    /**
     * An account that has set no recovery answer rejects every answer.
     */
    public function emptyRecoveryAnswerTestCase()
    {
        $result = $this->model->db->execute(
            "SELECT * FROM USERS WHERE USER_ID = ?", [11]);
        $user = $this->model->db->fetchArray($result);
        $this->assertFalse(
            $this->model->checkRecoveryAnswer($user, "anything"),
            "no stored answer means no answer verifies");
    }
    /**
     * Rewording the question with a null answer leaves the previously
     * stored answer in place so it still verifies.
     */
    public function keepAnswerOnQuestionEditTestCase()
    {
        $this->model->setRecoveryQuestion(11, "First pet's name?", "Rex");
        $this->model->setRecoveryQuestion(11, "Name of first pet?", null);
        $result = $this->model->db->execute(
            "SELECT * FROM USERS WHERE USER_ID = ?", [11]);
        $user = $this->model->db->fetchArray($result);
        $this->assertEqual("Name of first pet?",
            $user['RECOVERY_QUESTION'],
            "the question is updated");
        $this->assertTrue(
            $this->model->checkRecoveryAnswer($user, "Rex"),
            "the earlier answer still verifies after a question-only edit");
    }
    /**
     * namesOfferedExtendWhatWasTypedTestCase checks the search behind the
     * names a screen offers while somebody types. Only names beginning
     * with the letters typed come back, upper and lower case are treated
     * alike, and a letter the pattern language would read as a wildcard
     * looks for itself instead. A person can also be left out of the
     * answer, which is how the person doing the typing is kept from
     * being offered themselves.
     */
    public function namesOfferedExtendWhatWasTypedTestCase()
    {
        $model = new UserModel();
        $found = $model->usersStartingWith("ro", 5);
        $names = [];
        foreach ($found as $one) {
            $names[] = $one['USER_NAME'];
        }
        $this->assertTrue(in_array("root", $names),
            "a name beginning with the letters typed is offered");
        $upper = $model->usersStartingWith("RO", 5);
        $this->assertEqual(count($found), count($upper),
            "the same names come back whatever case is typed");
        $wild = $model->usersStartingWith("%", 5);
        $this->assertEqual(0, count($wild),
            "a typed wildcard looks for itself and matches nothing");
        $none = $model->usersStartingWith("", 5);
        $this->assertEqual(0, count($none),
            "nothing typed offers nothing");
        $without = $model->usersStartingWith("ro", 5, [C\ROOT_ID]);
        $left_out = [];
        foreach ($without as $one) {
            $left_out[] = $one['USER_ID'];
        }
        $this->assertTrue(!in_array(C\ROOT_ID, $left_out),
            "somebody left out of the answer does not appear in it");
    }
}
X