diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 0000000..589b5e5 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,39 @@ +name: Sanitizers + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + env: + SANITIZERS: "-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" + ASAN_OPTIONS: "detect_leaks=1:abort_on_error=1" + UBSAN_OPTIONS: "print_stacktrace=1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake build-essential clang + + - name: Configure (CMake) + run: | + mkdir -p build-sanitizers + cd build-sanitizers + CC=clang CXX=clang++ cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="$SANITIZERS" -DCMAKE_EXE_LINKER_FLAGS="$SANITIZERS" .. + + - name: Build + run: | + cd build-sanitizers + cmake --build . -- -j + + - name: Run tests + run: | + cd build-sanitizers + ctest --output-on-failure || (echo "ctest failed"; exit 1) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6007c54..416267e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,3 +173,28 @@ install(FILES LICENSES/GPL-2.0-only.txt LICENSES/GPL-3.0.txt DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME}) + +# --------------------------------------------------------- +# Unit tests +# --------------------------------------------------------- +option(BUILD_TESTS "Build unit tests" ON) + +if(BUILD_TESTS) + enable_testing() + file(GLOB_RECURSE TEST_SRC CONFIGURE_DEPENDS tests/*.cpp) + if(TEST_SRC) + foreach(TEST_FILE IN LISTS TEST_SRC) + get_filename_component(TEST_NAME ${TEST_FILE} NAME_WE) + add_executable(${TEST_NAME} ${TEST_FILE}) + target_link_libraries(${TEST_NAME} PRIVATE common sodium) + target_include_directories(${TEST_NAME} PRIVATE + ${PROJECT_SOURCE_DIR}/include + ${asio_SOURCE_DIR}/asio/include + ${sodium_SOURCE_DIR}/src/libsodium/include + ${sodium_BINARY_DIR}/src/libsodium/include + ) + add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + set_target_properties(${TEST_NAME} PROPERTIES OUTPUT_NAME "columnlynx_${TEST_NAME}") + endforeach() + endif() +endif() diff --git a/tests/test_libsodium_wrapper.cpp b/tests/test_libsodium_wrapper.cpp new file mode 100644 index 0000000..944f127 --- /dev/null +++ b/tests/test_libsodium_wrapper.cpp @@ -0,0 +1,42 @@ +// Tests for LibSodiumWrapper: random, symmetric encrypt/decrypt, sign/verify +#include +#include + +#include + +int main() { + using namespace ColumnLynx::Utils; + + // Random bytes uniqueness + auto a = LibSodiumWrapper::generateRandom256Bit(); + auto b = LibSodiumWrapper::generateRandom256Bit(); + assert(a != b && "generateRandom256Bit() should produce different outputs (very likely)"); + + // Symmetric encrypt/decrypt roundtrip + ColumnLynx::SymmetricKey key = {}; + for (size_t i = 0; i < key.size(); ++i) key[i] = static_cast(i); + auto nonce = LibSodiumWrapper::generateNonce(); + + std::string plaintext = "The quick brown fox jumps over the lazy dog"; + auto ct = LibSodiumWrapper::encryptMessage(reinterpret_cast(plaintext.data()), plaintext.size(), key, nonce, "aad"); + auto pt = LibSodiumWrapper::decryptMessage(ct.data(), ct.size(), key, nonce, "aad"); + std::string recovered(pt.begin(), pt.end()); + assert(recovered == plaintext && "decrypt should recover original plaintext"); + + // Sign and verify + ColumnLynx::PrivateKey sk{}; ColumnLynx::PublicKey pk{}; + randombytes_buf(sk.data(), sk.size()); + // naive keypair generation for test purposes: use libsodium functions via wrapper + // generate a real keypair using crypto_sign + if (crypto_sign_keypair(pk.data(), sk.data()) != 0) { + std::cerr << "Failed to generate keypair\n"; + return 2; + } + + auto sig = LibSodiumWrapper::signMessage(plaintext, sk); + bool ok = LibSodiumWrapper::verifyMessage(plaintext, sig, pk); + assert(ok && "Signature should verify"); + + std::cout << "LibSodiumWrapper tests passed\n"; + return 0; +} diff --git a/tests/test_session_registry.cpp b/tests/test_session_registry.cpp new file mode 100644 index 0000000..2d8bcf2 --- /dev/null +++ b/tests/test_session_registry.cpp @@ -0,0 +1,49 @@ +// Simple unit tests for SessionRegistry +#include +#include +#include + +#include +#include + +int main() { + using namespace ColumnLynx::Net; + using namespace ColumnLynx::Utils; + + auto ® = SessionRegistry::getInstance(); + + // Use a unique session id to avoid colliding with any running instance + const uint32_t sid = 0xDEADBEEF; + // ensure clean state + reg.erase(sid); + + auto key = LibSodiumWrapper::generateRandom256Bit(); + auto state = std::make_shared(key, std::chrono::hours(24), 0xC0A80101 /*192.168.1.1*/, 0, sid); + + reg.put(sid, state); + + assert(reg.exists(sid) && "Session should exist after put()"); + + auto got = reg.get(sid); + assert(got && got->sessionID == sid && "get() should return stored session"); + + auto byip = reg.getByIP(0xC0A80101); + assert(byip && byip->sessionID == sid && "getByIP() should find session by client IP"); + + // Erase and verify removed + reg.erase(sid); + assert(!reg.exists(sid) && "Session should not exist after erase()"); + assert(reg.getByIP(0xC0A80101) == nullptr && "getByIP() should return nullptr after erase"); + + // Test cleanupExpired: insert an already-expired session + const uint32_t sid2 = 0xFEEDBEEF; + reg.erase(sid2); + auto expiredState = std::make_shared(key, std::chrono::seconds(0), 0xC0A80102, 0, sid2); + reg.put(sid2, expiredState); + // Force cleanup + reg.cleanupExpired(); + assert(!reg.exists(sid2) && "Expired session should be removed by cleanupExpired()"); + + std::cout << "SessionRegistry tests passed\n"; + return 0; +} diff --git a/tests/test_session_registry_ip.cpp b/tests/test_session_registry_ip.cpp new file mode 100644 index 0000000..83f8ed8 --- /dev/null +++ b/tests/test_session_registry_ip.cpp @@ -0,0 +1,43 @@ +// Tests for SessionRegistry IP allocation and lock/dealloc +#include +#include + +#include +#include + +int main() { + using namespace ColumnLynx::Net; + using namespace ColumnLynx::Utils; + + auto ® = SessionRegistry::getInstance(); + + const uint32_t sid = 0xABCDEF01; + reg.erase(sid); + + auto key = LibSodiumWrapper::generateRandom256Bit(); + auto state = std::make_shared(key, std::chrono::hours(24), 0, 0, sid); + reg.put(sid, state); + + // Lock IP + uint32_t ip = 0xC0A80201; // 192.168.2.1 + reg.lockIP(sid, ip); + + auto byip = reg.getByIP(ip); + assert(byip && byip->sessionID == sid && "lockIP should populate mIPSessions"); + + // deallocIP + reg.deallocIP(sid); + assert(reg.getByIP(ip) == nullptr && "deallocIP should remove mapping"); + + // getFirstAvailableIP: choose a small /30 range to limit hosts + uint32_t base = 0x0A000000; // 10.0.0.0 + uint8_t mask = 30; // 2 usable hosts + uint32_t first = reg.getFirstAvailableIP(base, mask); + assert(first != 0 && "Should find available IP in empty registry"); + + // cleanup + reg.erase(sid); + + std::cout << "SessionRegistry IP tests passed\n"; + return 0; +} diff --git a/tests/test_tcp_message_handler_static.cpp b/tests/test_tcp_message_handler_static.cpp new file mode 100644 index 0000000..5f5c08e --- /dev/null +++ b/tests/test_tcp_message_handler_static.cpp @@ -0,0 +1,30 @@ +// Tests for TCP MessageHandler static helpers +#include +#include + +#include +#include + +int main() { + using namespace ColumnLynx::Net::TCP; + + // server message special codes + auto t1 = MessageHandler::decodeMessageType(0xFE); + // Expect GRACEFUL_DISCONNECT mapped + // Compare by converting back to uint8 + assert(MessageHandler::toUint8(t1) == 0xFE); + + auto t2 = MessageHandler::decodeMessageType(0xFF); + assert(MessageHandler::toUint8(t2) == 0xFF); + + // Client message range (>= 0xA0) + auto t3 = MessageHandler::decodeMessageType(0xA5); + assert(MessageHandler::toUint8(t3) == 0xA5); + + // Server message range (< 0xA0) and not special + auto t4 = MessageHandler::decodeMessageType(0x10); + assert(MessageHandler::toUint8(t4) == 0x10); + + std::cout << "TCP MessageHandler static helpers tests passed\n"; + return 0; +}