云端 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 物理机中选一台,按实际需要选择租期与节点。可用性以控制台实时返回为准。