Test URLSession redirect boundaries with a local HTTP fixture

Test URLSession redirect boundaries with a local HTTP fixture

When testing an iOS app on a cloud Mac, checking only an API’s final response can miss an HTTP redirect along the way. A download request, for example, might receive a 302 pointing to a different port. If the client follows it, a test request intended for one service reaches another. This walkthrough uses two local ports to reproduce that scenario, then uses XCTest to verify that the client follows same-origin redirects but rejects cross-origin ones. No external site is needed, and failures are easy to reproduce.

Define the boundary before writing assertions

Here, an “origin” is the combination of scheme, host, and port. A redirect from http://127.0.0.1:8765 to /target on the same port is same-origin; a redirect to http://127.0.0.1:8766/target is cross-origin, even though the hostname has not changed. The policy in this example follows only same-origin redirects. It is a demonstration policy, not a rule that every application must block cross-origin redirects. If a download service uses other domains, list the permitted destinations first and write the rules to match your requirements.

A redirect test needs to check the decision made along the way. Asserting only that the final response is 200 does not prove the request avoided an unexpected address.

Keep the fixture in the test repository and use a dummy token. Do not put production credentials in the script or test logs. If the production API uses HTTPS, test TLS separately as well. This local HTTP fixture checks redirect decisions; it cannot replace a production connection-security test.

Start two ports that listen only on localhost

Save the following code as redirect_fixture.py and run python3 redirect_fixture.py in a terminal on the cloud Mac. The first port serves two kinds of redirect; the second only receives requests to the cross-origin destination. The servers bind to 127.0.0.1, so the test ports are not exposed externally.

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

Check the fixture from another terminal first: curl -i http://127.0.0.1:8765/start should return a 302 containing the address on the second port, while curl -i http://127.0.0.1:8765/same should return a 302 pointing to /target. Do not add -L here: it would make the command-line tool follow the redirect and hide the initial response. After the tests, press Ctrl-C in the terminal running the fixture to stop the servers.

Make the decision in a URLSession delegate

Put the check in the redirect callback of URLSessionTaskDelegate. Compare the URL of the current response with the URL about to be requested, not just their hostnames. The following class can go in the iOS test target. It stores no task state, so each test can create its own instance.

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

Comparing ports explicitly covers the fixture’s two addresses. Production code also needs a rule for normalizing default ports: for example, whether an HTTPS URL with no port specified is equivalent to one that explicitly specifies 443. Do not let the simplified comparison in a test example quietly become product policy. When the delegate allows the new request, also check which request headers your application permits it to carry forward.

Write two XCTest cases with distinct outcomes

Add these cases to the test target. data(for:delegate:) gives the specified delegate control over redirects for this request. Each test uses an ephemeral session to avoid reusing session state. Start the fixture on the same cloud Mac that runs the tests before executing them.

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

Passing nil to the completion handler rejects the redirect. URLSession then returns the original 302 response to the test instead of requesting the second port. That is why the second test expects 302, not a network error. The example’s X-Test-Token illustrates that a request may carry a sensitive header. To verify that the destination service did not actually receive it, also have the fixture count requests to the second port and assert that the count is zero.

Troubleshoot failures layer by layer

If both tests report connection failures, first check that the fixture process is still running and that the tests really execute on the Mac where you started it. 127.0.0.1 always refers to the device making the request. If you run the same test directly inside an iOS simulator, it targets the simulator’s own loopback address and may not reach the fixture on the Mac. Projects that need to run the tests in a simulator should configure a reachable test-host address while exposing the server only on a trusted test network.

If the same-origin case returns 302, check that the delegate was passed to data(for:delegate:) and that the scheme, host, and port in the request and response match your expectations. If the cross-origin case unexpectedly returns 200, first check whether the callback called completionHandler(request). Also make sure another networking layer has not replaced the session used by the test.

Finally, add acceptance checks to the CI pipeline: the fixture starts, the two cases return 200 and 302 respectively, the server process exits after testing, and the logs contain no real tokens. If the redirect policy needs to be relaxed later, add a test for the newly permitted case before changing the delegate. That leaves an executable record of the policy change.

Frequently asked questions

Is a redirect to another port on the same host still same-origin?

No. Scheme, host, and port define the origin together; changing from port 8765 to 8766 crosses that boundary.

Why does the rejected redirect test receive a 302 response?

Passing nil to the redirect completion handler stops URLSession from requesting the target and returns the original redirect response.

Ready to put it to the test?

Rent a dedicated Mac mini and run your build

Choose from two M4 dedicated Mac models, then select a rental term and location to suit your needs. Check real-time availability in the console.

Rent a Mac mini now