SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / WebSiteTest.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\atto\Connection;
use seekquarry\atto\WebSocket;
use seekquarry\atto\WebSite;
use seekquarry\atto\WindowUpdateFrame;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * H2PacedResumeProbeWebSite is a WebSite whose protected HTTP/2 sending
 * steps are made public, so a test can drive a response by hand. A test
 * can set up the flow-control windows (how many bytes the client lets the
 * server send before it asks for more), start a paced response body, feed
 * in one received frame, drain what is queued to the client, and register
 * a stand-in connection for the drain to find.
 */
class H2PacedResumeProbeWebSite extends WebSite
{
    /**
     * initStreamWindows gives a connection its HTTP/2 send windows, as the
     * server does once it has read the client's opening SETTINGS frame.
     * The paced-sending cases call it before they start a body.
     *
     * @param object $conn connection holding the protocol state
     * @param array $client_settings client settings, keyed by the
     *      HTTP/2 settings ids, used here to seed the per-stream
     *      initial window
     */
    public function initStreamWindows($conn, $client_settings)
    {
        $this->initH2SendWindows($conn, $client_settings);
    }
    /**
     * startPacedBody hands a whole response body to the server's paced
     * HTTP/2 sender for one stream. A body larger than the stream's send
     * window then goes out over several passes of the event loop, as the
     * client gives more window.
     *
     * @param int $key connection identifier
     * @param resource $socket socket the frames go out on
     * @param object $conn connection holding the protocol state
     * @param int $stream_id stream the body belongs to
     * @param string $body the complete response body
     */
    public function startPacedBody($key, $socket, $conn, $stream_id,
        $body)
    {
        $this->sendH2BodyPaced($key, $socket, $conn, $stream_id, $body);
    }
    /**
     * handleFrame gives the server's HTTP/2 frame handler one frame, as if
     * the event loop had just read it from the client, such as the
     * WINDOW_UPDATE frame that lets a paced body go on.
     *
     * @param int $key connection identifier
     * @param resource $socket socket the connection is on
     * @param object $conn connection holding the protocol state
     * @param object $frame the frame object to process
     * @param string $payload the frame's raw body bytes
     * @param int $length the frame body length in bytes
     * @return mixed whatever the frame handler returns
     */
    public function handleFrame($key, $socket, $conn, $frame, $payload,
        $length)
    {
        return $this->processH2Frame($key, $socket, $conn, $frame,
            $payload, $length);
    }
    /**
     * drainResponses runs one pass of the server's writing step: every
     * connection with response bytes not yet sent gets as many written as
     * its socket will take.
     */
    public function drainResponses()
    {
        $this->processResponseStreams(
            $this->out_streams[self::CONNECTION] ?? []);
    }
    /**
     * registerConnection enters a stand-in connection and its socket in the
     * server's tables, as accepting a real connection does, so that
     * drainResponses and the frame handler find it.
     *
     * @param int $key connection identifier
     * @param object $conn the connection object
     * @param resource $socket the connection's socket
     */
    public function registerConnection($key, $conn, $socket)
    {
        $this->connections[$key] = $conn;
        $this->in_streams[self::CONNECTION][$key] = $socket;
    }
    /**
     * hasQueuedResponse says whether a connection still has response bytes
     * waiting in the server that have not been written to the client.
     *
     * @param int $key connection identifier
     * @return bool true when bytes are still waiting to go out
     */
    public function hasQueuedResponse($key)
    {
        return isset($this->out_streams[self::CONNECTION][$key])
            && isset($this->out_streams[self::DATA][$key]);
    }
    /**
     * readOnce has the server's HTTP/2 read step take in whatever has
     * arrived on a connection's socket, as the event loop does for a
     * readable connection. Unlike handleFrame, this runs the real read
     * path, including what it does on seeing the socket's end of file.
     *
     * @param int $key connection identifier
     * @param resource $socket the connection's socket to read from
     * @return mixed whatever the read path returns
     */
    public function readOnce($key, $socket)
    {
        return $this->parseH2Request($key, $socket);
    }
}

/**
 * H2FrameDrainProbeWebSite is a WebSite whose frame handler only notes
 * each frame the HTTP/2 read loop gives it, and answers the first as a
 * request that was dispatched. readDispatchesEveryRequestInOneReadTestCase
 * uses it to see that the read loop goes on through what it read after
 * that first dispatch, rather than stopping there.
 */
class H2FrameDrainProbeWebSite extends WebSite
{
    /**
     * $seen holds the stream id of each frame the read loop handed to the
     * frame handler, in order, one entry per call
     * @var array
     */
    public $seen = [];
    /**
     * readOnce has the server's HTTP/2 read step take in whatever has
     * arrived on a connection's socket, as the event loop does for a
     * readable connection, so every frame read reaches processH2Frame.
     *
     * @param int $key connection identifier
     * @param resource $socket the connection's socket to read from
     * @return mixed whatever the read path returns
     */
    public function readOnce($key, $socket)
    {
        return $this->parseH2Request($key, $socket);
    }
    /**
     * processH2Frame takes the place of the server's frame handler. It
     * adds the frame's stream id to $seen, and answers true for the first
     * frame, as a dispatched request does, and false for the rest. The
     * read loop must keep going after that first true, so every frame in
     * a single read shows up here.
     *
     * @param int $key connection identifier
     * @param resource $connection the connection's socket
     * @param object $conn connection holding the protocol state
     * @param object $frame the parsed frame
     * @param string $payload the frame's raw body bytes
     * @param int $length the frame body length in bytes
     * @return mixed true for the first frame, false afterward
     */
    protected function processH2Frame($key, $connection, $conn, $frame,
        $payload, $length)
    {
        $this->seen[] = $frame->stream_id;
        return count($this->seen) === 1;
    }
    /**
     * registerConnection enters a stand-in connection and its socket in the
     * server's tables, as accepting a real connection does, so that
     * readOnce finds it.
     *
     * @param int $key connection identifier
     * @param object $conn the connection object
     * @param resource $socket the connection's socket
     */
    public function registerConnection($key, $conn, $socket)
    {
        $this->connections[$key] = $conn;
        $this->in_streams[self::CONNECTION][$key] = $socket;
    }
}

/**
 * MemorySampleProbeWebSite is a WebSite whose check of when to log memory
 * use is made public, so the memorySample cases can give it chosen times
 * instead of the clock's.
 */
class MemorySampleProbeWebSite extends WebSite
{
    /**
     * setPeriod chooses how many seconds apart memory use is logged, the
     * value a running server takes from its settings.
     *
     * @param int $seconds whole seconds between samples, zero for off
     */
    public function setPeriod($seconds)
    {
        $this->memory_sample_period = $seconds;
    }
    /**
     * dueAt asks the server's timing check whether memory use is to be
     * logged, giving it a time chosen by the case as the present.
     *
     * @param int $now pretend wall-clock time in whole seconds
     * @return bool whether a memory sample is due at that time
     */
    public function dueAt($now)
    {
        return $this->memorySampleDue($now);
    }
}

/**
 * SessionFileProbeWebSite is a WebSite whose session storage is made
 * public, so oversizeSessionIsRefusedTestCase can write, read back and
 * drop a session without running a server.
 */
class SessionFileProbeWebSite extends WebSite
{
    /**
     * useDirectory chooses the folder session files are kept in and how
     * large one may grow, the values a running server takes from its
     * settings.
     *
     * @param string $directory where session files should be written
     * @param int $largest how many bytes a written session may reach
     */
    public function useDirectory($directory, $largest)
    {
        $this->default_server_globals['SESSION_DIR'] = $directory;
        $this->default_server_globals['MAX_REQUEST_LEN'] = $largest;
    }
    /**
     * write stores a session's contents through the server's own session
     * writer, which refuses contents larger than the limit useDirectory
     * set.
     *
     * @param string $session_id the session to write
     * @param array $session_data the contents to write
     * @return bool whether the contents were written
     */
    public function write($session_id, $session_data)
    {
        return $this->writeSessionData($session_id, $session_data);
    }
    /**
     * forget has the server let go of a session, deleting the file its
     * contents were kept in.
     *
     * @param string $session_id the session to drop
     */
    public function forget($session_id)
    {
        $this->forgetSession($session_id);
    }
    /**
     * pathOf gives the file the server keeps a session's contents in, so
     * a case can look at it directly.
     *
     * @param string $session_id the session to name a file for
     * @return string absolute path of that session's file
     */
    public function pathOf($session_id)
    {
        return $this->sessionFilePath($session_id);
    }
}

/**
 * RecordingQuicStreams stands in for the QUIC (the protocol HTTP/3 runs
 * over) connection of an HTTP/3 request. It keeps every byte the HTTP/3
 * transport writes to a stream and whether the stream was ended, so
 * deferredFileGoesOutOverHttp3TestCase can see what a client would get
 * without a network.
 */
