Refactor slightly; use strict types and add tests

This commit is contained in:
Jansen Price 2020-09-06 02:32:56 -05:00
parent d509bcea17
commit dadd3b4663
17 changed files with 1589 additions and 232 deletions

View File

@ -1,9 +1,14 @@
<?php
<?php declare(strict_types=1);
namespace Orbit;
use Monolog\Logger;
/**
* Cert
*
* @package Orbit
*/
class Cert
{
public $hostname = '';
@ -11,14 +16,25 @@ class Cert
public $tls_keyfile = '';
public $key_passphrase = '';
public function __construct(Config $config, Logger $logger)
/**
* __construct
*
* @param Config $config
* @param Logger $logger
* @return void
*/
public function __construct(Config $config, ?Logger $logger = null)
{
$this->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);

View File

@ -1,7 +1,12 @@
<?php
<?php declare(strict_types=1);
namespace Orbit;
/**
* Config
*
* @package Orbit
*/
class Config
{
public $host = "0.0.0.0";
@ -21,23 +26,34 @@ class Config
private $is_development_server = false;
public function __construct($is_development = false)
/**
* __construct
*
* @param bool $is_development
* @return void
*/
public function __construct($is_development_server = false)
{
$this->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'");

View File

@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);
namespace Orbit;
@ -6,9 +6,22 @@ use Monolog\Logger;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
/**
* Console
*
* @package Orbit
*/
class Console extends \Qi_Console_Client
{
public function execute()
public static $under_test = false;
/**
* Execute the console command
*
* @param Logger $logger
* @return int Status code
*/
public function execute(Logger $logger = null): int
{
if ($this->_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();

31
src/Orbit/Module.php Normal file
View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace Orbit;
use Monolog\Logger;
/**
* Abstract Module
*
* This represents some work that a request->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;
}
}

View File

@ -0,0 +1,152 @@
<?php declare(strict_types=1);
namespace Orbit\Module;
use Orbit\Module;
use Orbit\Request;
use Orbit\Response;
/**
* Static files server module
*
* @uses Module
* @package Orbit
*/
class Statics extends Module
{
/**
* Handle a request and generate a proper response
*
* @param Request $request The request object
* @param Response $response The already created response object
* @param string $real_root_dir The real path to root on disk
*/
public function handleResponse(Request $request, Response $response, $real_root_dir): array
{
$resource_path = rtrim($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, $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;
}
}

View File

@ -1,7 +1,12 @@
<?php
<?php declare(strict_types=1);
namespace Orbit;
/**
* Request
*
* @package Orbit
*/
class Request
{
public $url = '';
@ -14,13 +19,19 @@ class Request
public $query;
public $fragment;
public function __construct($request_input)
/**
* Construct a request object
*
* @param string $request_input Gemini request line (URL)
* @return void
*/
public function __construct(string $request_input)
{
$this->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 : '')

View File

@ -1,7 +1,15 @@
<?php
<?php declare(strict_types=1);
namespace Orbit;
/**
* Response
*
* See the following website for description of the response spec
* https://gemini.circumlunar.space/docs/specification.html
*
* @package Orbit
*/
class Response
{
const STATUS_INPUT = 10;
@ -28,32 +36,63 @@ class Response
public $body = "";
public $filepath;
/**
* Constructor
*
* @param string $status
* @param string $meta
* @return void
*/
public function __construct($status = "", $meta = "")
{
$this->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;
}
}

View File

@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);
namespace Orbit;
@ -6,11 +6,19 @@ use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use ForceUTF8\Encoding;
use Orbit\Module\Statics;
/**
* Server
*
* @package Orbit
*/
class Server
{
const SCHEME = "gemini";
const WORLD_READABLE = 0x0004;
public static $version = "0.3";
public static $version = "0.4";
public $config;
public $cert;
@ -23,47 +31,63 @@ class Server
private $metas = []; // Meta data for each client connection
private $real_root_dir;
public function __construct(Config $config = null, Cert $cert = null, Logger $logger = null)
/**
* Constructor
*
* @param Config $config
* @param Cert $cert
* @param Logger $logger
* @return void
*/
public function __construct(Config $config, ?Cert $cert = null, ?Logger $logger = null)
{
if ($config == null) {
$this->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;
}
}

41
src/Orbit/Util.php Normal file
View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace Orbit;
class Util
{
/**
* View hex chars of string
*
* Outputs a listing of hexidecimal values in 16 byte rows
*
* @param string $text Input text
* @return string
*/
public static function hexView(string $text): string
{
$width = 16;
$outStr = '';
// Specifically not using mb_strlen to get every byte
$charCount = strlen($text);
for ($i = 0; $i < $charCount; $i += $width) {
$printStr = '';
for ($j = 0; $j < $width; $j++) {
$char = (string) 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;
}
}

22
tests/phpunit.xml Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
bootstrap="../vendor/autoload.php"
>
<coverage processUncoveredFiles="true">
<include>
<directory>../src</directory>
</include>
</coverage>
<testsuites>
<testsuite name="Unit Tests">
<directory>.</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -0,0 +1,188 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Orbit\Cert;
use Orbit\Config;
use Monolog\Logger;
final class CertTest extends TestCase
{
public function makeTestLogger(): Logger
{
$logger = new Logger('test-orbit');
$logger->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");
}
}

View File

@ -0,0 +1,99 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Orbit\Config;
final class ConfigTest extends TestCase
{
public function testConstruct(): void
{
$config = new Config();
$this->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 = <<<EOF
host=1.1.1.1
port=1988
hostname=beatles.org
tls_certfile=1212
tls_keyfile=3434
keypassphrase=strawberry
log_file=xyz.log
log_level=cherry
root_dir=blueberry
index_file=led.gmi
enable_directory_index=0
EOF;
file_put_contents('test.ini', $data);
$config = new Config();
$config->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 = <<<EOF
invalid_key=true
EOF;
file_put_contents('test.ini', $data);
$config = new Config();
$config->readFromIniFile('test.ini');
$this->assertFalse(property_exists($config, 'invalid_key'));
@unlink('test.ini');
}
}

View File

@ -0,0 +1,164 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Monolog\Logger;
use Orbit\Console;
use Orbit\Config;
final class ConsoleTest extends TestCase
{
public function setUp(): void
{
Console::$under_test = true;
}
public function makeTestLogger(): Logger
{
$logger = new Logger('test-orbit');
$logger->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');
}
}

View File

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Orbit\Request;
final class RequestTest extends TestCase
{
public function testConstruct(): void
{
$request = new Request('foo');
$this->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);
}
}

View File

@ -0,0 +1,137 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Orbit\Response;
final class ResponseTest extends TestCase
{
public function testConstruct(): void
{
$response = new Response();
$this->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');
}
}

View File

@ -0,0 +1,280 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Orbit\Cert;
use Orbit\Config;
use Orbit\Server;
use Orbit\Request;
use Orbit\Response;
final class ServerTest extends TestCase
{
public function makeTestLogger(): Logger
{
$logger = new Logger('test-orbit');
$logger->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");
}
}

View File

@ -0,0 +1,48 @@
<?php declare(strict_types=1);
namespace Orbit\Tests;
use PHPUnit\Framework\TestCase;
use Orbit\Util;
final class UtilTest extends TestCase
{
public function testHexView(): void
{
$result = Util::hexView("aa");
$this->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);
}
}