在雲端 Mac 測試 iOS App 時,如果只驗證 API 最後回傳了什麼,很容易漏掉途中發生的 HTTP 重新導向。例如,下載請求先收到 302,但目標網址換了連接埠;用戶端繼續跟隨後,原本只打算送給某個服務的測試請求,就到了另一個服務。以下用兩個本機連接埠固定重現這個情境,再用 XCTest 分別驗證「允許同源重新導向」與「拒絕跨源重新導向」。測試不需要外部網站,失敗時也容易重現。
先界定邊界,再寫斷言
這裡的「源」由協定、主機和連接埠共同決定。從 http://127.0.0.1:8765 跳到相同連接埠的 /target 屬於同源;跳到 http://127.0.0.1:8766/target 則屬於跨源,即使主機名稱沒有改變。本例採用的規則是只跟隨同源重新導向。這只是示範規則,不表示所有業務都應禁止跨源:如果下載服務確實使用其他網域,應先列出允許的目標,再依實際需求編寫規則。
重新導向測試必須檢查途中做出的決定。只斷言最後收到
200,無法證明請求沒有經過預期之外的網址。
將測試夾具放在測試儲存庫中,使用假權杖,不要把正式環境憑證寫進指令碼或測試日誌。正式 API 使用 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)。也要確認沒有其他網路封裝層替換測試使用的工作階段。
最後,將驗收項目寫入 CI 流水線:夾具能啟動、兩個測試分別得到 200 和 302、測試結束後服務程序會退出,而且日誌不含真實權杖。日後需要放寬重新導向規則時,先新增對應的允許案例,再修改委派;這樣規則變更就會留下一筆可執行的紀錄。
常見問題
同一主機改用另一個連接埠,仍然算同源嗎?
不算。來源由通訊協定、主機與連接埠共同決定;從 127.0.0.1:8765 跳至 127.0.0.1:8766 應視為跨源。
拒絕重新導向後,為何測試收到 302?
委派將 nil 傳給 completionHandler 後,URLSession 不會請求目標位址,而會回傳原本的重新導向回應。
想親自測試看看?
租用專屬 Mac mini,直接執行你的建置工作
從兩款 M4 實體主機中挑選,並依需求選擇租用期間與節點。實際供應情況以控制台即時顯示為準。