用本地夹具测试 iOS URLSession 的重定向边界

用本地夹具测试 iOS URLSession 的重定向边界

云端 Mac 上的 iOS 测试如果只验证接口最终返回了什么,很容易漏掉中途的 HTTP 重定向。比如下载请求先收到 302,目标地址却换了端口;客户端继续跟随,原本只打算发给一个服务的测试请求就到了另一个服务。下面用两个本地端口固定这个场景,再让 XCTest 分别验证“允许同源跳转”和“拒绝跨源跳转”。测试不需要外部站点,失败也容易复现。

先定边界,而不是先写断言

这里的“源”指协议、主机、端口三者的组合。http://127.0.0.1:8765 跳到同端口的 /target 是同源;跳到 http://127.0.0.1:8766/target 则是跨源,尽管主机名没变。本例的策略是只跟随同源跳转。它是演示策略,不代表所有业务都该禁止跨源:如果下载服务确实使用其他域名,应先列明被允许的目标,再按实际需求编写规则。

重定向测试要检查中途的决策。只断言最后拿到了 200,无法证明请求没有走过意料之外的地址。

把夹具放在测试仓库中,使用假令牌,不要把生产凭据写进脚本或测试日志。正式接口使用 HTTPS 时,还须单独验证 TLS;本地 HTTP 夹具只负责检验跳转决策,不能代替线上连接安全测试。

启动两个只监听本机的端口

将下面代码保存为 redirect_fixture.py,在云端 Mac 的终端运行 python3 redirect_fixture.py。第一个端口提供两种跳转;第二个端口只用于接收跨源目标请求。服务绑定 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 应返回指向 /target 的 302。这里不要加 -L,否则命令行工具会替你跟随跳转,看不到最初的响应。测试跑完后在启动夹具的终端按 Ctrl-C 停止服务。

在 URLSession 委托中作决定

把判断放在 URLSessionTaskDelegate 的重定向回调中。比较对象是当前响应地址和将要请求的地址,而不是只比较两个主机名。下面的类可以放进 iOS 测试 target;它不保存任务状态,两条测试可以分别创建实例。

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、测试结束后服务进程退出、日志不含真实令牌。跳转规则以后要放宽时,先增加对应的允许用例,再修改委托;这样策略变化会留下一条可执行的记录。

常见问题

同一主机换了端口,仍算同源吗?

不算。源由协议、主机和端口共同确定;127.0.0.1:8765 跳到 127.0.0.1:8766 应按跨源跳转处理。

拒绝重定向后,为什么测试会收到 302?

委托向 completionHandler 传入 nil 后,URLSession 不再请求目标地址,而是把原始重定向响应交回调用方;测试应检查状态码和最终响应地址。

需要动手验证?

租一台独享 Mac mini,直接跑你的构建

从两档 M4 物理机中选一台,按实际需要选择租期与节点。可用性以控制台实时返回为准。

立即租用 Mac mini