<?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\controllers\RegisterController;
use seekquarry\yioop\library\UnitTest;
/**
* Checks the proof-of-work captcha that now guards account creation and
* recovery. The image captcha has been retired, so every sign-up and
* recovery form relies on the browser solving a small hash puzzle; this
* test pins down that the server accepts a correct solution and rejects
* a wrong one.
*
* @author Chris Pollett
*/
class RegisterControllerTest extends UnitTest
{
/**
* The controller whose proof-of-work check is under test.
* @var RegisterController
*/
public $controller;
/**
* Builds a controller for each test case without running its
* constructor, since the proof-of-work check only reads the session
* and request and needs none of the controller's normal set-up.
*/
public function setUp()
{
$reflection = new \ReflectionClass(RegisterController::class);
$this->controller = $reflection->newInstanceWithoutConstructor();
/* The cases about a suppressed address need the controller to
hold stand-ins for the two models they reach through. */
$reflection = new \ReflectionClass(RegisterController::class);
$this->controller = $reflection->newInstanceWithoutConstructor();
$this->controller->model_instances = [
"user" => new class {
/**
* Stand-in: no username is ever already in use.
*
* @param string $user the username to look up
* @return bool false, meaning the name is free
*/
public function getUserId($user)
{
return false;
}
},
"mailSuppression" => new class {
/**
* Stand-in suppression check: one address is suppressed.
*
* @param string $email address to check
* @return bool true when the address is the burned one
*/
public function isSuppressed($email)
{
return strtolower(trim((string) $email)) ===
"burned@example.com";
}
},
];
$_SESSION = [];
$_REQUEST = [];
}
/**
* Nothing to take down between test cases.
*/
public function tearDown()
{
}
/**
* Sets up a proof-of-work challenge in the session at difficulty two
* (the answer's hash must start with two zeros) and returns the text
* prefix the browser hashes its guesses against.
*
* @return string the "random:request_time:" prefix a guess is added to
*/
private function startChallenge()
{
$_SESSION = [];
$_SESSION["random_string"] = "test_random_string";
$_SESSION["request_time"] = time();
$_SESSION["level"] = 2;
return $_SESSION["random_string"] . ':' .
$_SESSION["request_time"] . ':';
}
/**
* A guess whose hash meets the difficulty is accepted as a human.
*/
public function acceptsValidProofOfWorkTestCase()
{
$prefix = $this->startChallenge();
$nonce = 0;
while (substr(sha1($prefix . $nonce), 0, 2) !== "00") {
$nonce++;
}
$_REQUEST['nonce_for_string'] = (string)$nonce;
$this->assertTrue($this->controller->validateHashCode(),
"a guess meeting the difficulty passes the proof-of-work");
}
/**
* A guess whose hash misses the difficulty is rejected.
*/
public function rejectsBadProofOfWorkTestCase()
{
$prefix = $this->startChallenge();
$bad = 0;
while (substr(sha1($prefix . $bad), 0, 2) === "00") {
$bad++;
}
$_REQUEST['nonce_for_string'] = (string)$bad;
$this->assertFalse($this->controller->validateHashCode(),
"a guess missing the difficulty fails the proof-of-work");
}
/**
* The shared proof-of-work check passes a hash with enough zeroes and
* fails one without, given the same inputs directly.
*/
public function meetsProofOfWorkChecksLeadingZeroesTestCase()
{
$random_string = "abc";
$request_time = "123";
$good = 0;
while (substr(sha1($random_string . ':' . $request_time . ':' .
$good), 0, 2) !== "00") {
$good++;
}
$this->assertTrue($this->controller->meetsProofOfWork($random_string,
$request_time, (string)$good, 2),
"a nonce producing two leading zeroes passes");
$bad = 0;
while (substr(sha1($random_string . ':' . $request_time . ':' .
$bad), 0, 2) === "00") {
$bad++;
}
$this->assertFalse($this->controller->meetsProofOfWork($random_string,
$request_time, (string)$bad, 2),
"a nonce missing two leading zeroes fails");
}
/**
* Runs the registration data check for one email address and returns
* the resulting data array.
*
* @param string $email address to register with
* @return array the data the check fills in, including SUCCESS
*/
public function checkEmail($email)
{
$_REQUEST = ["a" => "processAccountData", "user" => "newperson",
"email" => $email, "password" => "Abcdef1!",
"repassword" => "Abcdef1!", "first" => "New", "last" => "Person"];
$data = ["check_user" => true, "check_fields" =>
["user", "email", "password", "repassword", "first", "last"]];
$this->controller->dataIntegrityCheck($data);
return $data;
}
/**
* A suppressed address is refused and no account is allowed.
*/
public function suppressedEmailRefusedTestCase()
{
$data = $this->checkEmail("burned@example.com");
$this->assertFalse($data['SUCCESS'],
"registration with a suppressed address fails");
$this->assertTrue(strpos($data['SCRIPT'], "doMessage") !== false,
"the user is shown a message explaining the refusal");
}
/**
* An ordinary address passes the check.
*/
public function ordinaryEmailAllowedTestCase()
{
$data = $this->checkEmail("fresh@example.com");
$this->assertTrue($data['SUCCESS'],
"registration with an ordinary address passes the check");
}
/**
* The reserved name "bot" (the unsubscribe mailbox) cannot be taken
* by a new account.
*/
public function reservedBotNameRefusedTestCase()
{
$_REQUEST = ["a" => "createAccount", "user" => "bot",
"email" => "fresh@example.com", "password" => "Abcdef1!",
"repassword" => "Abcdef1!", "first" => "New",
"last" => "Person"];
$data = ["check_user" => true, "check_fields" =>
["user", "email", "password", "repassword", "first", "last"]];
$this->controller->dataIntegrityCheck($data);
$this->assertFalse($data['SUCCESS'],
"the reserved name bot cannot be registered");
}
/**
* codeAndResendSurviveRecoveryOffTestCase checks that a site with
* account recovery turned off still offers the emailed sign in
* code and the resending of an activation mail. Neither recovers
* an account: one signs in a user who has one, the other sends
* again the mail that makes a new account usable. A reader on such
* a site who follows either link and lands on the account form
* sees this case fail.
*/
public function codeAndResendSurviveRecoveryOffTestCase()
{
$offered = $this->controller->activities;
$this->assertTrue(in_array("signinCode", $offered),
"the emailed sign in code is offered");
$this->assertTrue(in_array("resendRegistration", $offered),
"resending an activation mail is offered");
}
}