From dadd3b466314b11b579155248266c260ff422fca Mon Sep 17 00:00:00 2001 From: Jansen Price Date: Sun, 6 Sep 2020 02:32:56 -0500 Subject: [PATCH] Refactor slightly; use strict types and add tests --- src/Orbit/Cert.php | 46 +++- src/Orbit/Config.php | 30 ++- src/Orbit/Console.php | 65 +++++- src/Orbit/Module.php | 31 +++ src/Orbit/Module/Statics.php | 152 +++++++++++++ src/Orbit/Request.php | 24 ++- src/Orbit/Response.php | 99 +++++++-- src/Orbit/Server.php | 353 +++++++++++++++---------------- src/Orbit/Util.php | 41 ++++ tests/phpunit.xml | 22 ++ tests/src/Orbit/CertTest.php | 188 ++++++++++++++++ tests/src/Orbit/ConfigTest.php | 99 +++++++++ tests/src/Orbit/ConsoleTest.php | 164 ++++++++++++++ tests/src/Orbit/RequestTest.php | 42 ++++ tests/src/Orbit/ResponseTest.php | 137 ++++++++++++ tests/src/Orbit/ServerTest.php | 280 ++++++++++++++++++++++++ tests/src/Orbit/UtilTest.php | 48 +++++ 17 files changed, 1589 insertions(+), 232 deletions(-) create mode 100644 src/Orbit/Module.php create mode 100644 src/Orbit/Module/Statics.php create mode 100644 src/Orbit/Util.php create mode 100644 tests/phpunit.xml create mode 100644 tests/src/Orbit/CertTest.php create mode 100644 tests/src/Orbit/ConfigTest.php create mode 100644 tests/src/Orbit/ConsoleTest.php create mode 100644 tests/src/Orbit/RequestTest.php create mode 100644 tests/src/Orbit/ResponseTest.php create mode 100644 tests/src/Orbit/ServerTest.php create mode 100644 tests/src/Orbit/UtilTest.php diff --git a/src/Orbit/Cert.php b/src/Orbit/Cert.php index 68ae6b7..af86fd7 100644 --- a/src/Orbit/Cert.php +++ b/src/Orbit/Cert.php @@ -1,9 +1,14 @@ -hostname = $config->hostname; $this->tls_certfile = $config->tls_certfile; $this->tls_keyfile = $config->tls_keyfile; $this->key_passphrase = $config->key_passphrase; - $this->logger = $logger; + if ($logger !== null) { + $this->logger = $logger; + } else { + $this->logger = new Logger('orbit-cert'); + } if ($config->getIsDevelopmentServer()) { $this->initDevelopment(); @@ -34,7 +50,7 @@ class Cert * * @return bool */ - public function initDevelopment() + public function initDevelopment(): bool { $this->logger->debug("Initialize cert for development mode."); @@ -71,7 +87,7 @@ class Cert * * @return bool */ - public function initProduction() + public function initProduction(): bool { $this->logger->debug("Initialize cert for production mode."); @@ -99,10 +115,16 @@ class Cert $this->logger->debug(sprintf("Using cert file '%s'", $this->tls_certfile)); $this->logger->debug(sprintf("Using key file '%s'", $this->tls_keyfile)); + return true; } - private function generateCert() + /** + * Generate a self-signed cert + * + * @return void + */ + private function generateCert(): void { // Certificate data $dn = [ @@ -157,7 +179,7 @@ class Cert * @param array $san_domains * @return string Filename */ - private function createOpenSslConf($san_domains = []) + private function createOpenSslConf(array $san_domains = []): string { $san_domains_string = implode(",", $san_domains); @@ -187,7 +209,15 @@ EOS; return $temp_filename; } - private function ensureDirExists($filename) + /** + * Ensure directory for a given filename exists + * + * Will recursively create parent directories if they don't exist + * + * @param string $filename + * @return void + */ + private function ensureDirExists(string $filename): void { $dir = dirname($filename); diff --git a/src/Orbit/Config.php b/src/Orbit/Config.php index 2ce3122..8f1a4c6 100644 --- a/src/Orbit/Config.php +++ b/src/Orbit/Config.php @@ -1,7 +1,12 @@ -setIsDevelopmentServer($is_development); + $this->setIsDevelopmentServer($is_development_server); } - public function setIsDevelopmentServer($is_development_server) + public function setIsDevelopmentServer($is_development_server): void { $this->is_development_server = (bool) $is_development_server; - return $this; } - public function getIsDevelopmentServer() + public function getIsDevelopmentServer(): bool { return $this->is_development_server; } - public function readFromIniFile($filename) + /** + * Read config values from ini file + * + * @param string $filename Path to ini file + * @return void + */ + public function readFromIniFile(string $filename) { if (!file_exists($filename) || !is_readable($filename)) { throw new \Exception("Cannot read config file '$filename'"); diff --git a/src/Orbit/Console.php b/src/Orbit/Console.php index 38096b9..1bf677a 100644 --- a/src/Orbit/Console.php +++ b/src/Orbit/Console.php @@ -1,4 +1,4 @@ -_args->get('no-color')) { $this->_terminal->setIsatty(false); @@ -25,7 +38,9 @@ class Console extends \Qi_Console_Client } $config = $this->makeConfig(); - $logger = $this->makeLogger($config, $this->_args->quiet); + if (!$logger) { + $logger = $this->makeLogger($config, $this->_args->quiet); + } if (!$this->_args->quiet) { print "Orbit // Gemini server software\n"; @@ -34,10 +49,20 @@ class Console extends \Qi_Console_Client $cert = new Cert($config, $logger); $server = new Server($config, $cert, $logger); - $server->listen(); + + if (!self::$under_test) { + $server->listen(); + } + + return 0; } - public function makeConfig() + /** + * Make Config object + * + * @return Config + */ + public function makeConfig(): Config { $config = new Config($this->_args->dev); @@ -80,7 +105,14 @@ class Console extends \Qi_Console_Client return $config; } - public function makeLogger($config, $is_quiet = false) + /** + * Make Logger object + * + * @param Config $config + * @param bool $is_quiet + * @return Logger + */ + public function makeLogger(Config $config, $is_quiet = false): Logger { $logger = new Logger('orbit'); @@ -102,7 +134,12 @@ class Console extends \Qi_Console_Client return $logger; } - public function makeLogFormatter($is_tty = false) + /** + * Make a LineFormatter object + * + * @return LineFormatter + */ + private function makeLogFormatter($is_tty = false): LineFormatter { $pid = getmypid(); @@ -118,12 +155,22 @@ class Console extends \Qi_Console_Client return new LineFormatter($output, 'Y-m-d\TH:i:s'); } - public function showVersion() + /** + * Print the version of orbit server + * + * @return void + */ + public function showVersion(): void { print "Orbit " . Server::$version . "\n"; } - public function showHelp() + /** + * Print the help message + * + * @return void + */ + public function showHelp(): void { $this->showVersion(); diff --git a/src/Orbit/Module.php b/src/Orbit/Module.php new file mode 100644 index 0000000..46e49fb --- /dev/null +++ b/src/Orbit/Module.php @@ -0,0 +1,31 @@ +response handler can do + * + * @package Orbit + */ +class Module +{ + protected $config; + protected $logger; + + /** + * Constructor + * + * @param Config $config + * @param Logger $logger + * @return void + */ + public function __construct(Config $config, Logger $logger) + { + $this->config = $config; + $this->logger = $logger; + } +} diff --git a/src/Orbit/Module/Statics.php b/src/Orbit/Module/Statics.php new file mode 100644 index 0000000..fbb75fc --- /dev/null +++ b/src/Orbit/Module/Statics.php @@ -0,0 +1,152 @@ +path; + + // Check if within the server root + // Realpath will translate any '..' in the path + $realpath = realpath($resource_path); + if ($realpath && strpos($realpath, $real_root_dir) !== 0) { + $response->setStatus(Response::STATUS_PERMANENT_FAILURE); + $response->setMeta("Invalid location"); + return [true, $response]; + } + + if (is_dir($resource_path)) { + // If missing the final slash, issue a redirect + if ($resource_path[-1] != "/") { + $response->setStatus(Response::STATUS_REDIRECT_PERMANENT); + $response->setMeta($request->getUrlAppendPath('/')); + return [true, $response]; + } + + // Check if index file exists + if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) { + $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file; + } else { + if (!$this->config->enable_directory_index) { + $response->setStatus(Response::STATUS_BAD_REQUEST); + $response->setMeta('Path not available'); + return [true, $response]; + } else { + $response->setStatus(Response::STATUS_SUCCESS); + $response->setMeta('text/gemini'); + $response->setBody($this->makeDirectoryIndex($resource_path, $real_root_dir)); + return [true, $response]; + } + } + } + + // File exists and is world readable + if (file_exists($resource_path) && self::isWorldReadble($resource_path)) { + $response->setStatus(Response::STATUS_SUCCESS); + + $pathinfo = pathinfo($resource_path); + + // TODO : handle files without extensions + if (!isset($pathinfo['extension'])) { + $response->setStatus(Response::STATUS_TEMPORARY_FAILURE); + $response->setMeta('Error reading resource'); + return [true, $response]; + } + + $meta = $this->getCustomMimeFromFileExtension($pathinfo['extension']); + if (!$meta) { + $meta = mime_content_type($resource_path); + } + $response->setMeta($meta); + $response->setStaticFile($resource_path); + } else { + $response->setStatus(Response::STATUS_NOT_FOUND); + $response->setMeta('Not found!'); + } + + return [true, $response]; + } + + /** + * Get mime type from file extension for custom types + * + * @param string $extension + * @return string + */ + public function getCustomMimeFromFileExtension($extension): string + { + switch ($extension) { + case 'gmi': + case 'gemini': + return 'text/gemini'; + break; + case 'md': + case 'markdown': + return 'text/gemini'; + break; + case 'ans': + case 'ansi': + return 'text/x-ansi'; + break; + default: + return ''; + } + } + + /** + * Make a directory index suitable as response content + * + * @param string $path Current path + * @param string $root Root path on disk of the server + * @return string + */ + public function makeDirectoryIndex($path, $root): string + { + $files = glob($path . "*"); + + $body = "# Directory listing " . str_replace($root, '', $path) . "\n\n"; + $body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n"; + + foreach ($files as $file) { + $relative_path = str_replace($path, '', $file); + + $is_dir = false; + if (is_dir($file)) { + $is_dir = true; + $size = ''; + } else { + $size = filesize($file); + } + + $body .= sprintf( + "=> %s%s %s%s%s\n", + urlencode($relative_path), + ($is_dir ? '/' : ''), + $relative_path, + ($is_dir ? '/' : ''), + ($size ? " ($size)" : '') + ); + } + + return $body; + } +} diff --git a/src/Orbit/Request.php b/src/Orbit/Request.php index acf86b5..1670fb0 100644 --- a/src/Orbit/Request.php +++ b/src/Orbit/Request.php @@ -1,7 +1,12 @@ -url = trim($request_input); $data = parse_url($this->url); foreach ($data as $key => $value) { - $this->{$key} = urldecode($value); + $this->{$key} = urldecode((string) $value); } // If scheme is missing, infer as default scheme @@ -29,7 +40,12 @@ class Request } } - public function getUrlAppendPath($text) + /** + * Get a new URL with some text appended to the path + * + * @return string New URL + */ + public function getUrlAppendPath(string $text): string { return $this->scheme . '://' . ($this->user ? $this->user : '') diff --git a/src/Orbit/Response.php b/src/Orbit/Response.php index 3814db2..0415f32 100644 --- a/src/Orbit/Response.php +++ b/src/Orbit/Response.php @@ -1,7 +1,15 @@ -status = $status; $this->meta = $meta; } - public function getHeader() + /** + * Get a header to be sent to client + * + * @return string + */ + public function getHeader(): string { return sprintf("%s %s\r\n", $this->status, $this->meta); } - public function send($client) + /** + * Send data to client + * + * @param resource $client + * @return int|false Number of bytes written + */ + public function send($client): int { + if (!is_resource($client)) { + throw new \Exception("Invalid resource to write to"); + } $result = fwrite($client, $this->getHeader()); if (!$result) { - return false; + throw new \Exception("Failed to write to client"); } if ($this->filepath) { - $size = filesize($this->filepath); - $fp = fopen($this->filepath, "rb"); - if (false === $fp) { + if (is_dir($this->filepath)) { + throw new \Exception("Cannot serve directory '{$this->filepath}'"); + } + + if (!file_exists($this->filepath)) { throw new \Exception("Error reading file '{$this->filepath}'"); } + $fp = fopen($this->filepath, "rb"); + + if (false === $fp) { + throw new \Exception("Cannot read file '{$this->filepath}'"); + } + + $size = filesize($this->filepath); + $result = 1; while (!feof($fp) && $result) { // If the client cancels, bail out before trying large files @@ -69,35 +108,63 @@ class Response } } - public function setBody($body = '') + /** + * Set response body + * + * @param string $body + * @return void + */ + public function setBody(string $body = ''): void { $this->body = $body; - return $this; } - public function getBody() + /** + * Get response body + * + * @return string + */ + public function getBody(): string { if ($this->filepath) { $this->body = file_get_contents($this->filepath); } + return $this->body; } - public function setStaticFile($filepath) + /** + * Set static file + * + * Indicates this response should consist of a static file on disk + * + * @param mixed $filepath + * @return void + */ + public function setStaticFile(string $filepath): void { $this->filepath = $filepath; - return $this; } - public function setStatus($status) + /** + * Set response status + * + * @param int $status + * @return void + */ + public function setStatus($status): void { $this->status = $status; - return $this; } - public function setMeta($meta) + /** + * Set response meta value + * + * @param string $meta + * @return void + */ + public function setMeta($meta): void { $this->meta = $meta; - return $this; } } diff --git a/src/Orbit/Server.php b/src/Orbit/Server.php index 78d0da1..869782d 100644 --- a/src/Orbit/Server.php +++ b/src/Orbit/Server.php @@ -1,4 +1,4 @@ -config = new Config(); - } else { - $this->config = $config; - } + $this->config = $config; if ($logger !== null) { $this->setLogger($logger); } else { - $this->getLogger(); - } - - if ($cert == null) { - $this->cert = new Cert($config, $this->getLogger()); - } else { - $this->cert = $cert; - } - - $this->timeout = ini_get("default_socket_timeout"); - - $this->ssl_context = $this->createSslContext(); - } - - public function setLogger(Logger $logger) - { - $this->logger = $logger; - } - - public function getLogger() - { - if (!$this->logger) { $this->logger = new Logger('orbit'); $this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO)); } + if ($cert == null) { + $this->cert = new Cert($this->config, $this->getLogger()); + } else { + $this->cert = $cert; + } + + $this->timeout = (int) ini_get("default_socket_timeout"); + + $this->ssl_context = $this->createSslContext(); + } + + /** + * Set logger object + * + * @param Logger $logger + */ + public function setLogger(Logger $logger): void + { + $this->logger = $logger; + } + + /** + * Get logger + * + * @return Logger + */ + public function getLogger(): Logger + { return $this->logger; } - public function listen($root_dir = null) + /** + * Listen and handle incoming requests + * + * @param mixed $root_dir The root directory from which to serve files + * @return void + */ + public function listen($root_dir = null): void { if (null == $root_dir) { $root_dir = $this->config->root_dir; @@ -78,7 +102,8 @@ class Server $server = stream_socket_server( $this->getListenAddress(), - $errno, $errstr, + $errno, + $errstr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN, $this->ssl_context ); @@ -115,16 +140,28 @@ class Server } } - public function intakeConnections($server) + /** + * Intake connections + * + * @param resource $server + * @return bool + */ + public function intakeConnections($server): bool { + if (!is_resource($server)) { + throw new \Exception("Stream server must be resource object"); + } + set_error_handler([$this, 'onWarning']); # onWarning is added here to swallow up the `timeout` warning $client = stream_socket_accept($server, $this->timeout, $peer); restore_error_handler(); if (!$client) { + # If the listener times out, it will run this and then start + # listening again $this->pruneExpiredConnections(); - return; + return false; } $time = ['start' => microtime(true)]; @@ -133,9 +170,17 @@ class Server $this->logger->debug("$peer Accepted", $meta); $this->connections[$peer] = $client; $this->metas[$peer] = ["input" => "", "time" => $time]; + + return true; } - public function handlePeerConnection($client) + /** + * Handle a single peer connection (client request) + * + * @param resource $client + * @return void + */ + public function handlePeerConnection($client): void { $peer = stream_socket_get_name($client, true); @@ -149,7 +194,7 @@ class Server || strpos($this->metas[$peer]['input'], "\r\n") !== false ) { $request_buffer = $this->metas[$peer]['input']; - //print($this->hexView($request_buffer)); + //print(Util::hexView($request_buffer)); //print("Length: " . mb_strlen($request_buffer) . "\n"); $this->logger->info("REQ: $request_buffer", ['client' => $peer]); @@ -157,15 +202,20 @@ class Server // Respond to client $response = $this->handleResponse($request); - $size = $response->send($client); - if ($size !== false) { - $time_end = microtime(true); - $this->logger->debug( - "RSP: " . trim($response->getHeader()), - ['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']] - ); + $size = 0; + + try { + $size = $response->send($client); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); } + $time_end = microtime(true); + $this->logger->debug( + "RSP: " . trim($response->getHeader()), + ['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']] + ); + fclose($client); $this->logger->debug("$peer Closed"); unset($this->connections[$peer]); @@ -173,79 +223,47 @@ class Server } } - public function handleResponse($request) + /** + * Handle creating a response and sending it to a client + * + * @param Request $request + * @return Response + */ + public function handleResponse(Request $request): Response { - list($is_valid, $response) = $this->validateRequest($request); - + [$is_valid, $response] = $this->validateRequest($request); if ($is_valid === false) { + // If not valid, just return the error response object created by + // the validator return $response; } - $resource_path = rtrim($this->real_root_dir, "/") . $request->path; - - // Check if within the server root - // Realpath will translate any '..' in the path - $realpath = realpath($resource_path); - if ($realpath && strpos($realpath, $this->real_root_dir) !== 0) { - $response->setStatus(Response::STATUS_PERMANENT_FAILURE); - $response->setMeta("Invalid location"); - return $response; - } - - if (is_dir($resource_path)) { - // If missing the final slash, issue a redirect - if ($resource_path[-1] != "/") { - $response->setStatus(Response::STATUS_REDIRECT_PERMANENT); - $response->setMeta($request->getUrlAppendPath('/')); - return $response; - } - - // Check if index file exists - if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) { - $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file; - } else { - if (!$this->config->enable_directory_index) { - $response->setStatus(Response::STATUS_BAD_REQUEST); - $response->setMeta('Path not available'); - return $response; - } else { - $response->setStatus(Response::STATUS_SUCCESS); - $response->setMeta('text/gemini'); - $response->setBody($this->makeDirectoryIndex($resource_path, $dir)); - return $response; - } - } - } - - // File exists and is world readable - if (file_exists($resource_path) && (fileperms($resource_path) & 0x0004)) { - $response->setStatus(Response::STATUS_SUCCESS); - - $pathinfo = pathinfo($resource_path); - if (!isset($pathinfo['extension'])) { - $response->setStatus(Response::STATUS_TEMPORARY_FAILURE); - $response->setMeta('Error reading resource'); - return $response; - } - - if ($pathinfo['extension'] == 'gmi' || $pathinfo['extension'] == 'gemini') { - $response->setMeta('text/gemini'); - } elseif ($pathinfo['extension'] == 'md' || $pathinfo['extension'] == 'markdown') { - $response->setMeta('text/gemini'); - } else { - $response->setMeta(mime_content_type($resource_path)); - } - - $response->setStaticFile($resource_path); - } else { - $response->setStatus(Response::STATUS_NOT_FOUND); - $response->setMeta('Not found!'); - } + $statics_module = new Statics($this->config, $this->logger); + [$success, $response] = $statics_module->handleResponse($request, $response, $this->real_root_dir); return $response; } - public function pruneExpiredConnections() + /** + * Report whether a given file is world readable or not + * + * @param string $file The file to check + * @return bool + */ + public static function isWorldReadble(string $file): bool + { + return (bool)(fileperms($file) & self::WORLD_READABLE); + } + + /** + * Prune any lingering connections + * + * Remove any connections that are still 'open' but have been open past a + * certain threshold timeout + * + * @return void + */ + public function pruneExpiredConnections(): void { $now = microtime(true); foreach ($this->metas as $peer => $meta) { @@ -260,7 +278,16 @@ class Server } } - public function validateRequest($request) + /** + * Validate a request + * + * Will return an tuple (array with two values). The first value is whether + * it passed the validation logic. The second value is the Response object. + * + * @param Request $request + * @return array [bool, Response] + */ + public function validateRequest(Request $request): array { $response = new Response(); @@ -279,7 +306,9 @@ class Server } // Valid URL must use targeted hostname - if ($request->host != "127.0.0.1" && $request->host != "localhost" && $request->host != $this->config->hostname) { + if ($request->host != "127.0.0.1" && $request->host != "localhost" + && $request->host != $this->config->hostname + ) { $response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED); $response->setMeta("Proxy error - invalid host"); return [false, $response]; @@ -310,59 +339,44 @@ class Server return [true, $response]; } - public function log($level, $message, $context = []) + /** + * Special error handler + * + * For warnings that occur when attempting to start server stream + * + * @param int $errno + * @param string $errstr + * @return bool + */ + public function onWarning($errno, $errstr): bool { - $this->getLogger()->log($level, $message, $context); - } - - public function onWarning($id, $message) - { - if (strpos($message, "timed out") !== false) { + if (strpos($errstr, "timed out") !== false) { // Do nothing - return; + return true; } // Something else happened. - $error = sprintf("Error while accepting connection %s: %s", $id, $message); - $this->logger->error($error); + $message = sprintf("Error while accepting connection %s: %s", $errno, $errstr); + $this->logger->error($message); + + return true; } - public function getListenAddress() + /** + * Get the listen address for the stream_server + * + * @return string + */ + public function getListenAddress(): string { return sprintf('tls://%s:%s', $this->config->host, $this->config->port); } - public function makeDirectoryIndex($path, $root) - { - $files = glob($path . "*"); - - $body = "# Directory listing " . str_replace($root, '', $path) . "\n\n"; - $body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n"; - - foreach ($files as $file) { - $relative_path = str_replace($path, '', $file); - - $is_dir = false; - if (is_dir($file)) { - $is_dir = true; - $size = ''; - } else { - $size = filesize($file); - } - - $body .= sprintf( - "=> %s%s %s%s%s\n", - urlencode($relative_path), - ($is_dir ? '/' : ''), - $relative_path, - ($is_dir ? '/' : ''), - ($size ? " ($size)" : '') - ); - } - - return $body; - } - + /** + * Create SSL context for stream server + * + * @return resource + */ public function createSslContext() { $context = stream_context_create(); @@ -377,41 +391,4 @@ class Server return $context; } - - /** - * View hex chars of string - * - * Outputs a listing of hexidecimal values in 16 byte rows - * - * @param mixed $text Input text - * @return string - */ - private function hexView($text) - { - $num = 16; - $outStr = ''; - $printableChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - . 'abcdefghijklmnopqrstuvwxyz' - . '0123456789~!@#$%^&*()_+-={}|[]\:";\'<>?,./'; - - $charCount = strlen($text); - for ($i = 0; $i < $charCount; $i += $num) { - $printStr = ''; - for ($j = 0; $j < $num; $j++) { - $char = substr($text, $i+$j, 1); - - $outStr .= sprintf("%02X", ord($char)) . " "; - - if (ord($char) >= 32 && ord($char) < 127) { - $printStr .= $char; - } else { - $printStr .= "."; - } - } - - $outStr .= " | " . $printStr . "\n"; - } - - return $outStr; - } } diff --git a/src/Orbit/Util.php b/src/Orbit/Util.php new file mode 100644 index 0000000..274a6eb --- /dev/null +++ b/src/Orbit/Util.php @@ -0,0 +1,41 @@ += 32 && ord($char) < 127) { + $printStr .= $char; + } else { + $printStr .= "."; + } + } + + $outStr .= " | " . $printStr . "\n"; + } + + return $outStr; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml new file mode 100644 index 0000000..e2ee11b --- /dev/null +++ b/tests/phpunit.xml @@ -0,0 +1,22 @@ + + + + + ../src + + + + + . + + + diff --git a/tests/src/Orbit/CertTest.php b/tests/src/Orbit/CertTest.php new file mode 100644 index 0000000..dca7712 --- /dev/null +++ b/tests/src/Orbit/CertTest.php @@ -0,0 +1,188 @@ +pushHandler(new \Monolog\Handler\TestHandler()); + + return $logger; + } + + public function getTestLogRecords($logger): array + { + return $logger->getHandlers()[0]->getRecords(); + } + + public function getTestLogMessages($logger): array + { + $messages = []; + foreach ($this->getTestLogRecords($logger) as $message) { + $messages[] = $message['level_name'] . ": " . $message['message']; + } + + return $messages; + } + + public function testConstructDev(): void + { + $config = new Config(true); + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + + $messages = $this->getTestLogMessages($logger); + $this->assertEquals(3, count($messages)); + $this->assertStringContainsString('development mode', $messages[0]); + $this->assertStringContainsString('Generating new cert file', $messages[1]); + $this->assertStringContainsString('Generating new key file', $messages[2]); + } + + public function testConstructDevExistingCertFile(): void + { + $config = new Config(true); + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('development mode', $messages[0]); + $this->assertStringContainsString('Generating new cert file', $messages[1]); + $this->assertStringContainsString('Generating new key file', $messages[2]); + + $logger = $this->makeTestLogger(); + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('Using existing cert file', $messages[1]); + $this->assertStringContainsString('Using existing key file', $messages[2]); + } + + public function testConstructDevOverwriteCertWarning(): void + { + $config = new Config(true); + $logger = $this->makeTestLogger(); + + @mkdir('certs'); + file_put_contents('certs/localhost.cert.pem', '####'); + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('May overwrite existing cert file', $messages[3]); + } + + public function testConstructDevSetCertFile(): void + { + $config = new Config(true); + $config->tls_certfile = 'certs/subd/wahoo.cert.pem'; + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('certs/subd/wahoo.cert.pem', $messages[1]); + @unlink("certs/subd/wahoo.cert.pem"); + } + + public function testConstructDevSetKeyFile(): void + { + $config = new Config(true); + $config->tls_keyfile = 'certs/wahoo.key.pem'; + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('certs/wahoo.key.pem', $messages[2]); + @unlink("certs/wahoo.key.pem"); + } + + public function testConstructDevFilesUsingHostname(): void + { + $config = new Config(true); + $config->hostname = 'particle'; + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('certs/particle.cert.pem', $messages[1]); + $this->assertStringContainsString('certs/particle.key.pem', $messages[2]); + @unlink("certs/particle.cert.pem"); + @unlink("certs/particle.key.pem"); + } + + public function testConstructDevOverwriteKeyWarning(): void + { + $config = new Config(true); + $logger = $this->makeTestLogger(); + + @mkdir('certs'); + file_put_contents('certs/localhost.key.pem', '####'); + $cert = new Cert($config, $logger); + $this->assertInstanceOf(Cert::class, $cert); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('May overwrite existing key file', $messages[3]); + } + + public function testConstructProd(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Missing required cert file"); + + $config = new Config(); + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + } + + public function testConstructProdDefinedButMissingFiles(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("does not exist or is not readable"); + + $config = new Config(); + $config->tls_certfile = 'localhost.cert.pem'; + $config->tls_keyfile = 'localhost.key.pem'; + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + } + + public function testConstructProdDefinedFiles(): void + { + // first run in development mode to auto-generate certs + $config = new Config(true); + $logger = $this->makeTestLogger(); + $cert = new Cert($config, $logger); + + // now run in prod mode to use those cert files + $config = new Config(); + $config->tls_certfile = 'certs/localhost.cert.pem'; + $config->tls_keyfile = 'certs/localhost.key.pem'; + $logger = $this->makeTestLogger(); + + $cert = new Cert($config, $logger); + + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('Using cert file', $messages[1]); + $this->assertStringContainsString('Using key file', $messages[2]); + } + + public function tearDown(): void + { + @unlink("certs/localhost.cert.pem"); + @unlink("certs/localhost.key.pem"); + @rmdir("certs/subd"); + } +} diff --git a/tests/src/Orbit/ConfigTest.php b/tests/src/Orbit/ConfigTest.php new file mode 100644 index 0000000..4492c07 --- /dev/null +++ b/tests/src/Orbit/ConfigTest.php @@ -0,0 +1,99 @@ +assertInstanceOf(Config::class, $config); + } + + public function testConstructDev(): void + { + $config = new Config(true); + $this->assertInstanceOf(Config::class, $config); + $this->assertTrue($config->getIsDevelopmentServer()); + } + + public function testConstructDevFromTruthyValue(): void + { + $config = new Config("yes"); + $this->assertInstanceOf(Config::class, $config); + $this->assertTrue($config->getIsDevelopmentServer()); + } + + public function testReadFromIniFile(): void + { + $data = "hostname=fabricate.dev"; + file_put_contents('test.ini', $data); + + $config = new Config(); + $config->readFromIniFile('test.ini'); + $this->assertSame('fabricate.dev', $config->hostname); + + @unlink('test.ini'); + } + + public function testReadFromIniNonFile(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Cannot read config file'); + + $config = new Config(); + $config->readFromIniFile('nonfile.ini'); + } + + public function testReadFromIniAllPossibleKeys(): void + { + $data = <<readFromIniFile('test.ini'); + $this->assertSame('1.1.1.1', $config->host); + $this->assertSame('1988', $config->port); + $this->assertSame('beatles.org', $config->hostname); + $this->assertSame('1212', $config->tls_certfile); + $this->assertSame('3434', $config->tls_keyfile); + $this->assertSame('strawberry', $config->keypassphrase); + $this->assertSame('xyz.log', $config->log_file); + $this->assertSame('cherry', $config->log_level); + $this->assertSame('blueberry', $config->root_dir); + $this->assertSame('led.gmi', $config->index_file); + $this->assertSame('0', $config->enable_directory_index); + + @unlink('test.ini'); + } + + public function testReadFromIniWithInvalidKey(): void + { + $data = <<readFromIniFile('test.ini'); + $this->assertFalse(property_exists($config, 'invalid_key')); + + @unlink('test.ini'); + } +} diff --git a/tests/src/Orbit/ConsoleTest.php b/tests/src/Orbit/ConsoleTest.php new file mode 100644 index 0000000..9944aa6 --- /dev/null +++ b/tests/src/Orbit/ConsoleTest.php @@ -0,0 +1,164 @@ +pushHandler(new \Monolog\Handler\TestHandler()); + + return $logger; + } + + public function getTestLogRecords($logger): array + { + return $logger->getHandlers()[0]->getRecords(); + } + + public function getTestLogMessages($logger): array + { + $messages = []; + foreach ($this->getTestLogRecords($logger) as $message) { + $messages[] = $message['level_name'] . ": " . $message['message']; + } + + return $messages; + } + + public function makeConsole($argv = []): Console + { + $args = new \Qi_Console_ArgV( + $argv, + [ + 'config|c:' => 'Use specified config file (.ini) for configuration', + 'host:' => 'Set host/ip address to listen on (default 0.0.0.0)', + 'port|p:' => 'Set port to listen on (default 1965)', + 'hostname:' => 'Set hostname of server (default localhost)', + 'tls-cert:' => 'Set cert PEM file to use (default null)', + 'tls-key:' => 'Set private key PEM file to use (default null)', + 'root-dir:' => 'Set the file root directory', + 'log:' => 'Set log filename (default orbit.log)', + 'dev' => 'Allow developer server functions (default false)', + 'help|h' => 'Show help', + 'verbose|v' => 'Include more verbose output', + 'quiet|q' => 'Print less messages', + 'no-color' => 'Don\'t use color output', + 'version' => 'Show version and exit', + ] + ); + $terminal = new \Qi_Console_Terminal(); + return new Console($args, $terminal); + } + + public function testConstruct(): void + { + $args = new \Qi_Console_ArgV([]); + $terminal = new \Qi_Console_Terminal(); + $console = new Console($args, $terminal); + $this->assertInstanceOf(Console::class, $console); + } + + public function testExecuteNocolor(): void + { + $console = $this->makeConsole(['p', '--no-color', '--dev']); + $logger = $this->makeTestLogger(); + ob_start(); + $status = $console->execute($logger); + $output = ob_get_contents(); + ob_get_clean(); + + $this->assertStringContainsString('Orbit // Gemini', $output); + } + + public function testExecuteVersion(): void + { + $console = $this->makeConsole(['p', '--version', '--dev']); + $logger = $this->makeTestLogger(); + ob_start(); + $status = $console->execute($logger); + $output = ob_get_contents(); + ob_get_clean(); + + $this->assertStringContainsString('Orbit 0.4', $output); + } + + public function testExecuteHelp(): void + { + $console = $this->makeConsole(['p', '--help', '--dev']); + $logger = $this->makeTestLogger(); + ob_start(); + $status = $console->execute($logger); + $output = ob_get_contents(); + ob_get_clean(); + + $this->assertStringContainsString('Orbit 0.4', $output); + } + + public function testMakeConfigWithConfig(): void + { + $data = 'host=rainbow.muffin'; + file_put_contents('test.ini', $data); + + $console = $this->makeConsole(['p', '--config', 'test.ini', '--dev']); + $config = $console->makeConfig(); + $this->assertSame('rainbow.muffin', $config->host); + @unlink('test.ini'); + } + + public function testMakeConfigSetValues(): void + { + $args = [ + 'p', '--host=a', '--port=b', '--hostname=c', '--log=d', + '--verbose', '--root-dir=e', '--tls-cert=f', '--tls-key=g' + ]; + $console = $this->makeConsole($args); + $config = $console->makeConfig(); + $this->assertSame('a', $config->host); + $this->assertSame('b', $config->port); + $this->assertSame('c', $config->hostname); + $this->assertSame('d', $config->log_file); + $this->assertsame(100, $config->log_level); + $this->assertSame('e', $config->root_dir); + $this->assertSame('f', $config->tls_certfile); + $this->assertSame('g', $config->tls_keyfile); + } + + public function testMakeLogger(): void + { + $config = new Config('dev'); + $console = $this->makeConsole([]); + $logger = $console->makeLogger($config); + $this->assertInstanceOf(Logger::class, $logger); + // There should be two handlers attached + $this->assertEquals(2, count($logger->getHandlers())); + } + + public function testMakeLoggerQuiet(): void + { + $config = new Config('dev'); + $console = $this->makeConsole([]); + $logger = $console->makeLogger($config, true); + $this->assertInstanceOf(Logger::class, $logger); + // There should be one handler attached + $this->assertEquals(1, count($logger->getHandlers())); + } + + public function tearDown(): void + { + @unlink('certs/localhost.cert.pem'); + @unlink('certs/localhost.key.pem'); + } +} diff --git a/tests/src/Orbit/RequestTest.php b/tests/src/Orbit/RequestTest.php new file mode 100644 index 0000000..ec77b75 --- /dev/null +++ b/tests/src/Orbit/RequestTest.php @@ -0,0 +1,42 @@ +assertInstanceOf(Request::class, $request); + $this->assertSame('foo', $request->url); + $this->assertSame('foo', $request->path); + } + + public function testConstructFullUrl(): void + { + $url = 'https://bob:haha@null.com:7000/x/y/z.bar?q=2&b=3#ok'; + $request = new Request($url); + $this->assertInstanceOf(Request::class, $request); + $this->assertSame($url, $request->url); + $this->assertSame('https', $request->scheme); + $this->assertSame('bob', $request->user); + $this->assertSame('haha', $request->pass); + $this->assertSame('null.com', $request->host); + $this->assertSame('7000', $request->port); + $this->assertSame('/x/y/z.bar', $request->path); + $this->assertSame('q=2&b=3', $request->query); + $this->assertSame('ok', $request->fragment); + } + + public function testGetUrlAppendPath(): void + { + $url = 'gemini://null.com/sub/?real=1'; + $request = new Request($url); + $updated = $request->getUrlAppendPath('addition'); + $this->assertSame('gemini://null.com/sub/addition?real=1', $updated); + } +} diff --git a/tests/src/Orbit/ResponseTest.php b/tests/src/Orbit/ResponseTest.php new file mode 100644 index 0000000..59a3f96 --- /dev/null +++ b/tests/src/Orbit/ResponseTest.php @@ -0,0 +1,137 @@ +assertInstanceOf(Response::class, $response); + } + + public function testGetHeader(): void + { + $response = new Response("20", "text/plain"); + $this->assertSame("20 text/plain\r\n", $response->getHeader()); + } + + public function testSendWithBody(): void + { + $response = new Response("20", "text/plain"); + $response->body = 'xyz'; + + $fp = fopen("php://memory", "w"); + $result = $response->send($fp); + + $this->assertSame(3, $result); + } + + public function testSendWithFile(): void + { + file_put_contents('test.txt', 'hiho'); + $response = new Response("20", "text/plain"); + $response->filepath = 'test.txt'; + + $fp = fopen("php://memory", "w"); + $result = $response->send($fp); + + $this->assertSame(4, $result); + @unlink('test.txt'); + } + + public function testSendResourceBrokenPipe(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Failed to write to client"); + + $response = new Response("20", "text/plain"); + $response->body = "123"; + + // Cannot write to this read-only stream resource + $fp = fopen("php://memory", "r"); + $result = $response->send($fp); + } + + public function testSendInvalidResource(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Invalid resource to write to"); + + $response = new Response("20", "text/plain"); + $response->body = "123"; + + $fp = null; + $result = $response->send($fp); + } + + public function testSendWithFileNoExists(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Error reading file"); + + $response = new Response("20", "text/plain"); + $response->filepath = 'nofile.txt'; + + $fp = fopen("php://memory", "w"); + $result = $response->send($fp); + + $this->assertSame(4, $result); + } + + public function testSendWithDirectory(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Cannot serve directory"); + + $dir = "test-aloof"; + mkdir($dir); + $response = new Response("20", "text/plain"); + $response->filepath = $dir; + + $fp = fopen("php://memory", "w"); + $result = $response->send($fp); + @rmdir($dir); + } + + public function testSetBody(): void + { + $response = new Response("20", "text/plain"); + $response->setBody('olive juice'); + + $body = $response->getBody(); + $this->assertEquals('olive juice', $body); + } + + public function testGetBodyWithFile(): void + { + file_put_contents('elem.gmi', '# Hi there'); + $response = new Response("20", "text/plain"); + $response->setStaticFile('elem.gmi'); + + $body = $response->getBody(); + $this->assertEquals('# Hi there', $body); + + unlink('elem.gmi'); + } + + public function testSetStatusSetMeta(): void + { + $response = new Response(); + $response->setStatus(20); + $response->setMeta('text/gemini'); + + $header = $response->getHeader(); + $this->assertEquals("20 text/gemini\r\n", $header); + + } + + public function tearDown(): void + { + @rmdir('test-aloof'); + } +} diff --git a/tests/src/Orbit/ServerTest.php b/tests/src/Orbit/ServerTest.php new file mode 100644 index 0000000..fdca51e --- /dev/null +++ b/tests/src/Orbit/ServerTest.php @@ -0,0 +1,280 @@ +pushHandler(new \Monolog\Handler\TestHandler()); + + return $logger; + } + + public function getTestLogRecords($logger): array + { + return $logger->getHandlers()[0]->getRecords(); + } + + public function getTestLogMessages($logger): array + { + $messages = []; + foreach ($this->getTestLogRecords($logger) as $message) { + $messages[] = $message['level_name'] . ": " . $message['message']; + } + + return $messages; + } + + public function testConstructFailsWithoutConfig(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Missing required cert file"); + + $config = new Config(); + $server = new Server($config); + } + + public function testConstructAutoCert(): void + { + $config = new Config(true); + $server = new Server($config); + $this->assertInstanceOf(Server::class, $server); + $this->assertEquals(1, count($server->getLogger()->getHandlers())); + } + + public function testConstructSetLogger(): void + { + $config = new Config(true); + $logger = new Logger('orbit-test'); + $server = new Server($config, null, $logger); + + $l = $server->getLogger(); + $this->assertSame($l, $logger); + $this->assertEquals(0, count($l->getHandlers())); + } + + public function testConstructSetCert(): void + { + $config = new Config(true); + $cert = new Cert($config); + $server = new Server($config, $cert); + + $this->assertSame($cert, $server->cert); + } + + public function testConstructSetsTimeout(): void + { + ini_set("default_socket_timeout", "32"); + + $server = new Server(new Config(true)); + $this->assertEquals(32, $server->timeout); + } + + public function testIntakeConnectionsMustBeResource(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Stream server must be resource object"); + + $server = new Server(new Config(true)); + + $s = new \stdClass(); + $result = $server->intakeConnections($s); + } + + public function testValidateRequestMustContainHost(): void + { + $request = new Request(''); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertFalse($is_valid); + $this->assertSame(Response::STATUS_BAD_REQUEST, $response->status); + $this->assertStringContainsString("Bad request", $response->meta); + } + + public function testValidateRequestMustHaveCorrectScheme(): void + { + $request = new Request('https://foo.bar/'); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertFalse($is_valid); + $this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status); + $this->assertStringContainsString("unsupported scheme", $response->meta); + } + + public function testValidateRequestMustHaveCorrectHost(): void + { + $request = new Request('gemini://superfly.com/'); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertFalse($is_valid); + $this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status); + $this->assertStringContainsString("invalid host", $response->meta); + } + + public function testValidateRequestMustHaveCorrectPort(): void + { + $request = new Request('gemini://localhost:8080/'); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertFalse($is_valid); + $this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status); + $this->assertStringContainsString("invalid port", $response->meta); + } + + public function testValidateRequestCanIncludePort(): void + { + $request = new Request('gemini://localhost:1965/'); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertTrue($is_valid); + $this->assertSame('', $response->status); + } + + public function testValidateRequestMustNotBeMoreThanLimit(): void + { + $url = 'gemini://localhost/'; + $request = new Request($url . str_repeat("x", 1025 - strlen($url))); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertFalse($is_valid); + $this->assertSame(Response::STATUS_BAD_REQUEST, $response->status); + $this->assertStringContainsString("too long", $response->meta); + } + + public function testValidateRequestCanBeAtLimit(): void + { + $url = 'gemini://localhost/'; + $request = new Request($url . str_repeat("x", 1024 - strlen($url))); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertTrue($is_valid); + $this->assertSame('', $response->status); + } + + public function testValidateRequestMustNotContainNonUtf8Bytes(): void + { + $request = new Request('gemini://localhost/' . chr(240) . chr(159) . chr(144) . chr(152)); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertFalse($is_valid); + $this->assertSame(Response::STATUS_BAD_REQUEST, $response->status); + $this->assertStringContainsString("non-UTF8", $response->meta); + } + + public function testValidateRequestLocalhostIsValid(): void + { + $request = new Request('gemini://localhost/'); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertTrue($is_valid); + $this->assertSame('', $response->status); + } + + public function testValidateRequestLoopbackAddrIsValid(): void + { + $request = new Request('gemini://127.0.0.1/'); + + $server = new Server(new Config(true)); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertTrue($is_valid); + $this->assertSame('', $response->status); + } + + public function testValidateRequestMatchesConfigHostname(): void + { + $request = new Request('gemini://dogdays.dev/'); + + $config = new Config(true); + $config->hostname = 'dogdays.dev'; + $server = new Server($config); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertTrue($is_valid); + $this->assertSame('', $response->status); + @unlink("certs/dogdays.dev.cert.pem"); + @unlink("certs/dogdays.dev.key.pem"); + } + + public function testValidateRequestMatchesConfigPort(): void + { + $request = new Request('gemini://localhost:1972/'); + + $config = new Config(true); + $config->port = '1972'; + $server = new Server($config); + [$is_valid, $response] = $server->validateRequest($request); + + $this->assertTrue($is_valid); + $this->assertSame('', $response->status); + } + + public function testOnWarningTimedOut(): void + { + $logger = $this->makeTestLogger(); + $server = new Server(new Config(true), null, $logger); + + $result = $server->onWarning(1, 'Connection timed out'); + $this->assertTrue($result); + $messages = $this->getTestLogMessages($logger); + $this->assertNotContains('Error while accepting', $messages); + } + + public function testOnWarningError(): void + { + $logger = $this->makeTestLogger(); + $server = new Server(new Config(true), null, $logger); + + $result = $server->onWarning(1, 'Something terrible happened'); + $this->assertTrue($result); + $messages = $this->getTestLogMessages($logger); + $this->assertStringContainsString('Error while accepting connection', $messages[3]); + } + + public function testGetListenAddress(): void + { + $config = new Config(true); + $config->host = '127.0.0.1'; + $config->port = 1888; + $server = new Server($config); + + $address = $server->getListenAddress(); + + $this->assertSame('tls://127.0.0.1:1888', $address); + } + + public function tearDown(): void + { + @unlink("certs/localhost.cert.pem"); + @unlink("certs/localhost.key.pem"); + } +} diff --git a/tests/src/Orbit/UtilTest.php b/tests/src/Orbit/UtilTest.php new file mode 100644 index 0000000..5843ebf --- /dev/null +++ b/tests/src/Orbit/UtilTest.php @@ -0,0 +1,48 @@ +assertStringContainsString("61 61", $result); + $this->assertStringContainsString("aa", $result); + } + + public function testHexViewSpecialChars(): void + { + $result = Util::hexView("√√ç∂¥˙˚ˆø©ååœ"); + + $this->assertStringContainsString("E2 88 9A E2 88 9A C3 A7 E2 88 82 C2 A5 CB 99 CB", $result); + $this->assertStringContainsString("9A CB 86 C3 B8 C2 A9 C3 A5 C3 A5 C5 93", $result); + } + + public function testHexViewNonPrintableChars(): void + { + $result = Util::hexView(chr(1) . chr(2) . chr(3) . chr(27)); + + $this->assertStringContainsString("01 02 03 1B", $result); + } + + public function testHexViewMultibyteChars(): void + { + $result = Util::hexView("0א0ڃ"); + + $this->assertStringContainsString("30 D7 90 30 DA 83", $result); + $this->assertStringContainsString("0..0", $result); + } + + public function testHexViewEmoji(): void + { + $result = Util::hexView("🍪"); + + $this->assertStringContainsString("F0 9F 8D AA", $result); + } +}