При тестировании iOS-приложения на облачном Mac легко пропустить промежуточную HTTP-переадресацию, если проверять только конечный ответ. Например, запрос на загрузку сначала получает ответ 302, но адрес назначения указывает на другой порт. Если клиент автоматически последует за переадресацией, тестовый запрос, предназначенный одному сервису, попадёт в другой. Ниже мы воспроизведём этот сценарий на двух локальных портах и с помощью XCTest проверим оба случая: разрешённый переход в пределах того же источника и запрещённый переход на другой источник. Внешние сайты не понадобятся, а сбои будет легко воспроизвести.
Сначала определите границу, затем пишите проверки
Под «источником» здесь понимается сочетание схемы, хоста и порта. Переход с http://127.0.0.1:8765 на /target на том же порту остаётся в пределах одного источника. Переход на http://127.0.0.1:8766/target ведёт к другому источнику, хотя хост не изменился. В этом примере мы разрешаем переходы только в пределах одного источника. Это демонстрационная политика, а не правило для всех приложений: если сервис загрузки использует другие домены, сначала перечислите разрешённые адреса назначения, а затем сформулируйте правило под свои требования.
Тест переадресации должен проверять решение, принятое в процессе перехода. Проверка только конечного ответа
200не доказывает, что запрос не прошёл через неожиданный адрес.
Поместите тестовый сервер в репозиторий с тестами и используйте подставной токен. Не записывайте рабочие учётные данные в скрипт или журналы тестов. Если рабочий API использует HTTPS, проверяйте TLS отдельно: локальный HTTP-сервер проверяет только решение о переадресации и не заменяет тестирование безопасности рабочего соединения.
Запустите два порта, доступных только с этого Mac
Сохраните следующий код в файл redirect_fixture.py и выполните python3 redirect_fixture.py в терминале облачного Mac. Первый порт выдаёт два варианта переадресации; второй нужен только для приёма запроса, направленного на другой источник. Серверы привязаны к 127.0.0.1, поэтому тестовые порты не открыты для внешних подключений.
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
port = self.server.server_port
if port == 8765 and self.path in ("/same", "/start"):
target = (
"/target" if self.path == "/same"
else "http://127.0.0.1:8766/target"
)
self.send_response(302)
self.send_header("Location", target)
self.end_headers()
return
if self.path == "/target":
body = b"ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_error(404)
servers = [
HTTPServer(("127.0.0.1", port), Handler)
for port in (8765, 8766)
]
threads = [
Thread(target=server.serve_forever, daemon=True)
for server in servers
]
for thread in threads:
thread.start()
threads[0].join()
Сначала проверьте сервер из другого терминала: curl -i http://127.0.0.1:8765/start должен вернуть 302 с адресом второго порта, а curl -i http://127.0.0.1:8765/same — 302 с переходом на /target. Не добавляйте -L: иначе curl сам выполнит переход и вы не увидите первоначальный ответ. После тестов остановите сервер сочетанием Ctrl-C в терминале, где он запущен.
Принимайте решение в делегате URLSession
Разместите проверку в обратном вызове переадресации URLSessionTaskDelegate. Сравнивать нужно адрес текущего ответа с адресом предстоящего запроса, а не только имена хостов. Следующий класс можно добавить в тестовый target iOS. Он не хранит состояние задачи, поэтому для каждого из двух тестов можно создать отдельный экземпляр.
import Foundation
final class SameOriginGate: NSObject, URLSessionTaskDelegate,
@unchecked Sendable {
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
guard let from = response.url,
let to = request.url,
let source = URLComponents(url: from, resolvingAgainstBaseURL: false),
let destination = URLComponents(url: to, resolvingAgainstBaseURL: false),
source.scheme == destination.scheme,
source.host == destination.host,
source.port == destination.port else {
completionHandler(nil)
return
}
completionHandler(request)
}
}
Явного сравнения портов достаточно для двух адресов этого тестового сервера. В рабочем коде нужно также определить правило нормализации портов по умолчанию: например, считать ли HTTPS-адрес без указанного порта эквивалентным адресу с явным 443. Упрощённая проверка из примера не должна незаметно стать политикой приложения. Разрешая новый запрос, также проверьте, какие заголовки ему допустимо передавать.
Напишите два теста XCTest с разными ожидаемыми результатами
Добавьте следующие тесты в тестовый target. Метод data(for:delegate:) передаёт решение о переадресации конкретного запроса указанному делегату. Каждый тест использует отдельную временную сессию, чтобы не переиспользовать её состояние. Перед запуском тестов запустите сервер на том же облачном Mac, где они выполняются.
import XCTest
final class RedirectBoundaryTests: XCTestCase {
func testSameOriginRedirectSucceeds() async throws {
let session = URLSession(configuration: .ephemeral)
defer { session.invalidateAndCancel() }
let url = URL(string: "http://127.0.0.1:8765/same")!
let (data, response) = try await session.data(
for: URLRequest(url: url),
delegate: SameOriginGate()
)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
XCTAssertEqual(String(data: data, encoding: .utf8), "ok")
XCTAssertEqual(response.url?.path, "/target")
}
func testDifferentPortRedirectStops() async throws {
let session = URLSession(configuration: .ephemeral)
defer { session.invalidateAndCancel() }
let url = URL(string: "http://127.0.0.1:8765/start")!
var request = URLRequest(url: url)
request.setValue("fixture-only", forHTTPHeaderField: "X-Test-Token")
let (_, response) = try await session.data(
for: request,
delegate: SameOriginGate()
)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 302)
XCTAssertEqual(response.url?.port, 8765)
}
}
Когда в обработчик завершения передаётся nil, URLSession возвращает тесту исходный ответ 302, не отправляя запрос на второй порт. Поэтому второй тест ожидает 302, а не сетевую ошибку. X-Test-Token в примере лишь показывает, что запрос может содержать чувствительный заголовок. Чтобы проверить, что целевой сервис действительно не получил его, дополните тестовый сервер подсчётом запросов ко второму порту и убедитесь, что счётчик равен нулю.
Ищите причину сбоя по уровням
Если оба теста сообщают об ошибке соединения, сначала проверьте, работает ли сервер и действительно ли тесты выполняются на том Mac, где он запущен. Адрес 127.0.0.1 всегда относится к устройству, отправляющему запрос. Если запустить те же тесты непосредственно в симуляторе iOS, они обратятся к локальному адресу симулятора и могут не достучаться до сервера на Mac. Для тестов, которые должны выполняться в симуляторе, настройте доступный ему адрес тестового хоста, оставляя сервер открытым только в доверенной тестовой сети.
Если тест перехода в пределах одного источника возвращает 302, проверьте, передан ли делегат в data(for:delegate:) и совпадают ли схема, хост и порт в запросе и ответе с ожидаемыми. Если тест перехода на другой источник неожиданно получает 200, прежде всего проверьте, не вызывается ли в обратном вызове completionHandler(request). Убедитесь также, что другая прослойка сетевого кода не подменяет сессию, используемую тестами.
Наконец, закрепите критерии приёмки в конвейере: тестовый сервер запускается, два теста получают соответственно 200 и 302, после тестов серверный процесс завершается, а журналы не содержат настоящих токенов. Если позже потребуется расширить правила переадресации, сначала добавьте тест для нового разрешённого перехода и только затем измените делегат. Так изменение политики останется зафиксированным в исполняемом тесте.
Часто задаваемые вопросы
Смена порта на том же хосте считается переходом на другой origin?
Да. Origin определяется схемой, хостом и портом; переход с 127.0.0.1:8765 на 127.0.0.1:8766 нужно проверять отдельно.
Почему после отклонения перехода тест получает код 302?
Если делегат передаёт nil в completionHandler, URLSession не запрашивает целевой адрес и возвращает исходный ответ переадресации.
Хотите проверить на практике?
Арендуйте выделенный Mac mini и сразу запускайте сборки
Выберите одну из двух конфигураций физических Mac на M4, срок аренды и локацию. Доступность проверяйте в консоли в реальном времени.