/ tests / YioopUserAuthenticatorTest.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\atto\MailSite;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\YioopUserAuthenticator;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\SigninModel;

/**
 * RecordingSigninModel stands in for SigninModel in the test that
 * checks verifyPassword reaches the model: it notes the username and
 * password it was asked about and gives back a set answer, so the case
 * can see the call was made and what was passed without a database. Its
 * constructor takes no action so no database connection is opened.
 *
 * @author Chris Pollett
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
class RecordingSigninModel extends SigninModel
{
    /**
     * $asked_username stores the username checkValidSignin was given.
     * @var string|null
     */
    public $asked_username = null;
    /**
     * $asked_password stores the password checkValidSignin was given.
     * @var string|null
     */
    public $asked_password = null;
    /**
     * $answer stores what checkValidSignin gives back, set by a case to
     * stand for an accepted or a rejected credential.
     * @var bool
     */
    public $answer = true;
    /**
     * Takes no action so building this stand-in opens no database
     * connection.
     */
    public function __construct()
    {
    }
    /**
     * checkValidSignin notes the username and password it was asked
     * about and gives back the set answer.
     * @param string $username login name checked, taken by reference to
     *      match the model it stands in for
     * @param string $password candidate password checked
     * @return bool the set answer
     */
    public function checkValidSignin(&$username, $password)
    {
        $this->asked_username = $username;
        $this->asked_password = $password;
        return $this->answer;
    }
}

/**
 * Tests how the reserved bot account is authenticated when the site
 * sends its own mail. Just before logging in as the bot the site's
 * mail sender writes a random secret to a shared file, and the
 * authenticator checks the supplied password against that file. These
 * cases cover that a correct secret is accepted and then consumed (the
 * file is emptied so it cannot be replayed), that a wrong secret is
 * rejected and leaves the pending secret untouched, that an empty or
 * missing file rejects any password, and that the bot is recognized by
 * its local part with or without a domain.
 *
 * @author Chris Pollett
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
class YioopUserAuthenticatorTest extends UnitTest
{
    /**
     * Authenticator under test.
     * @var YioopUserAuthenticator
     */
    public $authenticator;
    /**
     * $bot_name stores the local part of the address this site sends its
     * own mail from, which is the login the site's own mail sender uses.
     * @var string
     */
    public $bot_name;
    /**
     * Path to the shared bot secret file.
     * @var string
     */
    public $path;
    /**
     * Any secret file content present before a case, restored after
     * each case so a real file in the work directory is not disturbed.
     * @var string|null
     */
    public $saved;
    /**
     * Saves any existing secret file, makes sure its directory is
     * present, and builds a fresh authenticator for each case.
     */
    public function setUp()
    {
        /* The site's own mail sender is named after the address the site
           sends from, so a case that wrote the word bot would fail on a
           machine sending from another address. */
        $this->bot_name = MailSiteFactory::botLocalPart();
        /* The class under test extends Authenticator, which is
           defined inside MailSite.php rather than in its own file,
           so make sure that file is loaded before constructing the
           authenticator or the class would fail to load. */
        class_exists(MailSite::class);
        $this->path = C\SECURITY_DIR . "/bot_security.txt";
        $this->saved = is_file($this->path) ?
            file_get_contents($this->path) : null;
        $directory = dirname($this->path);
        if (!is_dir($directory)) {
            mkdir($directory, 0700, true);
        }
        $this->authenticator = new YioopUserAuthenticator();
    }
    /**
     * Restores the secret file to whatever was there before the case.
     */
    public function tearDown()
    {
        if ($this->saved !== null) {
            file_put_contents($this->path, $this->saved);
        } else if (is_file($this->path)) {
            unlink($this->path);
        }
    }
    /**
     * Writes a secret to the shared file the way the mail sender does.
     * @param string $secret the secret to place in the file
     */
    public function writeSecret($secret)
    {
        file_put_contents($this->path, $secret);
        @chmod($this->path, 0777);
    }
    /**
     * The bot is recognized by local part, with or without a domain.
     */
    public function botLocalPartIsRecognizedTestCase()
    {
        /* The address is passed rather than read from the settings, so
           that the case gives the same answer on a machine whose site
           sends its mail from some other address. */
        $sender = "bot@example.com";
        $this->assertTrue(MailSiteFactory::isBotUsername("bot", $sender),
            "bare bot name recognized");
        $this->assertTrue(
            MailSiteFactory::isBotUsername("bot@example.com", $sender),
            "bot with domain recognized");
    }
    /**
     * An ordinary username is not treated as the bot.
     */
    public function ordinaryUsernameIsNotBotTestCase()
    {
        $sender = "bot@example.com";
        $this->assertFalse(
            MailSiteFactory::isBotUsername("alice", $sender),
            "ordinary name is not the bot");
        $this->assertFalse(
            MailSiteFactory::isBotUsername("alice@example.com"),
            "ordinary name with domain is not the bot");
    }
    /**
     * verifyPasswordAsksTheSigninModelTestCase checks that
     * verifyPassword routes an ordinary login through the signin
     * model's checkValidSignin, the same routine the web form uses, and
     * hands back what it returns. A signin model that records the call
     * stands in for the real one so the case does not touch a database;
     * that a call reaches it at all is what a missing model-builder
     * would have stopped, since verifyPassword asks the authenticator to
     * build the signin model on demand for any name that is not the bot.
     */
    public function verifyPasswordAsksTheSigninModelTestCase()
    {
        $recorder = new RecordingSigninModel();
        $authenticator = new YioopUserAuthenticator(null, $recorder);
        $accepted = $authenticator->verifyPassword("alice", "secret");
        $this->assertTrue($accepted,
            "a valid credential the signin model accepts comes back true");
        $this->assertEqual("alice", $recorder->asked_username,
            "the typed username reaches the signin model");
        $this->assertEqual("secret", $recorder->asked_password,
            "the typed password reaches the signin model");
        $recorder->answer = false;
        $this->assertFalse(
            $authenticator->verifyPassword("alice", "wrong"),
            "a credential the signin model rejects comes back false");
    }
}
X