<?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 <http://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\controllers\Controller;
use seekquarry\yioop\controllers\StaticController;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
/**
* Checks the parts of the controller every controller inherits: the
* cleaning of values that came from a person, and the reduction of a folder
* path they named to one that cannot reach outside where it is resolved.
*
* Wiki resources live in folders under a page, and a person names a folder
* when moving a resource or when looking inside one. A path naming the
* parent folder repeatedly could otherwise reach the rest of the disk, so
* these cases pin down that the reduction resolves those steps rather than
* striking the characters out of the text, which leaves ways through.
*
* @author Chris Pollett
*/
class ControllerTest extends UnitTest
{
/**
* The values a help mark needs are taken from the address and made
* safe here, in the controller, before any view sees them. An
* address may write one name more than once, or write it with
* brackets, and what arrives is then a list; such a value is left
* out rather than handed on, since where a reader came from is
* written as flat pairs.
*/
public function helpValuesAreMadeSafeBeforeAnyViewTestCase()
{
$held = [$_GET, $_REQUEST];
$_GET = ["c" => "admin", "a" => "userRolesGroups",
"group_id" => "3", "arg" => "read",
"back_params" => ["c" => "admin"],
"note" => "a & b"];
$_REQUEST = $_GET;
$safe = $this->controller->safeHelpRequest();
$this->assertEqual("admin", $safe['controller'],
"which controller the reader is on is carried");
$this->assertEqual("userRolesGroups", $safe['activity'],
"and which activity");
$this->assertTrue(!isset($safe['back']['back_params']),
"a list among the values is left out");
$this->assertTrue(!isset($safe['back']['c']) &&
!isset($safe['back']['a']),
"the controller and activity are not repeated in the rest");
$this->assertTrue(!isset($safe['back']['group_id']) &&
!isset($safe['back']['page_name']),
"what names a page or a group is left out, since the mark " .
"carries the way back into a link that edits another page");
$this->assertTrue(strpos($safe['back']['note'], "&") !== false,
"and each value is made safe to write into a page");
list($_GET, $_REQUEST) = $held;
}
/**
* The help group is found by its name rather than by a number
* written into the settings. A site's groups are numbered in the
* order they were made, so which number the help group has depends
* on how that site was set up, and a mark pointing at a number
* pointed at whatever group happened to hold it.
*/
public function helpGroupIsFoundByNameTestCase()
{
$held = [$_GET, $_REQUEST];
$_GET = ["c" => "admin", "a" => "manageAccount"];
$_REQUEST = $_GET;
$safe = $this->controller->safeHelpRequest();
$this->assertEqual(C\p('HELP_GROUP_NAME'), $safe['help_group'],
"the group is the one the settings name");
$this->assertTrue($safe['help_group_id'] > 0,
"and its number is looked up rather than assumed");
list($_GET, $_REQUEST) = $held;
}
/**
* A controller whose inherited cleaning is under test.
* @var StaticController
*/
public $controller;
/**
* Builds a controller without running its constructor, since the
* cleaning works on a value handed to it and reads neither disk nor
* database.
*/
public function setUp()
{
$reflection = new \ReflectionClass(StaticController::class);
$this->controller = $reflection->newInstanceWithoutConstructor();
}
/**
* Lets go of the controller so one case does not leak into the next.
*/
public function tearDown()
{
$this->controller = null;
}
/**
* An ordinary path is left as it stands, apart from writing every
* separator the one way and dropping empty steps.
*/
public function ordinaryPathTestCase()
{
$this->assertEqual(Controller::normalizePath("docs/images"),
"docs/images", "a plain path is unchanged");
$this->assertEqual(Controller::normalizePath("docs\\\\images"),
"docs/images", "a backslash is written as a separator");
$this->assertEqual(Controller::normalizePath("/docs//images/"),
"docs/images", "empty steps and edge separators are dropped");
$this->assertEqual(Controller::normalizePath("docs/./images"),
"docs/images", "a step naming the folder itself is dropped");
}
/**
* A step naming the parent folder removes the step before it, and one
* with nothing before it is dropped, so no path reaches above where it
* starts.
*/
public function parentStepsResolveTestCase()
{
$this->assertEqual(Controller::normalizePath("docs/../images"),
"images", "a parent step removes the folder before it");
$this->assertEqual(Controller::normalizePath("../../etc"), "etc",
"parent steps at the start have nothing to remove");
$this->assertEqual(Controller::normalizePath("a/b/../../../c"), "c",
"more parent steps than folders still cannot reach outside");
$this->assertEqual(Controller::normalizePath(".."), "",
"a path that is only a parent step comes back empty");
}
/**
* Asking the cleaner for a path gives back the reduced form, so a
* caller reading a folder name out of a request need not remember to
* reduce it separately.
*/
public function cleanPathTypeTestCase()
{
$this->assertEqual($this->controller->clean("docs/../images",
"path"), "images", "the cleaner reduces a path it is given");
$this->assertEqual($this->controller->clean("../../etc", "path"),
"etc", "the cleaner leaves no way above where it starts");
$this->assertEqual($this->controller->clean("docs/images", "path"),
"docs/images", "a plain path comes back unchanged");
}
/**
* Paths written to defeat striking the two-dot sequence out of the text
* are reduced to names that stay inside, since the reduction reads whole
* steps rather than characters.
*/
public function textStrippingDefeatsTestCase()
{
$this->assertEqual(Controller::normalizePath("....//etc"),
"..../etc", "four dots name a folder, not two parent steps");
$this->assertEqual(Controller::normalizePath(".../....//secret"),
".../..../secret", "runs of dots are ordinary folder names");
$this->assertTrue(
strpos(Controller::normalizePath("../../../../etc/passwd"),
"..") === false, "the tidied path holds no step going up a folder");
}
/**
* Determines if the checkTimeInterval method can correctly determine
* if a time of day is between the times of day of two timestamps
*/
public function checkTimeIntervalTestCase()
{
$three_oh_five = 1592172350;
$one_hour = 3600;
$this->assertEqual(-1, L\checkTimeInterval("14:00", -1, $three_oh_five),
"(a) no sleep duration (-1) does not contain 3:05pm");
$this->assertEqual(-1, L\checkTimeInterval("16:00", -1, $three_oh_five),
"(b) no sleep duration (-1) does not contain 3:05pm");
$this->assertEqual(-1, L\checkTimeInterval("14:00", $one_hour,
$three_oh_five), "2pm +1hr does not contain 3:05pm");
$this->assertEqual(1592175600, L\checkTimeInterval("14:00",
2 * $one_hour, $three_oh_five),
"2pm +2hr interval contains 3:05pm and ends at 4pm");
}
/**
* Checks passwordPolicyViolations against explicit policies rather
* than the ambient config, so the result does not depend on the
* machine's profile: a length-only policy accepts an eight character
* password and flags short, over-long, and forbidden-character ones,
* and each require-a-class policy flags a password lacking that class
* and accepts one that has it.
*/
public function passwordPolicyViolationsTestCase()
{
$length_only = ["min_length" => 8, "max_length" => 64,
"lowercase" => false, "uppercase" => false,
"digit" => false, "symbol" => false, "forbidden" => "'\""];
$this->assertTrue(empty(L\passwordPolicyViolations("abcdefgh",
$length_only)), "eight chars pass a length-only policy");
$this->assertTrue(in_array("too_short",
L\passwordPolicyViolations("abc", $length_only)),
"a short password is flagged too short");
$this->assertTrue(in_array("too_long",
L\passwordPolicyViolations(str_repeat("a", 65),
$length_only)), "an over-long password is flagged too long");
$this->assertTrue(in_array("forbidden",
L\passwordPolicyViolations("abcdefg'h", $length_only)),
"a single quote is a forbidden character");
$this->assertTrue(in_array("forbidden",
L\passwordPolicyViolations('abcdefg"h', $length_only)),
"a double quote is a forbidden character");
$require_upper = array_merge($length_only,
["uppercase" => true]);
$this->assertTrue(in_array("uppercase",
L\passwordPolicyViolations("abcdefgh", $require_upper)),
"requiring uppercase flags an all-lowercase password");
$this->assertTrue(empty(L\passwordPolicyViolations("Abcdefgh",
$require_upper)), "a password with uppercase then passes");
$require_digit = array_merge($length_only, ["digit" => true]);
$this->assertTrue(in_array("digit",
L\passwordPolicyViolations("abcdefgh", $require_digit)),
"requiring a digit flags a letters-only password");
$require_symbol = array_merge($length_only, ["symbol" => true]);
$this->assertTrue(in_array("symbol",
L\passwordPolicyViolations("abcdefgh", $require_symbol)),
"requiring a symbol flags an alphanumeric password");
$require_lower = array_merge($length_only,
["lowercase" => true]);
$this->assertTrue(in_array("lowercase",
L\passwordPolicyViolations("ABCDEFGH", $require_lower)),
"requiring lowercase flags an all-uppercase password");
}
/**
* Used to check Encoding decoding using unary coding
*/
public function unaryCodeTestCase()
{
$start = 0;
$current_string = "";
for($i = 1; $i <= 20; $i++) {
$current_string = L\appendUnary($i, $current_string, $start);
}
for($j = 20; $j >= 1; $j--) {
$current_string = L\appendUnary($j, $current_string, $start);
}
$start = 0;
for($i = 1; $i <= 20; $i++) {
$decoded = L\decodeUnary($current_string, $start);
$this->assertEqual($i, $decoded, "(a) Decode Encode $i");
}
for($j = 20; $j >= 1; $j--) {
$decoded = L\decodeUnary($current_string, $start);
$this->assertEqual($j, $decoded, "(b) Decode Encode $j");
}
$start = 0;
for($i = 0; $i <= 15; $i++) {
$decoded = L\decodeUnary("\xFF\xFF", $start);
$this->assertEqual(1, $decoded, "$i th encoded 1 decodes to 1");
}
}
/**
* Used to check Encoding decoding using unary coding
*/
public function encodeDecodeBitsCodeTestCase()
{
$to_encodes = [1, 257, 4, 9, 65535, 93];
$bit_lens = [1, 9, 3, 4, 16, 7];
$start = 0;
$encoded = "";
foreach ($to_encodes as $to_encode) {
$encoded = L\appendBits($to_encode, $encoded, $start);
}
$i = 0;
$start = 0;
foreach ($bit_lens as $bit_len) {
$decode = L\decodeBits($encoded, $start, $bit_len);
$this->assertEqual($to_encodes[$i], $decode, "Encode ".
$to_encodes[$i] ." decodes as $decode");
$i++;
}
}
/**
* Used to check Encoding decoding gamma codes
*/
public function encodeDecodeGammaTestCase()
{
$to_encodes = [1, 257, 4, 9, 65535, 93];
$start = 0;
$encoded = "";
foreach ($to_encodes as $to_encode) {
$encoded = L\appendGamma($to_encode, $encoded, $start);
}
$start = 0;
$num_encoded = count($to_encodes);
$decodes = L\decodeGammaList($encoded, $start, $num_encoded);
for ($i = 0; $i < $num_encoded; $i++) {
$this->assertEqual($to_encodes[$i], $decodes[$i], "Encode ".
"{$to_encodes[$i]} decodes as {$decodes[$i]}");
}
}
/**
* Check that encoding and decoding integers using the vByte scheme works
*/
public function encodeDecodeVByteTestCase()
{
for ($i = 0; $i < 1000000; $i += 500) {
$enc = L\vByteEncode($i);
$start = 0;
$decode = L\vByteDecode($enc, $start);
$this->assertEqual($i, $decode,
"Encoding and decoding $i give $i");
}
}
/**
* The encode255 / decode255 pair must round trip exactly and, just
* as importantly, the encoded form must never contain a 0xFF byte.
* The postings file uses 0xFF as its record separator, so a single
* stray 0xFF inside an encoded record would split it in the wrong
* place and hand the index reader a corrupt offset and length, the
* fault behind the posting-decode errors seen in production. This
* walks inputs built from the bytes the codec rewrites, plus a
* string of every byte value, and checks both properties.
*/
public function encodeDecode255RoundTripTestCase()
{
$samples = ["", "\xFE", "\xFF", "\xFE\xFE", "\xFE\xFD",
"\xFF\xFF", "a\xFFb", "\xFE\xFF\xFE", "plain text",
"\x00\x01\xFE\xFF\xFD\x80"];
$all_bytes = "";
for ($i = 0; $i < 256; $i++) {
$all_bytes .= chr($i);
}
$samples[] = $all_bytes . $all_bytes;
foreach ($samples as $sample) {
$encoded = L\encode255($sample);
$this->assertTrue(strpos($encoded, "\xFF") === false,
"the encoded form holds no 0xFF separator byte");
$this->assertEqual($sample, L\decode255($encoded),
"encode255 then decode255 returns the original bytes");
}
}
/**
* Used to check Encoding decoding using unary coding
*/
public function encodeDecodeRiceTestCase()
{
$position_list = [90, 101, 570, 581, 737, 950, 1100, 1119, 1127,
1147, 1175, 1185, 1930, 1969, 2020, 2040, 2068, 2083, 2090, 2102,
2126, 2170, 2182, 2191, 2217, 2228, 2250, 2260, 2370, 2392, 2403,
2447, 2456, 2467, 2476, 2486, 2503, 2508, 2610, 2628, 2629, 2641,
2674, 2693, 2710, 2753, 2761, 2770, 2847, 2885, 2899, 2920, 2934,
3000, 3019, 3039, 3058, 3070, 3133, 3168, 3227, 3240, 3249, 3266,
3277, 3296, 3309, 3327, 3348, 3366, 3368, 3375, 3424, 3456, 3458,
3463, 3478, 3487, 3511, 3513, 3523, 3557, 3614, 3828, 3880, 3896,
3910, 3999, 4039, 4056, 4165, 4226, 4248, 4269, 4308, 4324, 4338,
4444, 4484, 4560, 4577, 4597, 4622, 4695, 4710, 4801, 4824, 4859,
4876, 4981, 5071, 5109, 5131, 5199, 5232, 5270, 5287, 5317, 5330,
5373, 5409, 5426, 5490, 5500, 5501, 5533, 5544, 5722, 5765, 5799,
5821, 5854, 5938, 5967, 6004, 6036, 6195, 6262, 6319, 6337, 6345,
6346, 6391, 6430, 6452, 6460, 6514, 6580, 6736, 6758, 6794, 6820,
6976];
$num_positions = count($position_list);
$average_gap = ($position_list[$num_positions - 1] -
$position_list[0])/$num_positions;
$modulus = max(ceil(log($average_gap + 1, 2)), 2);
$start = 0;
$encoded = L\appendRiceSequence($position_list, $modulus, "",
$start, 0);
$start = 0;
$decodes = L\decodeRiceSequence($encoded, $start, $num_positions, 0);
for ($i = 0; $i < $num_positions; $i++) {
$this->assertEqual($position_list[$i], $decodes[$i], "Encode ".
"{$position_list[$i]} decodes as {$decodes[$i]}");
}
}
/**
* Used to check Encoding decoding using unary coding
*/
public function encodeDecodePositionListTestCase()
{
$position_list = [90, 101, 570, 581, 737, 950, 1100, 1119, 1127,
1147, 1175, 1185, 1930, 1969, 2020, 2040, 2068, 2083, 2090, 2102,
2126, 2170, 2182, 2191, 2217, 2228, 2250, 2260, 2370, 2392, 2403,
2447, 2456, 2467, 2476, 2486, 2503, 2508, 2610, 2628, 2629, 2641,
2674, 2693, 2710, 2753, 2761, 2770, 2847, 2885, 2899, 2920, 2934,
3000, 3019, 3039, 3058, 3070, 3133, 3168, 3227, 3240, 3249, 3266,
3277, 3296, 3309, 3327, 3348, 3366, 3368, 3375, 3424, 3456, 3458,
3463, 3478, 3487, 3511, 3513, 3523, 3557, 3614, 3828, 3880, 3896,
3910, 3999, 4039, 4056, 4165, 4226, 4248, 4269, 4308, 4324, 4338,
4444, 4484, 4560, 4577, 4597, 4622, 4695, 4710, 4801, 4824, 4859,
4876, 4981, 5071, 5109, 5131, 5199, 5232, 5270, 5287, 5317, 5330,
5373, 5409, 5426, 5490, 5500, 5501, 5533, 5544, 5722, 5765, 5799,
5821, 5854, 5938, 5967, 6004, 6036, 6195, 6262, 6319, 6337, 6345,
6346, 6391, 6430, 6452, 6460, 6514, 6580, 6736, 6758, 6794, 6820,
6976];
$num_positions = count($position_list);
$encoded = L\encodePositionList($position_list);
$decodes = L\decodePositionList($encoded, $num_positions);
for ($i = 0; $i < $num_positions; $i++) {
$this->assertEqual($position_list[$i], $decodes[$i], "Encode ".
"{$position_list[$i]} decodes as {$decodes[$i]}");
}
}
/**
* Checks that short position lists, the ones with fewer than three
* entries that take the plain gamma path rather than the Rice path,
* survive a round trip. The larger position-list test only exercises
* the Rice path, so without this a break in the short path would go
* unnoticed.
*/
public function encodeDecodeSmallPositionListTestCase()
{
$small_lists = [[5], [5, 9], [1, 2], [90, 101]];
foreach ($small_lists as $position_list) {
$encoded = L\encodePositionList($position_list);
$decodes = L\decodePositionList($encoded, count($position_list));
$this->assertEqual($position_list, $decodes,
"Small list " . json_encode($position_list) .
" decodes as " . json_encode($decodes));
}
}
/**
* Checks that decoding a Rice sequence does not break when asked for
* more numbers than the input actually holds. Reading past the end of
* the input used to apply a decrement to a boolean, which PHP warns
* about; this confirms the real numbers at the front still decode and
* the call simply returns the count requested without that warning.
*/
public function decodeRiceSequenceShortInputTestCase()
{
$positions = [3, 10, 17, 18];
$encoded = L\encodePositionList($positions);
$start_bit_offset = 0;
$first = L\decodeGammaList($encoded, $start_bit_offset, 1)[0];
$warned = false;
set_error_handler(function ($number, $text) use (&$warned) {
if (strpos($text, "Decrement on type bool") !== false) {
$warned = true;
}
return true;
});
$over_count = count($positions) + 8;
$decodes = L\decodeRiceSequence($encoded, $start_bit_offset,
$over_count, $first);
restore_error_handler();
$this->assertEqual([10, 17, 18], array_slice($decodes, 0, 3),
"the first positions read back as the numbers put in");
$this->assertEqual($over_count, count($decodes),
"the decoder returns the number of values requested");
$this->assertTrue(!$warned,
"no decrement-on-boolean warning past the end of input");
}
/**
* Used to check Encoding decoding using Modified9 coding
*/
public function modified9TestCase()
{
$encode_list = [151466751, 11746, 11746];
$encoded = L\encodeModified9($encode_list);
$offset = 0;
$decode_list = L\decodeModified9($encoded, $offset);
$this->assertEqual($encode_list, $decode_list,
"Encoding and decoding an array with Modified9 gives same result");
}
/**
* Used to check if posting lists can be properly encoded/decoded
*/
public function packUnpackPostingTestCase()
{
$posting_list = [90, 101, 570, 581, 737, 950, 1100, 1119, 1127,
1147, 1175, 1185, 1930, 1969, 2020, 2040, 2068, 2083, 2090, 2102,
2126, 2170, 2182, 2191, 2217, 2228, 2250, 2260, 2370, 2392, 2403,
2447, 2456, 2467, 2476, 2486, 2503, 2508, 2610, 2628, 2629, 2641,
2674, 2693, 2710, 2753, 2761, 2770, 2847, 2885, 2899, 2920, 2934,
3000, 3019, 3039, 3058, 3070, 3133, 3168, 3227, 3240, 3249, 3266,
3277, 3296, 3309, 3327, 3348, 3366, 3368, 3375, 3424, 3456, 3458,
3463, 3478, 3487, 3511, 3513, 3523, 3557, 3614, 3828, 3880, 3896,
3910, 3999, 4039, 4056, 4165, 4226, 4248, 4269, 4308, 4324, 4338,
4444, 4484, 4560, 4577, 4597, 4622, 4695, 4710, 4801, 4824, 4859,
4876, 4981, 5071, 5109, 5131, 5199, 5232, 5270, 5287, 5317, 5330,
5373, 5409, 5426, 5490, 5500, 5501, 5533, 5544, 5722, 5765, 5799,
5821, 5854, 5938, 5967, 6004, 6036, 6195, 6262, 6319, 6337, 6345,
6346, 6391, 6430, 6452, 6460, 6514, 6580, 6736, 6758, 6794, 6820,
6976];
$packed = L\packPosting(10, $posting_list);
$offset = 0;
$out_doc_list = L\unpackPosting($packed, $offset, true);
$this->assertEqual($out_doc_list[0], 10,
"Doc index from unpack of long packed posting equal");
$this->assertEqual($out_doc_list[1], $posting_list,
"Unpack of long packed posting equal");
$offset = 0;
$posting_list = [254, 12000, 24000];
$packed = L\packPosting(33689, $posting_list);
$out_doc_list = L\unpackPosting($packed, $offset, true);
$this->assertEqual($out_doc_list[0], 33689,
"Doc index from unpack of first word has delta[0] case");
$this->assertEqual($out_doc_list[1], $posting_list,
"Unpack of delta[0] case");
$offset = 0;
$posting_list = [511, 12000, 24000];
$packed = L\packPosting(33689, $posting_list);
$out_doc_list = L\unpackPosting($packed, $offset, true);
$this->assertEqual($out_doc_list[0], 33689,
"Doc index from unpack of first word has delta[0] case 2");
$this->assertEqual($out_doc_list[1], $posting_list,
"Unpack of delta[0] case 2");
$posting_list = [6000, 12000, 24000];
$packed = L\packPosting(100000, $posting_list);
$offset = 0;
$out_doc_list = L\unpackPosting($packed, $offset, true);
$this->assertEqual($out_doc_list[0], 100000,
"Bigger Doc index from unpack of long packed posting equal");
$this->assertEqual($out_doc_list[1], $posting_list,
"Bigger Delta unpack of posting equal");
$posting_list = [1, 4, 7, 174];
$packed = L\packPosting(0, $posting_list);
$unpack_int = unpack("N*", $packed);
$offset = 0;
$out_doc_list = L\unpackPosting($packed, $offset, true);
$this->assertEqual($out_doc_list[0], 0,
"Doc index from unpack of doc index 0 case");
$this->assertEqual($out_doc_list[1], $posting_list,
"Unpack of doc index 0 case");
}
/**
* Checks webencode/webdecode to see inverses. Checks base64Hash/
* unbase64Hash to see inverses
*/
public function webencodeWebdecodeTestCase()
{
$expected = "=+~-@hi ya everyone!!@~+-=";
$encode_decoded = L\webdecode(L\webencode($expected));
$this->assertEqual($expected, $encode_decoded,
"a string written for a web address reads back unchanged");
$encode_decoded = L\unbase64Hash(L\base64Hash($expected));
$this->assertEqual($expected, $encode_decoded,
"a hash written short reads back as the hash put in");
$expected = "\xFE\xFD\xFF\xFE\xFD";
$encode_decoded = L\decode255(L\encode255("\xFE\xFD\xFF\xFE\xFD"));
$this->assertEqual($expected, $encode_decoded,
"bytes written for storage read back as the bytes put in");
}
/**
* Tests crawlAuthHash, the keyed HMAC-SHA256 sibling of
* crawlHash that the CSRF token machinery uses. Covers
* determinism, output shape (32 raw / 43 URL-safe base64
* chars, no padding or unsafe chars), input-sensitivity,
* key-sensitivity by way of AUTH_KEY mixing through the
* underlying hash_hmac call, hash_equals acceptance of
* matching pairs, hash_equals rejection of single-byte
* tampered pairs, and a roundtrip mirroring how
* Controller::generateCSRFToken / checkCSRFToken use the
* primitive (hash($user . $time) plus "*$time" suffix,
* length-54 token, hash_equals verification).
*/
public function crawlAuthHashTestCase()
{
/* Determinism: same input → same output */
$a = L\crawlAuthHash("hello");
$b = L\crawlAuthHash("hello");
$this->assertEqual($a, $b,
"crawlAuthHash is deterministic for the same input");
/* Output length: 32 raw bytes / 43 base64 chars */
$raw = L\crawlAuthHash("hello", true);
$this->assertEqual(32, strlen($raw),
"crawlAuthHash raw output is 32 bytes");
$this->assertEqual(43, strlen($a),
"crawlAuthHash base64 output is 43 chars");
/* URL-safe base64: no /, +, or = chars */
$this->assertTrue(strpbrk($a, "/+=") === false,
"crawlAuthHash output is URL-safe (no /, +, or =)");
/* Input-sensitivity: one-byte change flips many bits */
$c = L\crawlAuthHash("hellp");
$this->assertNotEqual($a, $c,
"crawlAuthHash differs when the input differs by one byte");
/* hash_equals accepts matching pairs */
$this->assertTrue(hash_equals(
L\crawlAuthHash("token-input"),
L\crawlAuthHash("token-input")),
"hash_equals returns true for two equal crawlAuthHash outputs");
/* hash_equals rejects a single-byte tamper */
$good = L\crawlAuthHash("token-input");
$bad = substr($good, 0, -1) .
(($good[-1] === "x") ? "y" : "x");
$this->assertFalse(hash_equals($good, $bad),
"hash_equals returns false when one byte differs");
/* CSRF-style roundtrip: token = hash($user.$time) . "*$time" */
$user = "alice";
$time = (string) time();
$token = L\crawlAuthHash($user . $time) . "*$time";
$this->assertEqual(54, strlen($token),
"CSRF-shaped token is 54 chars (43 hash + 1 star + 10 time)");
$parts = explode("*", $token);
$this->assertEqual(2, count($parts),
"CSRF-shaped token splits into hash and time on '*'");
$this->assertTrue(hash_equals(
L\crawlAuthHash($user . $parts[1]), $parts[0]),
"CSRF-shaped token verifies with hash_equals on the same user");
/* A different user with the same timestamp should not verify */
$this->assertFalse(hash_equals(
L\crawlAuthHash("bob" . $parts[1]), $parts[0]),
"CSRF-shaped token does not verify for a different user");
}
/**
* crawlCrypt computes a bcrypt hash with crypt(). It behaves the
* same whether or not it is called inside a fiber: there is no
* helper process and no suspend, so a fiber that calls it runs to
* completion in a single step and returns the very same hash a
* direct call gives. A low bcrypt cost keeps the test quick.
*/
public function crawlCryptCooperativeTestCase()
{
$salt = '$2y$04$' .
strtr(base64_encode(random_bytes(16)), '+', '.');
$inline = L\crawlCrypt("hunter2", $salt);
$this->assertEqual(crypt("hunter2", $salt), $inline,
"outside a fiber crawlCrypt is a plain crypt");
$suspended = false;
$fiber = new \Fiber(function () use ($salt) {
return L\crawlCrypt("hunter2", $salt);
});
$fiber->start();
while ($fiber->isSuspended()) {
$suspended = true;
$fiber->resume();
}
$in_fiber = $fiber->getReturn();
$this->assertFalse($suspended,
"in a fiber crawlCrypt runs inline without suspending");
$this->assertEqual($inline, $in_fiber,
"in a fiber crawlCrypt produces the same hash as inline");
}
}