class RecordingQuicStreams
{
    /**
     * $sent holds every byte written to the stream, in order
     * @var string
     */
    public $sent = "";
    /**
     * $finished says whether the stream has been ended
     * @var bool
     */
    public $finished = false;
    /**
     * sendStreamData records bytes the HTTP/3 transport writes to a stream,
     * and whether that write ends the stream.
     *
     * @param int $sid the stream written to
     * @param string $data the bytes written
     * @param bool $fin whether this write ends the stream
     */
    public function sendStreamData($sid, $data, $fin = false)
    {
        $this->sent .= $data;
        if ($fin) {
            $this->finished = true;
        }
    }
    /**
     * bufferedLength tells the HTTP/3 transport how many bytes of a stream
     * wait to be sent. Nothing waits here, since every byte is recorded as
     * soon as it is written, so the transport draws on its body each time.
     *
     * @param int $sid the stream asked about
     * @return int always 0
     */
    public function bufferedLength($sid)
    {
        return 0;
    }
}
/**
 * WebSiteTest holds the tests of the atto WebSite server, the web server
 * Yioop can run as itself. Many cover the HTTP/2 send path: a response
 * larger than the client's opening window for a stream has to arrive
 * whole, going out up to the window, waiting, and going on once the client
 * gives more window with a WINDOW_UPDATE frame. Others check that every
 * request read at once is dispatched, how socket frames are written, how a
 * reply's first line is chosen, how a file reply is sent over HTTP/1.1,
 * HTTP/2 and HTTP/3, and when memory use is logged. The HTTP/2 cases run
 * over an in-memory socket pair, so they touch no network.
 *
 * @author Chris Pollett
 */
