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