Pinger on Boost.Asio and unit testing

  • Tutorial
Hello! In one of our previous articles, we talked about implementing 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’ll talk about covering 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 architecture more competently. Nevertheless, the first thought about unit test coverage of asynchronous code on Boost.Asio was something like this: “What ?! This is absolutely impossible! How can I write a test based on the network availability of a node? ”

Then the idea came up to somehow emulate a remote host and its responses to commands received from our pinger. Upon further study of the implementation of asynchronous primitives from Boost.Asio, the idea arose of parameterizing ready-made primitives with test implementations of services that will respond to our commands.

This is what a simplified socket diagram looks like in Boost.Asio. 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  >
class basic_stream_socket
  : public basic_socket
{
}

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

  template 
  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 essentially just a wrapper that is parameterized by the types of protocol and service; a clear example of static polymorphism. So, in order to “replace” the implementation of socket methods, we need to set 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 a Compile time dependency injection , a simplified diagram for a 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 
	> TCPSocket;
	typedef ba::basic_raw_socket
	<
		ba::ip::icmp, 
		SocketService 
	> ICMPSocket;
	typedef ba::basic_deadline_timer
	<
		boost::posix_time::ptime, 
		ba::time_traits, 
		TimerService
		<
			boost::posix_time::ptime, 
			ba::time_traits 
		> 
	> Timer;
	typedef ba::ip::basic_resolver
	<
		ba::ip::tcp, 
		ResolverService 
	> Resolver;
};

SocketService, TimerService and ResolverService - implementations of test services.

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

And so, in a simplified form, the working and test implementations of the pinger will be presented.



In the code, it looks as follows.

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


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


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


So, we have access to individual primitive operations. Now you need to understand how to use them to organize a test case that covers the ping process. We can represent this process (node ​​ping) as a sequence of commands executed through the Boost.Asio library. We need a certain queue of commands that will be filled during the initialization of the test script and emptied during ping. Here is a state diagram that describes how the tests work.



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

The 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 Ptr;
	//! Error callback type
	typedef boost::function ErrorCallback;
	//! Error and size callback
	typedef boost::function ErrorAndSizeCallback;
	//! Resolver callback
	typedef boost::function 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& 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;
};


Moreover, methods not provided by a specific team will contain test statements: in this way we can control the sequence of commands.

Connection command implementation example
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 was 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 commands left in the queue after the test is completed.

Implementation of a “test case” having a command queue
//! Test fixture
class Fixture
{
	//! Commands list
	typedef std::list 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 test service implementation
template
	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 a test implementation for ICMP ping:

class BaseTest : boost::noncopyable
{
protected:
	//! Pinger implementation type
	typedef Net::PingerImpl 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
	void Cmd(const Status::Enum status)
	{
		m_Fixture.Push(new T(status));
	}
	template
	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 m_Pinger;
	Net::PingCommand m_Command;
};
// Параметризованные тесты для простоты понимания заменены обычными
// При создании юнит-теста описываем последовательность команд, которые должны будут выполниться. 
class ICMPTest :  public testing::Test, public BaseTest
{
};
TEST(ICMPTest, ICMPSuccess)
{
	m_Command.m_HostName = "ptsecurity.ru";
	Cmd(Status::Success,  m_Command.m_HostName); // получаем IP по имени
	Cmd(Status::Pending);  // взводим таймер таймаута, передав Status::Pending – говорим, что он не должен сработать 
	Cmd(Status::Success); // узел прислал пакет с данными в ответ
	m_Command.m_Flags = SCANMGR_PING_ICMP;
// выполнение команд пингером происходит в деструкторе класса BaseTest
}
TEST(ICMPTest, ICMPFail)
{
	m_Command.m_HostName = "ptsecurity.ru";
	Cmd(Status::Success,  m_Command.m_HostName);  // получаем IP по имени
	Cmd(Status::Success);  // взводим таймер таймаута, передав Status::Success – говорим, что он должен сработать 
	Cmd(Status::Pending);  // ждем получения данных от узла
	m_Command.m_Flags = SCANMGR_PING_ICMP;
// выполнение команд пингером происходит в деструкторе класса BaseTest
}

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



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

Test logic implementation
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 unit test examples
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 that is perfectly suitable for such purposes. In addition, as usual, in the process of covering unit tests, several serious bugs were identified :) Of course, we managed to save many hours of manual testing and debugging of the code. Since the introduction of pinger code in the product, it revealed only one minor bug related to inattention when writing code, which means that the time spent on developing and writing unit tests was not wasted!

From here we can conclude:

  • Unit testing is a very useful thing; unit tests should ideally cover all the code.
  • Almost any task of covering code with tests is solvable! It is only necessary to break the tested code into a sufficient number of abstractions.

Thank you all for your attention!

Posted by Sergey Karnaukhov ( CLRN ).

Also popular now: