Files
xxx/tests/php/Support/PageTestCase.php
T
tim ed959888b4 Add PHP unit tests and page tests.
Run "composer install", then "composer test".
2026-09-26 03:48:09 +02:00

205 lines
8.8 KiB
PHP

<?php
namespace Xxx\Tests\Support;
/*
* The base of the page tests. They run the site on PHP's built-in web
* server with tests/php/fixtures/router.php, and send real HTTP requests.
* Each test class gets one server, and each test gets empty state: the
* fake tables, the sessions, the temporary directory (with the login
* throttle files), the sent mail and the PHP error log.
*
* A test fails when a page logs a PHP warning, notice or error.
*/
abstract class PageTestCase extends \PHPUnit\Framework\TestCase {
protected static string $state;
protected static string $base;
private static $server = NULL;
// The cookies that the site set, name => value
protected array $cookies = array();
public static function setUpBeforeClass(): void {
self::$state = sys_get_temp_dir() . '/xxx-pages-' . bin2hex(random_bytes(6));
foreach (array('', '/sessions', '/tmp') as $dir)
mkdir(self::$state . $dir, 0700);
// A free port
$socket = stream_socket_server('tcp://127.0.0.1:0');
$address = stream_socket_get_name($socket, FALSE);
fclose($socket);
self::$base = "http://$address";
$sendmail = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(XXX_ROOT . '/tests/php/fixtures/fake-sendmail.php');
$command = array(PHP_BINARY, '-S', $address, '-t', XXX_ROOT,
'-d', 'error_reporting=-1', '-d', 'display_errors=stderr', '-d', 'log_errors=1',
'-d', 'session.save_path=' . self::$state . '/sessions',
'-d', 'session.serialize_handler=php_serialize',
'-d', 'sendmail_path=' . $sendmail,
XXX_ROOT . '/tests/php/fixtures/router.php');
$env = array(
'PATH' => getenv('PATH'),
'TMPDIR' => self::$state . '/tmp',
'XXX_PAGE_STATE' => self::$state,
'XXX_TEST_LOG' => self::$state . '/php.log',
'MAIL_LOG' => self::$state . '/mail.jsonl',
'UPLOAD_DIR' => self::$state . '/tmp/uploads',
);
$log = array('file', self::$state . '/server.log', 'a');
self::$server = proc_open($command, array(0 => array('file', '/dev/null', 'r'), 1 => $log, 2 => $log), $pipes, XXX_ROOT, $env);
for ($i = 0; $i < 100; $i++) {
$connection = @stream_socket_client("tcp://$address", $errno, $error, 0.1);
if ($connection) {
fclose($connection);
return;
}
usleep(50000);
}
self::fail('The PHP web server did not start: ' . @file_get_contents(self::$state . '/server.log'));
}
public static function tearDownAfterClass(): void {
if (self::$server) {
proc_terminate(self::$server);
proc_close(self::$server);
self::$server = NULL;
}
TestCase::removeTree(self::$state);
}
protected function setUp(): void {
foreach (array('sessions', 'tmp') as $dir) {
TestCase::removeTree(self::$state . "/$dir");
mkdir(self::$state . "/$dir", 0700);
}
foreach (array('queries.jsonl', 'mail.jsonl', 'php.log') as $file)
@unlink(self::$state . "/$file");
$this->cookies = array();
$this->setTables(array());
}
protected function tearDown(): void {
$log = (string)@file_get_contents(self::$state . '/php.log');
$this->assertDoesNotMatchRegularExpression('/PHP (Warning|Notice|Deprecated|Fatal error|Parse error)/', $log, $log);
}
// The fake tables (see SiteData) for the next requests
protected function setTables(array $tables, ?int $passwordLength = NULL): void {
$tables += array('xxx' => array(), 'queue' => array(), 'editors' => array(), 'persistent_sessions' => array());
foreach (array('xxx', 'queue') as $table)
$tables[$table] = array_map(array(SiteData::class, 'story'), $tables[$table]);
file_put_contents(self::$state . '/tables.ser', serialize(array('tables' => $tables, 'passwordLength' => $passwordLength)));
}
// The fake tables after the requests
protected function tables(): array {
return unserialize(file_get_contents(self::$state . '/tables.ser'))['tables'];
}
// The queries that the pages sent, each as array('sql' => ..., 'params' => ...)
protected function queries(string $pattern = '/./'): array {
$file = self::$state . '/queries.jsonl';
$queries = is_file($file) ? array_map(function ($line) { return json_decode($line, TRUE); }, file($file, FILE_IGNORE_NEW_LINES)) : array();
return array_values(array_filter($queries, function ($query) use ($pattern) { return preg_match($pattern, $query['sql']); }));
}
// The mail that the pages sent, decoded (see Mail::parse)
protected function mails(): array {
$file = self::$state . '/mail.jsonl';
if (!is_file($file))
return array();
return array_map(function ($line) { return Mail::parse(json_decode($line, TRUE)['message']); }, file($file, FILE_IGNORE_NEW_LINES));
}
// Start a session with $data, as if the browser had its cookie; returns the id
protected function startSession(array $data): string {
$id = bin2hex(random_bytes(16));
file_put_contents(self::$state . "/sessions/sess_$id", serialize($data));
$this->cookies['PHPSESSID'] = $id;
return $id;
}
// An editor's session, with a CSRF token; returns the token
protected function loginAs(string $username = 'ed', array $data = array()): string {
$token = str_repeat('c0ffee', 10) . 'c0de';
$this->startSession($data + array('editor' => $username, 'csrf_token' => $token));
return $token;
}
// The data of the session in the browser's cookie, or NULL
protected function session(): ?array {
$file = self::$state . '/sessions/sess_' . ($this->cookies['PHPSESSID'] ?? '');
if (!isset($this->cookies['PHPSESSID']) || !is_file($file))
return NULL;
$data = file_get_contents($file);
return $data === '' ? array() : unserialize($data);
}
// The CSRF token of the current session, from a page
protected function csrfToken(string $page): string {
$this->assertMatchesRegularExpression('/name="csrf_token" value="([0-9a-f]{64})"/', $page);
preg_match('/name="csrf_token" value="([0-9a-f]{64})"/', $page, $match);
return $match[1];
}
/*
* Send a request. Returns array('status' => code, 'headers' => lower-case
* name => list of values, 'body' => ...). Cookies go and come back as a
* browser sends them, without regard to domain or the secure flag.
*/
protected function request(string $method, string $path, array $post = array(), array $headers = array()): array {
if ($this->cookies) {
$headers['Cookie'] = implode('; ', array_map(function ($name, $value) { return "$name=" . rawurlencode($value); },
array_keys($this->cookies), $this->cookies));
}
$options = array('method' => $method, 'follow_location' => 0, 'ignore_errors' => TRUE, 'timeout' => 30);
if ($method === 'POST') {
$options['content'] = http_build_query($post);
$headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
$options['header'] = implode("\r\n", array_map(function ($name, $value) { return "$name: $value"; },
array_keys($headers), $headers));
$body = file_get_contents(self::$base . $path, FALSE, stream_context_create(array('http' => $options)));
$this->assertNotFalse($body, "No answer for $method $path");
$lines = http_get_last_response_headers();
preg_match('#^HTTP/\S+ (\d+)#', array_shift($lines), $match);
$response = array('status' => (int)$match[1], 'headers' => array(), 'body' => $body);
foreach ($lines as $line) {
list($name, $value) = explode(':', $line, 2);
$response['headers'][strtolower($name)][] = trim($value);
}
foreach ($response['headers']['set-cookie'] ?? array() as $cookie) {
$parts = explode(';', $cookie);
list($name, $value) = explode('=', array_shift($parts), 2);
$expired = FALSE;
foreach ($parts as $part) {
if (preg_match('/^\s*expires=(.*)$/i', $part, $match) && strtotime($match[1]) < time())
$expired = TRUE;
}
if ($expired || $value === '' || $value === 'deleted')
unset($this->cookies[$name]);
else
$this->cookies[$name] = rawurldecode($value);
}
return $response;
}
protected function get(string $path, array $headers = array()): array {
return $this->request('GET', $path, array(), $headers);
}
protected function post(string $path, array $post, array $headers = array()): array {
return $this->request('POST', $path, $post, $headers);
}
protected function header(array $response, string $name): ?string {
return $response['headers'][strtolower($name)][0] ?? NULL;
}
protected function assertRedirect(string $location, array $response): void {
$this->assertSame(302, $response['status'], $response['body']);
$this->assertSame($location, $this->header($response, 'Location'));
}
}