Add PHP unit tests and page tests.
Run "composer install", then "composer test".
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
/*
|
||||
* A stand-in for mysqli. open_db() makes one when the global $db_class
|
||||
* names this class. All connections share the static state below, so a
|
||||
* test sees the queries of every connection that the code opened.
|
||||
*
|
||||
* Handlers answer queries: FakeDb::on('/pattern/', $answer), where the
|
||||
* answer is a list of rows, TRUE, FALSE (a failed query) or a function
|
||||
* that takes ($sql, $params) and returns one of these. The handler added
|
||||
* last wins. A query that no handler matches gets no rows (SELECT) or
|
||||
* TRUE (anything else).
|
||||
*/
|
||||
class FakeDb {
|
||||
public static array $log = array();
|
||||
public static array $handlers = array();
|
||||
public static array $connections = array();
|
||||
public static ?string $connectError = NULL;
|
||||
// When set, every query is also appended to this file as a JSON line
|
||||
public static ?string $logFile = NULL;
|
||||
|
||||
public $connect_error = NULL;
|
||||
public $connect_errno = 0;
|
||||
public $error = '';
|
||||
public bool $closed = FALSE;
|
||||
|
||||
public static function reset(): void {
|
||||
self::$log = array();
|
||||
self::$handlers = array();
|
||||
self::$connections = array();
|
||||
self::$connectError = NULL;
|
||||
self::$logFile = NULL;
|
||||
}
|
||||
|
||||
public static function on(string $pattern, $answer): void {
|
||||
self::$handlers[] = array($pattern, $answer);
|
||||
}
|
||||
|
||||
// The SQL of every query so far
|
||||
public static function queries(): array {
|
||||
return array_column(self::$log, 'sql');
|
||||
}
|
||||
|
||||
// The queries whose SQL matches $pattern, each as array('sql' => ..., 'params' => ...)
|
||||
public static function matching(string $pattern): array {
|
||||
return array_values(array_filter(self::$log, function ($entry) use ($pattern) {
|
||||
return preg_match($pattern, $entry['sql']);
|
||||
}));
|
||||
}
|
||||
|
||||
public function __construct(...$args) {
|
||||
self::$connections[] = $args;
|
||||
if (self::$connectError !== NULL) {
|
||||
$this->connect_errno = 1045;
|
||||
$this->connect_error = self::$connectError;
|
||||
}
|
||||
}
|
||||
|
||||
public function real_escape_string($string) {
|
||||
return addslashes((string)$string);
|
||||
}
|
||||
|
||||
public function execute_query($sql, $params = array()) {
|
||||
if (substr_count($sql, '?') !== count($params))
|
||||
throw new \LogicException("Placeholder count does not match the parameters: $sql");
|
||||
return $this->run($sql, array_values($params));
|
||||
}
|
||||
|
||||
public function query($sql) {
|
||||
return $this->run($sql, array());
|
||||
}
|
||||
|
||||
public function close() {
|
||||
$this->closed = TRUE;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
private function run(string $sql, array $params) {
|
||||
$entry = array('sql' => $sql, 'params' => $params);
|
||||
self::$log[] = $entry;
|
||||
if (self::$logFile !== NULL)
|
||||
file_put_contents(self::$logFile, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
|
||||
|
||||
$answer = NULL;
|
||||
foreach (array_reverse(self::$handlers) as list($pattern, $handler)) {
|
||||
if (preg_match($pattern, $sql)) {
|
||||
$answer = is_callable($handler) ? $handler($sql, $params) : $handler;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($answer === NULL)
|
||||
$answer = preg_match('/^\s*SELECT/i', $sql) ? array() : TRUE;
|
||||
if ($answer === FALSE) {
|
||||
$this->error = 'Fake failure for: ' . $sql;
|
||||
return FALSE;
|
||||
}
|
||||
return is_array($answer) ? new FakeResult($answer) : $answer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
/*
|
||||
* A stream wrapper that takes the place of https:// while it is
|
||||
* installed, so that captcha_verify_response() talks to this class
|
||||
* instead of Google. It records each request (the URL, the HTTP context
|
||||
* options and the decoded POST body) and returns $reply: a string, FALSE
|
||||
* for "server not reachable", or a Closure that gets the POST fields and
|
||||
* returns one of these.
|
||||
*/
|
||||
class FakeRecaptcha {
|
||||
public static array $requests = array();
|
||||
public static $reply = '{"success": true}';
|
||||
private static bool $active = FALSE;
|
||||
|
||||
public $context;
|
||||
private string $data = '';
|
||||
private int $position = 0;
|
||||
|
||||
public static function install($reply = '{"success": true}'): void {
|
||||
self::$requests = array();
|
||||
self::$reply = $reply;
|
||||
if (!self::$active) {
|
||||
stream_wrapper_unregister('https');
|
||||
stream_wrapper_register('https', self::class);
|
||||
self::$active = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
public static function uninstall(): void {
|
||||
if (self::$active) {
|
||||
stream_wrapper_restore('https');
|
||||
self::$active = FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path) {
|
||||
$context = $this->context ? stream_context_get_options($this->context) : array();
|
||||
$http = $context['http'] ?? array();
|
||||
parse_str((string)($http['content'] ?? ''), $post);
|
||||
self::$requests[] = array('url' => $path, 'http' => $http, 'post' => $post);
|
||||
$reply = self::$reply instanceof \Closure ? (self::$reply)($post) : self::$reply;
|
||||
if ($reply === FALSE)
|
||||
return FALSE;
|
||||
$this->data = (string)$reply;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
public function stream_read($count) {
|
||||
$chunk = substr($this->data, $this->position, $count);
|
||||
$this->position += strlen($chunk);
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
public function stream_eof() {
|
||||
return $this->position >= strlen($this->data);
|
||||
}
|
||||
|
||||
public function stream_stat() {
|
||||
return array('size' => strlen($this->data));
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2) {
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
// The result of a FakeDb query
|
||||
class FakeResult {
|
||||
private array $rows;
|
||||
private int $next = 0;
|
||||
public int $num_rows;
|
||||
|
||||
public function __construct(array $rows) {
|
||||
$this->rows = array_values($rows);
|
||||
$this->num_rows = count($this->rows);
|
||||
}
|
||||
|
||||
public function fetch_assoc() {
|
||||
return $this->rows[$this->next++] ?? NULL;
|
||||
}
|
||||
|
||||
public function fetch_row() {
|
||||
$row = $this->fetch_assoc();
|
||||
return $row === NULL ? NULL : array_values($row);
|
||||
}
|
||||
|
||||
public function close() {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
/*
|
||||
* Mail sent by code in a subprocess. PHP reads sendmail_path only at
|
||||
* start-up, so code that sends mail runs in a new PHP process with
|
||||
* tests/php/fixtures/fake-sendmail.php as its sendmail.
|
||||
*/
|
||||
class Mail {
|
||||
/*
|
||||
* PhpProcess::run() with the fake sendmail. The result has an extra
|
||||
* 'mails' entry: a list of array('headers' => name => value,
|
||||
* 'subject' => ..., 'body' => ...), decoded.
|
||||
*/
|
||||
public static function run(string $code, array $env = array(), array $ini = array()): array {
|
||||
$log = tempnam(sys_get_temp_dir(), 'xxx-mail-');
|
||||
$sendmail = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(XXX_ROOT . '/tests/php/fixtures/fake-sendmail.php');
|
||||
$result = PhpProcess::run($code, array('MAIL_LOG' => $log) + $env, array('sendmail_path' => $sendmail) + $ini);
|
||||
$result['mails'] = array();
|
||||
foreach (file($log, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line)
|
||||
$result['mails'][] = self::parse(json_decode($line, TRUE)['message']);
|
||||
unlink($log);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function parse(string $message): array {
|
||||
list($head, $body) = preg_split('/\r?\n\r?\n/', $message, 2) + array('', '');
|
||||
$headers = array();
|
||||
foreach (preg_split('/\r?\n(?![ \t])/', $head) as $line) {
|
||||
list($name, $value) = explode(':', $line, 2) + array('', '');
|
||||
$headers[strtolower(trim($name))] = trim(preg_replace('/\r?\n[ \t]+/', ' ', $value));
|
||||
}
|
||||
$encoding = strtolower($headers['content-transfer-encoding'] ?? '');
|
||||
if ($encoding === 'base64')
|
||||
$body = base64_decode($body);
|
||||
else if ($encoding === 'quoted-printable')
|
||||
$body = quoted_printable_decode($body);
|
||||
return array('headers' => $headers, 'subject' => mb_decode_mimeheader($headers['subject'] ?? ''),
|
||||
'body' => str_replace("\r\n", "\n", $body));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
/*
|
||||
* Run PHP code in a new process, after tests/php/bootstrap.php. This is
|
||||
* for code that ends the script (die, exit) or that needs php.ini
|
||||
* settings which cannot change at run time, such as sendmail_path.
|
||||
*/
|
||||
class PhpProcess {
|
||||
/*
|
||||
* Returns array('status' => exit code, 'stdout' => ..., 'stderr' => ...,
|
||||
* 'log' => what the code wrote to the PHP error log).
|
||||
*/
|
||||
public static function run(string $code, array $env = array(), array $ini = array()): array {
|
||||
// One line, so that line numbers in $code stay the same; the code runs
|
||||
// in publish/, as the unit tests do
|
||||
return self::runPlain("require " . var_export(XXX_ROOT . '/tests/php/bootstrap.php', TRUE) . "; chdir(XXX_ROOT . '/publish');\n" . $code, $env, $ini);
|
||||
}
|
||||
|
||||
// The same with only the test environment, for code that includes a site library itself
|
||||
public static function runBare(string $code, array $env = array(), array $ini = array()): array {
|
||||
return self::runPlain("require " . var_export(XXX_ROOT . '/tests/php/environment.php', TRUE) . ";\n" . $code, $env, $ini);
|
||||
}
|
||||
|
||||
// The same without the bootstrap: the code loads what it needs itself
|
||||
public static function runPlain(string $code, array $env = array(), array $ini = array()): array {
|
||||
$script = tempnam(sys_get_temp_dir(), 'xxx-php-');
|
||||
$log = $script . '.log';
|
||||
file_put_contents($script, "<?php\n" . $code);
|
||||
$command = array(PHP_BINARY, '-d', 'display_errors=stderr', '-d', 'error_reporting=-1', '-d', "error_log=$log");
|
||||
foreach ($ini as $name => $value)
|
||||
array_push($command, '-d', "$name=$value");
|
||||
$command[] = $script;
|
||||
$env = array_merge(getenv(), array('XXX_TEST_LOG' => $log), $env);
|
||||
$process = proc_open($command, array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes, XXX_ROOT, $env);
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$status = proc_close($process);
|
||||
$logged = is_file($log) ? file_get_contents($log) : '';
|
||||
@unlink($script);
|
||||
@unlink($log);
|
||||
return array('status' => $status, 'stdout' => $stdout, 'stderr' => $stderr, 'log' => $logged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
/*
|
||||
* In-memory copies of the site tables (xxx, queue, editors and
|
||||
* persistent_sessions), with FakeDb handlers for the queries that the
|
||||
* code sends. It knows only those queries, not SQL in general.
|
||||
*
|
||||
* Story rows have the columns id, title, author, email, date, story,
|
||||
* hidden, img, mime, comments and editor.
|
||||
*/
|
||||
class SiteData {
|
||||
public static array $tables = array();
|
||||
public static int $lastInsertId = 0;
|
||||
// The size of editors.password; NULL means that the column keeps any hash
|
||||
public static ?int $passwordLength = NULL;
|
||||
|
||||
public static function install(array $tables = array()): void {
|
||||
self::$tables = $tables + array('xxx' => array(), 'queue' => array(), 'editors' => array(), 'persistent_sessions' => array());
|
||||
foreach (array('xxx', 'queue') as $table)
|
||||
self::$tables[$table] = array_map(array(self::class, 'story'), self::$tables[$table]);
|
||||
self::$lastInsertId = 0;
|
||||
|
||||
$stories = '(xxx|queue)';
|
||||
FakeDb::on("/^SELECT \\* FROM $stories\\b/", array(self::class, 'selectStories'));
|
||||
FakeDb::on("/^SELECT COUNT\\(\\*\\) FROM $stories\\b/", function ($sql, $params) {
|
||||
return array(array('COUNT(*)' => (string)count(self::filterStories($sql, $params))));
|
||||
});
|
||||
FakeDb::on('/^SELECT MAX\(`date`\) FROM xxx/', function () {
|
||||
$dates = array_column(self::$tables['xxx'], 'date');
|
||||
return array(array('MAX(`date`)' => $dates ? max($dates) : NULL));
|
||||
});
|
||||
FakeDb::on("/^SELECT img, mime FROM $stories\\b/i", function ($sql, $params) {
|
||||
$rows = self::filterStories($sql, $params);
|
||||
return array_map(function ($row) { return array('img' => $row['img'], 'mime' => $row['mime']); }, array_slice($rows, 0, 1));
|
||||
});
|
||||
FakeDb::on("/^INSERT INTO $stories SET /", array(self::class, 'insertStory'));
|
||||
FakeDb::on("/^UPDATE $stories SET /", array(self::class, 'updateStory'));
|
||||
FakeDb::on("/^DELETE FROM $stories WHERE `id` = \\? LIMIT 1/", function ($sql, $params) {
|
||||
$table = self::tableOf($sql);
|
||||
self::$tables[$table] = array_values(array_filter(self::$tables[$table], function ($row) use ($params) {
|
||||
return (int)$row['id'] !== (int)$params[0];
|
||||
}));
|
||||
return TRUE;
|
||||
});
|
||||
FakeDb::on('/^SELECT LAST_INSERT_ID\(\)/', function () {
|
||||
return array(array('LAST_INSERT_ID()' => (string)self::$lastInsertId));
|
||||
});
|
||||
|
||||
FakeDb::on('/^SELECT (\*|username|password) FROM editors WHERE username = \?/', function ($sql, $params) {
|
||||
return array_slice(array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
|
||||
return $row['username'] === $params[0];
|
||||
})), 0, 1);
|
||||
});
|
||||
FakeDb::on('/^UPDATE editors SET password = \? WHERE username = \?/', function ($sql, $params) {
|
||||
foreach (self::$tables['editors'] as &$row) {
|
||||
if ($row['username'] === $params[1])
|
||||
$row['password'] = self::cut($params[0]);
|
||||
}
|
||||
return TRUE;
|
||||
});
|
||||
FakeDb::on('/^INSERT INTO editors SET `username` = \?, `password` = \?, `email` = \?/', function ($sql, $params) {
|
||||
self::$tables['editors'][] = array('username' => $params[0], 'password' => self::cut($params[1]), 'email' => $params[2]);
|
||||
return TRUE;
|
||||
});
|
||||
FakeDb::on('/^DELETE FROM editors WHERE username = \?/', function ($sql, $params) {
|
||||
self::$tables['editors'] = array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
|
||||
return $row['username'] !== $params[0];
|
||||
}));
|
||||
return TRUE;
|
||||
});
|
||||
|
||||
FakeDb::on('/^SELECT \* FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
|
||||
return array_slice(array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
|
||||
return $row['username'] === $params[0] && $row['session'] === $params[1];
|
||||
})), 0, 1);
|
||||
});
|
||||
FakeDb::on('/^INSERT INTO persistent_sessions \(username, session\) VALUES \(\?, \?\)/', function ($sql, $params) {
|
||||
self::$tables['persistent_sessions'][] = array('username' => $params[0], 'session' => $params[1], 'created' => gmdate('Y-m-d H:i:s'));
|
||||
return TRUE;
|
||||
});
|
||||
FakeDb::on('/^DELETE FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
|
||||
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
|
||||
return !($row['username'] === $params[0] && $row['session'] === $params[1]);
|
||||
}));
|
||||
return TRUE;
|
||||
});
|
||||
FakeDb::on('/^DELETE FROM persistent_sessions\s+WHERE created < DATE_SUB/', function () {
|
||||
$limit = gmdate('Y-m-d H:i:s', strtotime('-1 year'));
|
||||
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($limit) {
|
||||
return strlen($row['session']) === 64 && ($row['created'] ?? $limit) >= $limit;
|
||||
}));
|
||||
return TRUE;
|
||||
});
|
||||
}
|
||||
|
||||
// A story row with every column, from the columns given
|
||||
public static function story(array $row): array {
|
||||
static $next = 1;
|
||||
$row += array('id' => $next, 'title' => 'Title', 'author' => 'Author', 'email' => '',
|
||||
'date' => '2020-01-01 10:00:00', 'story' => '<p>Story.</p>', 'hidden' => '0',
|
||||
'img' => NULL, 'mime' => NULL, 'comments' => '', 'editor' => 'original author');
|
||||
$next = max($next, (int)$row['id']) + 1;
|
||||
$row['id'] = (string)$row['id'];
|
||||
return $row;
|
||||
}
|
||||
|
||||
public static function tableOf(string $sql): string {
|
||||
preg_match('/\b(FROM|INTO|UPDATE)\s+`?(xxx|queue)\b/', $sql, $match);
|
||||
return $match[2];
|
||||
}
|
||||
|
||||
// The rows of the story table in $sql that its WHERE clause selects
|
||||
public static function filterStories(string $sql, array $params): array {
|
||||
$rows = self::$tables[self::tableOf($sql)];
|
||||
if (stripos($sql, 'hidden IS NOT TRUE') !== FALSE)
|
||||
$rows = array_filter($rows, function ($row) { return !$row['hidden']; });
|
||||
if (preg_match('/\bid = (\d+)/i', $sql, $match))
|
||||
$rows = array_filter($rows, function ($row) use ($match) { return (int)$row['id'] === (int)$match[1]; });
|
||||
else if (preg_match('/\bid = \?/i', $sql))
|
||||
$rows = array_filter($rows, function ($row) use ($params) { return (int)$row['id'] === (int)$params[0]; });
|
||||
if (preg_match('/title LIKE \?/', $sql)) {
|
||||
$term = strtolower(trim($params[0], '%'));
|
||||
$rows = array_filter($rows, function ($row) use ($term) {
|
||||
foreach (array('title', 'author', 'date', 'story') as $column) {
|
||||
if (str_contains(strtolower((string)$row[$column]), $term))
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
});
|
||||
}
|
||||
return array_values($rows);
|
||||
}
|
||||
|
||||
public static function selectStories(string $sql, array $params): array {
|
||||
$rows = self::filterStories($sql, $params);
|
||||
if (preg_match('/ORDER BY (\w+) (asc|desc)/i', $sql, $match)) {
|
||||
$column = $match[1];
|
||||
usort($rows, function ($a, $b) use ($column) { return strcmp((string)$a[$column], (string)$b[$column]); });
|
||||
if (strtolower($match[2]) === 'desc')
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
if (preg_match('/LIMIT (\d+)\s*,\s*(\d+)/', $sql, $match))
|
||||
$rows = array_slice($rows, (int)$match[1], (int)$match[2]);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// The column => value pairs of "SET `a` = ?, `b` = ?"
|
||||
private static function assignments(string $sql, array $params): array {
|
||||
preg_match_all('/`(\w+)` = \?/', explode(' WHERE ', $sql)[0], $matches);
|
||||
return array_combine($matches[1], array_slice($params, 0, count($matches[1])));
|
||||
}
|
||||
|
||||
public static function insertStory(string $sql, array $params) {
|
||||
$ids = array_merge(array_column(self::$tables['xxx'], 'id'), array_column(self::$tables['queue'], 'id'), array(0));
|
||||
$row = self::story(array('id' => max(array_map('intval', $ids)) + 1) + self::assignments($sql, $params));
|
||||
self::$tables[self::tableOf($sql)][] = $row;
|
||||
self::$lastInsertId = (int)$row['id'];
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
public static function updateStory(string $sql, array $params) {
|
||||
$id = (int)end($params);
|
||||
$table = self::tableOf($sql);
|
||||
foreach (self::$tables[$table] as &$row) {
|
||||
if ((int)$row['id'] === $id)
|
||||
$row = array_merge($row, self::assignments($sql, $params));
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
private static function cut(string $value): string {
|
||||
return self::$passwordLength === NULL ? $value : substr($value, 0, self::$passwordLength);
|
||||
}
|
||||
|
||||
// The rows of a story table, found by id
|
||||
public static function find(string $table, $id): ?array {
|
||||
foreach (self::$tables[$table] as $row) {
|
||||
if ((int)$row['id'] === (int)$id)
|
||||
return $row;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Support;
|
||||
|
||||
/*
|
||||
* The base of the unit tests. Before each test it moves to publish/ (as
|
||||
* the publishing pages run there), empties the request,
|
||||
* the session and the globals that the site code uses, gives the test a
|
||||
* fake database with empty tables and a temporary directory (also the
|
||||
* upload directory), and clears the error log.
|
||||
*/
|
||||
abstract class TestCase extends \PHPUnit\Framework\TestCase {
|
||||
protected string $tmp;
|
||||
private array $envNames = array();
|
||||
private string $cwd;
|
||||
|
||||
protected function setUp(): void {
|
||||
// publish/post.lib.php includes files by paths relative to publish/
|
||||
$this->cwd = getcwd();
|
||||
chdir(XXX_ROOT . '/publish');
|
||||
FakeDb::reset();
|
||||
SiteData::$passwordLength = NULL;
|
||||
SiteData::install();
|
||||
$GLOBALS['db'] = NULL;
|
||||
$GLOBALS['db_class'] = FakeDb::class;
|
||||
|
||||
$_GET = $_POST = $_REQUEST = $_FILES = $_COOKIE = $_SESSION = array();
|
||||
$_SERVER['REMOTE_ADDR'] = '192.0.2.1';
|
||||
$_SERVER['REQUEST_URI'] = '/';
|
||||
unset($_SERVER['HTTPS']);
|
||||
foreach (array('is_admin' => FALSE, 'action' => NULL, 'queue' => NULL, 'id' => NULL,
|
||||
'num_stories' => NULL, 'editable' => NULL, 'username' => NULL,
|
||||
'validate' => array()) as $name => $value)
|
||||
$GLOBALS[$name] = $value;
|
||||
unset($GLOBALS['file_src']);
|
||||
|
||||
$this->tmp = sys_get_temp_dir() . '/xxx-test-' . bin2hex(random_bytes(6));
|
||||
mkdir($this->tmp, 0700);
|
||||
$this->setEnv('UPLOAD_DIR', $this->tmp . '/uploads');
|
||||
file_put_contents(XXX_TEST_LOG, '');
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
FakeRecaptcha::uninstall();
|
||||
foreach ($this->envNames as $name)
|
||||
putenv($name);
|
||||
$this->envNames = array();
|
||||
self::removeTree($this->tmp);
|
||||
chdir($this->cwd);
|
||||
}
|
||||
|
||||
// Set an environment variable (which config() reads first) for this test
|
||||
protected function setEnv(string $name, string $value): void {
|
||||
$this->envNames[] = $name;
|
||||
putenv("$name=$value");
|
||||
}
|
||||
|
||||
protected function request(array $get = array(), array $post = array()): void {
|
||||
$_GET = $get;
|
||||
$_POST = $post;
|
||||
$_REQUEST = array_merge($get, $post);
|
||||
}
|
||||
|
||||
protected function asEditor(string $name = 'ed'): void {
|
||||
$GLOBALS['is_admin'] = TRUE;
|
||||
$GLOBALS['username'] = $name;
|
||||
$_SESSION['editor'] = $name;
|
||||
}
|
||||
|
||||
// What the code wrote to the PHP error log during this test
|
||||
protected function loggedErrors(): string {
|
||||
return (string)file_get_contents(XXX_TEST_LOG);
|
||||
}
|
||||
|
||||
// A 1x1 PNG image
|
||||
public static function png(): string {
|
||||
return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
||||
}
|
||||
|
||||
public static function removeTree(string $path): void {
|
||||
if (is_dir($path) && !is_link($path)) {
|
||||
foreach (scandir($path) as $name) {
|
||||
if ($name !== '.' && $name !== '..')
|
||||
self::removeTree("$path/$name");
|
||||
}
|
||||
rmdir($path);
|
||||
} else if (file_exists($path) || is_link($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user