Tester les limites de redirection d’URLSession en local

Tester les limites de redirection d’URLSession en local

Sur un Mac dans le cloud, un test iOS qui ne vérifie que la réponse finale d’une requête peut manquer une redirection HTTP intermédiaire. Une requête de téléchargement peut, par exemple, recevoir d’abord un 302 vers une adresse utilisant un autre port. Si le client suit cette redirection, une requête de test destinée à un seul service atteint alors un autre service. Nous allons reproduire ce scénario avec deux ports locaux, puis utiliser XCTest pour vérifier qu’une redirection de même origine est autorisée et qu’une redirection vers une autre origine est refusée. Aucun site externe n’est nécessaire, et les échecs sont faciles à reproduire.

Définir la limite avant d’écrire les assertions

Ici, une « origine » est la combinaison du protocole, de l’hôte et du port. Depuis http://127.0.0.1:8765, une redirection vers /target sur le même port reste de même origine ; une redirection vers http://127.0.0.1:8766/target change d’origine, même si le nom d’hôte ne change pas. La règle retenue dans cet exemple consiste à ne suivre que les redirections de même origine. Il s’agit d’une règle de démonstration, pas d’une interdiction générale des redirections entre origines : si un service de téléchargement utilise effectivement d’autres domaines, répertoriez d’abord les destinations autorisées, puis écrivez la règle adaptée au besoin réel.

Un test de redirection doit vérifier la décision prise en cours de route. Constater uniquement qu’un 200 a été obtenu à la fin ne prouve pas que la requête n’est pas passée par une adresse inattendue.

Placez le serveur de test dans le dépôt des tests, utilisez un faux jeton et n’inscrivez pas d’identifiants de production dans le script ou les journaux de test. Si l’API réelle utilise HTTPS, vérifiez aussi TLS séparément : le serveur HTTP local ne teste que la décision de redirection et ne remplace pas les tests de sécurité de la connexion en production.

Démarrer deux ports accessibles uniquement en local

Enregistrez le code suivant dans redirect_fixture.py, puis exécutez python3 redirect_fixture.py dans le terminal du Mac dans le cloud. Le premier port propose deux redirections ; le second sert uniquement à recevoir les requêtes visant la destination d’une autre origine. Le serveur se lie à 127.0.0.1 : les ports de test ne sont pas exposés à l’extérieur.

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()

Vérifiez d’abord le serveur depuis un autre terminal : curl -i http://127.0.0.1:8765/start doit renvoyer un 302 contenant l’adresse du second port ; curl -i http://127.0.0.1:8765/same doit renvoyer un 302 pointant vers /target. N’ajoutez pas -L : l’outil en ligne de commande suivrait la redirection à votre place et masquerait la réponse initiale. Une fois les tests terminés, arrêtez le serveur avec Ctrl-C dans le terminal où vous l’avez lancé.

Prendre la décision dans le délégué URLSession

Placez la vérification dans le rappel de redirection de URLSessionTaskDelegate. Comparez l’adresse de la réponse en cours avec celle de la requête à venir, et non simplement leurs deux noms d’hôte. La classe ci-dessous peut être ajoutée au target de test iOS ; elle ne conserve aucun état de tâche, ce qui permet de créer une instance pour chacun des deux tests.

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)
    }
}

La comparaison explicite des ports suffit pour les deux adresses du serveur de test. En production, il faut aussi définir une règle de normalisation des ports par défaut : par exemple, une adresse HTTPS sans port indiqué est-elle équivalente à une adresse indiquant explicitement 443 ? Ne laissez pas la comparaison simplifiée de cet exemple devenir implicitement une règle de production. Lorsque le délégué autorise une nouvelle requête, vérifiez également quels en-têtes la logique métier permet de transmettre.

Écrire deux tests XCTest qui distinguent les résultats

Ajoutez les cas suivants au target de test. data(for:delegate:) confie au délégué indiqué la décision de redirection pour cette requête ; chaque test utilise une session éphémère pour éviter de réutiliser l’état d’une session. Démarrez d’abord le serveur de test sur le même Mac dans le cloud que celui qui exécutera les tests, puis lancez-les.

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)
    }
}

Quand le délégué refuse la redirection en passant nil au rappel de fin, URLSession renvoie au test la réponse 302 initiale au lieu d’interroger le second port. La seconde assertion attend donc un 302, et non une erreur réseau. Dans cet exemple, X-Test-Token sert seulement à signaler qu’une requête peut contenir un en-tête sensible. Pour vérifier que le service de destination n’a effectivement pas reçu cet en-tête, faites aussi compter au serveur de test les requêtes reçues sur le second port et vérifiez que ce compteur reste à zéro.

Diagnostiquer les échecs par étape

Si les deux tests signalent un échec de connexion, vérifiez d’abord que le processus du serveur de test tourne encore et que les tests s’exécutent bien sur le Mac où il a été démarré. 127.0.0.1 désigne toujours l’appareil à l’origine de la requête : si les mêmes tests s’exécutent directement dans le simulateur iOS, ils visent l’adresse locale du simulateur et ne peuvent pas nécessairement joindre le serveur de test sur le Mac. Pour un projet dont les tests doivent s’exécuter dans le simulateur, configurez une adresse d’hôte de test accessible depuis celui-ci, tout en veillant à n’exposer le serveur que sur un réseau de test de confiance.

Si le test de même origine renvoie un 302, vérifiez que le délégué est bien transmis à data(for:delegate:) et que le protocole, l’hôte et le port de la requête et de la réponse correspondent aux valeurs attendues. Si le test de changement d’origine obtient un 200 contre toute attente, vérifiez en priorité si le rappel appelle completionHandler(request). Assurez-vous aussi qu’une autre couche réseau ne remplace pas la session utilisée par les tests.

Enfin, ajoutez les critères de validation au pipeline : le serveur de test démarre, les deux cas obtiennent respectivement 200 et 302, son processus s’arrête après les tests et les journaux ne contiennent aucun véritable jeton. Si la règle de redirection doit être assouplie par la suite, ajoutez d’abord le cas autorisé correspondant, puis modifiez le délégué. Ce changement de règle laissera ainsi une trace exécutable.

Questions fréquentes

Changer de port sur le même hôte conserve-t-il la même origine ?

Non. L’origine comprend le protocole, l’hôte et le port ; passer du port 8765 au port 8766 change donc d’origine.

Pourquoi le test reçoit-il un code 302 après un refus ?

En transmettant nil au completionHandler, le délégué empêche URLSession de contacter la cible ; la réponse initiale de redirection est renvoyée.

Envie de tester par vous-même ?

Louez un Mac mini dédié et lancez vos builds

Choisissez parmi deux configurations M4 dédiées, puis sélectionnez la durée de location et la région adaptées à vos besoins. La disponibilité est actualisée dans la console.

Louer un Mac mini