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