SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / FetchUrlTest.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\yioop\library\FetchUrl;
use seekquarry\yioop\library\UnitTest;

/**
 * FetchUrlTest checks the FetchUrl class, which downloads pages for the
 * crawler. Its cases cover page hashes, cookies, signed requests and
 * bot challenges.
 *
 * @author Chris Pollett
 */
class FetchUrlTest extends UnitTest
{
    /**
     * setUp does nothing, since each case makes what it needs.
     */
    public function setUp()
    {
    }
    /**
     * tearDown does nothing, since each case leaves nothing behind.
     */
    public function tearDown()
    {
    }
    /**
     * computePageHashTestCase checks that a script tag does not change a
     * page's hash, while text in other scripts, such as Devanagari, does.
     */
    public function computePageHashTestCase()
    {
        $with_script = "<script>i++</script>hi";
        $without_script = "hi";
        $this->assertEqual(FetchUrl::computePageHash($with_script, "text"),
            FetchUrl::computePageHash($without_script, "text"),
            'Script tags don\'t affect page hash');
        $with_unicode = "yoमहामा";
        $without_unicode = "yo";
        $this->assertTrue(FetchUrl::computePageHash($with_unicode) !=
            FetchUrl::computePageHash($without_unicode),
            'Unicode text counts in hash');
    }
    /**
     * emptyPagesAreNotDuplicatesTestCase checks that two text pages whose
     * descriptions hold no letters or digits, as www.pepsi.com's and
     * www.cyon.ch's did, get different hashes when their urls are given,
     * so the index does not group them as duplicates. Two pages with the
     * same words still get the same hash whatever their urls.
     */
    public function emptyPagesAreNotDuplicatesTestCase()
    {
        $this->assertTrue(FetchUrl::computePageHash("", "text",
            "https://www.pepsi.com/") != FetchUrl::computePageHash(". ",
            "text", "https://www.cyon.ch/"),
            "pages with no words hash apart by url");
        $this->assertEqual(FetchUrl::computePageHash("Same words", "text",
            "https://one.example.com/"), FetchUrl::computePageHash(
            "Same words", "text", "https://two.example.com/"),
            "pages with the same words hash the same");
    }
    /**
     * cookiesAreKeptAndSentTestCase checks the crawler's cookie handling:
     * cookies a host sets are sent back to that host and no other, a
     * cookie with Max-Age=0 is removed, and the Site Cookies option's
     * cookies go to the domain named and its subdomains.
     */
    public function cookiesAreKeptAndSentTestCase()
    {
        FetchUrl::$host_cookies = [];
        FetchUrl::setSiteCookies(["example.com: euconsent=granted",
            "other.org: a&#039;b=1"]);
        FetchUrl::keepCookies("https://shop.test/a", "HTTP/1.1 302 Found\r\n" .
            "Set-Cookie: session=abc; Path=/; HttpOnly\r\n" .
            "Set-Cookie: seen=1\r\n\r\nbody");
        $this->assertEqual(FetchUrl::cookieHeaderFor("https://shop.test/b"),
            "session=abc; seen=1", "a host gets back the cookies it set");
        $this->assertEqual(FetchUrl::cookieHeaderFor("https://other.test/"),
            "", "another host gets none of them");
        FetchUrl::keepCookies("https://shop.test/c", "HTTP/1.1 200 OK\r\n" .
            "Set-Cookie: seen=; Max-Age=0\r\n\r\n");
        $this->assertEqual(FetchUrl::cookieHeaderFor("https://shop.test/"),
            "session=abc", "a cookie with Max-Age=0 is removed");
        $this->assertEqual(FetchUrl::cookieHeaderFor(
            "https://www.example.com/x"), "euconsent=granted",
            "a subdomain gets the option's cookies for its domain");
        $this->assertEqual(FetchUrl::cookieHeaderFor("https://notexample.com/"),
            "", "a domain merely ending in the same letters gets none");
        $this->assertEqual(FetchUrl::cookieHeaderFor("https://other.org/"),
            "a'b=1", "entities saved by the options form are decoded");
        FetchUrl::setSiteCookies([]);
        FetchUrl::$host_cookies = [];
    }
    /**
     * webBotAuthSignsRequestsTestCase checks the Web Bot Auth headers and
     * key directory against Cloudflare's documentation. Its example public
     * key must give its example keyid, and its example request signature
     * must verify over the signature base this case builds. The headers
     * getPages adds for an https request, and the signed directory, must
     * then verify over the same kind of base, with a key made for the case.
     */
    public function webBotAuthSignsRequestsTestCase()
    {
        $example_x = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
        $example_params = '("@authority" "signature-agent");' .
            'created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5So' .
            'lqXBCW38r0U";alg="ed25519";expires=1735693200;nonce="e8N7S2' .
            'MFd/qrd6T2R3tdfAuuANngKI7LFtKYI/vowzk4lAZYadIX6wW25MwG7DCT9R' .
            'UKAJ0qVkU0mEeLElW1qg==";tag="web-bot-auth"';
        $example_signature = "jdq0SqOwHdyHr9+r5jw3iYZH6aNGKijYp/EstF4RQ" .
            "TQdi5N5YYKrD+mCT1HA1nZDsi6nJKuHxUi/5Syp3rLWBA==";
        $this->assertEqual(FetchUrl::webBotAuthKeyId($example_x),
            "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U",
            "the example key gives the example keyid");
        $example_key = base64_decode(strtr($example_x, "-_", "+/") . "=");
        $this->assertTrue(sodium_crypto_sign_verify_detached(
            base64_decode($example_signature), "\"@authority\": " .
            "example.com\n\"signature-agent\": " .
            "\"https://signature-agent.test\"\n\"@signature-params\": " .
            $example_params, $example_key),
            "the example signature verifies over the base built here");
        $key_pair = sodium_crypto_sign_keypair();
        FetchUrl::$web_bot_auth_key_pair = $key_pair;
        $public_key = sodium_crypto_sign_publickey($key_pair);
        $headers = FetchUrl::webBotAuthHeaders(
            "https://Example.com:8443/a?b=1", "https://bot.example.org");
        $this->assertEqual($headers[0] ?? "",
            'Signature-Agent: "https://bot.example.org"',
            "the agent is sent as a quoted string");
        $params = substr($headers[1] ?? "", strlen("Signature-Input: sig1="));
        $signature = substr($headers[2] ?? "", strlen("Signature: sig1=:"),
            -1);
        $this->assertTrue(sodium_crypto_sign_verify_detached(
            base64_decode($signature), "\"@authority\": example.com:8443\n" .
            "\"signature-agent\": \"https://bot.example.org\"\n" .
            "\"@signature-params\": $params", $public_key),
            "a request's signature verifies over its authority and agent");
        $this->assertEqual(FetchUrl::webBotAuthHeaders(
            "http://example.com/", "https://bot.example.org"), [],
            "a plain http request is not signed");
        $this->assertEqual(FetchUrl::webBotAuthHeaders(
            "https://example.com/", ""), [], "no agent means no signing");
        [$directory_headers, $body] =
            FetchUrl::webBotAuthDirectory("Bot.Example.org");
        $public_x = rtrim(strtr(base64_encode($public_key), "+/", "-_"), "=");
        $this->assertEqual(json_decode($body, true), ["keys" => [["kty" =>
            "OKP", "crv" => "Ed25519", "x" => $public_x]]],
            "the directory lists the public key");
        $params = substr($directory_headers[2], strlen(
            "Signature-Input: sig1="));
        $signature = substr($directory_headers[1], strlen(
            "Signature: sig1=:"), -1);
        $this->assertTrue(sodium_crypto_sign_verify_detached(
            base64_decode($signature), "\"@authority\";req: bot.example.org" .
            "\n\"@signature-params\": $params", $public_key),
            "the directory's signature verifies over the request authority");
        FetchUrl::$web_bot_auth_key_pair = null;
        $this->assertEqual(FetchUrl::webBotAuthHeaders(
            "https://example.com/", "https://bot.example.org"), [],
            "a fetcher not yet sent a key pair signs nothing");
    }
    /**
     * botChallengeIsKnownTestCase checks that isBotChallenge picks out
     * the page Imperva Incapsula sends while it checks a visitor, a
     * script and no text, and does not pick out a site's own page that
     * loads the same script beside its text.
     */
    public function botChallengeIsKnownTestCase()
    {
        $challenge = "<html><head><meta name='robots' " .
            "content='noindex,nofollow'><script src='/_Incapsula_Resource" .
            "?SWJIYLWA=719d34d31c8e3a6e6fffd425f7e032f3'></script></head>" .
            "<body></body></html>";
        $this->assertTrue(FetchUrl::isBotChallenge($challenge),
            "a page with the script and no text is a challenge");
        $own_page = "<html><head><title>Pepsi</title><script " .
            "src='/_Incapsula_Resource?SWJIYLWA=1'></script></head><body>" .
            "<main>" . str_repeat("The official home of Pepsi. ", 20) .
            "</main></body></html>";
        $this->assertFalse(FetchUrl::isBotChallenge($own_page),
            "a page with the script and its own text is not");
        $this->assertFalse(FetchUrl::isBotChallenge("<html></html>"),
            "an empty page without the script is not");
    }
}