Добавляем заголовки в HTTP-запросы в тестах на Selenium WebDriver при помощи BrowserMob-Proxy (Java)

Хоть добавление заголовков в http-запросы к серверу при тестировании пользовательского веб-интерфейса и не является повсеместной практикой, умение делать это сильно облегчит тестировщику-автоматизатору ряд задач, с которыми тот может столкнуться в потенциале. Заголовками можно контролировать различные аспекты поведения тестируемого веб-приложения или сервиса. При помощи заголовков можно включать часть логики, которая в обычном режиме была бы выключена (например, включать "тестовый" режим), либо обходить некоторые этапы пользовательского взаимодействия с приложением, которые неподконтрольны используемому WebDriver'у. Одним из таких этапов является т.н. Basic-аутентификация, на примере которой мы и рассмотрим нашу тему.

Как работает Basic-аутентификация

Basic-аутентификация - это простейший механизм подтверждения личности пользователя, который, не смотря на свою простоту и незащищенность, довольно широко используется в практике. Такой вид аутентификации поддерживается всеми современными браузерами, и часто становится весьма неприятным препятствием на пути тест-автоматизаторов, т.к. диалог пользовательских креденшелов не контролируется Selenium'ом.

Когда клиент обращается к ресурсу (URL), закрытому бейсик-аутентификацией, сервер на запрос клиента отвечает статусом 401 (Unauthorized) при этом, добавляя в ответ заголовок WWW-Authenticate с присвоенным значением, определяющим требуемый тип аутентификации (в нашем случае - Basic) а также опциональным описанием ресурса, находящегося под защитой (в формате realm="Описание ресурса").

Клиент (в нашем случае - браузер), получив такой ответ, включает свой механизм, опрашивающий пользователя на предмет его имени и пароля. Получив имя и пароль, клиент кодирует (заметьте - не шифрует) полученные от пользователя данные в формате username:passsword в кодировку Base64 и посылает новый запрос к ресурсу, включая в запрос заголовок Authorization с присвоенным значением определяющим тип аутентификации и закодированной в Base64 парой имени и пароля.

Как обойти запрос имени и пароля

Ответ очевиден. Чтобы обойти запрос, необходимо заранее включить закодированный креденшелы в запрос. Но как это сделать? Selenium не позволяет оперировать взаимодействием с веб-сервером на низком уровне http-запросов. Здесь нам на помощь придут прокси.

Прокси (proxy) - это такой компонент веб-инфраструктуры, который пропускает через себя все http (в случае http-proxy) запросы, находясь между клиентом и конечным ресурсом. В повседневной жизни такие компоненты могут использоваться в целях безопасности, пропуская, например, весь корпоративный трафик через себя и отсекая запросы к запрещенным ресурсам, либо логируя запросы в специальную базу. Прокси также могут модифицировать запросы, добавляя или удаляя любые их части. В том числе и заголовки.

Браузеры в свою очередь знают что такое прокси и умеют с ними работать. В том числе и браузеры, управляемые WebDriver'ом.

Используя прокси, мы можем добавить заголовок Authorization с уже готовыми значениями имени пользователя и пароля пропустив таким образом фазу ввода этих данных пользователем. Прокси может быть частью постоянной инфраструктуры тестового окружения. Это не очень удобно, т.к. потребует серьезных накладных расходов на поддержку, переконфигурацию при необходимости, а также станет фактором, который будет необходимо учитывать всем внутри этого тестового окружения. Гораздо удобнее запускать свою собственную проксю, а еще удобнее встраивать запуск и конфигурацию этой прокси прямо в тесты, обеспечивая таким образом воспроизводимость тестов в любом тестовом окружении.

Одним из программных решений, позволяющих реализовать описанную модель является библиотека browsermob-proxy. На примере этой библиотеки мы рассмотрим несколько способов, позволяющих реализовать обход диалога basic-аутентификации.

Описание примера

В подготовленном мною примере, мы создадим тест, который будет заходить на страницу https://webelement.click/stand/basic?lang=ru, закрытую бейсик-аутентификацией. При этом запуски и конфигурация прокси-компонента будет осуществляться в подготовительной части теста. Вариантов таких конфигураций будет три. В первом варианте мы будем добавлять заголовок во все запросы безусловно. Во втором варианте мы научимся добавлять логику в исходящие запросы, которая могла бы учитывать свойства исходящего запроса для определения условий его обработки (например мы можем добавить или не добавить заголовок в зависимости от того, на какой адрес реквест уходит). Последний же третий пример не будет напрямую демонстрировать вопрос управления заголовками запросов, но скорее покажет встроенный механизм используемой библиотеки, используемый для различных типов аутентификаций.

Подготовка к разработке теста

