Add PHP unit tests and page tests.
Run "composer install", then "composer test".
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user