/ tests / SigninModelTest.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\SigninModel;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;

/**
 * Tests the one-time sign-in code storage on SigninModel: storing a code,
 * reading it back, replacing it when a new one is requested, counting
 * wrong guesses, and deleting it so it can only ever be used once. It also
 * tests the LDAP account lookups: turning the email a directory returns
 * into the Yioop account that owns it, finding emails shared by more than
 * one account, and reporting whether an email is already taken by another
 * account. Each test runs against a fresh throwaway sqlite file so the
 * behavior is exercised in isolation.
 *
 * @author Chris Pollett
 */
class SigninModelTest extends UnitTest
{
    /**
     * Filesystem path for the throwaway test DB
     * @var string
     */
    public $db_path;
    /**
     * Model under test
     * @var SigninModel
     */
    public $model;
    /**
     * Sets up an empty DB holding the SIGNIN_CODE table for the one-time
     * code tests and a small USERS table for the LDAP email-to-account
     * lookup tests, then hands it to a fresh model instance.
     */
    public function setUp()
    {
        $tag = getmypid() . "_" . random_int(1000, 9999);
        $this->db_path = C\WORK_DIRECTORY . "/temp/" .
            "signin_code_test_$tag.db";
        $db = new Sqlite3Manager();
        $db->connect("", "", "", $this->db_path);
        /* Build the tables from ProfileModel's definitions, the same ones the
           live database uses, so this test tracks any schema change rather
           than hand-copied CREATE TABLEs that could drift. */
        $dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($db, $dbinfo);
        foreach (['SIGNIN_CODE', 'USERS'] as $table) {
            $db->execute($profile->create_statements[$table]);
        }
        $this->model = new SigninModel(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);
        }
    }
    /**
     * A stored code can be read back, starting with zero wrong guesses
     * and the expiry time it was given.
     */
    public function storeAndReadTestCase()
    {
        $expires = time() + C\SIGNIN_CODE_TIMEOUT;
        $this->model->setSigninCode(7, L\crawlCrypt("ABCD2345"), $expires);
        $record = $this->model->getSigninCode(7);
        $this->assertTrue(!empty($record),
            "getSigninCode returns the stored row");
        $this->assertEqual(0, intval($record['TRIES']),
            "a fresh code starts with zero wrong guesses");
        $this->assertEqual($expires, intval($record['EXPIRES']),
            "the expiry time is persisted");
    }
    /**
     * The right code matches the stored hash and a wrong one does not,
     * which is exactly the check the sign-in flow performs.
     */
    public function verifyMatchTestCase()
    {
        $code = "PQRS7890";
        $this->model->setSigninCode(7, L\crawlCrypt($code),
            time() + C\SIGNIN_CODE_TIMEOUT);
        $record = $this->model->getSigninCode(7);
        $this->assertTrue(hash_equals($record['CODE_HASH'],
            L\crawlCrypt($code, $record['CODE_HASH'])),
            "the right code matches the stored hash");
        $this->assertFalse(hash_equals($record['CODE_HASH'],
            L\crawlCrypt("WRONG234", $record['CODE_HASH'])),
            "a wrong code does not match");
    }
    /**
     * Requesting a new code replaces any earlier one, so only the most
     * recent code works and its wrong-guess count starts over.
     */
    public function replaceTestCase()
    {
        $this->model->setSigninCode(7, L\crawlCrypt("FIRST234"),
            time() + C\SIGNIN_CODE_TIMEOUT);
        $this->model->incrementSigninCodeTries(7);
        $this->model->setSigninCode(7, L\crawlCrypt("SECOND34"),
            time() + C\SIGNIN_CODE_TIMEOUT);
        $record = $this->model->getSigninCode(7);
        $this->assertTrue(hash_equals($record['CODE_HASH'],
            L\crawlCrypt("SECOND34", $record['CODE_HASH'])),
            "only the newest code is stored");
        $this->assertEqual(0, intval($record['TRIES']),
            "the replacement resets the wrong-guess count");
    }
    /**
     * Each wrong guess is counted so the flow can retire a code once it
     * reaches the allowed ceiling.
     */
    public function incrementTriesTestCase()
    {
        $this->model->setSigninCode(7, L\crawlCrypt("CODE2345"),
            time() + C\SIGNIN_CODE_TIMEOUT);
        $this->model->incrementSigninCodeTries(7);
        $this->model->incrementSigninCodeTries(7);
        $record = $this->model->getSigninCode(7);
        $this->assertEqual(2, intval($record['TRIES']),
            "each wrong guess bumps the count by one");
    }
    /**
     * A used code is deleted so the same code can never sign anyone in a
     * second time.
     */
    public function singleUseDeleteTestCase()
    {
        $this->model->setSigninCode(7, L\crawlCrypt("ONESHOT2"),
            time() + C\SIGNIN_CODE_TIMEOUT);
        $this->model->deleteSigninCode(7);
        $record = $this->model->getSigninCode(7);
        $this->assertTrue(empty($record),
            "a deleted code is gone");
    }
    /**
     * A user with no pending code simply has nothing to read.
     */
    public function unknownUserTestCase()
    {
        $record = $this->model->getSigninCode(999);
        $this->assertTrue(empty($record),
            "there is no row for a user without a code");
    }
    /**
     * Adds one account row to the throwaway USERS table for the LDAP
     * lookup tests below.
     *
     * @param int $user_id the account's numeric id
     * @param string $user_name the account's username
     * @param string $email the account's email
     */
    public function insertUser($user_id, $user_name, $email)
    {
        $this->model->db->execute("INSERT INTO USERS " .
            "(USER_ID, USER_NAME, EMAIL) VALUES (?, ?, ?)",
            [$user_id, $user_name, $email]);
    }
    /**
     * The email a directory returns maps to the one account that owns it,
     * case-insensitively; an unknown email, an empty email, and an email
     * shared by two accounts all map to false so a sign-in is never
     * granted to a guessed or ambiguous account.
     */
    public function localUserForLdapEmailTestCase()
    {
        $this->insertUser(1, "jane", "jane@example.com");
        $this->assertEqual("jane",
            $this->model->localUserForLdapEmail("jane@example.com"),
            "an email maps to the account that owns it");
        $this->assertEqual("jane",
            $this->model->localUserForLdapEmail("JANE@EXAMPLE.COM"),
            "the email match ignores letter case");
        $this->assertFalse(
            $this->model->localUserForLdapEmail("nobody@example.com"),
            "an unknown email maps to no account");
        $this->assertFalse($this->model->localUserForLdapEmail(""),
            "an empty email maps to no account");
        $this->insertUser(2, "jane2", "jane@example.com");
        $this->assertFalse(
            $this->model->localUserForLdapEmail("jane@example.com"),
            "an email two accounts share is refused as ambiguous");
    }
    /**
     * The conflict scan lists only emails more than one account holds,
     * with the usernames that share each, and skips accounts that have no
     * email at all.
     */
    public function emailConflictsTestCase()
    {
        $this->insertUser(1, "jane", "jane@example.com");
        $this->insertUser(2, "bob", "bob@example.com");
        $this->insertUser(3, "noemail", "");
        $this->assertEqual([], $this->model->emailConflicts(),
            "no conflict while every email is unique");
        $this->insertUser(4, "jane2", "JANE@example.com");
        $this->insertUser(5, "noemail2", "");
        $conflicts = $this->model->emailConflicts();
        $this->assertEqual(1, count($conflicts),
            "exactly one email is shared");
        $shared = array_values($conflicts)[0];
        sort($shared);
        $this->assertEqual(["jane", "jane2"], $shared,
            "both usernames that share the email are listed");
    }
    /**
     * An email already held by a different account is reported in use,
     * while the account's own email and a free email are not, and an empty
     * email is never in use.
     */
    public function emailInUseByOtherUserTestCase()
    {
        $this->insertUser(1, "jane", "jane@example.com");
        $this->assertTrue($this->model->emailInUseByOtherUser(
            "jane@example.com", "bob"),
            "an email another account holds is in use");
        $this->assertFalse($this->model->emailInUseByOtherUser(
            "jane@example.com", "jane"),
            "an account's own email is not a conflict for itself");
        $this->assertFalse($this->model->emailInUseByOtherUser(
            "free@example.com", "bob"),
            "an unused email is free");
        $this->assertFalse($this->model->emailInUseByOtherUser("", "bob"),
            "an empty email is never in use");
    }
    /**
     * The quick conflict check is false while every email is unique and
     * true once two accounts share one.
     */
    public function hasEmailConflictTestCase()
    {
        $this->insertUser(1, "jane", "jane@example.com");
        $this->insertUser(2, "bob", "bob@example.com");
        $this->assertFalse($this->model->hasEmailConflict(),
            "no conflict while every email is unique");
        $this->insertUser(3, "jane2", "jane@example.com");
        $this->assertTrue($this->model->hasEmailConflict(),
            "a shared email is a conflict");
    }
    /**
     * The readiness check lists every missing setting plus a root account
     * with no email when LDAP is otherwise blank, and returns an empty
     * list once the three settings are present, the root account has an
     * email, and no two accounts share one. A shared email shows up as its
     * own problem.
     */
    public function ldapConfigProblemsTestCase()
    {
        $this->assertEqual(
            ["no_servers", "no_suffix", "no_base_dn", "root_no_email"],
            $this->model->ldapConfigProblems("", "", ""),
            "blank LDAP with no root email lists every problem");
        $this->insertUser(C\ROOT_ID, "root", "root@example.com");
        $this->assertEqual([],
            $this->model->ldapConfigProblems("ldap.example.com",
            "@example.com", "dc=example,dc=com"),
            "filled settings with a root email and no clashes are ready");
        $this->insertUser(2, "jane", "jane@example.com");
        $this->insertUser(3, "jane2", "jane@example.com");
        $this->assertEqual(["email_conflicts"],
            $this->model->ldapConfigProblems("ldap.example.com",
            "@example.com", "dc=example,dc=com"),
            "a shared email is the only remaining problem");
    }
    /**
     * Checks that the live root directory check refuses to even try a bind
     * when something it needs is missing: a blank root username, a blank
     * password, or no directory server to reach. Each returns the
     * bind_failed token without contacting a directory, which keeps the site
     * on locally stored passwords. The successful bind and email match cannot
     * be exercised here because they need a real directory to answer.
     */
    public function ldapRootValidationProblemRejectsEmptyTestCase()
    {
        $this->assertEqual("bind_failed",
            $this->model->ldapRootValidationProblem("ldap.example.com",
            "@example.com", "dc=example,dc=com", "", "secret"),
            "a blank root username cannot validate");
        $this->assertEqual("bind_failed",
            $this->model->ldapRootValidationProblem("ldap.example.com",
            "@example.com", "dc=example,dc=com", "root", ""),
            "a blank root password cannot validate");
        $this->assertEqual("bind_failed",
            $this->model->ldapRootValidationProblem("",
            "@example.com", "dc=example,dc=com", "root", "secret"),
            "no directory server to reach cannot validate");
    }
}
X