From e6836f2dead13e9a501ec983368da98ef452044e Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 22:08:48 +0200 Subject: [PATCH 1/9] add: testUnicodeRejectsTruncatedUtf8Sequence --- src/Helper/Char.php | 36 ++++++++++++++++++++++++++++++++++-- tests/Helper/CharTest.php | 8 ++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Helper/Char.php b/src/Helper/Char.php index a96129c0..4dc523b3 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/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); From ed609dcec1916177c00b0837f252d9572a70c5f7 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Wed, 16 Sep 2026 23:40:37 +0200 Subject: [PATCH 2/9] fix: ssh remove unnecessary whitespace/CR/LF --- src/Transport/TSssh.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Transport/TSssh.php b/src/Transport/TSssh.php index 8bec53dd..97319d12 100644 --- a/src/Transport/TSssh.php +++ b/src/Transport/TSssh.php @@ -70,9 +70,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 +145,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 +155,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; } /** From 6770ae4a825b479b1b389e6c847f6e45c25e7061 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:07:20 +0200 Subject: [PATCH 3/9] fix: Resolved the hash collision issue for closures in `getCallbackHash` by using `spl_object_hash`, ensuring that callback registrations remain cleanly isolated. --- src/Helper/Signal.php | 4 ++++ 1 file changed, 4 insertions(+) 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'); } From 5ff791dd90ce9d725f700f149906709bf55a2470 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:08:02 +0200 Subject: [PATCH 4/9] fix: Securing the unwrapping of ServerQuery responses in whoami(), version(), and channelCreate(). --- src/Node/Host.php | 22 +++++++++++++++------- src/Node/Server.php | 21 +++++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/Node/Host.php b/src/Node/Host.php index bda43f09..626069d6 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; + } } } @@ -889,8 +893,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/Server.php b/src/Node/Server.php index 120949ef..77370438 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; + } + } } } } From 48fc8d6232f188e6323ed9fbc54941f93490f1c8 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:09:17 +0200 Subject: [PATCH 5/9] fix: mockServer and MockTCP improvements --- src/Adapter/MockServerQuery.php | 67 +++++ src/Transport/MockTCP.php | 465 +++++++++++++++++++++++++++++--- 2 files changed, 502 insertions(+), 30 deletions(-) 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/Transport/MockTCP.php b/src/Transport/MockTCP.php index c1153701..8351c225 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,54 @@ 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 $simulateDropConnection = false; + public function __construct(array $config = []) { parent::__construct($config); @@ -33,6 +77,22 @@ 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']; + } } public function connect(): void @@ -41,7 +101,14 @@ public function connect(): void return; } - // Simulated SSH connection + if ($this->failConnect) { + throw new TransportException("Connection to {$this->config['host']}:{$this->config['port']} failed"); + } + + 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 +116,7 @@ public function connect(): void public function isConnected(): bool { - return $this->connected; + return $this->connected && ! $this->simulateDropConnection; } public function disconnect(): void @@ -57,41 +124,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(); + } + + if ($this->simulateDropConnection) { + $this->connected = false; + throw new TransportException( + "Connection to server '{$this->config['host']}:{$this->config['port']}' lost" + ); + } + + $line = ''; - while (! $line->endsWith($token)) { - // Read from SSH transport - $data = $this->read(); // already returns StringHelper + while (! str_ends_with($line, $token)) { + $data = $this->read(); - Signal::getInstance()->emit(strtolower($this->getAdapterType()).'DataRead', $data->toString()); + $str = $data->toString(); + if ($str === '' || $str === "\n") { + if ($line !== '') { + $line .= $token; + break; + } - if ($data->count() === 0) { - if ($line->count()) { - $line->append($token); - } else { - throw new TransportException( - "Connection to server '{$this->config['host']}:{$this->config['port']}' lost" - ); + 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 +183,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 +253,301 @@ 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 'servergroupadd': + case 'servergroupdel': + case 'channelgroupadd': + case 'channelgroupdel': + case 'privilegekeydelete': + case 'privilegekeyuse': + return 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; } } From bbe22580786cc489052eb03128dc03e039926f8d Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:09:37 +0200 Subject: [PATCH 6/9] chore: cs-fixer --- src/Helper/Char.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 4dc523b3..8caca6f3 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -170,13 +170,13 @@ public function toUnicode(): int } elseif ($h < 0xC2) { return -1; } elseif ($h <= 0xDF) { - if ($length !== 2 || !$this->isContinuationByte(1)) { + 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)) { + if ($length !== 3 || ! $this->isContinuationByte(1) || ! $this->isContinuationByte(2)) { return -1; } @@ -184,9 +184,9 @@ public function toUnicode(): int } elseif ($h <= 0xF4) { if ( $length !== 4 - || !$this->isContinuationByte(1) - || !$this->isContinuationByte(2) - || !$this->isContinuationByte(3) + || ! $this->isContinuationByte(1) + || ! $this->isContinuationByte(2) + || ! $this->isContinuationByte(3) ) { return -1; } @@ -202,7 +202,7 @@ public function toUnicode(): int private function isContinuationByte(int $offset): bool { - if (!isset($this->char[$offset])) { + if (! isset($this->char[$offset])) { return false; } From b0d0601b1136968fe0fc658e36459e0a91900486 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:10:22 +0200 Subject: [PATCH 7/9] add: tcp mock tests --- tests/Feature/ChannelFeatureTest.php | 156 ++++++++++++++ tests/Feature/ClientFeatureTest.php | 173 +++++++++++++++ tests/Feature/ConnectionFeatureTest.php | 236 +++++++++++++++++++++ tests/Feature/ServerFeatureTest.php | 184 ++++++++++++++++ tests/Feature/TransportMockFeatureTest.php | 160 ++++++++++++++ 5 files changed, 909 insertions(+) create mode 100644 tests/Feature/ChannelFeatureTest.php create mode 100644 tests/Feature/ClientFeatureTest.php create mode 100644 tests/Feature/ConnectionFeatureTest.php create mode 100644 tests/Feature/ServerFeatureTest.php create mode 100644 tests/Feature/TransportMockFeatureTest.php 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..942ecfda --- /dev/null +++ b/tests/Feature/ConnectionFeatureTest.php @@ -0,0 +1,236 @@ +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); + } +} diff --git a/tests/Feature/ServerFeatureTest.php b/tests/Feature/ServerFeatureTest.php new file mode 100644 index 00000000..ea063bdc --- /dev/null +++ b/tests/Feature/ServerFeatureTest.php @@ -0,0 +1,184 @@ +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); + } +} 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']); + } +} From 211806c455b5d7078197ec33bee520a93f982bf1 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 00:10:38 +0200 Subject: [PATCH 8/9] update: coverage badge --- doc/coverage/coverage-badge.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/coverage/coverage-badge.svg b/doc/coverage/coverage-badge.svg index e7746bf0..930fb3a3 100644 --- a/doc/coverage/coverage-badge.svg +++ b/doc/coverage/coverage-badge.svg @@ -14,6 +14,6 @@ coverage - 69% + 71% \ No newline at end of file From 8a830b51586aead15c4bb840983c40b728e929c1 Mon Sep 17 00:00:00 2001 From: Oliver Nitzsche Date: Thu, 17 Sep 2026 08:12:33 +0200 Subject: [PATCH 9/9] change: Reviewing the codebase for security vulnerabilities, logic errors, type incompatibilities, and regressions --- src/Node/ChannelGroup.php | 2 +- src/Node/Group.php | 4 +- src/Node/Host.php | 25 +++++-- src/Node/Node.php | 2 +- src/Node/Server.php | 108 +++++++++++++++++----------- src/Node/ServerGroup.php | 5 +- src/Transport/MockTCP.php | 20 +++++- tests/Feature/ServerFeatureTest.php | 69 ++++++++++++++++++ 8 files changed, 181 insertions(+), 54 deletions(-) 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 626069d6..fc139e8d 100644 --- a/src/Node/Host.php +++ b/src/Node/Host.php @@ -193,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; } /** @@ -761,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'], @@ -779,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)); 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 77370438..f8dd5892 100644 --- a/src/Node/Server.php +++ b/src/Node/Server.php @@ -798,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; } @@ -1313,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; + } + } } } } @@ -1357,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; + } + } } } } @@ -1763,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; + } + } } } } @@ -1807,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; + } + } } } } @@ -2681,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']; } /** @@ -3141,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/Transport/MockTCP.php b/src/Transport/MockTCP.php index 8351c225..13acb8bf 100644 --- a/src/Transport/MockTCP.php +++ b/src/Transport/MockTCP.php @@ -348,14 +348,30 @@ protected function resolveDynamicDefaultResponse(string $cmd): string case 'servernotifyunregister': case 'servergroupaddclient': case 'servergroupdelclient': - case 'servergroupadd': case 'servergroupdel': - case 'channelgroupadd': 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; diff --git a/tests/Feature/ServerFeatureTest.php b/tests/Feature/ServerFeatureTest.php index ea063bdc..0b20c1e6 100644 --- a/tests/Feature/ServerFeatureTest.php +++ b/tests/Feature/ServerFeatureTest.php @@ -181,4 +181,73 @@ public function testInvalidServerIdException(): void $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); + } }