/ tests / ControllerTest.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 <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");
    }
}
X