Wer bei iOS-Tests auf einem Cloud-Mac nur die endgültige Antwort einer Schnittstelle prüft, übersieht leicht eine HTTP-Weiterleitung auf dem Weg dorthin. Eine Download-Anfrage kann beispielsweise zunächst eine 302-Antwort erhalten, deren Zieladresse aber einen anderen Port verwendet. Folgt der Client der Weiterleitung, landet eine Testanfrage, die nur für einen Dienst gedacht war, bei einem anderen. Mit zwei lokalen Ports stellen wir diesen Fall reproduzierbar nach. Anschließend prüft XCTest, ob eine Weiterleitung innerhalb desselben Ursprungs zugelassen und eine Weiterleitung zu einem anderen Ursprung abgelehnt wird. Externe Websites sind dafür nicht nötig.
Erst die Grenze festlegen, dann Assertions schreiben
Mit „Ursprung“ ist hier die Kombination aus Protokoll, Host und Port gemeint. Eine Weiterleitung von http://127.0.0.1:8765 nach /target auf demselben Port bleibt innerhalb desselben Ursprungs. Eine Weiterleitung nach http://127.0.0.1:8766/target wechselt dagegen den Ursprung, obwohl der Host gleich bleibt. Die Regel in diesem Beispiel lautet: Nur Weiterleitungen innerhalb desselben Ursprungs folgen. Das ist eine Regel zur Veranschaulichung, kein pauschales Verbot anderer Ursprünge. Wenn ein Download-Dienst tatsächlich andere Domains verwendet, lege zuerst die zulässigen Ziele fest und formuliere die Regel dann entsprechend den Anforderungen.
Ein Weiterleitungstest muss die Entscheidung unterwegs prüfen. Eine Assertion, die nur eine abschließende
200bestätigt, beweist nicht, dass die Anfrage keine unerwartete Adresse durchlaufen hat.
Lege den Testserver im Test-Repository ab und verwende einen Platzhalter-Token. Produktionszugangsdaten gehören weder in das Skript noch in Testprotokolle. Wenn die produktive Schnittstelle HTTPS verwendet, musst du TLS gesondert prüfen. Der lokale HTTP-Testserver prüft nur die Entscheidung über die Weiterleitung und ersetzt keinen Sicherheitstest für die produktive Verbindung.
Zwei Ports starten, die nur lokal erreichbar sind
Speichere den folgenden Code als redirect_fixture.py und führe im Terminal des Cloud-Macs python3 redirect_fixture.py aus. Der erste Port bietet zwei Weiterleitungen an; der zweite dient nur dazu, die Anfrage an das ursprungsfremde Ziel entgegenzunehmen. Die Server sind an 127.0.0.1 gebunden und geben die Testports nicht nach außen frei.
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()
Prüfe den Testserver zunächst in einem zweiten Terminal: curl -i http://127.0.0.1:8765/start sollte eine 302 mit der Adresse des zweiten Ports zurückgeben; curl -i http://127.0.0.1:8765/same sollte eine 302 mit dem Ziel /target liefern. Verwende hier kein -L, sonst folgt curl der Weiterleitung und die ursprüngliche Antwort ist nicht mehr zu sehen. Beende den Server nach den Tests mit Ctrl-C in dem Terminal, in dem du ihn gestartet hast.
Im URLSession-Delegate über die Weiterleitung entscheiden
Die Prüfung gehört in den Weiterleitungs-Callback von URLSessionTaskDelegate. Vergleiche die Adresse der aktuellen Antwort mit der Adresse der nächsten Anfrage, nicht nur die beiden Hostnamen. Die folgende Klasse kannst du in das iOS-Test-Target aufnehmen. Sie speichert keinen Task-Zustand, sodass beide Tests jeweils eine eigene Instanz erstellen können.
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)
}
}
Der explizite Portvergleich deckt die beiden Adressen des Testservers ab. Für Produktivcode musst du zusätzlich festlegen, wie Standardports normalisiert werden: Ist beispielsweise eine HTTPS-Adresse ohne Portangabe gleichwertig mit einer Adresse, die 443 ausdrücklich angibt? Die vereinfachte Prüfung aus dem Beispiel sollte nicht unbemerkt zur Produktregel werden. Wenn das Delegate die neue Anfrage zulässt, prüfe außerdem, welche Request-Header dabei weitergegeben werden dürfen.
Zwei XCTest-Tests mit unterschiedlichen Ergebnissen schreiben
Füge dem Test-Target die folgenden Testfälle hinzu. Mit data(for:delegate:) überlässt du die Weiterleitungsentscheidung für diese Anfrage dem angegebenen Delegate. Jeder Test verwendet eine eigene temporäre Session, damit kein Session-Zustand wiederverwendet wird. Starte den Testserver, bevor du die Tests auf demselben Cloud-Mac ausführst.
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)
}
}
Wenn das Delegate die Weiterleitung mit nil im Completion-Handler ablehnt, gibt URLSession die ursprüngliche 302-Antwort an den Test zurück, statt den zweiten Port anzufragen. Deshalb erwartet der zweite Test eine 302-Antwort und keinen Netzwerkfehler. X-Test-Token verdeutlicht hier lediglich, dass eine Anfrage sensible Header enthalten kann. Wenn du prüfen willst, dass der Zieldienst diesen Header tatsächlich nicht erhalten hat, lass den Testserver zusätzlich die Anfragen am zweiten Port zählen und prüfe, dass der Zähler null bleibt.
Fehler Schritt für Schritt eingrenzen
Wenn beide Tests einen Verbindungsfehler melden, prüfe zuerst, ob der Testserver noch läuft und ob die Tests wirklich auf dem Mac ausgeführt werden, auf dem du ihn gestartet hast. 127.0.0.1 bezeichnet immer das Gerät, das die Anfrage sendet. Laufen dieselben Tests direkt im iOS-Simulator, greifen sie auf dessen eigene lokale Adresse zu und erreichen den Testserver auf dem Mac möglicherweise nicht. Wenn ein Projekt Tests im Simulator ausführen muss, konfiguriere eine für ihn erreichbare Testhost-Adresse und halte den Server weiterhin nur in einem vertrauenswürdigen Testnetz erreichbar.
Gibt der Test für denselben Ursprung eine 302-Antwort zurück, prüfe, ob das Delegate an data(for:delegate:) übergeben wurde und ob Protokoll, Host und Port in Anfrage und Antwort den erwarteten Werten entsprechen. Erhält der Test für den anderen Ursprung unerwartet eine 200-Antwort, prüfe zuerst, ob der Callback completionHandler(request) aufruft. Stelle außerdem sicher, dass keine weitere Netzwerkschicht die von den Tests verwendete Session ersetzt.
Halte zum Schluss die Abnahmekriterien in der Pipeline fest: Der Testserver startet, die beiden Tests erhalten jeweils 200 und 302, der Serverprozess endet nach den Tests und die Protokolle enthalten keine echten Tokens. Wenn die Weiterleitungsregel später gelockert werden soll, ergänze zuerst einen Test für den neu erlaubten Fall und ändere erst dann das Delegate. So bleibt die Regeländerung als ausführbarer Test dokumentiert.
Häufig gestellte Fragen
Ist ein anderer Port auf demselben Host noch derselbe Ursprung?
Nein. Schema, Host und Port bestimmen gemeinsam den Ursprung; ein Wechsel von Port 8765 zu 8766 gilt hier als ursprungsübergreifend.
Warum liefert der Test nach einer abgelehnten Weiterleitung 302?
Bei completionHandler(nil) ruft URLSession das Weiterleitungsziel nicht ab, sondern gibt die ursprüngliche Weiterleitungsantwort zurück.
Möchten Sie es selbst testen?
Mieten Sie einen dedizierten Mac mini und starten Sie Ihren Build direkt
Wählen Sie zwischen zwei M4-Servermodellen und legen Sie Mietdauer und Standort nach Ihrem Bedarf fest. Die aktuelle Verfügbarkeit sehen Sie direkt in der Konsole.