Интеграционное тестирование веб-приложения на инъекции
Если у вас есть веб-приложение и вы задались тем что-бы идеально его покрыть тестами, то вот что у вас должно быть:
- unit-тесты бэкэнда — в основном покрываются модели, генерируется покрытие — получаете необходимость изолировать модели (заодно single responsibility principle выполняется)
- unit-тесты frontend — карма + phantomjs прокрутят все ваши angular-сервисы и backbone-модели — тоже приходится изолировать код
- e2e (сценарные, системные) тесты — наверняка основанный на selenium (protractor, selenide). Медленно тестируется функционал работающей системы из UI — приходится задумываться о том что пользователь вообще делает (use cases)
- db/entity тесты миграций — "с нуля" запускают изменения в БД и когда всё готово - сравнивают с entity/record классами для синхронизации кода с БД (так находятся лишние свойства и недостающие )
-
тестирования db-процедур я не рассматриваю, потому что PL/SQL не увлекаюсь
-
интеграционные тесты внешних систем/api - любого типа (rest, ftp, soap) и источника (соц.сети, бухгалтерия, склад, SMS-gateway), тестируют на
-
доступность (а-ля pingdom)
-
предсказуемый формат (банальный get и проверка json)
-
полное взаимодействие с записью (обычно партнёрская компания с разработчиком ставит тестовую машинку)
-
нагрузочные тесты (load, stress) тестируют всю систему что-бы определить макси мальное число подключённых клиентов
-
тесты производительности (performance) тестируют эффективность использования памяти, CPU, сети, HDD IO в среднем при разных запросах что-бы выявить какие конкретно области приложения медленные и в что можно улучшить
-
интеграционные тесты контроллеров/api — запускающиеся без браузера, через CURL запросы, эмулирующие вызов из javascript или мобильных приложений
-
простые get - запросы, проверяющие на наличие ошибок/stacktrace
-
post/put запросы, меняющие данные
-
в запущенных случаях (мобильные приложения), когда с мобильника e2e тесты не запустить, а функционал надо тестировать, то получаются последовательные сценарные (а не одинарные get-post) запросы, сохраняющие состояние сущностей и пользователя (в БД и сессии)
Вот на предпоследних я немножко и остановлюсь
Unit-тестирование контроллеров неудобно
Тестировать контроллеры с помощью юнит-тестов, хоть и быстро исполняется в phpunit, пишется очень с большим трудом. Да, я слышал Боба Мартина что код надо полностью покрывать, но контроллер это место сосредоточения нескольких сущностей:
- получение конфигурации (из php-include, yaml, БД, констант и проч) — значит надо либо исполнять всю загрузку системы, либо мокать, либо заменять константы/значения
- создание instance новых моделей – значит надо мокать
- вызовы глобальных IO-переменных/методов - значит надо их рефакторить, убирать в нетестируемые модели и мокать в контроллерах
- глобальные переменные, Factory-вызовы, статика - как и с конфигурацией, всё сложно
- аспекты - аннотации, доступ, логгинг + логика системы основанная на reflection — совсем какой-то магический мокинг должен быть. Например определение шаблона в аннотациях к методу контроллера..
Но хуже всего конечно не в самом мокании, а в количестве. Когда у вас метод контроллера использует 5 моделей, то вам надо столько же моков определить. А потом на каждую строчку поведения модели написать порой и не одну строчку эмулирования её поведения — какой и сколько раз метод вызвался, с какими аргументами, что вернулось. А порой ведь мок может вернуть обьект (как в PDO - PDOStatement например) и вызвать у него метод. Получается что моки надо связывать между собой. Я часто ещё и путаюсь в каком порядке их надо регистрировать, ведь вызов тестируемой функции в коде теста должно быть внизу, а регистрация моков в phpunit - до неё, фактически получается что код теста пишется задом наперёд. Короче — писать юнит-тесты для контроллеров опасно. (Впрочем некоторые советуют глянуть на phpspec)
Как стоит тестировать контроллеры
Интеграционные же тесты в чём-то схожи с e2e тестами, но они не включают в себя UI.
-
Пишем класс с CURL- запросами (get,post.. при необходимости delete и put, если ваш api их использует)
-
Решаем вопрос с авторизацией, если она есть (я использую сохранение сессии в cookie-файл) - CURL это поддерживает
-
Пишем зависимость всех тестов от login-теста, что-бы не насиловать сервер если авторизация провалилась
-
Простые get-запросы с существующими в БД id-шками должны вам будут сказать если где-то закралась ошибка, которую пропустили unit-тесты
Теперь POST/PUT - они чаще содержат ошибки на безопасность, потому что параметров и логики при изменении больше. Добавление и изме нения сущностей должно возвращать какой-то результат. Скажем {result:1, id:3}
в JSON скажет что обьект создался, id такой-то. Кроме обычных тестов на сохранение, надо попробовать во все возможные поля запихнуть sql-инъекцию и XSS.
SQL-инъекции должны либо выдать сразу ошибку, либо при чтении entity, переданное значение (скажем 1' OR 1=1) будет отличаться от сохранённого в БД (в данном случае, возможно "1"). Иногда, из-за приведения типов, строка станет int-значением и это тоже надо считать как ok.
С XSS чуть сложней - должен быть браузер. Я решаю это так, что e2e тесты запускаются после интеграционных и reset БД не происходит, а значит навигация по системе, где недавно внедрены alert-ы, должно сломать e2e тесты. Список атакующих XSS токенов есть на OWASP.
Для автоматизации я пишу trait для PHPUnit-тестов, потому что так проще всего использовать один и тот же код в разных тестах.
trait SQLinjection {
private $AttackTokens = [
'1" OR 1=1'
];
public function checkInfectedUpdate($saveURL,$readURL,$fields,callable $comparisonFn){
foreach($this->attackTokens as $injection){
$data = $fields;
foreach($fields as $k=>$v){
$data[$k]=($v=='*' ? $injection : $v);
}
$saveResult = $this->post($saveURL, $data);
$getResult = $this->get($getURL);
$comparisonFn($injection, $getResult, $saveResult);
}
}
public function checkInfectedInsert(..){..}
}
Каждый интеграционный тест наследует IntegrationBaseTest - в котором определены CURL-обёртки и путь к серверу. Метод теста сам должен решать как сравнивать результат с инъекцией, потому что иногда результат от вызова из API get() отличается в зависимости от entity - где-то это простой массив, а где-то иерархия со списками, по которым ещё надо пройтись (и скажем вычленить последнюю версию)
InvoiceControllerTest extends IntegrationBaseTest {
private $phpErrorDetection = 'error';
use SQLinjection;
/**
* @test
*/
function login() {
$result = parent::login();
}
/**
* @test
* @depends login
* @group security
*/
function postSave_AddingInjection() {
$self = $this;
$this->checkInfectedUpdate(
$this->baseURL . 'invoice/save',
$this->baseURL . 'invoice/get?id=3',
[
'company_id' => '1',
'title' => '*',
'description' => '*'
],
function ($injection, $getResponse, $insertResponse) use ($self) {
$this->assertEquals($injection, $getResponse['result']['title']);
$this->assertEquals($injection, json_decode($getResponse['result']['description']));
}
);
}
}
По мере того, как вы пишете тесты для контроллеров, получается что заодно вам приходится
- тестировать привилегии (кто может получить ответ?)
- рефакторить код контроллера, вынося логику в модели (потому что сложно понять толстые контроллеры)
- избавляться от stacktrace - потому что это выдаёт лишнюю информацию
- решать случаи с integrity violation, когда вы пытаетесь скажем добавить entity без проверки его связи с другими entity в БД
Описанный мною вариант не решает вопр осов с CSRF, редиректами, несолёными паролями, SSL, сессиями и ошибками конфигурации сервера, но зато улучшает функции приложения по безопасности/логичности, даже если у вас всюду используется безопасный PDO с bindParam().
См. также ZAP