Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion Sources/Replay/Playback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTTPURLResponse, Error>?
private var dataContinuation: AsyncThrowingStream<Data, Error>.Continuation?
// Held weakly because URLProtocol owns the streaming task that owns this delegate.
weak var urlProtocol: PlaybackURLProtocol?

var dataStream: AsyncThrowingStream<Data, Error> {
if let stream = _dataStream { return stream }
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
94 changes: 93 additions & 1 deletion Tests/ReplayTests/PlaybackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading