diff --git a/Sources/Replay/Playback.swift b/Sources/Replay/Playback.swift index 0618566..ed19c1e 100644 --- a/Sources/Replay/Playback.swift +++ b/Sources/Replay/Playback.swift @@ -242,6 +242,10 @@ public final class PlaybackURLProtocol: URLProtocol, @unchecked Sendable { // Use a delegate-based approach for proper cancellation support let delegate = StreamingDelegate() + // The streaming URLSession delegate only knows about URLSession callbacks. + // Keep a back-reference to the PlaybackURLProtocol so authentication + // challenges can be forwarded to the URLProtocol client that started this load. + delegate.urlProtocol = urlProtocol let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = .infinity config.timeoutIntervalForResource = .infinity @@ -355,9 +359,11 @@ public final class PlaybackURLProtocol: URLProtocol, @unchecked Sendable { // MARK: - Streaming Delegate /// A delegate that bridges URLSession callbacks to async streams for SSE support. -private final class StreamingDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { +final class StreamingDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { private var responseContinuation: CheckedContinuation? private var dataContinuation: AsyncThrowingStream.Continuation? + // Held weakly because URLProtocol owns the streaming task that owns this delegate. + weak var urlProtocol: PlaybackURLProtocol? var dataStream: AsyncThrowingStream { if let stream = _dataStream { return stream } @@ -390,6 +396,31 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate, @unchec dataContinuation?.yield(data) } + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard let urlProtocol, let client = urlProtocol.client else { + completionHandler(.performDefaultHandling, nil) + return + } + + // URLProtocol clients answer challenges through URLAuthenticationChallengeSender, + // while URLSession expects a completion handler. Rebuild the challenge with a + // sender that bridges the client's eventual decision back to URLSession. + let forwardedChallenge = URLAuthenticationChallenge( + protectionSpace: challenge.protectionSpace, + proposedCredential: challenge.proposedCredential, + previousFailureCount: challenge.previousFailureCount, + failureResponse: challenge.failureResponse, + error: challenge.error, + sender: PlaybackChallengeForwarder(completionHandler: completionHandler) + ) + client.urlProtocol(urlProtocol, didReceive: forwardedChallenge) + } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { responseContinuation?.resume(throwing: error) @@ -401,6 +432,47 @@ private final class StreamingDelegate: NSObject, URLSessionDataDelegate, @unchec } } +/// Bridges URLProtocolClient challenge decisions back into URLSession's challenge completion handler. +private final class PlaybackChallengeForwarder: NSObject, URLAuthenticationChallengeSender, @unchecked Sendable { + private let lock = NSLock() + private var completionHandler: ((URLSession.AuthChallengeDisposition, URLCredential?) -> Void)? + + init(completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + self.completionHandler = completionHandler + } + + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) { + complete(.useCredential, credential) + } + + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) { + complete(.useCredential, nil) + } + + func cancel(_ challenge: URLAuthenticationChallenge) { + complete(.cancelAuthenticationChallenge, nil) + } + + func performDefaultHandling(for challenge: URLAuthenticationChallenge) { + complete(.performDefaultHandling, nil) + } + + func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) { + complete(.rejectProtectionSpace, nil) + } + + private func complete(_ disposition: URLSession.AuthChallengeDisposition, _ credential: URLCredential?) { + // A challenge sender may receive more than one callback from a defensive client; + // URLSession completion handlers must only be invoked once. + lock.lock() + let handler = completionHandler + completionHandler = nil + lock.unlock() + + handler?(disposition, credential) + } +} + // MARK: - Playback Store /// An actor that replays requests from recorded traffic. diff --git a/Tests/ReplayTests/PlaybackTests.swift b/Tests/ReplayTests/PlaybackTests.swift index fe1069d..c52890a 100644 --- a/Tests/ReplayTests/PlaybackTests.swift +++ b/Tests/ReplayTests/PlaybackTests.swift @@ -7,7 +7,10 @@ import Testing @testable import Replay -@Suite("Playback Tests", .serialized) +// These tests touch global URLProtocol and PlaybackStore state directly. +// `.serialized` only orders tests inside this suite; `.playbackIsolated` +// also prevents cross-suite interference with tests using `.replay(...)`. +@Suite("Playback Tests", .serialized, .playbackIsolated) struct PlaybackTests { private final class NetworkStubURLProtocol: URLProtocol { // Test-only shared state. @@ -528,6 +531,33 @@ struct PlaybackTests { #expect(canonical.url == request.url) } + + @Test("streaming delegate forwards authentication challenges to URLProtocol client") + func streamingDelegateForwardsAuthenticationChallenges() { + let client = PlaybackChallengeClient() + let request = URLRequest(url: URL(string: "https://example.com")!) + let urlProtocol = PlaybackURLProtocol( + request: request, + cachedResponse: nil, + client: client + ) + let delegate = StreamingDelegate() + delegate.urlProtocol = urlProtocol + + let session = URLSession(configuration: .ephemeral) + let task = session.dataTask(with: request) + let challenge = makePlaybackChallenge() + + var disposition: URLSession.AuthChallengeDisposition? + delegate.urlSession(session, task: task, didReceive: challenge) { + disposition = $0 + #expect($1 == nil) + } + + #expect(client.didReceiveChallenge) + #expect(disposition == .cancelAuthenticationChallenge) + session.invalidateAndCancel() + } } // MARK: - Store HandleRequest Tests @@ -665,6 +695,68 @@ struct PlaybackTests { } } +private final class PlaybackChallengeClient: NSObject, URLProtocolClient, @unchecked Sendable { + var didReceiveChallenge = false + + func urlProtocol( + _ protocol: URLProtocol, + wasRedirectedTo request: URLRequest, + redirectResponse: URLResponse + ) {} + + func urlProtocol(_ protocol: URLProtocol, cachedResponseIsValid cachedResponse: CachedURLResponse) {} + + func urlProtocol( + _ protocol: URLProtocol, + didReceive response: URLResponse, + cacheStoragePolicy policy: URLCache.StoragePolicy + ) {} + + func urlProtocol(_ protocol: URLProtocol, didLoad data: Data) {} + + func urlProtocolDidFinishLoading(_ protocol: URLProtocol) {} + + func urlProtocol(_ protocol: URLProtocol, didFailWithError error: Error) {} + + func urlProtocol(_ protocol: URLProtocol, didReceive challenge: URLAuthenticationChallenge) { + didReceiveChallenge = true + challenge.sender?.cancel(challenge) + } + + func urlProtocol(_ protocol: URLProtocol, didCancel challenge: URLAuthenticationChallenge) {} +} + +private final class PlaybackChallengeSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + + func cancel(_ challenge: URLAuthenticationChallenge) {} + + func performDefaultHandling(for challenge: URLAuthenticationChallenge) {} + + func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) {} +} + +private func makePlaybackChallenge() -> URLAuthenticationChallenge { + let protectionSpace = URLProtectionSpace( + host: "example.com", + port: 443, + protocol: "https", + realm: nil, + authenticationMethod: NSURLAuthenticationMethodDefault + ) + + return URLAuthenticationChallenge( + protectionSpace: protectionSpace, + proposedCredential: nil, + previousFailureCount: 0, + failureResponse: nil, + error: nil, + sender: PlaybackChallengeSender() + ) +} + // MARK: - Test Helpers private func makeTestRequest() -> HAR.Request {