A new editor needs a username and a password of at least 12 characters. A persistent login needs an unexpired key and an existing account. Removing an account revokes its persistent logins, and its sessions end.
219 lines
9.4 KiB
PHP
219 lines
9.4 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. The pages
|
|
* log out an editor without an account, so this adds the account to the
|
|
* editors table if it is missing.
|
|
*/
|
|
protected function loginAs(string $username = 'ed', array $data = array()): string {
|
|
$saved = unserialize(file_get_contents(self::$state . '/tables.ser'));
|
|
if (!in_array($username, array_column($saved['tables']['editors'], 'username'), TRUE)) {
|
|
$saved['tables']['editors'][] = array('username' => $username, 'password' => '', 'email' => '');
|
|
file_put_contents(self::$state . '/tables.ser', serialize($saved));
|
|
}
|
|
$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'));
|
|
}
|
|
}
|