📜 ⬆️ ⬇️

Boost.Asio Pinger and Unit Testing

Hello! In one of our previous articles, we talked about the implementation of the function of asynchronous ping as part of the task of creating a "pinger" for its further use in pentest organizations with a large number of workstations. Today we will talk about the coverage of our pinger (logic and network part) with unit tests.

It is clear that the need to write code that will be tested, disciplines and helps to plan the architecture more intelligently. However, the first thought about covering the asynchronous code on Boost.Asio with unit tests was something like this: “What ?! It is absolutely impossible! How can I write a test based on the node’s network accessibility? ”

Then an idea appeared to somehow emulate a remote node and its responses to commands received from our pinger. Further study of the implementation of asynchronous primitives from Boost.Asio gave rise to the idea of ​​parameterizing ready-made primitives with test implementations of services that will respond to our commands.
')
This is what a simplified socket diagram in Boost.Asio looks like. For simplicity, we will consider only the methods of connecting, sending and receiving data.



In the library code, the implementation of this scheme is as follows:

template <typename Protocol, typename StreamSocketService = stream_socket_service<Protocol> > class basic_stream_socket : public basic_socket<Protocol, StreamSocketService> { } 

In this case, all calls in boost :: asio :: basic_stream_socket are delegated to the class StreamSocketService. Here is a part of the Boost.Asio library code that demonstrates this:

  template <typename ConnectHandler> void async_connect(const endpoint_type& peer_endpoint, BOOST_ASIO_MOVE_ARG(ConnectHandler) handler) { ..... this->get_service().async_connect(this->get_implementation(), peer_endpoint, BOOST_ASIO_MOVE_CAST(ConnectHandler)(handler)); } 

In other words, the socket class itself is, in fact, just a wrapper that is parameterized by protocol and service types; a good example of static polymorphism. So, in order to “replace” the implementation of socket methods, we need to specify our service implementation as a parameter of the socket template. This is what this socket hierarchy would look like when using dynamic polymorphism with the addition of a test service.



In our case, which is nothing more than Compile time dependency injection , the simplified diagram for the test socket will look like this.



In the code, test and working primitives are described as follows.

Standard primitives
 class BoostPrimitives { public: typedef boost::asio::ip::tcp::socket TCPSocket; typedef boost::asio::ip::icmp::socket ICMPSocket; typedef boost::asio::ip::tcp::resolver Resolver; typedef boost::asio::deadline_timer Timer; }; 

Test primitives
 class Primitives { public: typedef ba::basic_stream_socket < ba::ip::tcp, SocketService<ba::ip::tcp> > TCPSocket; typedef ba::basic_raw_socket < ba::ip::icmp, SocketService<ba::ip::icmp> > ICMPSocket; typedef ba::basic_deadline_timer < boost::posix_time::ptime, ba::time_traits<boost::posix_time::ptime>, TimerService < boost::posix_time::ptime, ba::time_traits<boost::posix_time::ptime> > > Timer; typedef ba::ip::basic_resolver < ba::ip::tcp, ResolverService<ba::ip::tcp> > Resolver; }; 

SocketService, TimerService and ResolverService are test service implementations.

The primitives of the timer and resolver of names, as well as their services have a similar structure, so we limit ourselves to the description of sockets and their services.

And this is how the working and test implementations of the pinger will be presented in a simplified form.



In the code, it looks like this.

