Пингер на Boost.Asio и модульное тестирование

в 12:47, , рубрики: boost.asio, c++, c++ библиотеки, Блог компании Positive Technologies, пингер, разработка, метки: , , ,

Пингер на Boost.Asio и модульное тестированиеВсем привет! В одной из наших предыдущих статей мы рассказали о реализации функции асинхронного пинга в рамках задачи по созданию «пингера» для его дальнейшего использования при пентестах организаций с большим количеством рабочих станций. Сегодня мы поговорим о покрытии нашего пингера (логика и сетевая часть) модульными тестами.

Понятно, что необходимость написать код, который пройдет тестирование, — дисциплинирует и помогает грамотнее планировать архитектуру. Тем не менее, первая мысль о покрытии юнит-тестами асинхронного кода на Boost.Asio была примерно такая: «Что?! Это абсолютно невозможно! Как можно написать тест, основанный на сетевой доступности узла?»

Потом появилась идея каким-то образом эмулировать удаленный узел и его ответы на команды, полученные от нашего пингера. При дальнейшем изучении реализации асинхронных примитивов из Boost.Asio возникла мысль о параметризации готовых примитивов тестовыми реализациями сервисов, которые и будут отвечать на наши команды.

Вот так выглядит упрощенная диаграмма сокета в Boost.Asio. Для простоты мы будем рассматривать только методы соединения, отправки и получения данных.

Пингер на Boost.Asio и модульное тестирование

В коде библиотеки реализация этой схемы выглядит следующим образом:

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

При этом все вызовы в boost::asio::basic_stream_socket делегируются классу StreamSocketService. Вот часть кода библиотеки Boost.Asio, которая это наглядно демонстрирует:

  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));
  }

Другим словами, сам класс сокета является, по сути, просто оберткой, которая параметризуется типами протокола и сервиса; наглядный пример статического полиморфизма. Итак, чтобы «подменить» реализацию методов сокета, нам необходимо задать в качестве параметра шаблона сокета нашу реализацию сервиса. Вот как выглядела бы эта иерархия сокета при использовании динамического полиморфизма с добавлением тестового сервиса.

Пингер на Boost.Asio и модульное тестирование

В нашем случае, представляющем собой не что иное, как Compile time dependency injection, упрощенная диаграмма для тестового сокета будет выглядеть так.

Пингер на Boost.Asio и модульное тестирование

В коде тестовые и рабочие примитивы описаны следующим образом.

Стандартные примитивы

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;
};

Тестовые примитивы
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 и ResolverService — реализации тестовых сервисов.

Примитивы таймера и резолвера имен, а также их сервисы имеют схожую структуру, поэтому мы ограничимся описанием сокетов и их сервисов.

А вот так в упрощенном виде будут представлены рабочая и тестовая реализации пингера.

Пингер на Boost.Asio и модульное тестирование

В коде это выглядит следующим образом.

Реализация пингера

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

Пингер в рабочей версии

class Pinger
{
	//! Implementation type
	typedef PingerImpl<BoostPrimitives> Impl;
	....
	private:

	//! Implementation
	std::auto_ptr<Impl> m_Impl;
};

Пингер в тестовой версии

class BaseTest : boost::noncopyable
{
protected:

	//! Pinger implementation type
	typedef Net::PingerImpl<Test::Primitives> TestPinger;
	....
};

Итак, у нас есть доступ к отдельным операциям примитивов. Теперь нужно понять, как с их помощью организовать тестовый случай, покрывающий процесс пинга. Мы можем представить этот процесс (пинг узла) как последовательность команд, выполняемых посредством библиотеки Boost.Asio. Нам необходима некая очередь команд, которая будет заполняться в процессе инициализации тестового сценария и опустошаться в процессе выполнения пинга. Вот диаграмма состояний, описывающая работу тестов.

Пингер на Boost.Asio и модульное тестирование

Введем абстракцию ICommand, которая будет предоставлять методы, аналогичные методам примитивов Boost.Asio, и создадим классы — реализации конкретных команд (класс Connect будет реализовывать соединения с узлом, класс Receive — получение данных и т. д.).

UML-диаграмма работы тестов представлена ниже.

Пингер на Boost.Asio и модульное тестирование

Абстракция команды

//! 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;
};

При этом методы, не предоставляемые конкретной командой, будут содержать тестовые утверждения: таким образом мы сможем контролировать последовательность выполнения команд.

Пример реализации команды соединения

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

Реализация «по умолчанию» сообщает о том, что команда извлечена не в свою очередь:

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

Также нам потребуется класс — тестовый случай, предоставляющий методы работы с очередью команд и проверяющий, что после выполнения теста в очереди не осталось команд.

Реализация «тестового случая», имеющего очередь команд

//! 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;
}

Часть реализации тестового сервиса

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); // извлекаем команду
	}

Юнит-тесты написаны на фреймворке Google, вот пример реализации теста для ICMP-пинга:

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

}

Итак, с тестированием сетевой части пингера все понятно: нужно лишь описать последовательности команд для каждого из возможных сценариев пинга. Напомним, что логика пингера содержит несколько виртуальных методов, переопределяемых в классе PingerImpl. Таким образом, нам удалось отвязать логику от сетевой части.

Пингер на Boost.Asio и модульное тестирование

На диаграмме класс TestLogic создан с помощью google mock. При этом в тестах логики определяется последовательность методов и аргументов, с которым они будут вызваны, при определенных входных параметрах.

Реализация тестовой логики

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 ());

};

Пара примеров юнит-тестов

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);
}

В итоге поставленную задачу удалось успешно решить, благо Boost.Asio — отличный фреймворк, прекрасно подходящий для подобных целей. Кроме того, как водится, в процессе покрытия юнит-тестами было выявлено несколько серьезных багов :) Само собой, нам удалось и сэкономить много часов ручного тестирования и отладки кода. С момента внедрения кода пингера в продукт, в нем был выявлен всего один мелкий баг, связанный с невнимательностью при написании кода, а значит, время на разработку и написание юнит-тестов потрачено не зря!

Отсюда можно сделать выводы:

  • Модульное тестирование очень полезная вещь, юнит-тестами в идеале должен быть покрыт весь код.
  • Практически любая задача покрытия кода тестами решаема! Нужно лишь разбить тестируемый код на достаточное количество абстракций.

Всем спасибо за внимание!

Автор: ptsecurity

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js