Тест мы будем разрабатывать на языке Java. В моем примере я буду использовать браузер FireFox и операционную систему Linux. Также, кроме Selenium я планирую использовать Maven и JUnit. Если вы пока не знакомы с этими фреймворками - не беда. Концептуально они не влияют на тему статьи, а используются скорее для того чтобы минимизировать вспомогательный код, сконцентрировавшись на действительно важном. Для того, чтобы наш код получил доступ к необходимым библиотекам, убедитесь, что ваш файл pom.xml выглядит примерно так:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>webelement-click</groupId>
    <artifactId>selenium-http-headers-example</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java -->
        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium-java</artifactId>
            <version>3.141.59</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.5.2</version>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/net.lightbody.bmp/browsermob-proxy -->
        <dependency>
            <groupId>net.lightbody.bmp</groupId>
            <artifactId>browsermob-core</artifactId>
            <version>2.1.5</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

В зависимостях нашего проекта мы прописываем три библиотеки. Непосредственно Selenium, JUnit (необходимый для простого запуска тестов и проверки соответствия актуального ожидаемому) и ключевую библиотеку нашей сегодняшней публикации - browsermob-core.

Создаём скелет теста

Сам тест незамысловат. После ряда манипуляций с настройками прокси, которые я выношу в отдельный метод (для наглядности), тест открывает страницу https://webelement.click/stand/basic?lang=ru, которая бы вызвала диалог ввода учетных данных пользователя если бы мы не пропускали запросы от браузера через нашу сконфигурированную проксю. Вы можете проверить поведение этой страницы вручную, чтобы убедиться, что диалог действительно отображается, а некорректная пара имени и пароля приводит к невозможности открыть страницу. Код скелета выглядит следующим образом.

package click.webelement.basicauth;

import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import net.lightbody.bmp.BrowserMobProxy;
import net.lightbody.bmp.BrowserMobProxyServer;
import net.lightbody.bmp.client.ClientUtil;
import net.lightbody.bmp.filters.RequestFilter;
import net.lightbody.bmp.proxy.auth.AuthType;
import net.lightbody.bmp.util.HttpMessageContents;
import net.lightbody.bmp.util.HttpMessageInfo;
import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;

import java.io.UnsupportedEncodingException;
import java.time.Duration;
import java.util.Base64;

public class BasicAuthenticationTest {

    WebDriver driver;
    BrowserMobProxy proxy;

    @BeforeAll
    public static void globalSetup(){
        System.setProperty("webdriver.gecko.driver", "/home/alexey/Dev/webdrivers/geckodriver");
    }

    @BeforeEach
    public void setUp(){
        setUpProxy();
        FirefoxOptions ffOptions = new FirefoxOptions();
        ffOptions.setProxy(ClientUtil.createSeleniumProxy(proxy));
        driver = new FirefoxDriver(ffOptions);
    }

    @Test
    public void testBasicAuth(){
        driver.get("https://webelement.click/stand/basic?lang=en");
        Wait<WebDriver> waiter = new FluentWait(driver).withTimeout(Duration.ofSeconds(30)).ignoring(NoSuchElementException.class);
        String greetings = waiter.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[@class='post-body']/div/h2"))).getText();
        Assertions.assertEquals("You have authorized successfully!", greetings, "Greeting message is displayed.");
    }

    @AfterEach
    public void tearDown(){
        if(driver != null){
            driver.quit();
        }
        if(proxy != null){
            proxy.stop();
        }
    }

    private void setUpProxy(){

    }

}

Если вы скопируете код в свою IDE "как есть", то скорее всего вы увидите множество неиспользуемых инструкций import. Дело в том, что все импорты, которые тут описаны есть объединение импортов кода, необходимого для демонстрации всех трёх примеров (напомню, что их будет три) одновременно. Мы будем переходить от одного примера к другому последовательно, но в целях экономии пространства я решил "показать" полный список, чтобы у вас не возникало вопросов какой пакет импортировать в том или ином случае, когда имя класса одновременно относится к нескольким пакетам.

В "скелете" мы видим, что каждый тест будет начинаться с настройки прокси setUpProxy();. Код этого метода я представлю в трёх вариациях. В оставшихся строках метода setUp() мы приводим прокси, описанную классом BrowserMobProxy к классу, понятному вебдрайверу. Делается это при помощи статического метода ClientUtil.createSeleniumProxy(), поставляемого в самой библиотеке prowsermob-proxy.

Тестовый метод ничего не знает об аутентификации, требуемой на странице. Он просто открывает необходимый URL и ожидает увидеть там приветственное сообщение. Очевидно, что при неудачной аутентификации, тест завершится с ошибкой.

Добавляем заголовки безусловно

Этот способ подойдет в том случае, если все запросы, которые генерирует ваша веб-страница должны содержать тот или иной заголовок. Для этого, необходимо запустить прокси, и воспользоваться методом, который называется addHeader(..).

Такой вариант может в некоторых случаях не подойти. Дело в том что он будет добавлять заголовок вообще всем запросам, которые уходят из вашего браузера. Когда вы открываете веб-страницу запрос который уходит из вашего браузера может быть во-первых не один, и во-вторых посылаться не только на ваш сервер. Страница может включать в себя множество ресурсов, таких как медиа-элементы (например, картинки), джавскрипты, CSS, фреймы и т.д.