class WebSiteTest extends UnitTest
{
    /**
     * FRONT_WAIT_TICKS is the most running time, in hundredths of a second,
     * the front process may use while a worker takes half a second over a
     * request. A front that waits on the worker's link uses almost none;
     * one that polls the link without sleeping uses about fifty.
     */
    const FRONT_WAIT_TICKS = 15;
    /**
     * WORKER_FILE_REPLY the file the test site's /file route writes and
     * hands over as its reply's body, which the server deletes once sent
     */
    const WORKER_FILE_REPLY = __DIR__ . "/test_files/worker_file_reply.txt";
    /**
     * OVERDUE_RUNS is the file the test site's /overduecount route adds a
     * line to each time a worker starts running it, so a case can count
     * how many times one request was run
     */
    const OVERDUE_RUNS = __DIR__ . "/test_files/overdue_runs.txt";
    /**
     * SLOW_START_FLAG is a file whose presence makes each worker the test
     * site starts wait SLOW_START_SECONDS before it connects back, as a
     * worker loading a large site does
     */
    const SLOW_START_FLAG = __DIR__ . "/test_files/slow_worker_start.txt";
    /**
     * SLOW_START_SECONDS is how long a worker waits before connecting back
     * while SLOW_START_FLAG exists
     */
    const SLOW_START_SECONDS = 3;
    /**
     * FRONT_REPLY_SECONDS is the most a request the front answers itself
     * may take in frontServesWhileWorkerStartsTestCase: well under
     * SLOW_START_SECONDS, so a front that waited on the starting worker
     * misses it
     */
    const FRONT_REPLY_SECONDS = 1;
    /**
     * QUICK_REQUESTS is how many half-second requests
     * slowRequestHoldsUpNoOtherTestCase sends after its overlong one: the
     * 11 that were queued behind the worker yioop.com ended on a slow
     * search on 21 October 2026
     */
    const QUICK_REQUESTS = 11;
    /**
     * QUICK_REPLY_SECONDS is how long slowRequestHoldsUpNoOtherTestCase
     * waits for each reply: well past the overlong request's limit and the
     * half-second requests run one after another, so only a request that
     * is never answered runs out of it
     */
    const QUICK_REPLY_SECONDS = 20;
    /**
     * GROWING_FILE_REPLY is the file the test site's /growing route names
     * as its reply's body while it is still writing it, and which the
     * server deletes once sent
     */
    const GROWING_FILE_REPLY = __DIR__ .
        "/test_files/growing_file_reply.txt";
    /**
     * GROWING_PIECE_WAIT is how many seconds the /growing route waits
     * between the pieces it writes, so a client can tell a reply sent as it
     * is written from one sent once finished
     */
    const GROWING_PIECE_WAIT = 0.3;
    /**
     * GROWING_PIECES is how many pieces the /growing route writes
     */
    const GROWING_PIECES = 5;
    /**
     * CLOSED_STREAM_RUN is how many streams the closed-stream case opens,
     * completes, and then sends a late WINDOW_UPDATE for. It is larger
     * than the HTTP/2 guard on how many streams may be open at once, so a
     * server that kept window credit for closed streams would gather
     * enough of it to trip the guard and close the connection.
     */
    const CLOSED_STREAM_RUN = 120;
    /**
     * $sockets holds the socket pair a case uses, which tearDown closes so
     * a case that fails part way through still lets its sockets go
     * @var array
     */
    public $sockets;
    /**
     * $key is the connection key the socket frame cases write against,
     * standing for one client
     * @var int
     */
    public $key = 7;
    /**
     * $site is the server setUp makes for the socket frame cases, whose
     * writing of frames they check
     * @var WebSite
     */
    public $site;
    /**
     * $in_streams reaches the streams a server reads from, through
     * reflection because a server keeps them to itself. A write is dropped
     * unless its connection is still among them.
     * @var \ReflectionProperty
     */
    public $in_streams;
    /**
     * $out_streams reaches the streams a server writes to, through
     * reflection because a server keeps them to itself. What a case wants
     * to read is the bytes waiting there.
     * @var \ReflectionProperty
     */
    public $out_streams;
    /**
     * $enqueue_h2 reaches, through reflection, the server's method that
     * wraps socket frames in HTTP/2 DATA frames
     * @var \ReflectionMethod
     */
    public $enqueue_h2;
    /**
     * $send_frame reaches, through reflection, the server's method that
     * picks which way a socket frame is written
     * @var \ReflectionMethod
     */
    public $send_frame;
    /**
     * $uses_socket_drain reaches, through reflection, the server's method
     * that says whether a drained connection carries nothing but a socket
     * @var \ReflectionMethod
     */
    public $uses_socket_drain;
    /**
     * setUp runs before each case. It loads the atto WebSite file, which
     * defines WebSite, Connection and the frame classes together, before
     * a case builds any of them. It then stands up a server and a
     * connection for the cases that check how socket frames are written,
     * and keeps the request globals so tearDown can put them back.
     */
    public function setUp()
    {
        /* Some cases run requests, which replace the request globals as
           a web request would; they are put back in tearDown, since the
           test runner keeps settings such as NO_LOGGING in $_SERVER. */
        $this->test_objects['SAVED_GLOBALS'] = [$_SERVER, $_GET, $_POST,
            $_REQUEST, $_COOKIE, $_FILES];
        class_exists('seekquarry\\atto\\WebSite');
        $this->sockets = [];
        $reflection = new \ReflectionClass(WebSite::class);
        $this->site = $reflection->newInstanceWithoutConstructor();
        /* what a class keeps to itself has been reachable through
           reflection without asking since PHP 8.1, and asking is an
           error as of 8.5, so nothing is asked for here. */
        $this->in_streams = new \ReflectionProperty(WebSite::class,
            "in_streams");
        $this->out_streams = new \ReflectionProperty(WebSite::class,
            "out_streams");
        $this->enqueue_h2 = new \ReflectionMethod(WebSite::class,
            "wsEnqueueH2Write");
        $this->send_frame = new \ReflectionMethod(WebSite::class,
            "wsSendFrame");
        $this->uses_socket_drain = new \ReflectionMethod(WebSite::class,
            "usesSocketDrain");
        $this->in_streams->setValue($this->site,
            [WebSite::CONNECTION => [$this->key => "a-connection"]]);
        $this->out_streams->setValue($this->site, []);
        /* What the cases about a reply's first line reach for. */
        $this->status_line = new \ReflectionMethod(WebSite::class,
            "statusLineForHeaders");
        $this->header_data = new \ReflectionProperty(WebSite::class,
            "header_data");
        $_SERVER['SERVER_PROTOCOL'] = "HTTP/1.1";
    }
    /**
     * tearDown runs after each case. It puts back the request globals
     * setUp kept and closes the socket pair the case opened.
     */
    public function tearDown()
    {
        [$_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE, $_FILES] =
            $this->test_objects['SAVED_GLOBALS'];
        foreach ($this->sockets as $socket) {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
        $this->sockets = [];
    }
    /**
     * setUpConnection makes an HTTP/2 connection on one end of an
     * in-memory socket pair for the paced-sending cases. Its send windows
     * are those a client might give in its handshake: a
     * per-stream initial window the body will not fit in, and a large
     * connection-level window so the connection window is never the
     * limit.
     *
     * @param object $site the probe WebSite driving the exchange
     * @param int $stream_window the client's per-stream initial window
     * @return array [connection key, connection object, server socket]
     */
    private function setUpConnection($site, $stream_window)
    {
        $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM,
            0);
        $server = $pair[0];
        $client = $pair[1];
        stream_set_blocking($server, false);
        stream_set_blocking($client, false);
        $this->sockets = [$server, $client];
        $key = (int)$server;
        $connection = new Connection($server, false);
        $connection->protocol = 'h2';
        $connection->is_websocket = false;
        $site->initStreamWindows($connection,
            [WebSite::H2_SETTING_INITIAL_WINDOW => $stream_window]);
        $connection->protocol_state['send_window'] = 12436774;
        $site->registerConnection($key, $connection, $server);
        return [$key, $connection, $server];
    }
    /**
     * drainAndRead alternates the server's writing step with reading the
     * client end of the socket pair, so a full socket buffer never stops
     * the writing. It gives back what was collected before with every
     * byte now read from the client end added on.
     *
     * @param object $site the probe WebSite to drain
     * @param int $key connection identifier to drain
     * @param resource $client the client end to read from
     * @param string $collected bytes read on earlier drains
     * @return string $collected plus the newly read bytes
     */
    private function drainAndRead($site, $key, $client, $collected)
    {
        for ($pass = 0; $pass < 2000
            && $site->hasQueuedResponse($key); $pass++) {
            $site->drainResponses();
            while (($chunk = fread($client, 65536)) !== false
                && $chunk !== "") {
                $collected .= $chunk;
            }
        }
        while (($chunk = fread($client, 65536)) !== false
            && $chunk !== "") {
            $collected .= $chunk;
        }
        return $collected;
    }
    /**
     * countDataPayload adds up how many body bytes the HTTP/2 DATA frames
     * for one stream carry, in the frames a client end read, and says
     * whether any of them was marked END_STREAM, the end of the body. Each
     * frame is a nine byte header (three byte length, one byte type, one
     * byte flags, four byte stream id) followed by its body.
     *
     * @param string $buffer serialized frame bytes read from the client
     * @param int $stream the stream id whose DATA payload to total
     * @return array [total DATA payload bytes, whether END_STREAM seen]
     */
    private function countDataPayload($buffer, $stream)
    {
        $position = 0;
        $total = 0;
        $length = strlen($buffer);
        $end_seen = false;
        while ($position + 9 <= $length) {
            $frame_length = (ord($buffer[$position]) << 16)
                | (ord($buffer[$position + 1]) << 8)
                | ord($buffer[$position + 2]);
            $type = ord($buffer[$position + 3]);
            $flags = ord($buffer[$position + 4]);
            $frame_stream = ((ord($buffer[$position + 5]) & 0x7f) << 24)
                | (ord($buffer[$position + 6]) << 16)
                | (ord($buffer[$position + 7]) << 8)
                | ord($buffer[$position + 8]);
            $position += 9;
            if ($position + $frame_length > $length) {
                break;
            }
            if ($type === 0x00 && $frame_stream === $stream) {
                $total += $frame_length;
                if ($flags & 0x01) {
                    $end_seen = true;
                }
            }
            $position += $frame_length;
        }
        return [$total, $end_seen];
    }
    /**
     * pacedResumeDeliversWholeBodyTestCase sends a body larger than the
     * client's window for its stream. Only one window's worth may go out
     * at first; after a WINDOW_UPDATE frame gives more, the client has to
     * receive the whole body, with the last DATA frame marked END_STREAM.
     */
    public function pacedResumeDeliversWholeBodyTestCase()
    {
        $site = new H2PacedResumeProbeWebSite("/");
        list($key, $connection, $server) =
            $this->setUpConnection($site, 131072);
        $client = $this->sockets[1];
        $stream = 15;
        $body = str_repeat("A", 185349);
        $site->startPacedBody($key, $server, $connection, $stream,
            $body);
        $received = $this->drainAndRead($site, $key, $client, "");
        list($first_total, ) =
            $this->countDataPayload($received, $stream);
        $this->assertEqual(131072, $first_total,
            'only the first window goes out before any credit returns');
        $frame = new WindowUpdateFrame($stream);
        $payload = pack("N", 12451840);
        $site->handleFrame($key, $server, $connection, $frame,
            $payload, 4);
        $received = $this->drainAndRead($site, $key, $client, $received);
        list($total, $end_seen) =
            $this->countDataPayload($received, $stream);
        $this->assertEqual(strlen($body), $total,
            'the whole body reaches the client after the window update');
        $this->assertTrue($end_seen,
            'the final data frame carries the end of stream flag');
    }
    /**
     * oneBurstDeliversWholeBodyTestCase sends a body that fits within the
     * client's window for its stream. It has to arrive whole in one go,
     * with the last DATA frame marked END_STREAM and no WINDOW_UPDATE
     * needed.
     */
    public function oneBurstDeliversWholeBodyTestCase()
    {
        $site = new H2PacedResumeProbeWebSite("/");
        list($key, $connection, $server) =
            $this->setUpConnection($site, 131072);
        $client = $this->sockets[1];
        $stream = 7;
        $body = str_repeat("B", 40000);
        $site->startPacedBody($key, $server, $connection, $stream,
            $body);
        $received = $this->drainAndRead($site, $key, $client, "");
        list($total, $end_seen) =
            $this->countDataPayload($received, $stream);
        $this->assertEqual(strlen($body), $total,
            'a body within the window is delivered in one burst');
        $this->assertTrue($end_seen,
            'the final data frame carries the end of stream flag');
    }
    /**
     * readCreditThenKeepsFlushingPastEofTestCase has the client write a
     * WINDOW_UPDATE frame and then close its sending side, and the server
     * reads both through its real read path. Reading the
     * credit must resume the parked body, and the end-of-file the close
     * raises must not tear the stream down while the rest of the body is
     * still queued, so the client receives the whole body with the final
     * DATA frame marked END_STREAM. This guards the partial-transfer
     * failure where a stalled response, woken only when the socket next
     * went readable, was dropped at that wake because end-of-file was
     * treated as a reason to close even though bytes were still pending.
     */
    public function readCreditThenKeepsFlushingPastEofTestCase()
    {
        $site = new H2PacedResumeProbeWebSite("/");
        list($key, $connection, $server) =
            $this->setUpConnection($site, 131072);
        $client = $this->sockets[1];
        $stream = 15;
        $body = str_repeat("A", 185349);
        $site->startPacedBody($key, $server, $connection, $stream,
            $body);
        $received = $this->drainAndRead($site, $key, $client, "");
        list($first_total, ) =
            $this->countDataPayload($received, $stream);
        $this->assertEqual(131072, $first_total,
            'only the first window goes out before any credit returns');
        $increment = 12451840;
        $update = chr(0) . chr(0) . chr(4) . chr(0x08) . chr(0)
            . pack("N", $stream & 0x7fffffff) . pack("N", $increment);
        fwrite($client, $update);
        stream_socket_shutdown($client, STREAM_SHUT_WR);
        $site->readOnce($key, $server);
        $site->readOnce($key, $server);
        $received = $this->drainAndRead($site, $key, $client, $received);
        list($total, $end_seen) =
            $this->countDataPayload($received, $stream);
        $this->assertEqual(strlen($body), $total,
            'the whole body still reaches the client after the close');
        $this->assertTrue($end_seen,
            'the final data frame carries the end of stream flag');
    }
    /**
     * dataFrameBytes writes out one HTTP/2 frame as it travels: a nine byte
     * header
     * (three byte length, one byte type, one byte flags, four byte
     * stream id) followed by the payload. The type here is DATA, which
     * is enough for a test that only checks the read loop hands each
     * framed-off chunk to the frame handler.
     *
     * @param int $stream the stream id to put in the frame header
     * @param string $payload the frame body bytes
     * @return string the serialized frame
     */
    private function dataFrameBytes($stream, $payload)
    {
        $length = strlen($payload);
        $header = chr(($length >> 16) & 0xff)
            . chr(($length >> 8) & 0xff)
            . chr($length & 0xff)
            . chr(0x00)
            . chr(0x00)
            . pack("N", $stream & 0x7fffffff);
        return $header . $payload;
    }
    /**
     * readDispatchesEveryRequestInOneReadTestCase writes two requests to
     * the server's socket at once, so one read takes in both. Both have to
     * reach the frame handler, not only the first. This guards
     * the failure where, after one read carried several streams'
     * frames, only the first stream got a response and the rest hung
     * because nothing was left to wake the loop and read them.
     */
    public function readDispatchesEveryRequestInOneReadTestCase()
    {
        $site = new H2FrameDrainProbeWebSite("/");
        $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM,
            0);
        $server = $pair[0];
        $client = $pair[1];
        stream_set_blocking($server, false);
        stream_set_blocking($client, false);
        $this->sockets = [$server, $client];
        $key = (int)$server;
        $connection = new Connection($server, false);
        $connection->protocol = 'h2';
        $connection->is_websocket = false;
        $connection->protocol_state = [];
        $site->registerConnection($key, $connection, $server);
        $first = $this->dataFrameBytes(1, "abc");
        $second = $this->dataFrameBytes(3, "def");
        fwrite($client, $first . $second);
        $site->readOnce($key, $server);
        $this->assertEqual([1, 3], $site->seen,
            'both requests arriving in one read are handed off, not '
            . 'only the first');
    }
    /**
     * oversizeSessionIsRefusedTestCase writes a session larger than the
     * server accepts as a request body. The server has to refuse it, write
     * no file, and read it back as empty, since holding one that large
     * would cost the memory that limit exists to bound.
     */
    public function oversizeSessionIsRefusedTestCase()
    {
        $probe = new SessionFileProbeWebSite();
        $probe->useDirectory(C\TEST_DIR . "/test_files", 100);
        $identifier = "oversize" . getmypid();
        $this->assertTrue(!$probe->write($identifier,
            ['BIG' => str_repeat("x", 500)]),
            "a session past the limit is refused");
        $this->assertTrue(!file_exists($probe->pathOf($identifier)),
            "nothing is written for it");
        $this->assertEqual($probe->sessionData($identifier), [],
            "and it reads back as empty");
    }
    /**
     * memorySampleOffWhenUnsetTestCase leaves the period for logging
     * memory use unset. The timing check must then never say a log line is
     * due, however much time passes.
     */
    public function memorySampleOffWhenUnsetTestCase()
    {
        $site = new MemorySampleProbeWebSite("/");
        $this->assertTrue(!$site->dueAt(1000),
            "sampling is off when no period is configured");
        $this->assertTrue(!$site->dueAt(500000),
            "still off no matter how much time passes");
    }
    /**
     * memorySampleOffWhenZeroTestCase sets the period for logging memory
     * use to zero, which is how a site turns the logging back off. The
     * timing check must then never say a log line is due.
     */
    public function memorySampleOffWhenZeroTestCase()
    {
        $site = new MemorySampleProbeWebSite("/");
        $site->setPeriod(0);
        $this->assertTrue(!$site->dueAt(1000),
            "a period of zero turns sampling off");
    }
    /**
     * memorySampleFiresEveryPeriodTestCase sets a period of ten seconds
     * for logging memory use. The first check has to be due, the next
     * ones not until ten seconds have passed, and then due again.
     */
    public function memorySampleFiresEveryPeriodTestCase()
    {
        $site = new MemorySampleProbeWebSite("/");
        $site->setPeriod(10);
        $this->assertTrue($site->dueAt(1000),
            "the first sample is due");
        $this->assertTrue(!$site->dueAt(1005),
            "a sample is not due before the period elapses");
        $this->assertTrue($site->dueAt(1010),
            "a sample is due once the period has elapsed");
        $this->assertTrue(!$site->dueAt(1011),
            "a sample is not due again right away");
    }
    /**
     * closedStreamWindowUpdateForClosedStreamIgnoredTestCase sends a late
     * WINDOW_UPDATE frame for each of CLOSED_STREAM_RUN streams the server
     * has already finished and closed, as a browser does when it drains
     * each finished response. The server has to ignore them rather than
     * keep them as credit waiting to be used. A connection that serves a
     * long run of responses, such as a video fetched range by range, then
     * never gathers one stranded credit entry per closed stream, and never
     * trips the guard on waiting credit that would close the connection.
     */
    public function closedStreamWindowUpdateForClosedStreamIgnoredTestCase()
    {
        $site = new H2PacedResumeProbeWebSite("/");
        list($key, $connection, $server) =
            $this->setUpConnection($site, 131072);
        $client = $this->sockets[1];
        $closed_count = self::CLOSED_STREAM_RUN;
        for ($index = 0; $index < $closed_count; $index++) {
            $stream = 1 + 2 * $index;
            $site->startPacedBody($key, $server, $connection, $stream,
                "a small whole body that completes at once");
            $this->drainAndRead($site, $key, $client, "");
            $frame = new WindowUpdateFrame($stream);
            $site->handleFrame($key, $server, $connection, $frame,
                pack("N", 65536), 4);
        }
        $credit = $connection->protocol_state[
            'pending_stream_window_credit'] ?? [];
        $this->assertEqual(0, count($credit),
            'no credit is stashed for streams that already closed');
        $this->assertTrue($site->connection($key) !== null,
            'the connection is not torn down by a guard tripping');
    }
    /**
     * preGrantForOpenStreamStillAppliedTestCase sends a WINDOW_UPDATE for
     * a stream the server has opened but not yet begun answering on. That
     * credit has to be held
     * and still applied when the response starts, so a browser that
     * enlarges a stream's window the instant it opens (the grant that
     * lets a larger-than-window page go out in full) is unaffected by
     * the closed-stream check.
     */
    public function preGrantForOpenStreamStillAppliedTestCase()
    {
        $site = new H2PacedResumeProbeWebSite("/");
        list($key, $connection, $server) =
            $this->setUpConnection($site, 65536);
        $client = $this->sockets[1];
        $stream = 7;
        $frame = new WindowUpdateFrame($stream);
        $site->handleFrame($key, $server, $connection, $frame,
            pack("N", 65536), 4);
        $credit = $connection->protocol_state[
            'pending_stream_window_credit'] ?? [];
        $this->assertEqual(65536, $credit[$stream] ?? 0,
            'a pre-grant for an open stream is held, not ignored');
        $body = str_repeat("B", 200000);
        $site->startPacedBody($key, $server, $connection, $stream,
            $body);
        $received = $this->drainAndRead($site, $key, $client, "");
        list($first_total, ) =
            $this->countDataPayload($received, $stream);
        $this->assertEqual(131072, $first_total,
            'the held pre-grant is added to the initial window so two '
            . 'windows worth go out before any further credit');
    }
    /**
     * queued reads, from the server setUp made, the bytes waiting to be
     * written to the connection at $key, which the socket frame cases
     * check.
     *
     * @return string bytes queued so far
     */
    private function queued()
    {
        $streams = $this->out_streams->getValue($this->site);
        return $streams[WebSite::DATA][$this->key] ?? "";
    }
    /**
     * framesIn splits a run of HTTP/2 frames apart and says what each one
     * is, for the socket frame cases. Every
     * frame is nine bytes of heading and then its body, so a reader can
     * walk them without knowing what they carry.
     *
     * @param string $bytes the queued bytes to read
     * @return array one entry per frame, each with its type, flags,
     *      stream id and body
     */
    private function framesIn($bytes)
    {
        $frames = [];
        $offset = 0;
        while ($offset + 9 <= strlen($bytes)) {
            $length = (ord($bytes[$offset]) << 16) +
                (ord($bytes[$offset + 1]) << 8) + ord($bytes[$offset + 2]);
            $type = ord($bytes[$offset + 3]);
            $flags = ord($bytes[$offset + 4]);
            $stream_id = unpack('N',
                substr($bytes, $offset + 5, 4))[1] & 0x7FFFFFFF;
            $body = substr($bytes, $offset + 9, $length);
            $frames[] = ['type' => $type, 'flags' => $flags,
                'stream_id' => $stream_id, 'body' => $body];
            $offset += 9 + $length;
        }
        return $frames;
    }
    /**
     * socketOnStream makes a WebSocket (a two-way message channel kept
     * open over an HTTP connection) on the connection at $key. It rides
     * the given HTTP/2 stream, or the plain connection when the stream is
     * zero.
     *
     * @param int $stream_id the stream the socket rides, zero for none
     * @return WebSocket the socket handle
     */
    private function socketOnStream($stream_id)
    {
        $socket = new WebSocket("a-connection", $this->key, $this->site);
        $socket->h2_stream_id = $stream_id;
        return $socket;
    }
    /**
     * smallFrameGoesAsOneDataFrameTestCase has the server write a small
     * WebSocket frame over HTTP/2. It has to go out as one DATA frame on
     * its own stream, carrying exactly what was given.
     */
    public function smallFrameGoesAsOneDataFrameTestCase()
    {
        $payload = str_repeat("x", 40);
        $this->enqueue_h2->invoke($this->site, $this->key, 5, $payload);
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 1,
            "a small socket frame goes out as one data frame");
        $this->assertEqual($frames[0]['type'], 0,
            "the frame it goes out in is a data frame");
        $this->assertEqual($frames[0]['stream_id'], 5,
            "the data frame rides the stream it was given");
        $this->assertEqual($frames[0]['body'], $payload,
            "the data frame carries what was given, unchanged");
        $this->assertEqual($frames[0]['flags'], 0,
            "carrying a socket frame does not end the stream");
    }
    /**
     * largeFrameIsSplitToFrameSizeTestCase writes a WebSocket frame larger
     * than a reader takes in one HTTP/2 DATA frame. It has to be split
     * across several, all on its stream, and joined back up they have to be
     * what was sent. A single oversized frame had been dropped by readers.
     */
    public function largeFrameIsSplitToFrameSizeTestCase()
    {
        $limit = WebSite::H2_MAX_FRAME_SIZE;
        $payload = str_repeat("y", 2 * $limit + 100);
        $this->enqueue_h2->invoke($this->site, $this->key, 9, $payload);
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 3,
            "a frame over twice the size goes out as three");
        $joined = "";
        foreach ($frames as $frame) {
            $this->assertTrue(strlen($frame['body']) <= $limit,
                "no data frame carries more than a reader will take");
            $this->assertEqual($frame['stream_id'], 9,
                "every piece rides the same stream");
            $joined .= $frame['body'];
        }
        $this->assertEqual($joined, $payload,
            "the pieces joined back up are what was sent");
    }
    /**
     * frameOfExactlyTheLimitIsNotSplitTestCase writes a WebSocket frame of
     * exactly H2_MAX_FRAME_SIZE bytes, the most one HTTP/2 DATA frame
     * carries. It has to go out as one DATA frame, with no empty one after
     * it.
     */
    public function frameOfExactlyTheLimitIsNotSplitTestCase()
    {
        $payload = str_repeat("z", WebSite::H2_MAX_FRAME_SIZE);
        $this->enqueue_h2->invoke($this->site, $this->key, 3, $payload);
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 1,
            "a frame of exactly the limit goes out as one");
        $this->assertEqual(strlen($frames[0]['body']),
            WebSite::H2_MAX_FRAME_SIZE,
            "that one frame carries the whole of it");
    }
    /**
     * answerOnACarriedSocketIsWrappedTestCase sends a WebSocket answer on
     * a socket that rides an HTTP/2 stream. It has to go out inside a DATA
     * frame on that stream. Writing it as raw bytes had left those bytes
     * where a frame heading was looked for, so everything sent after it
     * on that connection could not be read.
     */
    public function answerOnACarriedSocketIsWrappedTestCase()
    {
        $socket = $this->socketOnStream(11);
        $this->send_frame->invoke($this->site, $this->key, $socket,
            "\x88\x02\x03\xe8");
        $frames = $this->framesIn($this->queued());
        $this->assertEqual(count($frames), 1,
            "the answer goes out as one data frame");
        $this->assertEqual($frames[0]['type'], 0,
            "the answer is wrapped in a data frame");
        $this->assertEqual($frames[0]['stream_id'], 11,
            "the wrapping rides the socket's own stream");
        $this->assertEqual($frames[0]['body'], "\x88\x02\x03\xe8",
            "the wrapping carries the answer unchanged");
    }
    /**
     * answerOnAPlainSocketGoesAsItStandsTestCase sends a WebSocket answer
     * on a socket that has a connection to itself. It has to go out as it
     * stands, since there is no HTTP/2 framing around it to keep.
     */
    public function answerOnAPlainSocketGoesAsItStandsTestCase()
    {
        $socket = $this->socketOnStream(0);
        $this->send_frame->invoke($this->site, $this->key, $socket,
            "\x8a\x00");
        $this->assertEqual($this->queued(), "\x8a\x00",
            "a plain socket's answer goes out as it stands");
    }
    /**
     * writeForAGoneConnectionIsDroppedTestCase takes the connection away
     * and then writes a WebSocket frame for it. Nothing may be queued,
     * rather than the server reaching for a connection no longer there.
     */
    public function writeForAGoneConnectionIsDroppedTestCase()
    {
        $this->in_streams->setValue($this->site,
            [WebSite::CONNECTION => []]);
        $socket = $this->socketOnStream(13);
        $this->send_frame->invoke($this->site, $this->key, $socket,
            "\x81\x01a");
        $this->assertEqual($this->queued(), "",
            "nothing is queued for a connection that is gone");
    }
    /**
     * closedBeforeFirstByteIsDroppedTestCase connects to a Transport Layer
     * Security (TLS) port and closes before sending a byte. The server has
     * to drop that connection at its first handshake step. Peeking at such
     * a socket finds nothing and does not mark the stream at its end. Left
     * open, the connection would stay until its handshake deadline, readable at
     * every select, and the server's loop would run without sleeping for
     * up to 30 seconds per such connection.
     */
    public function closedBeforeFirstByteIsDroppedTestCase()
    {
        $server = stream_socket_server("tcp://127.0.0.1:0");
        $client = stream_socket_client(stream_socket_get_name($server,
            false));
        $accepted = stream_socket_accept($server);
        stream_set_blocking($accepted, false);
        fclose($client);
        $readable = [$accepted];
        $unused = null;
        stream_select($readable, $unused, $unused, 2);
        $site = new WebSite();
        $connection = new Connection($accepted, true);
        $connection->protocol = 'handshake';
        $connection->protocol_state['handshake_deadline'] =
            microtime(true) + 30;
        $key = 11;
        $connections = new \ReflectionProperty($site, "connections");
        $connections->setValue($site, [$key => $connection]);
        $in_streams = new \ReflectionProperty($site, "in_streams");
        $in_streams->setValue($site, [WebSite::CONNECTION =>
            [$key => $accepted]]);
        $site->stepHandshake($key, $connection);
        $this->assertTrue($site->connection($key) === null,
            "a connection closed before its first byte is dropped");
        fclose($server);
    }
    /**
     * connectionSpeaking makes a Connection, without opening anything,
     * that speaks the given protocol and does or does not carry a
     * WebSocket, for drainPathFollowsTheProtocolTestCase.
     *
     * @param string $protocol what the connection speaks
     * @param bool $is_websocket whether it carries a socket
     * @return Connection the connection
     */
    private function connectionSpeaking($protocol, $is_websocket)
    {
        $reflection = new \ReflectionClass(Connection::class);
        $connection = $reflection->newInstanceWithoutConstructor();
        $connection->protocol = $protocol;
        $connection->is_websocket = $is_websocket;
        return $connection;
    }
    /**
     * fileBackedReplyDrainsInPiecesTestCase names a file of about one and
     * a half megabytes as a reply and runs the HTTP/1.1 refill step until
     * it is done. Every byte has to go out, and no more than OUT_FILE_STEP
     * bytes may be held at a time. A pack built for a git pull
     * can be larger than the memory the server has, and gathering it as a
     * string exhausted the process on a live site.
     */
    public function fileBackedReplyDrainsInPiecesTestCase()
    {
        $site = new WebSite();
        $path = tempnam(C\TEST_DIR . "/test_files", "yioop_reply_");
        $piece = str_repeat("a line of the reply\n", 20000);
        $handle = fopen($path, "wb");
        for ($count = 0; $count < 4; $count++) {
            fwrite($handle, $piece);
        }
        fclose($handle);
        $whole = filesize($path);
        $key = "test-file-reply";
        $naming = new \ReflectionMethod($site, "sendRestFromFile");
        $this->assertTrue($naming->invoke($site, $key, $path),
            "a reply may be named as a file");
        $refill = new \ReflectionMethod($site, "refillFromFile");
        $reading = new \ReflectionProperty($site, "out_streams");
        $sent = 0;
        $largest = 0;
        for ($step = 0; $step < 200; $step++) {
            $refill->invoke($site, $key);
            $streams = $reading->getValue($site);
            $held = $streams[WebSite::DATA][$key] ?? "";
            if (empty($streams[WebSite::DATA_FILE][$key]) && $held === "") {
                break;
            }
            $sent += strlen($held);
            $largest = max($largest, strlen($held));
            $streams[WebSite::DATA][$key] = "";
            $reading->setValue($site, $streams);
        }
        unlink($path);
        $this->assertEqual($sent, $whole,
            "every byte of the file goes out");
        $this->assertTrue($largest <= WebSite::OUT_FILE_STEP,
            "no more than one piece is held at a time");
    }
    /**
     * connectionWithDeferredWorkIsNotIdleTestCase marks a connection as
     * having a deferred request at work. While it does, hasReplyInFlight
     * has to say a reply is on its way, so the sweep for idle connections
     * leaves the connection open. A git clone of a large repository can
     * send nothing while its history is walked, and closing the connection
     * after the idle time reached the client as an empty reply.
     */
    public function connectionWithDeferredWorkIsNotIdleTestCase()
    {
        $site = new WebSite();
        $in_flight = new \ReflectionMethod($site, "hasReplyInFlight");
        $running = new \ReflectionProperty($site, "deferred_running");
        $key = 7;
        $this->assertTrue(!$in_flight->invoke($site, $key),
            "a connection with nothing to send is idle");
        $running->setValue($site, [$key => 1]);
        $this->assertTrue($in_flight->invoke($site, $key),
            "a connection whose deferred work is running is not idle");
        $running->setValue($site, []);
        $this->assertTrue(!$in_flight->invoke($site, $key),
            "once the work is done the connection may be idle again");
    }
    /**
     * drainPathFollowsTheProtocolTestCase asks usesSocketDrain about
     * connections of each kind. A connection speaking HTTP/2 has to keep to
     * the HTTP/2 way of finishing a write even while it carries a socket,
     * since the rest of a large reply is still to be sent on it. A
     * connection upgraded from HTTP/1.1 carries its socket alone and has
     * to take the socket way.
     */
    public function drainPathFollowsTheProtocolTestCase()
    {
        $this->assertTrue(!$this->uses_socket_drain->invoke($this->site,
            $this->connectionSpeaking('h2', true)),
            "a connection speaking HTTP/2 keeps to the HTTP/2 way");
        $this->assertTrue($this->uses_socket_drain->invoke($this->site,
            $this->connectionSpeaking('ws', true)),
            "a connection upgraded to a socket takes the socket way");
        $this->assertTrue(!$this->uses_socket_drain->invoke($this->site,
            $this->connectionSpeaking('h1', false)),
            "an ordinary connection takes neither");
        $this->assertTrue(!$this->uses_socket_drain->invoke($this->site,
            null),
            "a connection already taken down takes neither");
    }
    /**
     * pictureNamesAreKnownTestCase asks mimeType for the kind of a file
     * named in each picture format a page may hold. Each has to come back
     * as its picture kind. A format
     * missing from this list is not a picture as far as a page is
     * concerned, which is what once left a screenshot showing as a link.
     */
    public function pictureNamesAreKnownTestCase()
    {
        $pictures = [
            "shot.webp" => "image/webp",
            "shot.avif" => "image/avif",
            "shot.svg" => "image/svg+xml",
            "shot.tif" => "image/tiff",
            "shot.tiff" => "image/tiff",
            "shot.png" => "image/png",
            "shot.jpg" => "image/jpeg",
            "shot.gif" => "image/gif"
        ];
        foreach ($pictures as $name => $kind) {
            $this->assertEqual($kind, WebSite::mimeType($name),
                "$name is known to be a picture");
        }
    }
    /**
     * kindIsReadFromTheNameNotTheFileTestCase asks mimeType about names
     * with no file behind them. The kind has to be read from the name
     * whether or not a file of that name is on disk, since the bytes are
     * never opened; a name of no known kind is
     * unknown, and a name never written but of a known kind is still read
     * from its extension rather than opened.
     */
    public function kindIsReadFromTheNameNotTheFileTestCase()
    {
        $this->assertEqual("unknown", WebSite::mimeType("mystery.qqq"),
            "a name of no known kind is unknown");
        $this->assertEqual("image/webp",
            WebSite::mimeType(C\TEST_DIR . "/test_files/never_written.webp"),
            "a known extension is read from the name with no file present");
    }
    /**
     * kindStandsAloneTestCase asks mimeType for a zip file's kind. The
     * answer has to carry the kind alone, with no note of the character
     * set it is written in, because callers compare it against a
     * kind whole and an answer carrying more than the kind fails them.
     */
    public function kindStandsAloneTestCase()
    {
        $kind = WebSite::mimeType("an_archive.zip");
        $this->assertEqual("application/zip", $kind,
            "a zip is named application/zip");
        $this->assertTrue(!str_contains($kind, ";"),
            "the kind stands alone, with no character set after it");
    }
    /**
     * $status_line reaches, through reflection, the server's method that
     * chooses a reply's status line, which a server keeps to itself
     * @var \ReflectionMethod
     */
    public $status_line;
    /**
     * $header_data reaches, through reflection, the headers a server has
     * gathered for a reply, from which the status line is chosen
     * @var \ReflectionProperty
     */
    public $header_data;
    /**
     * statusFor puts the given headers in the server setUp made and gives
     * back the status line the server chooses for them, for the cases
     * about a reply's first line.
     *
     * @param string $headers the headers gathered so far
     * @return string the status line they are given
     */
    private function statusFor($headers)
    {
        $this->header_data->setValue($this->site, $headers);
        return $this->status_line->invoke($this->site);
    }
    /**
     * locationHeaderSendsTheReaderOnTestCase gives the server headers
     * holding a Location header. The status has to be 302 Found, which
     * sends the reader on, rather than 301, a move for good. A move
     * for good is one a browser may remember and act on without asking
     * again, which would leave a page like signing out never reached a
     * second time.
     */
    public function locationHeaderSendsTheReaderOnTestCase()
    {
        $this->assertEqual($this->statusFor(
            "Location: /elsewhere\x0D\x0A"),
            "HTTP/1.1 302 Found\x0D\x0A",
            "a Location header sends the reader on");
        $this->assertEqual($this->statusFor(
            "Content-Type: text/html\x0D\x0ALocation: /elsewhere\x0D\x0A"),
            "HTTP/1.1 302 Found\x0D\x0A",
            "a Location header counts wherever in the block it stands");
        $this->assertEqual($this->statusFor(
            "location: /elsewhere\x0D\x0A"),
            "HTTP/1.1 302 Found\x0D\x0A",
            "a header name is the same name however it is capitalized");
        $this->assertTrue(strpos($this->statusFor(
            "Location: /elsewhere\x0D\x0A"), "301") === false,
            "and it is never called a move for good");
    }
    /**
     * refreshHeaderFoundTestCase gives the server a Refresh header, which
     * says the reader is to be sent on shortly. The status has to be 302
     * Found.
     */
    public function refreshHeaderFoundTestCase()
    {
        $this->assertEqual($this->statusFor("Refresh: 5; url=/x\x0D\x0A"),
            "HTTP/1.1 302 Found\x0D\x0A",
            "a Refresh header says the reader is sent on shortly");
    }
    /**
     * ordinaryReplyIsOkayTestCase gives the server headers that neither
     * move nor send the reader on. The status has to be 200 OK.
     */
    public function ordinaryReplyIsOkayTestCase()
    {
        $this->assertEqual($this->statusFor("Content-Type: image/webp\x0D\x0A"),
            "HTTP/1.1 200 OK\x0D\x0A",
            "an ordinary reply is answered as one");
        $this->assertEqual($this->statusFor(""),
            "HTTP/1.1 200 OK\x0D\x0A",
            "headers saying nothing in particular are an ordinary reply");
    }
    /**
     * whatAHeaderCarriesIsNotAHeaderTestCase gives the server headers whose
     * values hold the words location and refresh. The status has to be 200
     * OK, since what a header carries is not itself a header. A picture
     * whose name
     * mentions a location has not moved, and one whose name mentions a
     * refresh is not sending the reader on; both were served with a
     * redirect's status stamped over a picture, and browsers showed
     * nothing at all.
     */
    public function whatAHeaderCarriesIsNotAHeaderTestCase()
    {
        $this->assertEqual($this->statusFor("Content-Disposition: " .
            "filename=\"link_game_location1.webp\"\x0D\x0A"),
            "HTTP/1.1 200 OK\x0D\x0A",
            "a picture named for a location has not moved anywhere");
        $this->assertEqual($this->statusFor("Content-Disposition: " .
            "filename=\"page_refresh_button.png\"\x0D\x0A"),
            "HTTP/1.1 200 OK\x0D\x0A",
            "a picture named for a refresh sends nobody anywhere");
        $this->assertEqual($this->statusFor(
            "X-Note: the location of the refresh is here\x0D\x0A"),
            "HTTP/1.1 200 OK\x0D\x0A",
            "the words in a header's value decide nothing");
        $this->assertEqual($this->statusFor(
            "Content-Disposition: filename=\"a.png\"\x0D\x0A" .
            "Location: /elsewhere\x0D\x0A"),
            "HTTP/1.1 302 Found\x0D\x0A",
            "a real Location header still counts beside other headers");
    }
    /**
     * requestTimeoutPageIsSelfContainedTestCase reads the page
     * requestTimeoutPage gives for a request that ran too long. It has to
     * be a complete, self-contained HTML document that names the timeout
     * and holds no site content, since the timeout means the real page
     * could not be finished.
     */
    public function requestTimeoutPageIsSelfContainedTestCase()
    {
        $page_method = new \ReflectionMethod(WebSite::class,
            "requestTimeoutPage");
        $page = $page_method->invoke($this->site);
        $this->assertTrue(strpos($page, "<!DOCTYPE html>") === 0,
            "the timeout page is a whole HTML document");
        $this->assertTrue(strpos($page, "408") !== false,
            "the timeout page names the 408 status");
        $this->assertTrue(stripos($page, "too long") !== false,
            "the timeout page says the request took too long");
    }
    /**
     * emptyBracketFileFieldsGatherUnderOneNameTestCase checks that a
     * plain HTML file input marked multiple, whose parts all arrive
     * named field[] with the brackets empty, gathers as one
     * $_FILES entry whose parts are arrays, the same shape a file input
     * naming its indexes gives. Mail compose uses the empty-bracket
     * form, and reading it as a field name of its own left the entry
     * under a key holding brackets, so a message went out with its
     * attachments quietly missing.
     */
    public function emptyBracketFileFieldsGatherUnderOneNameTestCase()
    {
        $boundary = "----probeboundary";
        $body = "";
        foreach (["first.txt", "second.txt"] as $index => $file_name) {
            $body .= "--$boundary\x0D\x0A" .
                "Content-Disposition: form-data; " .
                "name=\"attachments[]\"; filename=\"$file_name\"" .
                "\x0D\x0A" .
                "Content-Type: text/plain\x0D\x0A\x0D\x0A" .
                "body of $file_name\x0D\x0A";
        }
        $body .= "--$boundary--\x0D\x0A";
        $_FILES = [];
        $this->site->default_server_globals =
            WebSite::defaultServerGlobals();
        $this->site->setGlobals([
            "CONTENT_TYPE" => "multipart/form-data; boundary=$boundary",
            "CONTENT" => $body, "QUERY_STRING" => "",
            "REQUEST_METHOD" => "POST"]);
        $this->assertTrue(isset($_FILES["attachments"]),
            "the files gather under the name without its brackets");
        $this->assertTrue(is_array($_FILES["attachments"]["name"] ?? null),
            "and that entry's parts are arrays");
        $this->assertEqual(["first.txt", "second.txt"],
            array_values($_FILES["attachments"]["name"]),
            "with every file that was sent, in the order sent");
    }
    /**
     * indexedFileFieldsStillGatherUnderOneNameTestCase checks that a
     * file input naming its own indexes, which the wiki resource
     * upload builds, keeps gathering the way it did, so accepting the
     * empty-bracket form costs the indexed form nothing.
     */
    public function indexedFileFieldsStillGatherUnderOneNameTestCase()
    {
        $boundary = "----probeboundary";
        $body = "";
        foreach (["one.bin", "two.bin"] as $index => $file_name) {
            $body .= "--$boundary\x0D\x0A" .
                "Content-Disposition: form-data; " .
                "name=\"page_resource[$index]\"; " .
                "filename=\"$file_name\"\x0D\x0A" .
                "Content-Type: application/octet-stream" .
                "\x0D\x0A\x0D\x0A" .
                "bytes of $file_name\x0D\x0A";
        }
        $body .= "--$boundary--\x0D\x0A";
        $_FILES = [];
        $this->site->default_server_globals =
            WebSite::defaultServerGlobals();
        $this->site->setGlobals([
            "CONTENT_TYPE" => "multipart/form-data; boundary=$boundary",
            "CONTENT" => $body, "QUERY_STRING" => "",
            "REQUEST_METHOD" => "POST"]);
        $this->assertEqual(["one.bin", "two.bin"],
            array_values($_FILES["page_resource"]["name"] ?? []),
            "an indexed file field gathers as it did before");
    }
    /**
     * uploadedFileKeepsExactlyItsOwnBytesTestCase checks that the line
     * ending which separates a part from the boundary below it is not
     * kept as part of the file. Left on, every uploaded file arrived two
     * bytes longer than it was sent and its recorded size was two too
     * many, which a picture or any other file read by its bytes does not
     * survive.
     */
    public function uploadedFileKeepsExactlyItsOwnBytesTestCase()
    {
        $boundary = "----probeboundary";
        $sent = "\x89PNG\x0D\x0A\x1A\x0Aexactly these bytes";
        $body = "--$boundary\x0D\x0A" .
            "Content-Disposition: form-data; " .
            "name=\"attachments[]\"; filename=\"shot.png\"\x0D\x0A" .
            "Content-Type: image/png\x0D\x0A\x0D\x0A" .
            $sent . "\x0D\x0A--$boundary--\x0D\x0A";
        $_FILES = [];
        $this->site->default_server_globals =
            WebSite::defaultServerGlobals();
        $this->site->setGlobals([
            "CONTENT_TYPE" => "multipart/form-data; boundary=$boundary",
            "CONTENT" => $body, "QUERY_STRING" => "",
            "REQUEST_METHOD" => "POST"]);
        $this->assertEqual($sent,
            $_FILES["attachments"]["data"][0] ?? "",
            "the file holds the bytes that were sent and no others");
        $this->assertEqual(strlen($sent),
            $_FILES["attachments"]["size"][0] ?? 0,
            "and its recorded size counts those bytes");
    }
    /**
     * deferredFileHeadersCarryTheFileSizeTestCase finishes the headers of
     * a deferred reply whose body is a file. They have to give the file's
     * size as Content-Length, not the size of the empty string the headers
     * were finished with.
     * A ten megabyte download went out with Content-Length: 0, so the
     * client read no body and closed the connection, and the server took
     * that close for a reset.
     */
    public function deferredFileHeadersCarryTheFileSizeTestCase()
    {
        $finalize = new \ReflectionMethod(WebSite::class,
            "finalizeDeferredResponse");
        $this->header_data->setValue($this->site,
            "HTTP/1.1 200 OK\x0D\x0AContent-Type: application/pdf\x0D\x0A" .
            "Content-Length: 10485755\x0D\x0A");
        $head = $finalize->invoke($this->site, "", true, 10485755);
        $this->assertTrue(
            strpos($head, "Content-Length: 10485755\x0D\x0A") !== false,
            "the headers of a file reply carry the file's size");
        $this->assertTrue(
            strpos($head, "Content-Length: 0") === false,
            "and not the length of the empty string");
    }
    /**
     * compressedContentsAreCachedBesideThePlainTestCase checks that
     * fileGetContents asked for the gzip compressed contents of a file
     * gives bytes that decode to the plain contents, that the plain read
     * of the same file is unchanged by it, and that a second compressed
     * read comes from the cache under its own key rather than the file
     * being compressed again.
     */
    public function compressedContentsAreCachedBesideThePlainTestCase()
    {
        $web_site = new WebSite();
        $path = tempnam(C\TEST_DIR . "/test_files", "site");
        file_put_contents($path, str_repeat("a style sheet line\n", 500));
        $compressed = $web_site->fileGetContents($path, false, true);
        $plain = $web_site->fileGetContents($path);
        $this->assertTrue(strlen($compressed) < strlen($plain) / 10,
            "the compressed contents are far smaller");
        $this->assertEqual($plain, gzdecode($compressed),
            "and decode to the plain contents");
        $this->assertEqual(file_get_contents($path), $plain,
            "the plain read is the file");
        file_put_contents($path, "changed");
        $this->assertEqual($compressed,
            $web_site->fileGetContents($path, false, true),
            "a second compressed read comes from the cache");
        $this->assertEqual("changed",
            gzdecode($web_site->fileGetContents($path, true, true)),
            "a forced compressed read sees the new file");
        unlink($path);
    }
    /**
     * writeReplacesTheCompressedCopyTestCase checks that a file written
     * through filePutContents is not served afterwards from an old
     * compressed copy. ResourceController sends an HTML resource gzip
     * compressed from the cache, so a saved edit must show in the
     * compressed read as well as in the plain one.
     */
    public function writeReplacesTheCompressedCopyTestCase()
    {
        $site = new WebSite();
        $path = C\TEST_DIR . "/test_files/write_check" . getmypid() . ".html";
        file_put_contents($path, "old page");
        $site->fileGetContents($path);
        $site->fileGetContents($path, false, true);
        $site->filePutContents($path, "saved page");
        $this->assertEqual($site->fileGetContents($path), "saved page",
            "a plain read after the write gives the saved page");
        $this->assertEqual(gzdecode($site->fileGetContents($path, false,
            true)), "saved page",
            "a compressed read after the write gives the saved page");
        unlink($path);
    }
    /**
     * dechunkedBody joins the chunks of an HTTP/1.1 reply body sent with
     * Transfer-Encoding: chunked. Each chunk is its length in hexadecimal,
     * a line end, that many bytes and a line end, and a chunk of length
     * zero ends the body. growingFileArrivesAsItIsWrittenTestCase uses it
     * to read what the server sent.
     *
     * @param string $body the body as it came over the connection
     * @return string the body with the chunk lengths taken out, or the
     *      part read before a chunk that does not follow that form
     */
    public function dechunkedBody($body)
    {
        $joined = "";
        $at = 0;
        while (($line_end = strpos($body, "\r\n", $at)) !== false) {
            $length = hexdec(substr($body, $at, $line_end - $at));
            if ($length == 0) {
                break;
            }
            $joined .= substr($body, $line_end + 2, $length);
            $at = $line_end + 2 + $length + 2;
        }
        return $joined;
    }
    /**
     * savedSessionsComeBackTestCase checks that loadSavedSessions takes
     * back what saveSessionsForRestart wrote: a site that saved its
     * sessions to a file hands them to a new site, which then knows the
     * same session ids, keeps its sequence count past theirs, and skips
     * keys that are not session ids, such as TERMINAL.
     */
    public function savedSessionsComeBackTestCase()
    {
        $sessions = new \ReflectionProperty(WebSite::class, "sessions");
        $sequence = new \ReflectionProperty(WebSite::class,
            "session_sequence");
        $old_site = new WebSite();
        $old_site->default_server_globals['SESSION_DIR'] =
            C\TEST_DIR . "/test_files";
        $signed_in = str_repeat("ab", 32);
        $sessions->setValue($old_site, [
            $signed_in => ['TIME' => time(), 'SEQ' => 7]]);
        $sequence->setValue($old_site, 7);
        $path = C\TEST_DIR . "/test_files/saved_sessions_test.txt";
        $old_site->saveSessionsForRestart($path);
        $session_info = unserialize(file_get_contents($path));
        unlink($path);
        $session_info['SESSIONS']['TERMINAL'] = true;
        $new_site = new WebSite();
        $new_site->default_server_globals['SESSION_DIR'] =
            C\TEST_DIR . "/test_files";
        $new_site->loadSavedSessions($session_info);
        $restored = $sessions->getValue($new_site);
        $this->assertTrue(isset($restored[$signed_in]),
            "the new site knows the saved session id");
        $this->assertFalse(isset($restored['TERMINAL']),
            "a key that is not a session id is skipped");
        $this->assertEqual(7, $sequence->getValue($new_site),
            "the sequence count continues from the saved one");
    }
    /**
     * deferredFileGoesOutOverHttp3TestCase hands the HTTP/3 transport a
     * deferred reply whose body is a file still being written, as a git
     * clone's pack is, over a RecordingQuicStreams connection. The first
     * piece has to go out at once, the second once it is written, and the
     * stream has to end, with the file deleted, only after the writer has
     * finished. Before, a deferred file reply over HTTP/3 was handed to
     * the HTTP/2 sender, which found no HTTP/2 connection and sent nothing.
     */
    public function deferredFileGoesOutOverHttp3TestCase()
    {
        require_once C\BASE_DIR . "/library/atto_servers/H3Listener.php";
        $kept_server = $_SERVER;
        $_SERVER['SERVER_PROTOCOL'] = "HTTP/3";
        $site = new WebSite();
        $transport = (new \ReflectionClass(
            \seekquarry\atto\H3Transport::class))
            ->newInstanceWithoutConstructor();
        (new \ReflectionProperty($transport, "site"))->setValue($transport,
            $site);
        $connection = new \stdClass();
        $connection->quic = new RecordingQuicStreams();
        $connection->h3_streams = [];
        file_put_contents(self::GROWING_FILE_REPLY, "first piece ");
        $task = new \stdClass();
        $task->file = self::GROWING_FILE_REPLY;
        $task->remove_file = true;
        $task->growing = true;
        $task->growing_done = false;
        $task->send = ["protocol" => "h3", "listener" => $transport,
            "connection" => $connection, "stream_id" => 0];
        $site->header("Content-Type: text/plain");
        (new \ReflectionMethod($site, "completeDeferredFile"))->invoke(
            $site, $task);
        $after_first = [str_contains($connection->quic->sent, "first piece"),
            $connection->quic->finished];
        file_put_contents(self::GROWING_FILE_REPLY, "second piece",
            FILE_APPEND);
        $transport->advanceH3StreamingGenerator($connection, 0);
        $after_second = [str_contains($connection->quic->sent,
            "second piece"), $connection->quic->finished];
        $task->growing_done = true;
        $transport->advanceH3StreamingGenerator($connection, 0);
        $_SERVER = $kept_server;
        $this->assertEqual([$after_first, $after_second,
            $connection->quic->finished,
            file_exists(self::GROWING_FILE_REPLY)],
            [[true, false], [true, false], true, false],
            "a growing file goes out over HTTP/3 as it is written, and the " .
            "stream ends and the file is deleted once the writer finishes");
    }
    /**
     * http3RequestKeepsTheScriptNameTestCase checks that a request read
     * over HTTP/3 keeps the script name the server was started with. The
     * request's own settings named an empty script name, which made
     * Yioop's router take every path for one under the script and send
     * it to the error page, so a thumbnail fetched over HTTP/3 came back
     * as a redirect.
     */
    public function http3RequestKeepsTheScriptNameTestCase()
    {
        require_once C\BASE_DIR . "/library/atto_servers/H3Listener.php";
        $kept_server = $_SERVER;
        $site = new WebSite();
        $site->default_server_globals['SCRIPT_NAME'] = "index.php";
        $transport = (new \ReflectionClass(
            \seekquarry\atto\H3Transport::class))
            ->newInstanceWithoutConstructor();
        $listener = new \stdClass();
        $listener->globals = ['SERVER_NAME' => 'localhost',
            'SERVER_PORT' => 443];
        $connection = new \stdClass();
        $connection->peer_address = "127.0.0.1:5000";
        $request = ['method' => 'GET', 'authority' => 'localhost',
            'path' => '/wd/thumbs/-/2/66/news+2026.pdf?made=1',
            'headers' => []];
        $context = (new \ReflectionMethod(
            \seekquarry\atto\H3Transport::class, 'buildContext'))
            ->invoke($transport, $listener, $connection, $request);
        $site->setGlobals($context);
        $script_name = $_SERVER['SCRIPT_NAME'] ?? "";
        $path = $_SERVER['PATH_INFO'] ?? "";
        $_SERVER = $kept_server;
        $this->assertEqual("index.php", $script_name,
            "the script name the server started with is kept");
        $this->assertEqual("/wd/thumbs/-/2/66/news+2026.pdf", $path,
            "the path is the request's, without its query");
    }
    /**
     * unknownFrameTypeIsSkippedTestCase checks that the header of an
     * HTTP/2 frame of a type the server does not know, here a
     * PRIORITY_UPDATE frame (type 0x10), reads as a frame to skip with
     * its length, rather than raising an exception. The exception ended
     * the whole connection, so every response still owed on it was lost.
     */
    public function unknownFrameTypeIsSkippedTestCase()
    {
        $body = pack("N", 3) . "u=1, i";
        $header = substr(pack("N", strlen($body)), 1) . chr(0x10) .
            chr(0) . pack("N", 0);
        $read = null;
        try {
            $read = \seekquarry\atto\Frame::parseFrameHeader($header);
        } catch (\Exception $e) {
            $read = $e->getMessage();
        }
        $this->assertEqual([null, strlen($body)], $read,
            "the frame is skipped by its length");
    }
    /**
     * chunkedRequestBodyIsJoinedTestCase checks that WebSite reads an
     * HTTP/1.1 request body sent with Transfer-Encoding: chunked, as git
     * sends a push larger than one megabyte. Each chunk is its length in
     * hexadecimal, a line end, its bytes and a line end; a chunk of length
     * 0 and an empty line end the body. The joined body comes back whole;
     * a body still arriving gives false, so the server waits for more;
     * and bytes that are not chunks give null, so the request is refused.
     */
    public function chunkedRequestBodyIsJoinedTestCase()
    {
        $site = new WebSite();
        $decode = new \ReflectionMethod($site, "decodeChunkedBody");
        $chunked = "5\r\nhello\r\n7;name=x\r\n, world\r\n0\r\n\r\n";
        $this->assertEqual([$decode->invoke($site, $chunked),
            $decode->invoke($site, substr($chunked, 0, 12)),
            $decode->invoke($site, "zz\r\nhello\r\n")],
            ["hello, world", false, null],
            "a whole body is joined, a part waits, and junk is refused");
    }
}