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')); } }