Интеграционное тестирование веб-приложения на инъекции

Если у вас есть веб-приложение и вы задались тем что-бы идеально его покрыть тестами, то вот что у вас должно быть:

  • 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.

  1. Пишем класс с CURL- запросами (get,post.. при необходимости delete и put, если ваш api их использует)
  2. Решаем вопрос с авторизацией, если она есть (я использую сохранение сессии в cookie-файл) — CURL это поддерживает
  3. Пишем зависимость всех тестов от login-теста, что-бы не насиловать сервер если авторизация провалилась
  4. Простые 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']));
            }
        );
    }
}

По мере того, как вы пишете тесты для контроллеров, получается что заодно вам приходится

  1. тестировать привилегии (кто может получить ответ?)
  2. рефакторить код контроллера, вынося логику в модели (потому что сложно понять толстые контроллеры)
  3. избавляться от stacktrace — потому что это выдаёт лишнюю информацию
  4. решать случаи с integrity violation, когда вы пытаетесь скажем добавить entity без проверки его связи с другими entity в БД

Описанный мною вариант не решает вопросов с CSRF, редиректами, несолёными паролями, SSL, сессиями и ошибками конфигурации сервера, но зато улучшает функции приложения по безопасности/логичности, даже если у вас всюду используется безопасный PDO с bindParam().