Кроме того, на странице может исполняться асинхронный скрипт, который может запрашивать данные со сторонних ресурсов или других веб-серверов. Все такие ресурсы запрашиваются браузером отдельными http-запросами, и не для всех таких запросов ваш добавленный заголовок может быть необходим. А для некоторых он вообще может повлиять на то, правильный ли ресурс загрузится на вашу страницу.

Если добавление заголовков ко всем без исключения запросам - это ваш осознанный выбор, то в таком случае наш метод setUpProxy() будет выглядеть так.

public void setUpProxy(){
	proxy = new BrowserMobProxyServer();
	try {
		String authHeader = "Basic " + Base64.getEncoder().encodeToString("webelement:click".getBytes("utf-8"));
		proxy.addHeader("Authorization", authHeader);
	} catch (UnsupportedEncodingException e) {
		System.err.println("Couldn't add authorization header..");
		e.printStackTrace();
	}
	proxy.start(0);
}

Здесь в строке String authHeader = "Basic " + Base64.getEncoder().encodeToString("webelement:click".getBytes("utf-8")); мы формируем заголовок, который необходимо добавить. Далее добавляем его в список "дополнительных" заголовков для запросов, проходящих через прокси proxy.addHeader("Authorization", authHeader);, и запускаем наш прокси-компонент указывая 0 в качестве параметра запуска, что означает, что прокси запустится на том порту, который выделит нам операционная система.

Добавляем заголовки к избранным запросам

Если способ, описанный выше нам по каким-то причинам не подходит, библиотека позволяет добавлять фильтры запросов. Используя такие фильтры можно применять условия, определяющие логику обработки конкретных запросов. Такой "обработкой", например, может и стать добавление в запросы заголовков. Посмотрим, как будет выглядеть настройка прокси в таком случае:

public void setUpProxy(){
	proxy = new BrowserMobProxyServer();
	proxy.addRequestFilter(new RequestFilter() {
		@Override
		public HttpResponse filterRequest(HttpRequest httpRequest, HttpMessageContents httpMessageContents, HttpMessageInfo httpMessageInfo) {
			try {
				if(httpRequest.getUri().toLowerCase().endsWith("css")){
					System.out.println("Skip adding authorization header for: " + httpRequest.getUri());
				}else{
					String authHeader = "Basic " + Base64.getEncoder().encodeToString("webelement:click".getBytes("utf-8"));
					System.out.println("Adding header for: " + httpRequest.getUri());
					httpRequest.headers().add("Authorization", authHeader);
				}
			} catch (UnsupportedEncodingException e) {
				System.err.println("Couldn't add authorization header..");
				e.printStackTrace();
			}
			return null;
		}
	});
	proxy.start(0);
}

В этом примере мы используем метод объекта proxy, который называется addRequestFilter и который принимает объект, реализующий интерфейс RequestFilter в качестве параметра. Интерфейс определяет только один метод filterRequest. Поэтому в примере мы создаем анонимный объект и передаем его в качестве параметра. В экземпляре, который мы передаем, мы переопределяем метод. Разработчики библиотеки позаботились о том, чтобы в наш фильтр, прокси передавал три объекта, одним из которых является объект класса HttpRequest. Этот объект представляет запрос, проходящий через прокси со стороны нашего клиента.

В примере мы добавляем заголовок ко всем запросам, адрес которых не заканчивается на css. Этот пример, возможно, не несет серьёзной смысловой нагрузки. Тем не менее, он показывает как можно изменять запросы, используя свойства самих запросов в качестве условия. В реальной жизни, вы могли бы ограничивать добавление заголовков только теми запросами, которые уходят на подконтрольные вам домены.

Проходим аутентификацию без прямого доступа к заголовкам

Не смотря на то, что тема нашего сегодняшнего рассмотрения - оперирование заголовками, пример мы выбрали с Basic-аутентификацией. Поэтому несправедливо было бы не упомянуть способ, предлагающийся библиотекой prowsermob-proxy для аутентификации, без непосредственного доступа к заголовкам запросов (конечно же так или иначе модификация заголовков будет происходить и в этом случае, т.к. это есть требование спецификации). Взглянем на третий вариант метода setUpProxy().

public void setUpProxy(){
	proxy = new BrowserMobProxyServer();
	proxy.autoAuthorization("webelement.click", "webelement", "click", AuthType.BASIC);
	proxy.start(0);
}

Я всё настроил, но у меня ничего не работает

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

  • Ваша конечная цель не располагается на вашем собственном компьютере. Если вы используете адрес localhost или 127.0.0.1-127.255.255.255 (т.н. loopback-адреса), запросы могут пойти в обход прокси-сервера, а значит заголовков не будет добавлено. Используйте имя машины в качестве адреса.

  • Возможно правила файрвола блокируют взаимодействия с прокси-компонентом

  • По возможности включите логирование входящих запросов на стороне конечного сервера

Остались вопросы? Задавайте их тут. Я постараюсь дополнить статью опираясь на ваши замечания.