<?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");
}
}