diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c75319..247de9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,8 @@ find_package(Threads REQUIRED) # include top level include directory include_directories(${PROJECT_SOURCE_DIR}/inc) +enable_testing() + add_subdirectory(3rd) add_subdirectory(src) add_subdirectory(test) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 01fbaf4..b2d7c01 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -14,10 +14,26 @@ set(PROJECT_HOMEPAGE "https://github.com/Ohto-Ai/CMakeProjectTemplate/") string(TOUPPER ${PROJECT_NAME} PROJECT_NAME_VAR) string(REPLACE "-" "_" PROJECT_NAME_VAR ${PROJECT_NAME_VAR}) -# Add source files +# Build ssh_connection as a static library so tests can link against it +add_library(webssh_cpp_ssh_lib STATIC + ssh_connection/ssh_connection.cpp +) +target_include_directories(webssh_cpp_ssh_lib PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/inc + ${PROJECT_BINARY_DIR}/generated/inc) +target_link_libraries(webssh_cpp_ssh_lib PUBLIC + Threads::Threads + $<$:OpenSSL::SSL> + $<$:OpenSSL::Crypto> + libssh2_static + $<$>:atomic> + spdlog + $<$:ws2_32>) + +# Add source files for the main executable set(SOURCES main.cpp - ssh_connection/ssh_connection.cpp ) # Build the executable @@ -28,11 +44,5 @@ target_include_directories(${PROJECT_NAME} PRIVATE inc) target_include_directories(${PROJECT_NAME} PRIVATE ${PROJECT_BINARY_DIR}/generated/inc) target_link_libraries(${PROJECT_NAME} PRIVATE - Threads::Threads - $<$:OpenSSL::SSL> - $<$:OpenSSL::Crypto> - hv_static - libssh2_static - $<$>:atomic> - spdlog - $<$:ws2_32>) + webssh_cpp_ssh_lib + hv_static) diff --git a/src/ssh_connection/ssh_connection.cpp b/src/ssh_connection/ssh_connection.cpp index 35550f6..a726a59 100644 --- a/src/ssh_connection/ssh_connection.cpp +++ b/src/ssh_connection/ssh_connection.cpp @@ -517,3 +517,15 @@ ohtoai::ssh::detail::ssh_channel_ptr ohtoai::ssh::detail::ssh_pty_connection_man } return iter->second.lock(); } + +void ohtoai::ssh::detail::ssh_pty_connection_manager::close_channel(const detail::channel_id_t &id) { + auto iter = channels.find(id); + if (iter == channels.end()) { + return; + } + auto ch = iter->second.lock(); + channels.erase(iter); + if (ch) { + ch->close(); + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 920b6d0..31e9e3b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,9 +1,7 @@ cmake_minimum_required(VERSION 3.10) -project(test) +project(webssh_cpp_test) -enable_testing() - -add_custom_target(${PROJECT_NAME} ALL) +add_custom_target(webssh_cpp_tests ALL) # Add test files file(GLOB test_files "*.test.cpp") @@ -15,7 +13,7 @@ foreach (test_file ${test_files}) target_include_directories(${test_name} PRIVATE ${CMAKE_BINARY_DIR}/src/generated/inc) target_link_libraries(${test_name} PRIVATE Catch2 Catch2WithMain - Threads::Threads + webssh_cpp_ssh_lib $<$:OpenSSL::SSL> $<$:OpenSSL::Crypto> spdlog $<$:ws2_32>) @@ -23,7 +21,7 @@ foreach (test_file ${test_files}) PRIVATE $<$:/bigobj>) add_test(NAME ${test_name} COMMAND ${test_name}) - add_dependencies(${PROJECT_NAME} ${test_name}) + add_dependencies(webssh_cpp_tests ${test_name}) list(APPEND test_list ${test_name}) endforeach () diff --git a/test/mini_buffer.test.cpp b/test/mini_buffer.test.cpp new file mode 100644 index 0000000..d5b458b --- /dev/null +++ b/test/mini_buffer.test.cpp @@ -0,0 +1,159 @@ +#include +#include +#include + +TEST_CASE("mini_buffer default constructor", "[mini_buffer]") { + ohtoai::mini_buffer buf; + REQUIRE(buf.data == nullptr); + REQUIRE(buf.size == 0); + REQUIRE(buf.capacity == 0); +} + +TEST_CASE("mini_buffer capacity constructor", "[mini_buffer]") { + ohtoai::mini_buffer buf(128); + REQUIRE(buf.data != nullptr); + REQUIRE(buf.size == 0); + REQUIRE(buf.capacity == 128); +} + +TEST_CASE("mini_buffer reserve", "[mini_buffer]") { + SECTION("reserve grows capacity") { + ohtoai::mini_buffer buf(64); + buf.reserve(256); + REQUIRE(buf.capacity == 256); + REQUIRE(buf.size == 0); + } + + SECTION("reserve smaller than current capacity is a no-op") { + ohtoai::mini_buffer buf(256); + buf.reserve(64); + REQUIRE(buf.capacity == 256); + } + + SECTION("reserve preserves existing data") { + ohtoai::mini_buffer buf(64); + const char *text = "hello"; + std::memcpy(buf.data, text, 5); + buf.size = 5; + buf.reserve(256); + REQUIRE(std::memcmp(buf.data, text, 5) == 0); + } +} + +TEST_CASE("mini_buffer resize", "[mini_buffer]") { + SECTION("resize within capacity only changes size") { + ohtoai::mini_buffer buf(128); + buf.resize(64); + REQUIRE(buf.size == 64); + REQUIRE(buf.capacity >= 64); + } + + SECTION("resize larger than capacity grows the buffer") { + ohtoai::mini_buffer buf(64); + buf.resize(256); + REQUIRE(buf.size == 256); + REQUIRE(buf.capacity >= 256); + } + + SECTION("resize to zero") { + ohtoai::mini_buffer buf(64); + buf.resize(0); + REQUIRE(buf.size == 0); + } +} + +TEST_CASE("mini_buffer clear", "[mini_buffer]") { + ohtoai::mini_buffer buf(128); + buf.resize(64); + REQUIRE(buf.size == 64); + + buf.clear(); + REQUIRE(buf.size == 0); + REQUIRE(buf.capacity == 128); + REQUIRE(buf.data != nullptr); +} + +TEST_CASE("mini_buffer append", "[mini_buffer]") { + SECTION("append raw data") { + ohtoai::mini_buffer buf(64); + const char *text = "hello"; + buf.append(text, 5); + REQUIRE(buf.size == 5); + REQUIRE(std::memcmp(buf.data, text, 5) == 0); + } + + SECTION("append std::string") { + ohtoai::mini_buffer buf(64); + std::string text = "world"; + buf.append(text); + REQUIRE(buf.size == 5); + REQUIRE(std::memcmp(buf.data, text.data(), 5) == 0); + } + + SECTION("multiple appends accumulate data") { + ohtoai::mini_buffer buf(64); + buf.append("foo", 3); + buf.append("bar", 3); + REQUIRE(buf.size == 6); + REQUIRE(std::memcmp(buf.data, "foobar", 6) == 0); + } + + SECTION("append another mini_buffer") { + ohtoai::mini_buffer src(64); + src.append("data", 4); + + ohtoai::mini_buffer dst(64); + dst.append(src); + REQUIRE(dst.size == 4); + REQUIRE(std::memcmp(dst.data, "data", 4) == 0); + } + + SECTION("append triggers resize when exceeding capacity") { + ohtoai::mini_buffer buf(4); + buf.append("hello world!", 12); + REQUIRE(buf.size == 12); + REQUIRE(buf.capacity >= 12); + REQUIRE(std::memcmp(buf.data, "hello world!", 12) == 0); + } +} + +TEST_CASE("mini_buffer move semantics", "[mini_buffer]") { + SECTION("move constructor transfers ownership") { + ohtoai::mini_buffer src(128); + src.append("test", 4); + char *original_data = src.data; + + ohtoai::mini_buffer dst(std::move(src)); + REQUIRE(dst.data == original_data); + REQUIRE(dst.size == 4); + REQUIRE(dst.capacity == 128); + REQUIRE(src.data == nullptr); + REQUIRE(src.size == 0); + REQUIRE(src.capacity == 0); + } + + SECTION("move assignment transfers ownership") { + ohtoai::mini_buffer src(128); + src.append("test", 4); + char *original_data = src.data; + + ohtoai::mini_buffer dst(64); + dst = std::move(src); + REQUIRE(dst.data == original_data); + REQUIRE(dst.size == 4); + REQUIRE(dst.capacity == 128); + REQUIRE(src.data == nullptr); + } +} + +TEST_CASE("mini_buffer implicit char* conversion", "[mini_buffer]") { + ohtoai::mini_buffer buf(64); + buf.append("hello", 5); + + char *ptr = buf; + REQUIRE(ptr == buf.data); + + const ohtoai::mini_buffer &cbuf = buf; + const char *cptr = cbuf; + REQUIRE(cptr == cbuf.data); +} diff --git a/test/ssh_connection.test.cpp b/test/ssh_connection.test.cpp new file mode 100644 index 0000000..a7804ee --- /dev/null +++ b/test/ssh_connection.test.cpp @@ -0,0 +1,245 @@ +#include +#include +#include +#include + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers – read environment variables for the test SSH server. +// +// Set these variables to run the integration tests: +// TEST_SSH_HOST – hostname or IP of the SSH server (required) +// TEST_SSH_PORT – port number (default: 22) +// TEST_SSH_USER – username (required) +// TEST_SSH_PASS – password (required) +// +// When TEST_SSH_HOST is not set the tests are skipped. +// ────────────────────────────────────────────────────────────────────────────── +namespace { + +struct SshConfig { + std::string host; + int port = 22; + std::string username; + std::string password; + bool available = false; +}; + +SshConfig get_ssh_config() { + SshConfig cfg; + const char *host = std::getenv("TEST_SSH_HOST"); + if (!host || std::string(host).empty()) { + return cfg; // available == false → tests will be skipped + } + cfg.host = host; + + const char *port = std::getenv("TEST_SSH_PORT"); + if (port && *port) { + try { cfg.port = std::stoi(port); } catch (...) { cfg.port = 22; } + } + + const char *user = std::getenv("TEST_SSH_USER"); + cfg.username = (user && *user) ? user : ""; + + const char *pass = std::getenv("TEST_SSH_PASS"); + cfg.password = (pass && *pass) ? pass : ""; + + cfg.available = !cfg.host.empty() && !cfg.username.empty() && !cfg.password.empty(); + return cfg; +} + +} // anonymous namespace + +using namespace ohtoai::ssh; + +// ────────────────────────────────────────────────────────────────────────────── +// Connection and authentication tests +// ────────────────────────────────────────────────────────────────────────────── +TEST_CASE("SSH session connects and authenticates successfully", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + ssh_session session; + REQUIRE_NOTHROW(session.connect(cfg.host, cfg.port)); + REQUIRE_NOTHROW(session.authenticate(cfg.username, cfg.password)); + REQUIRE_NOTHROW(session.disconnect()); +} + +TEST_CASE("SSH session rejects wrong password", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + ssh_session session; + REQUIRE_NOTHROW(session.connect(cfg.host, cfg.port)); + REQUIRE_THROWS(session.authenticate(cfg.username, "__definitely_wrong_password__")); +} + +TEST_CASE("SSH session fails to connect to a bad host", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + ssh_session session; + // Port 1 is almost never open; expect a connection failure. + REQUIRE_THROWS(session.connect(cfg.host, 1)); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Channel lifecycle tests +// ────────────────────────────────────────────────────────────────────────────── +TEST_CASE("SSH channel can be opened and closed", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + ssh_session session; + session.connect(cfg.host, cfg.port); + session.authenticate(cfg.username, cfg.password); + + ssh_channel_ptr channel; + REQUIRE_NOTHROW(channel = session.open_channel()); + REQUIRE(channel != nullptr); + REQUIRE(channel->is_open()); + + REQUIRE_NOTHROW(channel->close()); + REQUIRE_FALSE(channel->is_open()); +} + +TEST_CASE("SSH channel supports PTY request and shell", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + ssh_session session; + session.connect(cfg.host, cfg.port); + session.authenticate(cfg.username, cfg.password); + + auto channel = session.open_channel(); + REQUIRE(channel != nullptr); + + REQUIRE_NOTHROW(channel->request_pty("xterm-256color")); + REQUIRE_NOTHROW(channel->shell()); + REQUIRE(channel->is_open()); + + REQUIRE_NOTHROW(channel->close()); +} + +TEST_CASE("SSH channel supports PTY resize", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + ssh_session session; + session.connect(cfg.host, cfg.port); + session.authenticate(cfg.username, cfg.password); + + auto channel = session.open_channel(); + channel->request_pty("xterm-256color"); + channel->shell(); + + REQUIRE_NOTHROW(channel->resize_pty(120, 40)); + + channel->close(); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Connection manager integration tests +// ────────────────────────────────────────────────────────────────────────────── +TEST_CASE("Connection manager creates a channel via get_channel", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + auto &mgr = ssh_pty_connection_manager::get_instance(); + size_t sessions_before = mgr.get_session_count(); + size_t channels_before = mgr.get_channel_count(); + + ssh_channel_ptr ch; + REQUIRE_NOTHROW(ch = mgr.get_channel(cfg.host, cfg.port, cfg.username, cfg.password)); + REQUIRE(ch != nullptr); + + REQUIRE(mgr.get_session_count() > sessions_before); + REQUIRE(mgr.get_channel_count() > channels_before); + REQUIRE(mgr.get_channel_alive_count() >= 1); + + // Retrieve the same channel by ID + auto ch2 = mgr.get_channel(ch->id); + REQUIRE(ch2 != nullptr); + REQUIRE(ch2->id == ch->id); + + mgr.close_channel(ch->id); + // After closing the last channel in a session the session itself is removed. + REQUIRE(mgr.get_channel_alive_count() == channels_before); +} + +TEST_CASE("Connection manager reuses session for multiple channels", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + auto &mgr = ssh_pty_connection_manager::get_instance(); + // Allow at least 2 channels per session for this test + mgr.set_max_channel_in_session(5); + + size_t sessions_before = mgr.get_session_count(); + + auto ch1 = mgr.get_channel(cfg.host, cfg.port, cfg.username, cfg.password); + auto ch2 = mgr.get_channel(cfg.host, cfg.port, cfg.username, cfg.password); + + REQUIRE(ch1 != nullptr); + REQUIRE(ch2 != nullptr); + REQUIRE(ch1->id != ch2->id); + + // Both channels should share the same session → session count unchanged. + REQUIRE(mgr.get_session_count() == sessions_before + 1); + + mgr.close_channel(ch1->id); + mgr.close_channel(ch2->id); + + // restore default + mgr.set_max_channel_in_session(3); +} + +TEST_CASE("Connection manager opens a new session when max channels reached", "[ssh_integration]") { + auto cfg = get_ssh_config(); + if (!cfg.available) { + WARN("Skipping SSH integration tests: TEST_SSH_HOST / TEST_SSH_USER / TEST_SSH_PASS not set"); + return; + } + + auto &mgr = ssh_pty_connection_manager::get_instance(); + mgr.set_max_channel_in_session(1); // only 1 channel per session + + size_t sessions_before = mgr.get_session_count(); + + auto ch1 = mgr.get_channel(cfg.host, cfg.port, cfg.username, cfg.password); + auto ch2 = mgr.get_channel(cfg.host, cfg.port, cfg.username, cfg.password); + + REQUIRE(ch1 != nullptr); + REQUIRE(ch2 != nullptr); + // With max=1, a second channel forces a second session. + REQUIRE(mgr.get_session_count() >= sessions_before + 2); + + mgr.close_channel(ch1->id); + mgr.close_channel(ch2->id); + + // restore default + mgr.set_max_channel_in_session(3); +} diff --git a/test/ssh_session.test.cpp b/test/ssh_session.test.cpp new file mode 100644 index 0000000..a751109 --- /dev/null +++ b/test/ssh_session.test.cpp @@ -0,0 +1,87 @@ +#include +#include + +using namespace ohtoai::ssh; + +// ────────────────────────────────────────────────────────────────────────────── +// ssh_session::generate_id() +// ────────────────────────────────────────────────────────────────────────────── +TEST_CASE("ssh_session::generate_id with username", "[ssh_session]") { + auto id = ssh_session::generate_id("example.com", 22, "alice"); + REQUIRE(id == "alice@example.com:22"); +} + +TEST_CASE("ssh_session::generate_id without username", "[ssh_session]") { + auto id = ssh_session::generate_id("example.com", 22, ""); + REQUIRE(id == "example.com:22"); +} + +TEST_CASE("ssh_session::generate_id with non-default port", "[ssh_session]") { + auto id = ssh_session::generate_id("192.168.1.1", 2222, "bob"); + REQUIRE(id == "bob@192.168.1.1:2222"); +} + +TEST_CASE("ssh_session::generate_id with custom suffix", "[ssh_session]") { + auto id = ssh_session::generate_id("host.local", 22, "carol", "/extra"); + REQUIRE(id == "carol@host.local:22/extra"); +} + +TEST_CASE("ssh_session::generate_id without username but with custom suffix", "[ssh_session]") { + auto id = ssh_session::generate_id("host.local", 22, "", "/extra"); + REQUIRE(id == "host.local:22/extra"); +} + +TEST_CASE("ssh_session::generate_id distinguishes different users on the same host", "[ssh_session]") { + auto id1 = ssh_session::generate_id("host.local", 22, "alice"); + auto id2 = ssh_session::generate_id("host.local", 22, "bob"); + REQUIRE(id1 != id2); +} + +TEST_CASE("ssh_session::generate_id distinguishes different ports on the same host", "[ssh_session]") { + auto id1 = ssh_session::generate_id("host.local", 22, "alice"); + auto id2 = ssh_session::generate_id("host.local", 2222, "alice"); + REQUIRE(id1 != id2); +} + +// ────────────────────────────────────────────────────────────────────────────── +// ssh_pty_connection_manager – state management (no network required) +// ────────────────────────────────────────────────────────────────────────────── +TEST_CASE("ssh_pty_connection_manager default max_channel_in_session is 3", "[connection_manager]") { + auto &mgr = ssh_pty_connection_manager::get_instance(); + REQUIRE(mgr.get_max_channel_in_session() == 3); +} + +TEST_CASE("ssh_pty_connection_manager set_max_channel_in_session", "[connection_manager]") { + auto &mgr = ssh_pty_connection_manager::get_instance(); + mgr.set_max_channel_in_session(5); + REQUIRE(mgr.get_max_channel_in_session() == 5); + // restore default + mgr.set_max_channel_in_session(3); + REQUIRE(mgr.get_max_channel_in_session() == 3); +} + +TEST_CASE("ssh_pty_connection_manager set_max_channel_in_session to zero (unlimited)", "[connection_manager]") { + auto &mgr = ssh_pty_connection_manager::get_instance(); + mgr.set_max_channel_in_session(0); + REQUIRE(mgr.get_max_channel_in_session() == 0); + // restore default + mgr.set_max_channel_in_session(3); +} + +TEST_CASE("ssh_pty_connection_manager get_channel returns nullptr for unknown channel_id", "[connection_manager]") { + auto &mgr = ssh_pty_connection_manager::get_instance(); + auto ch = mgr.get_channel("nonexistent-channel-id-xyz"); + REQUIRE(ch == nullptr); +} + +TEST_CASE("ssh_pty_connection_manager close_channel on unknown id is safe", "[connection_manager]") { + auto &mgr = ssh_pty_connection_manager::get_instance(); + // Should not throw or crash + REQUIRE_NOTHROW(mgr.close_channel("nonexistent-channel-id-xyz")); +} + +TEST_CASE("ssh_pty_connection_manager get_channel_count for unknown session_id is zero", "[connection_manager]") { + auto &mgr = ssh_pty_connection_manager::get_instance(); + auto count = mgr.get_channel_count("nonexistent@host:22"); + REQUIRE(count == 0); +}