Same-site redirects are now relative paths, and a saved page on another site is ignored. The HTTP to HTTPS redirect takes its host from the new SITE_URL setting, and without it the pages refuse plain HTTP.
210 lines
8.9 KiB
PHP
210 lines
8.9 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;
|
|
|
|
// Environment variables for the server, on top of the usual ones
|
|
protected static function serverEnv(): array {
|
|
return array();
|
|
}
|
|
|
|
// 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',
|
|
) + static::serverEnv();
|
|
$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'));
|
|
}
|
|
}
|