diff --git a/.env.testing.example b/.env.testing.example
index 62c677a5..a2156cef 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -12,3 +12,4 @@ DEV_LIVE_SERVER_UNIT_TEST_SIGNALS=false
DEV_LIVE_SERVER_UNIT_TEST_USER_EXTEND=UnitTestUser2
DEV_LIVE_SERVER_UNIT_TEST_SERVER_PORT=9987
DEV_LIVE_SERVER_UNIT_TEST_SERVER_QUERY_LOGIN_NAME=ts3-bot-dev
+DEV_LIVE_SERVER_UNIT_TEST_SERVER_HOST_KEY=yourServerFingerPrintFromHostKey
diff --git a/doc/coverage/coverage-badge.svg b/doc/coverage/coverage-badge.svg
index e7746bf0..c2ad907e 100644
--- a/doc/coverage/coverage-badge.svg
+++ b/doc/coverage/coverage-badge.svg
@@ -14,6 +14,6 @@
coverage
- 69%
+ 72%
\ No newline at end of file
diff --git a/src/Adapter/MockServerQuery.php b/src/Adapter/MockServerQuery.php
index 40ebca84..312c5cca 100644
--- a/src/Adapter/MockServerQuery.php
+++ b/src/Adapter/MockServerQuery.php
@@ -6,6 +6,7 @@
use PlanetTeamSpeak\TeamSpeak3Framework\Exception\TransportException;
use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Profiler;
use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Signal;
+use PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper;
use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3;
use PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP;
@@ -27,6 +28,7 @@ protected function syn(): void
Profiler::init(spl_object_hash($this));
$rdy = $this->getTransport()->readLine();
+ $rdy = StringHelper::factory($rdy ?? '');
if (! $rdy->startsWith(TeamSpeak3::TS3_PROTO_IDENT) && ! $rdy->startsWith(TeamSpeak3::TEA_PROTO_IDENT)) {
throw new AdapterException('invalid reply from the server ('.$rdy.')');
@@ -34,4 +36,69 @@ protected function syn(): void
Signal::getInstance()->emit('serverqueryConnected', $this);
}
+
+ /**
+ * Return the underlying MockTCP transport.
+ */
+ public function getMockTransport(): MockTCP
+ {
+ /** @var MockTCP $transport */
+ $transport = $this->getTransport();
+
+ return $transport;
+ }
+
+ /**
+ * Register a custom response on the mock transport.
+ */
+ public function registerResponse(string $cmd, string $reply): self
+ {
+ $this->getMockTransport()->registerResponse($cmd, $reply);
+
+ return $this;
+ }
+
+ /**
+ * Queue a response on the mock transport.
+ */
+ public function queueResponse(string $reply): self
+ {
+ $this->getMockTransport()->queueResponse($reply);
+
+ return $this;
+ }
+
+ /**
+ * Set a custom dynamic command handler.
+ */
+ public function setCommandHandler(callable $handler): self
+ {
+ $this->getMockTransport()->setCommandHandler($handler);
+
+ return $this;
+ }
+
+ /**
+ * Get sent commands.
+ */
+ public function getSentCommands(): array
+ {
+ return $this->getMockTransport()->getSentCommands();
+ }
+
+ /**
+ * Get the last sent command.
+ */
+ public function getLastCommand(): ?string
+ {
+ return $this->getMockTransport()->getLastCommand();
+ }
+
+ /**
+ * Check if a command was sent.
+ */
+ public function hasSentCommand(string $command): bool
+ {
+ return $this->getMockTransport()->hasSentCommand($command);
+ }
}
diff --git a/src/Helper/Char.php b/src/Helper/Char.php
index a96129c0..8caca6f3 100644
--- a/src/Helper/Char.php
+++ b/src/Helper/Char.php
@@ -162,23 +162,55 @@ public function toAscii(): int
*/
public function toUnicode(): int
{
+ $length = strlen($this->char);
$h = ord($this->char[0]);
if ($h <= 0x7F) {
- return $h;
+ return $length === 1 ? $h : -1;
} elseif ($h < 0xC2) {
return -1;
} elseif ($h <= 0xDF) {
+ if ($length !== 2 || ! $this->isContinuationByte(1)) {
+ return -1;
+ }
+
return ($h & 0x1F) << 6 | (ord($this->char[1]) & 0x3F);
} elseif ($h <= 0xEF) {
+ if ($length !== 3 || ! $this->isContinuationByte(1) || ! $this->isContinuationByte(2)) {
+ return -1;
+ }
+
return ($h & 0x0F) << 12 | (ord($this->char[1]) & 0x3F) << 6 | (ord($this->char[2]) & 0x3F);
} elseif ($h <= 0xF4) {
- return ($h & 0x0F) << 18 | (ord($this->char[1]) & 0x3F) << 12 | (ord($this->char[2]) & 0x3F) << 6 | (ord($this->char[3]) & 0x3F);
+ if (
+ $length !== 4
+ || ! $this->isContinuationByte(1)
+ || ! $this->isContinuationByte(2)
+ || ! $this->isContinuationByte(3)
+ ) {
+ return -1;
+ }
+
+ return ($h & 0x07) << 18
+ | (ord($this->char[1]) & 0x3F) << 12
+ | (ord($this->char[2]) & 0x3F) << 6
+ | (ord($this->char[3]) & 0x3F);
} else {
return -1;
}
}
+ private function isContinuationByte(int $offset): bool
+ {
+ if (! isset($this->char[$offset])) {
+ return false;
+ }
+
+ $byte = ord($this->char[$offset]);
+
+ return $byte >= 0x80 && $byte <= 0xBF;
+ }
+
/**
* Returns the hexadecimal value of the char.
*
diff --git a/src/Helper/Signal.php b/src/Helper/Signal.php
index b425a863..4ae74c6e 100644
--- a/src/Helper/Signal.php
+++ b/src/Helper/Signal.php
@@ -68,6 +68,10 @@ public function emit(string $signal, mixed $params = null): mixed
*/
public function getCallbackHash(mixed $callback): string
{
+ if ($callback instanceof \Closure) {
+ return spl_object_hash($callback);
+ }
+
if (! is_callable($callback, true, $callable_name)) {
throw new SignalException('invalid callback specified');
}
diff --git a/src/Helper/Uri.php b/src/Helper/Uri.php
index f19554ba..ec57e350 100644
--- a/src/Helper/Uri.php
+++ b/src/Helper/Uri.php
@@ -479,7 +479,7 @@ public function getQuery(array $default = []): array
return $default;
}
- parse_str(rawurldecode($this->query), $queryArray);
+ parse_str($this->query, $queryArray);
return $queryArray;
}
@@ -514,7 +514,7 @@ public function getQueryVar(string $key, mixed $default = null): mixed
return $default;
}
- parse_str(rawurldecode($this->query), $queryArray);
+ parse_str($this->query, $queryArray);
if (array_key_exists($key, $queryArray)) {
$val = $queryArray[$key];
diff --git a/src/Node/ChannelGroup.php b/src/Node/ChannelGroup.php
index e5cfd564..347b77bf 100644
--- a/src/Node/ChannelGroup.php
+++ b/src/Node/ChannelGroup.php
@@ -154,7 +154,7 @@ public function clientList(int $cid = null, int $cldbid = null, bool $resolve =
*/
public function privilegeKeyCreate(int $cid, string $description = null, string $customset = null): StringHelper
{
- return $this->getParent()->privilegeKeyCreate($this->getId(), TeamSpeak3::TOKEN_CHANNELGROUP, $cid, $description, $customset);
+ return $this->getParent()->privilegeKeyCreate($this->getId(), $cid, TeamSpeak3::TOKEN_CHANNELGROUP, $description, $customset);
}
/**
diff --git a/src/Node/Group.php b/src/Node/Group.php
index ece6a7ed..49e22210 100644
--- a/src/Node/Group.php
+++ b/src/Node/Group.php
@@ -24,12 +24,12 @@ abstract class Group extends Node
public function message(string $msg): void
{
// get all clients in this group
- $clients = $this->getParent()->channelGroupClientList($this->getId());
+ $clients = $this->clientList();
// get client id from dbid and send a textmessage
foreach ($clients as $client) {
try {
- $targetClientID = $this->getParent()->clientgetbydbid($client['cldbid'])->getId();
+ $targetClientID = $this->getParent()->clientGetByDbid($client['cldbid'])->getId();
$this->execute('sendtextmessage', ['msg' => $msg, 'target' => $targetClientID, 'targetmode' => TeamSpeak3::TEXTMSG_CLIENT]);
} catch (ServerQueryException $e) {
/* ERROR_client_invalid_id */
diff --git a/src/Node/Host.php b/src/Node/Host.php
index bda43f09..fc139e8d 100644
--- a/src/Node/Host.php
+++ b/src/Node/Host.php
@@ -76,11 +76,15 @@ public function version(): array
if ($this->version === null) {
$raw = $this->request('version')->toList();
- // Find the first array that contains real data
- foreach ($raw as $item) {
- if (is_array($item) && isset($item['version'])) {
- $this->version = $item;
- break;
+ if (isset($raw['version'])) {
+ $this->version = $raw;
+ } elseif (is_array($raw)) {
+ // Find the first array that contains real data
+ foreach ($raw as $item) {
+ if (is_array($item) && isset($item['version'])) {
+ $this->version = $item;
+ break;
+ }
}
}
@@ -189,7 +193,19 @@ public function serverIdGetByPort(int $port): int
{
$sid = $this->execute('serveridgetbyport', ['virtualserver_port' => $port])->toList();
- return $sid[1]['server_id'];
+ if (isset($sid['server_id'])) {
+ return (int) $sid['server_id'];
+ }
+
+ if (isset($sid[0]['server_id'])) {
+ return (int) $sid[0]['server_id'];
+ }
+
+ if (isset($sid[1]['server_id'])) {
+ return (int) $sid[1]['server_id'];
+ }
+
+ return 0;
}
/**
@@ -757,9 +773,10 @@ public function login(string $username, string $password): void
if ($this->predefined_query_name) {
$clients = $this->request('clientlist -uid')->toList();
+ $clientRows = isset($clients['clid']) ? [$clients] : (is_array($clients) ? $clients : []);
- foreach ($clients as $client) {
- if ($client['client_nickname'] === $this->predefined_query_name) {
+ foreach ($clientRows as $client) {
+ if (is_array($client) && isset($client['client_nickname']) && $client['client_nickname'] === $this->predefined_query_name) {
// Kick old query with same nickname
$this->execute('clientkick', [
'clid' => $client['clid'],
@@ -775,7 +792,11 @@ public function login(string $username, string $password): void
]);
}
- $crypt = new Crypt($username);
+ $cryptKey = substr($username, 0, 56);
+ if ($cryptKey === '') {
+ $cryptKey = 'serveradmin';
+ }
+ $crypt = new Crypt($cryptKey);
$this->setStorage('_login_user', $username);
$this->setStorage('_login_pass', $crypt->encrypt($password));
@@ -889,8 +910,12 @@ public function whoami(): array
// Execute server request
$response = $this->request('whoami')->toList();
- // response[1] contains the actual data
- $data = $response[1] ?? [];
+ // Check if single-row associative array or nested list
+ if (isset($response['client_id']) || isset($response['virtualserver_id']) || isset($response['client_nickname'])) {
+ $data = $response;
+ } else {
+ $data = $response[1] ?? ($response[0] ?? []);
+ }
// Automatically convert StringHelper to strings
foreach ($data as $key => $val) {
diff --git a/src/Node/Node.php b/src/Node/Node.php
index 2bf1d895..0cabdb02 100644
--- a/src/Node/Node.php
+++ b/src/Node/Node.php
@@ -309,7 +309,7 @@ public function getInfo(bool $extend = true, bool $convert = false): array
// Now include all standard commands
if ($this instanceof Server) {
- $standardCommands = ['whoami', 'serverinfo', 'serverinfoserver info'];
+ $standardCommands = ['whoami', 'serverinfo'];
foreach ($standardCommands as $cmd) {
try {
$result = $this->{$cmd}(); // Calls the respective function
diff --git a/src/Node/Server.php b/src/Node/Server.php
index 120949ef..f8dd5892 100644
--- a/src/Node/Server.php
+++ b/src/Node/Server.php
@@ -162,14 +162,19 @@ public function channelCreate(array $properties): int
$cid = null;
- // Go backwards through the lines – the last line usually contains the new channel ID.
- for ($i = count($result) - 1; $i >= 0; $i--) {
- foreach ($result[$i] as $key => $value) {
- if (stripos($key, 'cid') !== false) {
- // Extract only the leading digit
- if (preg_match('/\d+/', $value, $matches)) {
- $cid = (int) $matches[0];
- break 2;
+ if (isset($result['cid'])) {
+ $cid = (int) $result['cid'];
+ } else {
+ $lines = isset($result[0]) ? $result : [$result];
+ for ($i = count($lines) - 1; $i >= 0; $i--) {
+ if (is_array($lines[$i])) {
+ foreach ($lines[$i] as $key => $value) {
+ if (stripos($key, 'cid') !== false) {
+ if (preg_match('/\d+/', (string) $value, $matches)) {
+ $cid = (int) $matches[0];
+ break 2;
+ }
+ }
}
}
}
@@ -793,23 +798,23 @@ public function clientInfoDb(int $cldbid): array
{
$result = $this->execute('clientdbinfo', ['cldbid' => $cldbid])->toList();
- $metaCldbid = null;
- if (isset($result[0]['cldbid'])) {
- $metaCldbid = (int) $result[0]['cldbid'];
- }
+ if (isset($result['client_database_id']) || isset($result['client_unique_identifier'])) {
+ if (! isset($result['cldbid'])) {
+ $result['cldbid'] = $cldbid;
+ }
- $filtered = array_values(array_filter($result, static function ($row) {
- return isset($row['client_database_id']) || isset($row['client_unique_identifier']);
- }));
+ return $result;
+ }
+ $rows = is_array($result) && isset($result[0]) ? $result : [$result];
$flat = [];
- foreach ($filtered as $row) {
- $flat = array_merge($flat, $row);
+ foreach ($rows as $row) {
+ if (is_array($row)) {
+ $flat = array_merge($flat, $row);
+ }
}
- if (! isset($flat['cldbid']) && $metaCldbid !== null) {
- $flat['cldbid'] = $metaCldbid;
- } elseif (! isset($flat['cldbid'])) {
+ if (! isset($flat['cldbid'])) {
$flat['cldbid'] = $cldbid;
}
@@ -1308,13 +1313,19 @@ public function serverGroupCreate(string $name, int $type = TeamSpeak3::GROUP_DB
$sgid = null;
- for ($i = count($result) - 1; $i >= 0; $i--) {
- foreach ($result[$i] as $key => $value) {
- if (stripos($key, 'sgid') !== false) {
- // Extract only the leading digit
- if (preg_match('/\d+/', $value, $matches)) {
- $sgid = (int) $matches[0];
- break 2;
+ if (isset($result['sgid'])) {
+ $sgid = (int) $result['sgid'];
+ } else {
+ $lines = isset($result[0]) ? $result : [$result];
+ for ($i = count($lines) - 1; $i >= 0; $i--) {
+ if (is_array($lines[$i])) {
+ foreach ($lines[$i] as $key => $value) {
+ if (stripos($key, 'sgid') !== false) {
+ if (preg_match('/\d+/', (string) $value, $matches)) {
+ $sgid = (int) $matches[0];
+ break 2;
+ }
+ }
}
}
}
@@ -1352,13 +1363,19 @@ public function serverGroupCopy(int $ssgid, string $name = null, int $tsgid = 0,
// Search for the new sgid in all elements of the result array
$sgid = null;
- for ($i = count($result) - 1; $i >= 0; $i--) {
- foreach ($result[$i] as $key => $value) {
- if (stripos($key, 'sgid') !== false) {
- // Extract only the leading digits
- if (preg_match('/\d+/', $value, $matches)) {
- $sgid = (int) $matches[0];
- break 2;
+ if (isset($result['sgid'])) {
+ $sgid = (int) $result['sgid'];
+ } else {
+ $lines = isset($result[0]) ? $result : [$result];
+ for ($i = count($lines) - 1; $i >= 0; $i--) {
+ if (is_array($lines[$i])) {
+ foreach ($lines[$i] as $key => $value) {
+ if (stripos($key, 'sgid') !== false) {
+ if (preg_match('/\d+/', (string) $value, $matches)) {
+ $sgid = (int) $matches[0];
+ break 2;
+ }
+ }
}
}
}
@@ -1758,13 +1775,19 @@ public function channelGroupCreate(string $name, int $type = TeamSpeak3::GROUP_D
$cgid = null;
- for ($i = count($result) - 1; $i >= 0; $i--) {
- foreach ($result[$i] as $key => $value) {
- if (stripos($key, 'cgid') !== false) {
- // Extract only the leading digit
- if (preg_match('/\d+/', $value, $matches)) {
- $cgid = (int) $matches[0];
- break 2;
+ if (isset($result['cgid'])) {
+ $cgid = (int) $result['cgid'];
+ } else {
+ $lines = isset($result[0]) ? $result : [$result];
+ for ($i = count($lines) - 1; $i >= 0; $i--) {
+ if (is_array($lines[$i])) {
+ foreach ($lines[$i] as $key => $value) {
+ if (stripos($key, 'cgid') !== false) {
+ if (preg_match('/\d+/', (string) $value, $matches)) {
+ $cgid = (int) $matches[0];
+ break 2;
+ }
+ }
}
}
}
@@ -1802,13 +1825,19 @@ public function channelGroupCopy(int $scgid, string $name = null, int $tcgid = 0
// Search for the new scgid in all elements of the result array
$cgid = null;
- for ($i = count($result) - 1; $i >= 0; $i--) {
- foreach ($result[$i] as $key => $value) {
- if (stripos($key, 'scgid') !== false) {
- // Extract only the leading digits
- if (preg_match('/\d+/', $value, $matches)) {
- $cgid = (int) $matches[0];
- break 2;
+ if (isset($result['cgid'])) {
+ $cgid = (int) $result['cgid'];
+ } else {
+ $lines = isset($result[0]) ? $result : [$result];
+ for ($i = count($lines) - 1; $i >= 0; $i--) {
+ if (is_array($lines[$i])) {
+ foreach ($lines[$i] as $key => $value) {
+ if (stripos($key, 'scgid') !== false || stripos($key, 'cgid') !== false) {
+ if (preg_match('/\d+/', (string) $value, $matches)) {
+ $cgid = (int) $matches[0];
+ break 2;
+ }
+ }
}
}
}
@@ -2676,7 +2705,7 @@ public function privilegeKeyCreate(
Signal::getInstance()->emit('notifyTokencreated', $this, $token['token']);
- return $token['token'];
+ return is_string($token['token']) ? StringHelper::factory($token['token']) : $token['token'];
}
/**
@@ -3136,7 +3165,7 @@ public function selfUpdateLogin(string $username): StringHelper
*/
public function selfPermOverview(): array
{
- return $this->execute('permoverview', ['cldbid' => $this->getParent()->whoamiGet('client_database_id'), 'cid' => $this->getParent()()->whoamiGet('client_channel_id'), 'permid' => 0])
+ return $this->execute('permoverview', ['cldbid' => $this->getParent()->whoamiGet('client_database_id'), 'cid' => $this->getParent()->whoamiGet('client_channel_id'), 'permid' => 0])
->toArray();
}
diff --git a/src/Node/ServerGroup.php b/src/Node/ServerGroup.php
index fdee024e..4f29ca3b 100644
--- a/src/Node/ServerGroup.php
+++ b/src/Node/ServerGroup.php
@@ -6,6 +6,7 @@
use PlanetTeamSpeak\TeamSpeak3Framework\Exception\NodeException;
use PlanetTeamSpeak\TeamSpeak3Framework\Exception\ServerQueryException;
use PlanetTeamSpeak\TeamSpeak3Framework\Exception\TransportException;
+use PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper;
use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3;
/**
@@ -178,10 +179,10 @@ public function clientDel(int $cldbid): void
* @throws ServerQueryException
* @throws TransportException
*/
- public function privilegeKeyCreate(string $description = null, string $customset = null): string
+ public function privilegeKeyCreate(string $description = null, string $customset = null): StringHelper
{
return $this->getParent()
- ->privilegeKeyCreate($this->getId(), TeamSpeak3::TOKEN_SERVERGROUP, 0, $description, $customset);
+ ->privilegeKeyCreate($this->getId(), 0, TeamSpeak3::TOKEN_SERVERGROUP, $description, $customset);
}
/**
diff --git a/src/TeamSpeak3.php b/src/TeamSpeak3.php
index f0421da6..4bdf0a95 100644
--- a/src/TeamSpeak3.php
+++ b/src/TeamSpeak3.php
@@ -384,9 +384,9 @@ public static function factory(string $uri): Host|Server|ServerQuery|MockServerQ
'host' => $uri->getHost(),
'port' => $uri->getPort(),
'timeout' => (int) $uri->getQueryVar('timeout', 10),
- 'blocking' => 0,
- 'tls' => 0, // TODO maybe unnecessary?
- 'ssh' => 1,
+ 'blocking' => (int) $uri->getQueryVar('blocking', 0),
+ 'tls' => (int) $uri->getQueryVar('tls', 0),
+ 'ssh' => (int) $uri->getQueryVar('ssh', 1),
];
self::loadClass($adapter);
@@ -394,6 +394,28 @@ public static function factory(string $uri): Host|Server|ServerQuery|MockServerQ
$options['username'] = $uri->getUser();
$options['password'] = $uri->getPass();
+ if ($uri->hasQueryVar('fingerprint')) {
+ $options['fingerprint'] = (string) $uri->getQueryVar('fingerprint');
+ }
+ if ($uri->hasQueryVar('simulate_prompt')) {
+ $options['simulate_prompt'] = (bool) $uri->getQueryVar('simulate_prompt');
+ }
+ if ($uri->hasQueryVar('simulate_ansi')) {
+ $options['simulate_ansi'] = (bool) $uri->getQueryVar('simulate_ansi');
+ }
+ if ($uri->hasQueryVar('fail_connect')) {
+ $options['fail_connect'] = (bool) $uri->getQueryVar('fail_connect');
+ }
+ if ($uri->hasQueryVar('fail_login')) {
+ $options['fail_login'] = (bool) $uri->getQueryVar('fail_login');
+ }
+ if ($uri->hasQueryVar('fail_fingerprint')) {
+ $options['fail_fingerprint'] = (bool) $uri->getQueryVar('fail_fingerprint');
+ }
+ if ($uri->hasQueryVar('simulate_host_key')) {
+ $options['simulate_host_key'] = (string) $uri->getQueryVar('simulate_host_key');
+ }
+
$adapterClass = 'PlanetTeamSpeak\\TeamSpeak3Framework\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $adapter);
$object = new $adapterClass($options);
diff --git a/src/Transport/MockTCP.php b/src/Transport/MockTCP.php
index c1153701..9f2e7fd4 100644
--- a/src/Transport/MockTCP.php
+++ b/src/Transport/MockTCP.php
@@ -2,6 +2,8 @@
namespace PlanetTeamSpeak\TeamSpeak3Framework\Transport;
+use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\Adapter;
+use PlanetTeamSpeak\TeamSpeak3Framework\Adapter\ServerQuery;
use PlanetTeamSpeak\TeamSpeak3Framework\Exception\TransportException;
use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Signal;
use PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper;
@@ -19,12 +21,56 @@ class MockTCP extends TSssh
'login client_login_name=serveradmin client_login_password=secret' => self::S_ERROR_OK,
];
+ /**
+ * Default responses for standard TS3 ServerQuery commands.
+ */
+ protected const DEFAULT_RESPONSES = [
+ 'logout' => self::S_ERROR_OK,
+ 'quit' => self::S_ERROR_OK,
+ 'servernotifyunregister' => self::S_ERROR_OK,
+ 'whoami' => "virtualserver_status=online virtualserver_id=1 virtualserver_unique_identifier=mock_server_uid virtualserver_port=9987 client_id=1 client_channel_id=1 client_nickname=serveradmin client_database_id=1 client_login_name=serveradmin client_unique_identifier=serveradmin_uid client_origin_server_id=0\nerror id=0 msg=ok",
+ 'version' => "version=3.13.7\s[Build:\s1600000000] build=1600000000 platform=Linux\nerror id=0 msg=ok",
+ 'serverinfo' => "virtualserver_id=1 virtualserver_port=9987 virtualserver_name=TeamSpeak\s]Test[\sServer virtualserver_welcomemessage=Welcome\sto\sTeamSpeak virtualserver_platform=Linux virtualserver_version=3.13.7\s[Build:\s1600000000] virtualserver_maxclients=32 virtualserver_clientsonline=2 virtualserver_channelsonline=2 virtualserver_uptime=123456 virtualserver_status=online virtualserver_autostart=1 virtualserver_ask_for_privilegekey=0 virtualserver_unique_identifier=mock_server_uid virtualserver_flag_password=0\nerror id=0 msg=ok",
+ 'serverlist' => "virtualserver_id=1 virtualserver_port=9987 virtualserver_status=online virtualserver_clientsonline=2 virtualserver_queryclientsonline=1 virtualserver_maxclients=32 virtualserver_uptime=123456 virtualserver_name=TeamSpeak\s]Test[\sServer virtualserver_autostart=1 virtualserver_machine_id virtualserver_unique_identifier=mock_server_uid\nerror id=0 msg=ok",
+ 'serverlist -all' => "virtualserver_id=1 virtualserver_port=9987 virtualserver_status=online virtualserver_clientsonline=2 virtualserver_queryclientsonline=1 virtualserver_maxclients=32 virtualserver_uptime=123456 virtualserver_name=TeamSpeak\s]Test[\sServer virtualserver_autostart=1 virtualserver_machine_id virtualserver_unique_identifier=mock_server_uid\nerror id=0 msg=ok",
+ 'servergrouplist' => "sgid=6 name=Server\sAdmin type=1 iconid=300 savedb=1 sortid=0 namemode=0 n_modifyp=75 n_member_addp=75 n_member_removep=75|sgid=7 name=Normal type=1 iconid=0 savedb=1 sortid=0 namemode=0 n_modifyp=75 n_member_addp=75 n_member_removep=75\nerror id=0 msg=ok",
+ 'channelgrouplist' => "cgid=5 name=Channel\sAdmin type=1 iconid=100 savedb=1 sortid=0 namemode=0 n_modifyp=75 n_member_addp=75 n_member_removep=75|cgid=6 name=Operator type=1 iconid=0 savedb=1 sortid=0 namemode=0 n_modifyp=75 n_member_addp=75 n_member_removep=75\nerror id=0 msg=ok",
+ 'instanceinfo' => "serverinstance_database_version=23 serverinstance_filetransfer_port=30033 serverinstance_max_download_total_bandwidth=18446744073709551615 serverinstance_max_upload_total_bandwidth=18446744073709551615 serverinstance_guest_serverquery_group=1 serverinstance_template_serveradmin_group=3 serverinstance_template_serverdefault_group=5 serverinstance_template_channeladmin_group=1 serverinstance_template_channeldefault_group=4 serverinstance_permissions_version=15\nerror id=0 msg=ok",
+ ];
+
protected bool $connected = false;
protected ?string $reply = null;
protected array $buffer = [];
+ protected array $customResponses = [];
+
+ protected array $patternResponses = [];
+
+ protected array $responseQueue = [];
+
+ /** @var callable|null */
+ protected $commandHandler = null;
+
+ protected array $sentCommands = [];
+
+ protected string $transportType = 'ssh';
+
+ protected bool $simulatePrompt = false;
+
+ protected string $promptString = 'ts-bot-dev@9987(1):online> ';
+
+ protected bool $simulateAnsi = false;
+
+ protected bool $failConnect = false;
+
+ protected bool $failLogin = false;
+
+ protected bool $failFingerprint = false;
+
+ protected bool $simulateDropConnection = false;
+
public function __construct(array $config = [])
{
parent::__construct($config);
@@ -33,6 +79,25 @@ public function __construct(array $config = [])
'port' => 10022,
'blocking' => 0,
], $config);
+
+ if (isset($config['transport_type'])) {
+ $this->transportType = (string) $config['transport_type'];
+ }
+ if (isset($config['simulate_prompt'])) {
+ $this->simulatePrompt = (bool) $config['simulate_prompt'];
+ }
+ if (isset($config['simulate_ansi'])) {
+ $this->simulateAnsi = (bool) $config['simulate_ansi'];
+ }
+ if (isset($config['fail_connect'])) {
+ $this->failConnect = (bool) $config['fail_connect'];
+ }
+ if (isset($config['fail_login'])) {
+ $this->failLogin = (bool) $config['fail_login'];
+ }
+ if (isset($config['fail_fingerprint'])) {
+ $this->failFingerprint = (bool) $config['fail_fingerprint'];
+ }
}
public function connect(): void
@@ -41,7 +106,22 @@ public function connect(): void
return;
}
- // Simulated SSH connection
+ if ($this->failConnect) {
+ throw new TransportException("Connection to {$this->config['host']}:{$this->config['port']} failed");
+ }
+
+ if (! empty($this->config['fingerprint'])) {
+ $hostKey = $this->config['simulate_host_key'] ?? 'rsa-sha2-512 AAAAB3NzaC1yc2EAAAADAQABAAABAQC3r7Yh5N1xXj1234567890abcdefghijklmnopqrstuvwxyz';
+ if ($this->failFingerprint || ! empty($this->config['fail_fingerprint'])) {
+ $hostKey = 'rsa-sha2-512 AAAAB3NzaC1yc2EAAAADAQABAAABAAAAA_different_key';
+ }
+ $this->verifyFingerprint($hostKey, (string) $this->config['fingerprint']);
+ }
+
+ if ($this->failLogin) {
+ throw new TransportException('Login failed: incorrect username or password');
+ }
+
$this->connected = true;
$this->reply = sprintf("%s\n%s\n", self::S_WELCOME_L0, self::S_WELCOME_L1);
$this->buffer = explode("\n", trim($this->reply));
@@ -49,7 +129,7 @@ public function connect(): void
public function isConnected(): bool
{
- return $this->connected;
+ return $this->connected && ! $this->simulateDropConnection;
}
public function disconnect(): void
@@ -57,41 +137,57 @@ public function disconnect(): void
$this->connected = false;
$this->reply = null;
$this->buffer = [];
+
+ Signal::getInstance()->emit(strtolower($this->getAdapterType()).'Disconnected', $this);
}
/**
- * Simulated reading of a line (SSH read)
+ * Simulated reading of a line (SSH / TCP read)
* @throws TransportException
*/
- public function readLine(int $timeout = 1, string $token = "\n"): StringHelper
+ public function readLine(int $timeout = 1, string $token = "\n"): ?StringHelper
{
- $this->connect();
- $line = StringHelper::factory('');
+ if (! $this->connected) {
+ $this->connect();
+ }
- while (! $line->endsWith($token)) {
- // Read from SSH transport
- $data = $this->read(); // already returns StringHelper
+ if ($this->simulateDropConnection) {
+ $this->connected = false;
+ throw new TransportException(
+ "Connection to server '{$this->config['host']}:{$this->config['port']}' lost"
+ );
+ }
- Signal::getInstance()->emit(strtolower($this->getAdapterType()).'DataRead', $data->toString());
+ $line = '';
- if ($data->count() === 0) {
- if ($line->count()) {
- $line->append($token);
- } else {
- throw new TransportException(
- "Connection to server '{$this->config['host']}:{$this->config['port']}' lost"
- );
+ while (! str_ends_with($line, $token)) {
+ $data = $this->read();
+
+ $str = $data->toString();
+ if ($str === '' || $str === "\n") {
+ if ($line !== '') {
+ $line .= $token;
+ break;
+ }
+
+ if (empty($this->reply) && empty($this->buffer)) {
+ return null;
}
- } else {
- $line->append($data);
}
+
+ $line .= $str;
+ }
+
+ if ($this->transportType === 'ssh' || $this->simulatePrompt) {
+ $line = trim($line, "\0\t\n\r\x0B");
+ $line = preg_replace('/^[A-Za-z0-9\-_@()]+:[^\s>]*>\s*/', '', $line);
}
- return $line->trim();
+ return StringHelper::factory($line)->trim();
}
/**
- * SSH-compatible “read” mock
+ * Read mock data with ANSI filter simulation
* @throws TransportException
*/
public function read(int $length = 4096): StringHelper
@@ -100,39 +196,68 @@ public function read(int $length = 4096): StringHelper
$this->connect();
}
+ if ($this->simulateDropConnection) {
+ $this->connected = false;
+ throw new TransportException(
+ "Connection to server '{$this->config['host']}:{$this->config['port']}' lost"
+ );
+ }
+
if (empty($this->reply)) {
return StringHelper::factory('');
}
- // Copy to a local variable instead of overwriting property
$lines = explode("\n", $this->reply);
$data = array_shift($lines);
$this->reply = implode("\n", $lines);
$data = substr($data, 0, $length);
+ if ($this->transportType === 'ssh' || $this->simulateAnsi) {
+ $data = preg_replace([
+ '/\x1B\[[0-?]*[ -\/]*[@-~]/',
+ '/\x1B][^\x07]*\x07/',
+ '/\x1B\[?=\d*[A-Za-z]/',
+ ], '', $data);
+ }
+
+ Signal::getInstance()->emit(strtolower($this->getAdapterType()).'DataRead', $data);
+
return StringHelper::factory($data."\n");
}
/**
- * SSH-kompatibles „write“ Mock
+ * Send command and generate mock reply
* @throws TransportException
*/
public function write(string $data): void
{
- if (! $this->connected) {
+ if ($this->simulateDropConnection) {
+ $this->connected = false;
+ throw new TransportException(
+ "Connection to server '{$this->config['host']}:{$this->config['port']}' lost"
+ );
+ }
+
+ if (! $this->isConnected()) {
throw new TransportException('Not connected to mock server');
}
+ $this->sentCommands[] = $data;
+
Signal::getInstance()->emit(strtolower($this->getAdapterType()).'DataSend', $data);
- // Simulate response
- if (isset(self::CMD[$data])) {
- $this->reply = self::CMD[$data]."\n".self::S_ERROR_OK."\n";
- } else {
- $this->reply = "error id=1 msg=unknown_command\n";
+ $response = $this->resolveResponse($data);
+
+ if ($this->simulateAnsi) {
+ $response = "\x1B[32m".$response."\x1B[0m";
+ }
+
+ if ($this->simulatePrompt) {
+ $response = $this->promptString.$response;
}
+ $this->reply = $response;
$this->buffer = explode("\n", trim($this->reply));
}
@@ -141,8 +266,317 @@ public function sendLine(string $data, string $separator = "\n"): void
$this->write($data);
}
+ public function send(string $data): void
+ {
+ $this->write(rtrim($data, "\r\n"));
+ }
+
+ /**
+ * Resolve the response for a given ServerQuery command.
+ */
+ protected function resolveResponse(string $cmd): string
+ {
+ // 1. Response queue
+ if (! empty($this->responseQueue)) {
+ $queued = array_shift($this->responseQueue);
+
+ return $this->formatReply($queued);
+ }
+
+ // 2. Custom callable handler
+ if ($this->commandHandler !== null) {
+ $handled = call_user_func($this->commandHandler, $cmd);
+ if ($handled !== null) {
+ return $this->formatReply($handled);
+ }
+ }
+
+ // 3. Exact custom response
+ if (isset($this->customResponses[$cmd])) {
+ return $this->formatReply($this->customResponses[$cmd]);
+ }
+
+ // 4. Exact CMD constant
+ if (isset(self::CMD[$cmd])) {
+ return $this->formatReply(self::CMD[$cmd]);
+ }
+
+ // 5. Pattern-based custom responses
+ foreach ($this->patternResponses as $pattern => $handler) {
+ if (preg_match($pattern, $cmd, $matches)) {
+ $resp = is_callable($handler) ? $handler($cmd, $matches) : $handler;
+
+ return $this->formatReply($resp);
+ }
+ }
+
+ // 6. Default command dictionary
+ if (isset(self::DEFAULT_RESPONSES[$cmd])) {
+ return $this->formatReply(self::DEFAULT_RESPONSES[$cmd]);
+ }
+
+ // 7. Dynamic built-in handlers based on command prefix/type
+ return $this->formatReply($this->resolveDynamicDefaultResponse($cmd));
+ }
+
+ /**
+ * Formats reply ensuring proper newline and error line.
+ */
+ protected function formatReply(string $reply): string
+ {
+ $reply = rtrim($reply, "\r\n");
+
+ if (! str_contains($reply, 'error id=')) {
+ $reply .= "\n".self::S_ERROR_OK;
+ }
+
+ return $reply."\n";
+ }
+
+ /**
+ * Resolves dynamic TS3 ServerQuery commands.
+ */
+ protected function resolveDynamicDefaultResponse(string $cmd): string
+ {
+ $parts = explode(' ', $cmd);
+ $action = $parts[0] ?? '';
+
+ switch ($action) {
+ case 'login':
+ case 'logout':
+ case 'use':
+ case 'clientupdate':
+ case 'channeledit':
+ case 'channeldelete':
+ case 'channelmove':
+ case 'clientpoke':
+ case 'clientkick':
+ case 'clientmove':
+ case 'sendtextmessage':
+ case 'gm':
+ case 'serverstart':
+ case 'serverstop':
+ case 'serverdelete':
+ case 'servernotifyregister':
+ case 'servernotifyunregister':
+ case 'servergroupaddclient':
+ case 'servergroupdelclient':
+ case 'servergroupdel':
+ case 'channelgroupdel':
+ case 'privilegekeydelete':
+ case 'privilegekeyuse':
+ return self::S_ERROR_OK;
+
+ case 'servergroupadd':
+ return "sgid=10\n".self::S_ERROR_OK;
+
+ case 'servergroupcopy':
+ return "sgid=11\n".self::S_ERROR_OK;
+
+ case 'channelgroupadd':
+ return "cgid=10\n".self::S_ERROR_OK;
+
+ case 'channelgroupcopy':
+ return "cgid=11\n".self::S_ERROR_OK;
+
+ case 'clientdbinfo':
+ return "cldbid=1 client_database_id=1 client_unique_identifier=mock_admin_uid client_nickname=serveradmin\n".self::S_ERROR_OK;
+
+ case 'permoverview':
+ return "permid=1 permvalue=100 permskip=0 permnegated=0\n".self::S_ERROR_OK;
+
+ case 'serveridgetbyport':
+ case 'serveridgetbyname':
+ return "server_id=1\n".self::S_ERROR_OK;
+
+ case 'serverlist':
+ return "virtualserver_id=1 virtualserver_port=9987 virtualserver_status=online virtualserver_clientsonline=2 virtualserver_queryclientsonline=1 virtualserver_maxclients=32 virtualserver_uptime=123456 virtualserver_name=TeamSpeak\s]Test[\sServer virtualserver_autostart=1 virtualserver_machine_id virtualserver_unique_identifier=mock_server_uid\n".self::S_ERROR_OK;
+
+ case 'servercreate':
+ return "sid=2 token=mock_token_abcdef123456\n".self::S_ERROR_OK;
+
+ case 'channellist':
+ return "cid=1 pid=0 channel_order=0 channel_name=Default\sChannel channel_topic channel_flag_default=1 channel_flag_password=0 channel_flag_permanent=1 channel_flag_semi_permanent=0 channel_codec=4 channel_codec_quality=6 channel_needed_talk_power=0 total_clients=1|cid=2 pid=0 channel_order=1 channel_name=UnitTest channel_topic=UnitTest\sTopic channel_flag_default=0 channel_flag_password=0 channel_flag_permanent=1 channel_flag_semi_permanent=0 channel_codec=4 channel_codec_quality=6 channel_needed_talk_power=0 total_clients=1\n".self::S_ERROR_OK;
+
+ case 'channelinfo':
+ return "channel_name=Default\sChannel channel_topic channel_description channel_password channel_codec=4 channel_codec_quality=6 channel_maxclients=-1 channel_maxfamilyclients=-1 channel_order=0 channel_flag_permanent=1 channel_flag_semi_permanent=0 channel_flag_default=1 channel_flag_password=0 channel_codec_latency_factor=1 channel_codec_is_unencrypted=1 channel_security_salt channel_delete_delay=0 channel_needed_talk_power=0 channel_forced_silence=0 channel_name_phonetic channel_icon_id=0\n".self::S_ERROR_OK;
+
+ case 'channelcreate':
+ return "cid=3\n".self::S_ERROR_OK;
+
+ case 'clientlist':
+ return "clid=1 cid=1 client_database_id=1 client_nickname=serveradmin client_type=1|clid=2 cid=1 client_database_id=2 client_nickname=UnitTestUser client_type=0 client_unique_identifier=mock_user_uid_123 client_away=0 client_input_muted=0 client_output_muted=0 client_is_talker=0 client_channel_group_id=5 client_servergroups=6 client_created=1600000000 client_lastconnected=1600000100 client_totalconnections=10\n".self::S_ERROR_OK;
+
+ case 'clientinfo':
+ return "cid=1 client_database_id=2 client_nickname=UnitTestUser client_type=0 client_unique_identifier=mock_user_uid_123 client_flag_avatar client_description client_month_bytes_uploaded=0 client_month_bytes_downloaded=0 client_total_bytes_uploaded=0 client_total_bytes_downloaded=0 client_icon_id=0 client_channel_group_id=5 client_servergroups=6 client_away=0 client_away_message client_talk_power=75 client_is_talker=0 client_is_priority_speaker=0 client_is_recording=0 client_is_channel_commander=0 connection_client_ip=127.0.0.1\n".self::S_ERROR_OK;
+
+ case 'clientfind':
+ return "clid=2 client_nickname=UnitTestUser\n".self::S_ERROR_OK;
+
+ case 'clientfinddb':
+ return "cldbid=2\n".self::S_ERROR_OK;
+
+ case 'clientgetids':
+ return "cluid=mock_user_uid_123 clid=2 name=UnitTestUser\n".self::S_ERROR_OK;
+
+ case 'clientgetdbidfromuid':
+ return "cluid=mock_user_uid_123 cldbid=2\n".self::S_ERROR_OK;
+
+ case 'clientgetnamefromuid':
+ case 'clientgetnamefromdbid':
+ return "cluid=mock_user_uid_123 cldbid=2 name=UnitTestUser\n".self::S_ERROR_OK;
+
+ case 'servergroupclientlist':
+ return "cldbid=2 client_nickname=UnitTestUser client_unique_identifier=mock_user_uid_123\n".self::S_ERROR_OK;
+
+ case 'channelgroupclientlist':
+ return "cid=1 cldbid=2 cgid=5\n".self::S_ERROR_OK;
+
+ case 'permissionlist':
+ return "permid=1 permname=b_serverinstance_help_view permdesc=view\shelp permsid=b_serverinstance_help_view|permid=2 permname=b_serverinstance_version_view permdesc=view\sversion permsid=b_serverinstance_version_view\n".self::S_ERROR_OK;
+
+ case 'permfind':
+ return "id1=6 id2=0 p=1 v=1 n=0 a=0\n".self::S_ERROR_OK;
+
+ case 'permget':
+ return "permid=1 permvalue=1 permnegated=0 permskip=0\n".self::S_ERROR_OK;
+
+ case 'privilegekeylist':
+ return "token=mock_token_123456 token_type=0 token_id1=6 token_id2=0 token_description token_customset\n".self::S_ERROR_OK;
+
+ case 'privilegekeyadd':
+ return "token=mock_token_created_123\n".self::S_ERROR_OK;
+
+ case 'help':
+ return "help command list\n".self::S_ERROR_OK;
+
+ default:
+ return 'error id=1 msg=unknown_command';
+ }
+ }
+
+ public function registerResponse(string $cmd, string $reply): self
+ {
+ $this->customResponses[$cmd] = $reply;
+
+ return $this;
+ }
+
+ public function registerPattern(string $pattern, string|callable $reply): self
+ {
+ $this->patternResponses[$pattern] = $reply;
+
+ return $this;
+ }
+
+ public function queueResponse(string $reply): self
+ {
+ $this->responseQueue[] = $reply;
+
+ return $this;
+ }
+
+ public function setCommandHandler(?callable $handler): self
+ {
+ $this->commandHandler = $handler;
+
+ return $this;
+ }
+
+ public function setTransportType(string $type): self
+ {
+ $this->transportType = $type;
+
+ return $this;
+ }
+
+ public function setSimulatePrompt(bool $enable, string $prompt = 'ts-bot-dev@9987(1):online> '): self
+ {
+ $this->simulatePrompt = $enable;
+ $this->promptString = $prompt;
+
+ return $this;
+ }
+
+ public function setSimulateAnsi(bool $enable): self
+ {
+ $this->simulateAnsi = $enable;
+
+ return $this;
+ }
+
+ public function setFailConnect(bool $fail): self
+ {
+ $this->failConnect = $fail;
+
+ return $this;
+ }
+
+ public function setFailLogin(bool $fail): self
+ {
+ $this->failLogin = $fail;
+
+ return $this;
+ }
+
+ public function setSimulateDropConnection(bool $drop): self
+ {
+ $this->simulateDropConnection = $drop;
+
+ return $this;
+ }
+
+ public function getSentCommands(): array
+ {
+ return $this->sentCommands;
+ }
+
+ public function getLastCommand(): ?string
+ {
+ return ! empty($this->sentCommands) ? end($this->sentCommands) : null;
+ }
+
+ public function hasSentCommand(string $command): bool
+ {
+ return in_array($command, $this->sentCommands, true);
+ }
+
+ public function clearSentCommands(): self
+ {
+ $this->sentCommands = [];
+
+ return $this;
+ }
+
+ public function reset(): self
+ {
+ $this->customResponses = [];
+ $this->patternResponses = [];
+ $this->responseQueue = [];
+ $this->commandHandler = null;
+ $this->sentCommands = [];
+ $this->simulatePrompt = false;
+ $this->simulateAnsi = false;
+ $this->failConnect = false;
+ $this->failLogin = false;
+ $this->simulateDropConnection = false;
+ $this->connected = false;
+ $this->reply = null;
+ $this->buffer = [];
+
+ return $this;
+ }
+
public function getAdapterType(): string
{
- return 'ssh';
+ if ($this->adapter instanceof ServerQuery) {
+ return 'ServerQuery';
+ }
+
+ if ($this->adapter instanceof Adapter) {
+ return parent::getAdapterType();
+ }
+
+ return $this->transportType;
}
}
diff --git a/src/Transport/TSssh.php b/src/Transport/TSssh.php
index 8bec53dd..5603ec2f 100644
--- a/src/Transport/TSssh.php
+++ b/src/Transport/TSssh.php
@@ -23,6 +23,10 @@ public function connect(): void
'hostkey' => ['rsa-sha2-512', 'rsa-sha2-256', 'ssh-rsa'],
]);
+ if (! empty($this->config['fingerprint'])) {
+ $this->verifyFingerprint($this->ssh->getServerPublicHostKey(), (string) $this->config['fingerprint']);
+ }
+
// activate non-blocking mode
if (isset($this->config['blocking']) && $this->config['blocking'] === 0) {
$this->stream = $this->ssh->fsock ?? null;
@@ -36,6 +40,58 @@ public function connect(): void
}
}
+ /**
+ * Verifies the server host key against the expected fingerprint.
+ *
+ * @param mixed $serverHostKey
+ * @param string $expectedFingerprint
+ * @return bool
+ * @throws TransportException
+ */
+ public function verifyFingerprint(mixed $serverHostKey, string $expectedFingerprint): bool
+ {
+ if ($serverHostKey === false || empty($serverHostKey)) {
+ throw new TransportException('Host key verification failed: The servers host key could not be verified.');
+ }
+
+ // phpseclib3 returns " " (e.g. "rsa-sha2-512 AAAAB3NzaC1...") or raw key string
+ $keyParts = explode(' ', trim((string) $serverHostKey));
+ $keyBlobBase64 = count($keyParts) > 1 ? $keyParts[1] : $keyParts[0];
+ $rawKeyBlob = base64_decode($keyBlobBase64, true) ?: (string) $serverHostKey;
+
+ // 1. OpenSSH SHA256-Fingerprint (Base64 with and without padding)
+ $rawSha256 = hash('sha256', $rawKeyBlob, true);
+ $b64Fingerprint = base64_encode($rawSha256);
+ $b64Unpadded = rtrim($b64Fingerprint, '=');
+ $openSshFingerprint = 'SHA256:'.$b64Fingerprint;
+ $openSshUnpadded = 'SHA256:'.$b64Unpadded;
+
+ // 2. Hex SHA256-Fingerprint
+ $hexFingerprint = hash('sha256', $rawKeyBlob);
+
+ // 3. Normalized expected
+ $expected = trim($expectedFingerprint);
+ $expectedFixedPlus = str_replace(' ', '+', $expected);
+
+ // 4. Tolerant comparison against all standard formats
+ $isValid = hash_equals($openSshFingerprint, $expected)
+ || hash_equals($openSshUnpadded, $expected)
+ || hash_equals($b64Fingerprint, $expected)
+ || hash_equals($b64Unpadded, $expected)
+ || hash_equals($hexFingerprint, strtolower($expected))
+ || hash_equals('sha256:'.$hexFingerprint, strtolower($expected))
+ || hash_equals($openSshFingerprint, $expectedFixedPlus)
+ || hash_equals($openSshUnpadded, $expectedFixedPlus)
+ || hash_equals($b64Fingerprint, $expectedFixedPlus)
+ || hash_equals($b64Unpadded, $expectedFixedPlus);
+
+ if (! $isValid) {
+ throw new TransportException('Hostkey verification failed: The expected fingerprint does not match the server fingerprint!');
+ }
+
+ return true;
+ }
+
/**
* Check whether connected
*/
@@ -70,9 +126,6 @@ public function read(int $length = 4096): StringHelper
'/\x1B\[?=\d*[A-Za-z]/', // fallback for exotic forms (optional)
], '', $data);
- // Remove unnecessary whitespace/CR/LF at the beginning/end
- $data = trim($data, "\0\t\n\r\x0B");
-
Signal::getInstance()->emit(strtolower($this->getAdapterType()).'DataRead', $data);
return new StringHelper($data);
@@ -148,7 +201,7 @@ public function sendLine(string $data, string $separator = "\n"): void
/**
* Wait until data is available
*/
- public function waitForReadyRead(int $time = 5): void
+ protected function waitForReadyRead(int $time = 5): void
{
if (! $this->isConnected()) {
return;
@@ -158,14 +211,10 @@ public function waitForReadyRead(int $time = 5): void
while ((time() - $start) < $time) {
$data = $this->ssh->read();
if ($data !== false && $data !== '') {
- echo 'New data: '.$data.PHP_EOL;
-
return;
}
usleep(100_000); // 100 ms break
}
-
- echo "Timeout: no data within {$time}s".PHP_EOL;
}
/**
diff --git a/tests/DevLiveServer/ConnectionTest.php b/tests/DevLiveServer/ConnectionTest.php
index fc0d99a4..ce988c04 100644
--- a/tests/DevLiveServer/ConnectionTest.php
+++ b/tests/DevLiveServer/ConnectionTest.php
@@ -33,6 +33,8 @@ class ConnectionTest extends TestCase
private string $serverQueryLoginName;
+ private string $serverHostKeyFingerPrint;
+
public function setUp(): void
{
//proof test active
@@ -45,6 +47,7 @@ public function setUp(): void
$this->password = str_replace('DEV_LIVE_SERVER_QUERY_USER_PASSWORD=', '', preg_replace('#\n(?!\n)#', '', $env[6]));
$this->serverPort = str_replace('DEV_LIVE_SERVER_UNIT_TEST_SERVER_PORT=', '', preg_replace('#\n(?!\n)#', '', $env[12]));
$this->serverQueryLoginName = str_replace('DEV_LIVE_SERVER_UNIT_TEST_SERVER_QUERY_LOGIN_NAME=', '', preg_replace('#\n(?!\n)#', '', $env[13]));
+ $this->serverHostKeyFingerPrint = str_replace('DEV_LIVE_SERVER_UNIT_TEST_SERVER_HOST_KEY=', '', preg_replace('#\n(?!\n)#', '', $env[14]));
} else {
$this->active = 'false';
}
@@ -53,7 +56,8 @@ public function setUp(): void
'/?server_port='.$this->serverPort.
'&no_query_clients=0'.
'&blocking=0'.
- '&timeout=30';
+ '&timeout=30'.
+ '&fingerprint='.$this->serverHostKeyFingerPrint;
}
/**
@@ -68,6 +72,32 @@ public function test_can_ssh_connect()
$this->markTestSkipped('DevLiveServer ist not active');
}
+ $ts3_server_uri = 'serverquery://'.$this->user.':'.$this->password.'@'.$this->host.':'.$this->queryPort.
+ '/?server_port='.$this->serverPort.
+ '&no_query_clients=0'.
+ '&blocking=0'.
+ '&timeout=30'.
+ '&fingerprint=INVALIDFingerPrint';
+
+ try {
+ TeamSpeak3::factory($ts3_server_uri);
+ } catch (\Exception $e) {
+ $this->assertEquals('Hostkey verification failed: The expected fingerprint does not match the server fingerprint!', $e->getMessage());
+ }
+ }
+
+ /**
+ * @throws AdapterException
+ * @throws TransportException
+ * @throws ServerQueryException
+ * @throws HelperException
+ */
+ public function test_can_ssh_connect_failed()
+ {
+ if ($this->active == 'false') {
+ $this->markTestSkipped('DevLiveServer ist not active');
+ }
+
$ts3_Host = TeamSpeak3::factory($this->ts3_server_uri);
$nodeInfo = $ts3_Host->getInfo();
$whoami = $ts3_Host->whoami();
diff --git a/tests/DevLiveServer/FileTransferTest.php b/tests/DevLiveServer/FileTransferTest.php
index 5ec07ddc..17d414dd 100644
--- a/tests/DevLiveServer/FileTransferTest.php
+++ b/tests/DevLiveServer/FileTransferTest.php
@@ -399,6 +399,12 @@ public function test_can_download_channel_icon()
*/
private function set_play_test_servergroup(Server $ts3VirtualServer): void
{
+ try {
+ $existing = $ts3VirtualServer->serverGroupGetByName('UnitTest');
+ $ts3VirtualServer->serverGroupDelete($existing->getId(), true);
+ } catch (ServerQueryException) {
+ // Ignore if group does not exist
+ }
$this->sgid = $ts3VirtualServer->serverGroupCreate('UnitTest', 1);
}
diff --git a/tests/DevLiveServer/ServerGroupTest.php b/tests/DevLiveServer/ServerGroupTest.php
index d40b3396..8d898c54 100644
--- a/tests/DevLiveServer/ServerGroupTest.php
+++ b/tests/DevLiveServer/ServerGroupTest.php
@@ -208,6 +208,7 @@ public function test_can_assign_remove_permissions_to_servergroup()
/**
* @throws AdapterException
* @throws HelperException
+ * @throws NodeException
* @throws ServerQueryException
* @throws TransportException
*/
@@ -230,12 +231,20 @@ public function test_can_get_iconList()
}
/**
+ * @param Server $ts3VirtualServer
* @throws AdapterException
+ * @throws NodeException
* @throws ServerQueryException
* @throws TransportException
*/
private function set_play_test_servergroup(Server $ts3VirtualServer): void
{
+ try {
+ $existing = $ts3VirtualServer->serverGroupGetByName('UnitTest');
+ $ts3VirtualServer->serverGroupDelete($existing->getId(), true);
+ } catch (ServerQueryException) {
+ // Ignore if group does not exist
+ }
$this->sgid = $ts3VirtualServer->serverGroupCreate('UnitTest', 1);
}
diff --git a/tests/Feature/ChannelFeatureTest.php b/tests/Feature/ChannelFeatureTest.php
new file mode 100644
index 00000000..673d0177
--- /dev/null
+++ b/tests/Feature/ChannelFeatureTest.php
@@ -0,0 +1,156 @@
+server = TeamSpeak3::factory('mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987');
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->server->getAdapter()->getTransport()->isConnected()) {
+ $this->server->getAdapter()->getTransport()->disconnect();
+ }
+ }
+
+ /**
+ * Test retrieving channel list.
+ */
+ public function testChannelList(): void
+ {
+ $channels = $this->server->channelList();
+
+ $this->assertIsArray($channels);
+ $this->assertCount(2, $channels);
+ $this->assertArrayHasKey(1, $channels);
+ $this->assertArrayHasKey(2, $channels);
+ $this->assertEquals('Default Channel', $channels[1]['channel_name']);
+ $this->assertEquals('UnitTest', $channels[2]['channel_name']);
+ }
+
+ /**
+ * Test getting a channel by ID.
+ */
+ public function testChannelGetById(): void
+ {
+ $channel = $this->server->channelGetById(1);
+
+ $this->assertInstanceOf(Channel::class, $channel);
+ $this->assertEquals(1, $channel->getId());
+ $this->assertEquals('Default Channel', $channel['channel_name']);
+ $this->assertEquals(1, $channel['channel_flag_permanent']);
+ $this->assertEquals(1, $channel['channel_flag_default']);
+ }
+
+ /**
+ * Test getting a channel by Name.
+ */
+ public function testChannelGetByName(): void
+ {
+ $channel = $this->server->channelGetByName('UnitTest');
+
+ $this->assertInstanceOf(Channel::class, $channel);
+ $this->assertEquals(2, $channel->getId());
+ $this->assertEquals('UnitTest', $channel['channel_name']);
+ }
+
+ /**
+ * Test channel properties and info.
+ */
+ public function testChannelProperties(): void
+ {
+ $channel = $this->server->channelGetById(1);
+ $info = $channel->getInfo();
+
+ $this->assertIsArray($info);
+ $this->assertArrayHasKey('channel_name', $info);
+ $this->assertEquals('Default Channel', $info['channel_name']);
+ $this->assertEquals(4, $channel['channel_codec']);
+ $this->assertEquals(6, $channel['channel_codec_quality']);
+ $this->assertEquals(-1, $channel['channel_maxclients']);
+ }
+
+ /**
+ * Test creating a channel.
+ */
+ public function testChannelCreate(): void
+ {
+ $cid = $this->server->channelCreate([
+ 'channel_name' => 'New Channel',
+ 'channel_topic' => 'Topic',
+ 'channel_flag_permanent' => 1,
+ ]);
+
+ $this->assertEquals(3, $cid);
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('channelcreate channel_name=New\sChannel channel_topic=Topic channel_flag_permanent=1'));
+ }
+
+ /**
+ * Test deleting a channel.
+ */
+ public function testChannelDelete(): void
+ {
+ $this->server->channelDelete(2, true);
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('channeldelete cid=2 force=1'));
+ }
+
+ /**
+ * Test channel move operation.
+ */
+ public function testChannelMove(): void
+ {
+ $channel = $this->server->channelGetById(2);
+ $channel->move(1, 0);
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('channelmove cid=2 cpid=1 order=0'));
+ }
+
+ /**
+ * Test creating and managing multiple channels.
+ */
+ public function testChannelCreateAndManage(): void
+ {
+ $cid = $this->server->channelCreate([
+ 'channel_name' => 'Support Channel',
+ 'channel_topic' => 'Helpdesk',
+ ]);
+
+ $this->assertEquals(3, $cid);
+
+ $this->server->channelDelete($cid);
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('channeldelete cid=3 force=0'));
+ }
+
+ /**
+ * Test channel not found exception.
+ */
+ public function testChannelNotFoundException(): void
+ {
+ $this->expectException(ServerQueryException::class);
+ $this->expectExceptionCode(0x300);
+
+ $this->server->channelGetByName('NonExistentChannel');
+ }
+}
diff --git a/tests/Feature/ClientFeatureTest.php b/tests/Feature/ClientFeatureTest.php
new file mode 100644
index 00000000..9e7c2bc5
--- /dev/null
+++ b/tests/Feature/ClientFeatureTest.php
@@ -0,0 +1,173 @@
+server = TeamSpeak3::factory('mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987');
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->server->getAdapter()->getTransport()->isConnected()) {
+ $this->server->getAdapter()->getTransport()->disconnect();
+ }
+ }
+
+ /**
+ * Test retrieving client list.
+ */
+ public function testClientList(): void
+ {
+ $clients = $this->server->clientList();
+
+ $this->assertIsArray($clients);
+ $this->assertCount(2, $clients);
+ $this->assertArrayHasKey(1, $clients);
+ $this->assertArrayHasKey(2, $clients);
+ $this->assertEquals('serveradmin', $clients[1]['client_nickname']);
+ $this->assertEquals('UnitTestUser', $clients[2]['client_nickname']);
+ }
+
+ /**
+ * Test getting client by ID.
+ */
+ public function testClientGetById(): void
+ {
+ $client = $this->server->clientGetById(2);
+
+ $this->assertInstanceOf(Client::class, $client);
+ $this->assertEquals(2, $client->getId());
+ $this->assertEquals('UnitTestUser', $client['client_nickname']);
+ }
+
+ /**
+ * Test getting client by Name.
+ */
+ public function testClientGetByName(): void
+ {
+ $client = $this->server->clientGetByName('UnitTestUser');
+
+ $this->assertInstanceOf(Client::class, $client);
+ $this->assertEquals(2, $client->getId());
+ $this->assertEquals('UnitTestUser', $client['client_nickname']);
+ }
+
+ /**
+ * Test getting client by UID.
+ */
+ public function testClientGetByUid(): void
+ {
+ $client = $this->server->clientGetByUid('mock_user_uid_123');
+
+ $this->assertInstanceOf(Client::class, $client);
+ $this->assertEquals(2, $client->getId());
+ $this->assertEquals('UnitTestUser', $client['client_nickname']);
+ }
+
+ /**
+ * Test client properties and info.
+ */
+ public function testClientProperties(): void
+ {
+ $client = $this->server->clientGetById(2);
+ $info = $client->getInfo();
+
+ $this->assertIsArray($info);
+ $this->assertArrayHasKey('client_nickname', $info);
+ $this->assertEquals('UnitTestUser', $info['client_nickname']);
+ $this->assertEquals('mock_user_uid_123', $client['client_unique_identifier']);
+ $this->assertEquals(2, $client['client_database_id']);
+ $this->assertEquals(1, $client['cid']);
+ $this->assertEquals(0, $client['client_type']);
+ $this->assertEquals(0, $client['client_away']);
+ }
+
+ /**
+ * Test client poke action.
+ */
+ public function testClientPoke(): void
+ {
+ $client = $this->server->clientGetById(2);
+ $client->poke('Wake up!');
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('clientpoke clid=2 msg=Wake\sup!'));
+ }
+
+ /**
+ * Test client kick action.
+ */
+ public function testClientKick(): void
+ {
+ $client = $this->server->clientGetById(2);
+ $client->kick(TeamSpeak3::KICK_SERVER, 'Rule violation');
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('clientkick clid=2 reasonid=5 reasonmsg=Rule\sviolation'));
+ }
+
+ /**
+ * Test client move action.
+ */
+ public function testClientMove(): void
+ {
+ $client = $this->server->clientGetById(2);
+ $client->move(2);
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('clientmove clid=2 cid=2'));
+ }
+
+ /**
+ * Test client message action.
+ */
+ public function testClientMessage(): void
+ {
+ $client = $this->server->clientGetById(2);
+ $client->message('Private message');
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('sendtextmessage msg=Private\smessage target=2 targetmode=1'));
+ }
+
+ /**
+ * Test server group assignment to client.
+ */
+ public function testClientServerGroupAssignment(): void
+ {
+ $this->server->serverGroupClientAdd(6, 2);
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('servergroupaddclient sgid=6 cldbid=2'));
+
+ $this->server->serverGroupClientDel(6, 2);
+ $this->assertTrue($transport->hasSentCommand('servergroupdelclient sgid=6 cldbid=2'));
+ }
+
+ /**
+ * Test client not found exception.
+ */
+ public function testClientNotFoundException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Client not found');
+
+ $this->server->clientGetByName('UnknownNonExistentUser');
+ }
+}
diff --git a/tests/Feature/ConnectionFeatureTest.php b/tests/Feature/ConnectionFeatureTest.php
new file mode 100644
index 00000000..d73daff3
--- /dev/null
+++ b/tests/Feature/ConnectionFeatureTest.php
@@ -0,0 +1,315 @@
+assertInstanceOf(Server::class, $server);
+ $this->assertEquals(1, $server->getId());
+ $this->assertTrue($server->getAdapter()->getTransport()->isConnected());
+
+ $server->getAdapter()->getTransport()->disconnect();
+ $this->assertFalse($server->getAdapter()->getTransport()->isConnected());
+ }
+
+ /**
+ * Test factory connect without selecting a server (returns Host node).
+ */
+ public function testFactoryConnectHostOnly(): void
+ {
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022';
+ $host = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Host::class, $host);
+ $whoami = $host->whoami();
+
+ $this->assertEquals('serveradmin', $whoami['client_nickname']);
+ $this->assertEquals('1', $whoami['virtualserver_id']);
+
+ $host->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test factory connection with server_id parameter.
+ */
+ public function testFactoryConnectWithServerId(): void
+ {
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_id=1';
+ $server = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertEquals(1, $server->getId());
+
+ $server->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test factory connection with nickname parameter.
+ */
+ public function testFactoryConnectWithNickname(): void
+ {
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&nickname=UnitTestBot';
+ $server = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertTrue($server->getAdapter()->getTransport()->isConnected());
+
+ $server->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test factory connection with URI flags and query options.
+ */
+ public function testFactoryConnectWithOptionsAndFlags(): void
+ {
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&use_offline_as_virtual=1&no_query_clients=1&clients_before_channels=1';
+ $server = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertTrue($server->getParent()->getUseOfflineAsVirtual());
+ $this->assertTrue($server->getParent()->getExcludeQueryClients());
+ $this->assertTrue($server->getParent()->getLoadClientlistFirst());
+
+ $server->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test SSH connection failure simulation.
+ */
+ public function testSSHConnectFailure(): void
+ {
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Connection to');
+
+ new MockServerQuery([
+ 'host' => '127.0.0.1',
+ 'port' => 10022,
+ 'fail_connect' => true,
+ ]);
+ }
+
+ /**
+ * Test login failure simulation.
+ */
+ public function testLoginFailure(): void
+ {
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Login failed: incorrect username or password');
+
+ new MockServerQuery([
+ 'host' => '127.0.0.1',
+ 'port' => 10022,
+ 'fail_login' => true,
+ ]);
+ }
+
+ /**
+ * Test SSH prompt stripping during command response reading.
+ */
+ public function testSSHPromptStripping(): void
+ {
+ $mockQuery = new MockServerQuery([
+ 'host' => '127.0.0.1',
+ 'port' => 10022,
+ 'simulate_prompt' => true,
+ ]);
+
+ $reply = $mockQuery->request('whoami');
+ $this->assertInstanceOf(\PlanetTeamSpeak\TeamSpeak3Framework\Adapter\ServerQuery\Reply::class, $reply);
+ $this->assertArrayHasKey('client_nickname', $reply->toList());
+ $this->assertEquals('serveradmin', $reply->toList()['client_nickname']);
+
+ $mockQuery->getTransport()->disconnect();
+ }
+
+ /**
+ * Test ANSI escape sequence filtering during SSH reading.
+ */
+ public function testSSHAnsiEscapeFiltering(): void
+ {
+ $mockQuery = new MockServerQuery([
+ 'host' => '127.0.0.1',
+ 'port' => 10022,
+ 'simulate_ansi' => true,
+ ]);
+
+ $reply = $mockQuery->request('version');
+ $this->assertArrayHasKey('version', $reply->toList());
+ $this->assertStringContainsString('3.13.7', $reply->toList()['version']);
+
+ $mockQuery->getTransport()->disconnect();
+ }
+
+ /**
+ * Test TCP transport mode simulation.
+ */
+ public function testTCPTransportMode(): void
+ {
+ $mockQuery = new MockServerQuery([
+ 'host' => '127.0.0.1',
+ 'port' => 10011,
+ 'transport_type' => 'tcp',
+ ]);
+
+ /** @var MockTCP $transport */
+ $transport = $mockQuery->getTransport();
+ $this->assertTrue($transport->isConnected());
+
+ $reply = $mockQuery->request('whoami');
+ $this->assertEquals('serveradmin', $reply->toList()['client_nickname']);
+
+ $transport->disconnect();
+ $this->assertFalse($transport->isConnected());
+ }
+
+ /**
+ * Test connection lost simulation.
+ */
+ public function testConnectionLostSimulation(): void
+ {
+ $mockQuery = new MockServerQuery([
+ 'host' => '127.0.0.1',
+ 'port' => 10022,
+ ]);
+
+ /** @var MockTCP $transport */
+ $transport = $mockQuery->getTransport();
+ $transport->setSimulateDropConnection(true);
+
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('lost');
+
+ $mockQuery->request('whoami');
+ }
+
+ /**
+ * Test Signal emissions for connection and data transfer.
+ */
+ public function testConnectionSignalsEmitted(): void
+ {
+ $events = [];
+
+ Signal::getInstance()->subscribe('serverqueryConnected', function ($adapter) use (&$events) {
+ $events[] = 'connected';
+ });
+
+ Signal::getInstance()->subscribe('serverqueryCommandStarted', function ($cmd) use (&$events) {
+ $events[] = 'cmd_started: '.$cmd;
+ });
+
+ Signal::getInstance()->subscribe('serverqueryCommandFinished', function ($cmd, $reply) use (&$events) {
+ $events[] = 'cmd_finished: '.$cmd;
+ });
+
+ Signal::getInstance()->subscribe('serverqueryDisconnected', function () use (&$events) {
+ $events[] = 'disconnected';
+ });
+
+ $mockQuery = new MockServerQuery(['host' => '127.0.0.1', 'port' => '10022']);
+ $mockQuery->request('whoami');
+ $mockQuery->getTransport()->disconnect();
+
+ $this->assertContains('connected', $events);
+ $this->assertContains('cmd_started: whoami', $events);
+ $this->assertContains('cmd_finished: whoami', $events);
+ $this->assertContains('disconnected', $events);
+ }
+
+ /**
+ * Test factory connection with matching fingerprint in URI.
+ */
+ public function testFactoryConnectWithMatchingFingerprint(): void
+ {
+ $rawBlob = base64_decode('AAAAB3NzaC1yc2EAAAADAQABAAABAQC3r7Yh5N1xXj1234567890abcdefghijklmnopqrstuvwxyz');
+ $validFingerprint = 'SHA256:'.base64_encode(hash('sha256', $rawBlob, true));
+
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&fingerprint='.urlencode($validFingerprint);
+ $server = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertTrue($server->getAdapter()->getTransport()->isConnected());
+ $this->assertEquals($validFingerprint, $server->getAdapter()->getTransport()->getConfig('fingerprint'));
+
+ $server->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test factory connection with raw (unencoded plus) fingerprint in URI.
+ */
+ public function testFactoryConnectWithRawFingerprint(): void
+ {
+ $rawBlob = base64_decode('AAAAB3NzaC1yc2EAAAADAQABAAABAQC3r7Yh5N1xXj1234567890abcdefghijklmnopqrstuvwxyz');
+ $validFingerprint = 'SHA256:'.base64_encode(hash('sha256', $rawBlob, true));
+
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&fingerprint='.$validFingerprint;
+ $server = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertTrue($server->getAdapter()->getTransport()->isConnected());
+
+ $server->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test factory connection with Hex fingerprint in URI.
+ */
+ public function testFactoryConnectWithHexFingerprint(): void
+ {
+ $rawBlob = base64_decode('AAAAB3NzaC1yc2EAAAADAQABAAABAQC3r7Yh5N1xXj1234567890abcdefghijklmnopqrstuvwxyz');
+ $hexFingerprint = hash('sha256', $rawBlob);
+
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&fingerprint='.$hexFingerprint;
+ $server = TeamSpeak3::factory($uri);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertTrue($server->getAdapter()->getTransport()->isConnected());
+
+ $server->getAdapter()->getTransport()->disconnect();
+ }
+
+ /**
+ * Test factory connection with mismatched fingerprint throws TransportException.
+ */
+ public function testFactoryConnectWithMismatchedFingerprintThrowsException(): void
+ {
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Hostkey verification failed: The expected fingerprint does not match the server fingerprint!');
+
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&fingerprint=SHA256:invalidfingerprint';
+ TeamSpeak3::factory($uri);
+ }
+
+ /**
+ * Test factory connection with fail_fingerprint flag throws TransportException.
+ */
+ public function testFactoryConnectWithFailFingerprintFlagThrowsException(): void
+ {
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Hostkey verification failed: The expected fingerprint does not match the server fingerprint!');
+
+ $rawBlob = base64_decode('AAAAB3NzaC1yc2EAAAADAQABAAABAQC3r7Yh5N1xXj1234567890abcdefghijklmnopqrstuvwxyz');
+ $validFingerprint = 'SHA256:'.base64_encode(hash('sha256', $rawBlob, true));
+
+ $uri = 'mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987&fingerprint='.urlencode($validFingerprint).'&fail_fingerprint=1';
+ TeamSpeak3::factory($uri);
+ }
+}
diff --git a/tests/Feature/ServerFeatureTest.php b/tests/Feature/ServerFeatureTest.php
new file mode 100644
index 00000000..0b20c1e6
--- /dev/null
+++ b/tests/Feature/ServerFeatureTest.php
@@ -0,0 +1,253 @@
+server = TeamSpeak3::factory('mock://serveradmin:secret@127.0.0.1:10022/?server_port=9987');
+ $this->host = $this->server->getParent();
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->server->getAdapter()->getTransport()->isConnected()) {
+ $this->server->getAdapter()->getTransport()->disconnect();
+ }
+ }
+
+ /**
+ * Test retrieving host version info.
+ */
+ public function testHostVersion(): void
+ {
+ $version = $this->host->version();
+
+ $this->assertIsArray($version);
+ $this->assertArrayHasKey('version', $version);
+ $this->assertStringContainsString('3.13.7', $version['version']);
+ $this->assertEquals('Linux', $version['platform']);
+ }
+
+ /**
+ * Test retrieving host whoami info.
+ */
+ public function testHostWhoami(): void
+ {
+ $whoami = $this->host->whoami();
+
+ $this->assertIsArray($whoami);
+ $this->assertEquals('serveradmin', $whoami['client_nickname']);
+ $this->assertEquals(1, $whoami['client_id']);
+ $this->assertEquals(1, $whoami['virtualserver_id']);
+ }
+
+ /**
+ * Test retrieving virtual server list.
+ */
+ public function testHostServerList(): void
+ {
+ $serverList = $this->host->serverList();
+
+ $this->assertIsArray($serverList);
+ $this->assertArrayHasKey(1, $serverList);
+ $this->assertEquals('TeamSpeak ]Test[ Server', $serverList[1]['virtualserver_name']);
+ $this->assertEquals('9987', $serverList[1]['virtualserver_port']);
+ }
+
+ /**
+ * Test retrieving server by ID.
+ */
+ public function testHostServerGetById(): void
+ {
+ $server = $this->host->serverGetById(1);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertEquals(1, $server->getId());
+ }
+
+ /**
+ * Test retrieving server by Port.
+ */
+ public function testHostServerGetByPort(): void
+ {
+ $server = $this->host->serverGetByPort(9987);
+
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertEquals(1, $server->getId());
+ }
+
+ /**
+ * Test server basic info and properties.
+ */
+ public function testServerInfoAndProperties(): void
+ {
+ $info = $this->server->getInfo();
+
+ $this->assertIsArray($info);
+ $this->assertEquals('TeamSpeak ]Test[ Server', $this->server['virtualserver_name']);
+ $this->assertEquals('Linux', $this->server['virtualserver_platform']);
+ $this->assertEquals(32, $this->server['virtualserver_maxclients']);
+ $this->assertEquals('online', $this->server['virtualserver_status']);
+ $this->assertEquals(1, $this->server->getId());
+ $this->assertEquals('mock_server_uid', $this->server['virtualserver_unique_identifier']);
+ $this->assertTrue($this->server->isOnline());
+ }
+
+ /**
+ * Test sending a server message.
+ */
+ public function testServerMessage(): void
+ {
+ $this->server->message('Hello from feature test!');
+
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Transport\MockTCP $transport */
+ $transport = $this->server->getAdapter()->getTransport();
+ $this->assertTrue($transport->hasSentCommand('sendtextmessage msg=Hello\sfrom\sfeature\stest! target=1 targetmode=3'));
+ }
+
+ /**
+ * Test server group list.
+ */
+ public function testServerGroupList(): void
+ {
+ $groups = $this->server->serverGroupList();
+
+ $this->assertIsArray($groups);
+ $this->assertArrayHasKey(6, $groups);
+ $this->assertEquals('Server Admin', $groups[6]['name']);
+ }
+
+ /**
+ * Test channel group list.
+ */
+ public function testChannelGroupList(): void
+ {
+ $groups = $this->server->channelGroupList();
+
+ $this->assertIsArray($groups);
+ $this->assertArrayHasKey(5, $groups);
+ $this->assertEquals('Channel Admin', $groups[5]['name']);
+ }
+
+ /**
+ * Test server start / stop / create / delete signals and execution.
+ */
+ public function testServerLifecycleSignals(): void
+ {
+ $signals = [];
+ Signal::getInstance()->subscribe('notifyServerstopped', function ($host, $sid) use (&$signals) {
+ $signals[] = 'stopped: '.$sid;
+ });
+ Signal::getInstance()->subscribe('notifyServerstarted', function ($host, $sid) use (&$signals) {
+ $signals[] = 'started: '.$sid;
+ });
+ Signal::getInstance()->subscribe('notifyServerdeleted', function ($host, $sid) use (&$signals) {
+ $signals[] = 'deleted: '.$sid;
+ });
+
+ $this->host->serverStop(1, 'Maintenance');
+ $this->host->serverStart(1);
+ $this->host->serverDelete(2);
+
+ $this->assertContains('stopped: 1', $signals);
+ $this->assertContains('started: 1', $signals);
+ $this->assertContains('deleted: 2', $signals);
+ }
+
+ /**
+ * Test error reply when server ID is invalid.
+ */
+ public function testInvalidServerIdException(): void
+ {
+ /** @var \PlanetTeamSpeak\TeamSpeak3Framework\Adapter\MockServerQuery $adapter */
+ $adapter = $this->server->getAdapter();
+ $adapter->registerResponse('use sid=999', "error id=1024 msg=invalid\sserverID\n");
+
+ $this->expectException(ServerQueryException::class);
+ $this->expectExceptionCode(0x400);
+
+ $this->host->serverSelectById(999);
+ }
+
+ /**
+ * Test serverGroupCreate and serverGroupCopy.
+ */
+ public function testServerGroupCreateAndCopy(): void
+ {
+ $sgid = $this->server->serverGroupCreate('TestServerGroup');
+ $this->assertEquals(10, $sgid);
+
+ $copySgid = $this->server->serverGroupCopy(10, 'TestServerGroupCopy');
+ $this->assertEquals(11, $copySgid);
+ }
+
+ /**
+ * Test channelGroupCreate and channelGroupCopy.
+ */
+ public function testChannelGroupCreateAndCopy(): void
+ {
+ $cgid = $this->server->channelGroupCreate('TestChannelGroup');
+ $this->assertEquals(10, $cgid);
+
+ $copyCgid = $this->server->channelGroupCopy(10, 'TestChannelGroupCopy');
+ $this->assertEquals(11, $copyCgid);
+ }
+
+ /**
+ * Test clientInfoDb.
+ */
+ public function testClientInfoDb(): void
+ {
+ $info = $this->server->clientInfoDb(1);
+ $this->assertIsArray($info);
+ $this->assertEquals('1', $info['cldbid']);
+ $this->assertEquals('serveradmin', $info['client_nickname']);
+ }
+
+ /**
+ * Test serverIdGetByPort on Host.
+ */
+ public function testServerIdGetByPort(): void
+ {
+ $sid = $this->host->serverIdGetByPort(9987);
+ $this->assertEquals(1, $sid);
+ }
+
+ /**
+ * Test privilegeKeyCreate on ServerGroup and ChannelGroup.
+ */
+ public function testPrivilegeKeyCreateOnGroups(): void
+ {
+ $serverGroup = $this->server->serverGroupGetById(6);
+ $token1 = $serverGroup->privilegeKeyCreate('Admin Token');
+ $this->assertInstanceOf(\PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper::class, $token1);
+ $this->assertEquals('mock_token_created_123', (string) $token1);
+
+ $channelGroup = $this->server->channelGroupGetById(5);
+ $token2 = $channelGroup->privilegeKeyCreate(1, 'Channel Admin Token');
+ $this->assertInstanceOf(\PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper::class, $token2);
+ $this->assertEquals('mock_token_created_123', (string) $token2);
+ }
+
+ /**
+ * Test selfPermOverview.
+ */
+ public function testSelfPermOverview(): void
+ {
+ $overview = $this->server->selfPermOverview();
+ $this->assertIsArray($overview);
+ }
+}
diff --git a/tests/Feature/TransportMockFeatureTest.php b/tests/Feature/TransportMockFeatureTest.php
new file mode 100644
index 00000000..cced8928
--- /dev/null
+++ b/tests/Feature/TransportMockFeatureTest.php
@@ -0,0 +1,160 @@
+mockQuery = new MockServerQuery(['host' => '127.0.0.1', 'port' => 10022]);
+ $this->transport = $this->mockQuery->getMockTransport();
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->transport->isConnected()) {
+ $this->transport->disconnect();
+ }
+ }
+
+ /**
+ * Test registering exact custom response.
+ */
+ public function testRegisterCustomResponse(): void
+ {
+ $this->mockQuery->registerResponse('customcommand foo=bar', "custom_data=123 value=test\nerror id=0 msg=ok\n");
+
+ $reply = $this->mockQuery->request('customcommand foo=bar');
+ $assoc = $reply->toList();
+
+ $this->assertEquals('123', $assoc['custom_data']);
+ $this->assertEquals('test', $assoc['value']);
+ }
+
+ /**
+ * Test registering regex pattern matching response.
+ */
+ public function testRegisterPatternResponse(): void
+ {
+ $this->transport->registerPattern('/^custompattern id=(\d+)/', function ($cmd, $matches) {
+ $id = $matches[1];
+
+ return "result_id={$id} status=success\nerror id=0 msg=ok\n";
+ });
+
+ $reply1 = $this->mockQuery->request('custompattern id=42');
+ $this->assertEquals('42', $reply1->toList()['result_id']);
+
+ $reply2 = $this->mockQuery->request('custompattern id=999');
+ $this->assertEquals('999', $reply2->toList()['result_id']);
+ }
+
+ /**
+ * Test response queue for sequential multi-step responses.
+ */
+ public function testResponseQueue(): void
+ {
+ $this->mockQuery->queueResponse("step=first\nerror id=0 msg=ok\n");
+ $this->mockQuery->queueResponse("step=second\nerror id=0 msg=ok\n");
+
+ $reply1 = $this->mockQuery->request('anycommand');
+ $this->assertEquals('first', $reply1->toList()['step']);
+
+ $reply2 = $this->mockQuery->request('anycommand');
+ $this->assertEquals('second', $reply2->toList()['step']);
+ }
+
+ /**
+ * Test custom command handler callback.
+ */
+ public function testCommandHandlerCallback(): void
+ {
+ $this->mockQuery->setCommandHandler(function ($cmd) {
+ if (str_starts_with($cmd, 'calc ')) {
+ $num = (int) substr($cmd, 5);
+
+ return 'result='.($num * 2);
+ }
+
+ return null; // fallback to default
+ });
+
+ $reply = $this->mockQuery->request('calc 21');
+ $this->assertEquals('42', $reply->toList()['result']);
+
+ // Non-calc command falls back to default
+ $versionReply = $this->mockQuery->request('version');
+ $this->assertArrayHasKey('version', $versionReply->toList());
+ }
+
+ /**
+ * Test tracking of sent commands.
+ */
+ public function testCommandTracking(): void
+ {
+ $this->mockQuery->request('whoami');
+ $this->mockQuery->request('version');
+ $this->mockQuery->request('clientlist');
+
+ $commands = $this->mockQuery->getSentCommands();
+ $this->assertCount(3, $commands);
+ $this->assertEquals('whoami', $commands[0]);
+ $this->assertEquals('version', $commands[1]);
+ $this->assertEquals('clientlist', $commands[2]);
+
+ $this->assertEquals('clientlist', $this->mockQuery->getLastCommand());
+ $this->assertTrue($this->mockQuery->hasSentCommand('whoami'));
+ $this->assertFalse($this->mockQuery->hasSentCommand('nonexistent'));
+ }
+
+ /**
+ * Test resetting mock state.
+ */
+ public function testMockReset(): void
+ {
+ $this->mockQuery->registerResponse('test', 'data=1');
+ $this->mockQuery->request('whoami');
+
+ $this->transport->reset();
+
+ $this->assertEmpty($this->transport->getSentCommands());
+ $this->assertFalse($this->transport->isConnected());
+ }
+
+ /**
+ * Test simulated ServerQuery error response exception.
+ */
+ public function testSimulatedServerErrorException(): void
+ {
+ $this->mockQuery->registerResponse('failcmd', "error id=2568 msg=insufficient\sclient\spermissions\n");
+
+ $this->expectException(ServerQueryException::class);
+ $this->expectExceptionCode(0xA08);
+ $this->expectExceptionMessage('insufficient client permissions');
+
+ $this->mockQuery->request('failcmd');
+ }
+
+ /**
+ * Test SSH prompt and ANSI sequence simulation simultaneously.
+ */
+ public function testPromptAndAnsiSimulationTogether(): void
+ {
+ $this->transport->setSimulatePrompt(true, 'custom-bot@9987(1):online> ');
+ $this->transport->setSimulateAnsi(true);
+
+ $reply = $this->mockQuery->request('whoami');
+ $this->assertEquals('serveradmin', $reply->toList()['client_nickname']);
+ }
+}
diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php
index c8822680..7ca23519 100644
--- a/tests/Helper/CharTest.php
+++ b/tests/Helper/CharTest.php
@@ -353,6 +353,14 @@ public function testUnicode1Byte()
);
}
+ /**
+ * @throws HelperException
+ */
+ public function testUnicodeRejectsTruncatedUtf8Sequence(): void
+ {
+ $this->assertSame(-1, Char::fromHex('C2')->toUnicode());
+ }
+
public function testUnicodeFailed()
{
$this->expectException(HelperException::class);
diff --git a/tests/Helper/UriTest.php b/tests/Helper/UriTest.php
index e901001d..5d7d554f 100644
--- a/tests/Helper/UriTest.php
+++ b/tests/Helper/UriTest.php
@@ -589,4 +589,16 @@ public function testStripslashesRecursiveHandlesNestedArrays()
$result = $this->callProtectedStatic('stripslashesRecursive', [$input]);
$this->assertEquals($expected, $result);
}
+
+ /**
+ * @throws HelperException
+ */
+ public function testQueryVarWithUrlEncodedSpecialChars(): void
+ {
+ $uri = new Uri('serverquery://user:password@127.0.0.1:10022/?server_port=9987&fingerprint=SHA256%3ADta1vhZkN87oWmXT6jgjXfFRvvLHxtxoOCr%2BtUR%2F7XY%3D');
+
+ $this->assertTrue($uri->hasQueryVar('fingerprint'));
+ $this->assertEquals('SHA256:Dta1vhZkN87oWmXT6jgjXfFRvvLHxtxoOCr+tUR/7XY=', (string) $uri->getQueryVar('fingerprint'));
+ $this->assertEquals('SHA256:Dta1vhZkN87oWmXT6jgjXfFRvvLHxtxoOCr+tUR/7XY=', $uri->getQuery()['fingerprint']);
+ }
}
diff --git a/tests/Transport/TSsshTest.php b/tests/Transport/TSsshTest.php
new file mode 100644
index 00000000..9841d1ce
--- /dev/null
+++ b/tests/Transport/TSsshTest.php
@@ -0,0 +1,216 @@
+ "
+ private string $sampleHostKey = 'rsa-sha2-512 AAAAB3NzaC1yc2EAAAADAQABAAABAQC3r7Yh5N1xXj1234567890abcdefghijklmnopqrstuvwxyz';
+
+ /**
+ * @throws TransportException
+ */
+ public function testConstructorNoException(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $this->assertInstanceOf(TSssh::class, $transport);
+ $this->assertEquals($this->host, $transport->getConfig('host'));
+ $this->assertEquals($this->port, $transport->getConfig('port'));
+ $this->assertEquals(10, $transport->getConfig('timeout'));
+ $this->assertEquals(1, $transport->getConfig('blocking'));
+ }
+
+ public function testConstructorExceptionNoHost(): void
+ {
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage("config must have a key for 'host'");
+
+ new TSssh(['port' => $this->port]);
+ }
+
+ public function testConstructorExceptionNoPort(): void
+ {
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage("config must have a key for 'port'");
+
+ new TSssh(['host' => $this->host]);
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testConstructorWithFingerprintConfig(): void
+ {
+ $fingerprint = 'SHA256:Dta1vhZkN87oWmXT6jgjXfFRvvLHxtxoOCr+tUR/7XY';
+ $transport = new TSssh([
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'fingerprint' => $fingerprint,
+ ]);
+
+ $this->assertEquals($fingerprint, $transport->getConfig('fingerprint'));
+ $this->assertCount(5, $transport->getConfig());
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testGetConfigWithoutFingerprint(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $config = $transport->getConfig();
+ $this->assertIsArray($config);
+ $this->assertCount(4, $config);
+ $this->assertArrayNotHasKey('fingerprint', $config);
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintOpenSshFormat(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $keyParts = explode(' ', $this->sampleHostKey);
+ $rawBlob = base64_decode($keyParts[1]);
+ $expectedB64 = base64_encode(hash('sha256', $rawBlob, true));
+ $expectedOpenSsh = 'SHA256:'.$expectedB64;
+
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, $expectedOpenSsh));
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintUnpaddedOpenSshFormat(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $keyParts = explode(' ', $this->sampleHostKey);
+ $rawBlob = base64_decode($keyParts[1]);
+ $expectedB64Unpadded = rtrim(base64_encode(hash('sha256', $rawBlob, true)), '=');
+ $expectedOpenSshUnpadded = 'SHA256:'.$expectedB64Unpadded;
+
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, $expectedOpenSshUnpadded));
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintBase64Format(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $keyParts = explode(' ', $this->sampleHostKey);
+ $rawBlob = base64_decode($keyParts[1]);
+ $expectedB64 = base64_encode(hash('sha256', $rawBlob, true));
+
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, $expectedB64));
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintUnpaddedBase64Format(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $keyParts = explode(' ', $this->sampleHostKey);
+ $rawBlob = base64_decode($keyParts[1]);
+ $expectedB64Unpadded = rtrim(base64_encode(hash('sha256', $rawBlob, true)), '=');
+
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, $expectedB64Unpadded));
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintHexFormat(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $keyParts = explode(' ', $this->sampleHostKey);
+ $rawBlob = base64_decode($keyParts[1]);
+ $expectedHex = hash('sha256', $rawBlob);
+
+ // Test lowercase hex
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, strtolower($expectedHex)));
+ // Test uppercase hex
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, strtoupper($expectedHex)));
+ // Test sha256: prefixed hex
+ $this->assertTrue($transport->verifyFingerprint($this->sampleHostKey, 'sha256:'.$expectedHex));
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintWithRawBlob(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $rawBlob = 'some_raw_binary_key_data_string_for_testing';
+ $expectedOpenSsh = 'SHA256:'.base64_encode(hash('sha256', $rawBlob, true));
+
+ $this->assertTrue($transport->verifyFingerprint($rawBlob, $expectedOpenSsh));
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintMismatchThrowsException(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Hostkey verification failed: The expected fingerprint does not match the server fingerprint!');
+
+ $transport->verifyFingerprint($this->sampleHostKey, 'SHA256:invalidfingerprintstring1234567890');
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintEmptyHostKeyThrowsException(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Host key verification failed: The servers host key could not be verified.');
+
+ $transport->verifyFingerprint('', 'SHA256:test');
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testVerifyFingerprintFalseHostKeyThrowsException(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage('Host key verification failed: The servers host key could not be verified.');
+
+ $transport->verifyFingerprint(false, 'SHA256:test');
+ }
+
+ /**
+ * @throws TransportException
+ */
+ public function testDisconnectNoConnection(): void
+ {
+ $transport = new TSssh(['host' => $this->host, 'port' => $this->port]);
+ $this->assertNull($transport->getStream());
+ $transport->disconnect();
+ $this->assertFalse($transport->isConnected());
+ }
+}