Pinger implementation
 template<typename Traits> class PingerImpl { ..... //! Socket type typedef typename Traits::TCPSocket TCPSocket; ..... } 


Pinger in the working version
 class Pinger { //! Implementation type typedef PingerImpl<BoostPrimitives> Impl; .... private: //! Implementation std::auto_ptr<Impl> m_Impl; }; 


Pinger in the test version
 class BaseTest : boost::noncopyable { protected: //! Pinger implementation type typedef Net::PingerImpl<Test::Primitives> TestPinger; .... }; 


So, we have access to individual operations of primitives. Now you need to understand how to use them to organize a test case covering the ping process. We can represent this process (ping a node) as a sequence of commands executed via the Boost.Asio library. We need a certain queue of commands that will be filled in during the initialization of the test script and empty during the execution of the ping. Here is a state diagram describing test performance.



We introduce the ICommand abstraction, which will provide methods similar to the methods of the Boost.Asio primitives, and create classes for the implementation of specific commands (the Connect class will implement connections to the node, the Receive class - receive data, etc.).

UML-diagram of the tests is presented below.



Team abstraction
 //! Pinger test command interface class ICommand : boost::noncopyable { public: //! Command pointer typedef boost::shared_ptr<ICommand> Ptr; //! Error callback type typedef boost::function<void(const boost::system::error_code&)> ErrorCallback; //! Error and size callback typedef boost::function<void(const boost::system::error_code&, std::size_t)> ErrorAndSizeCallback; //! Resolver callback typedef boost::function<void(const boost::system::error_code&, boost::asio::ip::tcp::resolver::iterator)> ResolverCallback; public: ICommand(const Status::Enum status) : m_Status(status) {} //! Timer wait virtual void AsyncWait(ErrorCallback& callback, boost::asio::io_service& io); //! Async connect virtual void AsyncConnect(ErrorCallback& callback, boost::asio::io_service& io); //! Async receive virtual void AsyncReceive(ErrorAndSizeCallback& callback, const std::vector<char>& sended, const boost::asio::mutable_buffer& buffer, boost::asio::io_service& io); //! Async resolve virtual void AsyncResolve(ResolverCallback& callback, boost::asio::io_service& io); //! Dtor virtual ~ICommand() {} protected: Status::Enum m_Status; }; 


At the same time, methods that are not provided by a specific command will contain test statements: this way we will be able to control the sequence of execution of commands.

Example implementation of the join command
 void Connect::AsyncConnect(ErrorCallback& callback, boost::asio::io_service& io) { if (m_Status != Status::Pending) { io.post(boost::bind(callback, m_Code)); callback = ErrorCallback(); } } 


The "default" implementation reports that the command is not extracted in turn:

 void ICommand::AsyncConnect(ErrorCallback& /*callback*/, boost::asio::io_service& /*io*/) { assert(false); } 

We also need a class - a test case that provides methods for working with the command queue and verifies that there are no teams left in the queue after the test has been completed.

The implementation of the "test case" with a queue of commands
 //! Test fixture class Fixture { //! Commands list typedef std::list<ICommand::Ptr> Commands; public: Fixture(); ~Fixture(); static void Push(ICommand* cmd); static ICommand::Ptr Pop(); private: static Commands s_Commands; }; Fixture::Commands Fixture::s_Commands; Fixture::Fixture() { assert(s_Commands.empty()); // ,       - } Fixture::~Fixture() { assert(s_Commands.empty()); //      } void Fixture::Push(ICommand* cmd) { s_Commands.push_back(ICommand::Ptr(cmd)); } ICommand::Ptr Fixture::Pop() { assert(!s_Commands.empty()); const ICommand::Ptr result = s_Commands.front(); s_Commands.pop_front(); return result; } 


Part of the implementation of the test service
 template<typename T> void async_connect(implementation_type& /*impl*/, const endpoint& /*ep*/, const T& callback) { m_ConnectCallback = callback; Fixture::Pop()->AsyncConnect(m_ConnectCallback, m_Service); //   } 


Unit tests are written on the Google framework , here is an example of the implementation of a test for ICMP ping:

 class BaseTest : boost::noncopyable { protected: //! Pinger implementation type typedef Net::PingerImpl<Test::Primitives> TestPinger; BaseTest() { m_Pinger.reset(new TestPinger(boost::bind(&BaseTest::Callback, this, _1, _2))); } virtual ~BaseTest() { m_Pinger->AddRequest(m_Command); while (m_Pinger->IsActive()) boost::this_thread::interruptible_wait(100); } template<typename T> void Cmd(const Status::Enum status) { m_Fixture.Push(new T(status)); } template<typename T, typename A> void Cmd(const Status::Enum status, const A& arg) { m_Fixture.Push(new T(status, arg)); } void Callback(const Net::PingCommand& /*cmd*/, const Net::PingResult& /*rslt*/) { //     ,     } Fixture m_Fixture; std::auto_ptr<TestPinger> m_Pinger; Net::PingCommand m_Command; }; //        //   -   ,    . class ICMPTest : public testing::Test, public BaseTest { }; TEST(ICMPTest, ICMPSuccess) { m_Command.m_HostName = "ptsecurity.ru"; Cmd<Resolve>(Status::Success, m_Command.m_HostName); //  IP   Cmd<Wait>(Status::Pending); //   ,  Status::Pending – ,      Cmd<Receive>(Status::Success); //        m_Command.m_Flags = SCANMGR_PING_ICMP; //        BaseTest } TEST(ICMPTest, ICMPFail) { m_Command.m_HostName = "ptsecurity.ru"; Cmd<Resolve>(Status::Success, m_Command.m_HostName); //  IP   Cmd<Wait>(Status::Success); //   ,  Status::Success – ,     Cmd<Receive>(Status::Pending); //      m_Command.m_Flags = SCANMGR_PING_ICMP; //        BaseTest } 

So, with testing the network part of the pinger, everything is clear: you only need to describe the sequence of commands for each of the possible ping scenarios. Recall that the pinger logic contains several virtual methods that are redefined in the PingerImpl class. Thus, we managed to untie the logic from the network part.



In the diagram, the TestLogic class is created using a google mock . In this case, logic tests determine the sequence of methods and arguments with which they will be invoked, with certain input parameters.

Implementing Test Logic
 class TestLogic : public Net::PingerLogic { public: TestLogic(const Net::PingCommand& cmd, const Net::Pinger::Callback& callback) : Net::PingerLogic(cmd, callback) { } MOCK_METHOD1(InitPorts, void (const std::string& ports)); MOCK_METHOD1(ResolveIP, bool (const std::string& name)); MOCK_METHOD1(StartResolveNameByIp, void (unsigned long ip)); MOCK_METHOD1(StartResolveIpByName, void (const std::string& name)); MOCK_METHOD1(StartTCPPing, void (std::size_t timeout)); MOCK_METHOD1(StartICMPPing, void (std::size_t timeout)); MOCK_METHOD1(StartGetNetBiosName, void (const std::string& name)); MOCK_METHOD0(Cancel, void ()); }; 


A couple of examples of unit tests
 TEST(Logic, Start) { const std::string host = "ptsecurity.ru"; EXPECT_CALL(*m_Logic, InitPorts(g_TargetPorts)).Times(Exactly(1)); EXPECT_CALL(*m_Logic, ResolveIP(host)).Times(Exactly(1)).WillOnce(Return(true)); EXPECT_CALL(*m_Logic, StartResolveIpByName(host)).Times(Exactly(1)); m_Logic->OnStart(); } TEST(Logic, ResolveIp) { static const unsigned long ip = 0x10101010; EXPECT_CALL(*m_Logic, StartResolveNameByIp(ip)).Times(Exactly(1)); EXPECT_CALL(*m_Logic, StartICMPPing(1)).Times(Exactly(1)); EXPECT_CALL(*m_Logic, StartTCPPing(1)).Times(Exactly(1)); m_Logic->OnIpResolved(ip); } 


As a result, the task was successfully solved, the benefit of Boost.Asio is an excellent framework, perfectly suited for such purposes. In addition, as usual, in the process of covering with unit tests, several serious bugs were revealed :) Of course, we managed to save many hours of manual testing and debugging of the code. From the moment the pinger code was introduced into the product, it revealed only one minor bug related to inattention when writing the code, which means that the time to develop and write unit tests was not wasted!

From here we can draw conclusions:


Thank you all for your attention!

Author: Sergey Karnaukhov ( CLRN ).

Source: https://habr.com/ru/post/166139/


All Articles