diff --git a/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt b/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt index 0400e804d79f..0ede373835b0 100644 --- a/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt +++ b/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt @@ -638,11 +638,17 @@ public class MockWebServer : Closeable { } } + public var handshakeFailureSslSocketFactory: SSLSocketFactory? = null + @Throws(Exception::class) private fun processHandshakeFailure(raw: Socket) { - val context = SSLContext.getInstance("TLS") - context.init(null, arrayOf(UNTRUSTED_TRUST_MANAGER), SecureRandom()) - val sslSocketFactory = context.socketFactory + val sslSocketFactory = handshakeFailureSslSocketFactory + ?: run { + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(UNTRUSTED_TRUST_MANAGER), SecureRandom()) + context.socketFactory + } + val socket = sslSocketFactory.createSocket( raw, @@ -650,6 +656,7 @@ public class MockWebServer : Closeable { raw.port, true, ) as SSLSocket + socket.useClientMode = false try { socket.startHandshake() // we're testing a handshake failure throw AssertionError() diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/internal/concurrent/TaskRunnerTesting.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/internal/concurrent/TaskRunnerTesting.kt new file mode 100644 index 000000000000..3573e41db817 --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/internal/concurrent/TaskRunnerTesting.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.concurrent + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +fun TaskRunner.schedule( + name: String, + delay: Duration = 0.milliseconds, + block: () -> Unit, +) { + newQueue().schedule(name, delay.inWholeNanoseconds) { + block() + -1L + } +} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeConnection.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeConnection.kt new file mode 100644 index 000000000000..f5dddffd5edc --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeConnection.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.sockets + +import java.net.InetSocketAddress +import java.net.SocketException +import okhttp3.internal.concurrent.Lockable +import okhttp3.internal.concurrent.notifyAll +import okhttp3.internal.concurrent.withLock +import okio.Socket +import okio.Timeout + +/** + * This class implements a rendezvous point for [Handshaker.ClientInputs] and [Handshaker.ServerInputs]. When they're + * both provided, the client caller does a handshake and shares the handshake result. + */ +internal class FakeConnection( + val clientAddress: InetSocketAddress, + val serverAddress: InetSocketAddress, + val clientSocket: Socket, + val serverSocket: Socket, +) : Lockable { + private var closed = false + private var serverInputs: Handshaker.ServerInputs? = null + private var handshakeResult: Result? = null + + fun handshake( + handshaker: Handshaker, + clientInputs: Handshaker.ClientInputs, + timeout: Timeout, + ): Handshaker.Result { + val serverInputs = + withLock { + awaitServerInputs(timeout) + } + + // Destroy the unencrypted socket pair; we'll build a new encrypted one to replace it. + clientSocket.cancel() + serverSocket.cancel() + + val result = + runCatching { + handshaker.handshake(clientInputs, serverInputs) + } + + withLock { + this.handshakeResult = result + notifyAll() + } + + return result.getOrThrow() + } + + private tailrec fun awaitServerInputs(timeout: Timeout): Handshaker.ServerInputs { + if (closed) throw SocketException("closed") + serverInputs?.let { return it } + timeout.waitUntilNotified(this) + return awaitServerInputs(timeout) + } + + fun handshake( + serverInputs: Handshaker.ServerInputs, + timeout: Timeout, + ): Handshaker.Result { + withLock { + this.serverInputs = serverInputs + notifyAll() + return awaitResult(timeout).getOrThrow() + } + } + + private tailrec fun awaitResult(timeout: Timeout): Result { + if (closed) throw SocketException("closed") + handshakeResult?.let { return it } + timeout.waitUntilNotified(this) + return awaitResult(timeout) + } + + fun close() { + withLock { + closed = true + notifyAll() + } + } + + override fun toString() = "$clientAddress<->$serverAddress" +} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetwork.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetwork.kt index b50d63c85771..c20fd0e8513e 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetwork.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetwork.kt @@ -31,7 +31,6 @@ import okhttp3.internal.concurrent.wait import okhttp3.internal.concurrent.withLock import okhttp3.internal.connection.asBufferedSocket import okio.Buffer -import okio.Socket import okio.Timeout import okio.inMemorySocketPair @@ -57,7 +56,7 @@ class FakeNetwork { get() = InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0)) /** Generate a new unique address. */ - internal fun nextSocketAddress(): InetSocketAddress { + fun nextSocketAddress(): InetSocketAddress { val ipv4AddressInt = nextIpv4Address.getAndIncrement() val ipv4AddressBytes = Buffer() @@ -269,12 +268,3 @@ internal class BoundServer( override fun toString() = "Server@$serverAddress" } - -internal class FakeConnection( - val clientAddress: InetSocketAddress, - val serverAddress: InetSocketAddress, - val clientSocket: Socket, - val serverSocket: Socket, -) { - override fun toString() = "$clientAddress<->$serverAddress" -} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetworkTesting.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetworkTesting.kt new file mode 100644 index 000000000000..4215b294b3cd --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeNetworkTesting.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.sockets + +import okio.inMemorySocketPair + +/** Returns a two-element array containing mutually-connected sockets. */ +internal fun FakeNetwork.socketPair(): Array { + val (clientOkioSocket, serverOkioSocket) = inMemorySocketPair(maxBufferSize = 1024 * 1024) + val connection = + FakeConnection( + clientAddress = nextSocketAddress(), + serverAddress = nextSocketAddress(), + clientSocket = clientOkioSocket, + serverSocket = serverOkioSocket, + ) + + val clientJavaNetSocket = + FakeSocket( + network = this, + initialState = + FakeSocket.State.Connected( + connection = connection, + localAddress = connection.clientAddress, + remoteAddress = connection.serverAddress, + socket = connection.clientSocket, + ), + ) + + val serverJavaNetSocket = + FakeSocket( + network = this, + initialState = + FakeSocket.State.Connected( + connection = connection, + localAddress = connection.serverAddress, + remoteAddress = connection.clientAddress, + socket = connection.serverSocket, + ), + ) + + return arrayOf(clientJavaNetSocket, serverJavaNetSocket) +} + +internal fun FakeTls.clientSocket( + socket: FakeSocket, + serverHostname: String = "testing.lysine.dev", + serverPort: Int = 443, +): FakeSslSocket = sslSocketFactory.createSocket(socket, serverHostname, serverPort, true) as FakeSslSocket + +internal fun FakeTls.serverSocket( + socket: FakeSocket, + clientPort: Int = 1024, +): FakeSslSocket { + val result = sslSocketFactory.createSocket(socket, null, clientPort, true) as FakeSslSocket + result.useClientMode = false + return result +} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeServerSocket.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeServerSocket.kt index 82b5bb78c005..031e704ab2ab 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeServerSocket.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeServerSocket.kt @@ -79,10 +79,10 @@ internal class FakeServerSocket( network = network, initialState = FakeSocket.State.Connected( + connection = connection, localAddress = connection.serverAddress, remoteAddress = connection.clientAddress, - source = SocketSource(connection.serverSocket.source), - sink = SocketSink(connection.serverSocket.sink), + socket = connection.serverSocket, ), ) diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSocket.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSocket.kt index ac6d8775834b..e35aaccfeab7 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSocket.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSocket.kt @@ -18,14 +18,19 @@ package okhttp3.sockets import java.io.IOException +import java.io.InputStream +import java.io.OutputStream import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress import java.net.SocketException import java.net.SocketOption import java.util.concurrent.atomic.AtomicReference +import okhttp3.sockets.FakeSslSocket.HandshakeState import okio.Buffer +import okio.Closeable import okio.Sink +import okio.Socket as OkioSocket import okio.Source import okio.buffer @@ -38,12 +43,15 @@ internal class FakeSocket( val network: FakeNetwork, initialState: State = State.New, ) : Socket() { - private val atomicState = AtomicReference(initialState) - private val state: State + internal val atomicState = AtomicReference(initialState) + internal val state: State get() = atomicState.get() private var socketReadTimeoutMillis: Long = 0 + val connection: FakeConnection? + get() = (state as? State.Connected)?.connection + override fun getInputStream() = (state as? State.Connected)?.inputStream ?: throw IOException("not connected") @@ -113,10 +121,10 @@ internal class FakeSocket( val next = State.Connected( + connection = connection, localAddress = attempt.clientAddress, remoteAddress = attempt.serverAddress, - source = SocketSource(connection.clientSocket.source), - sink = SocketSink(connection.clientSocket.sink), + socket = connection.clientSocket, ) // If the state changed while we were connecting, the other state wins. @@ -141,6 +149,7 @@ internal class FakeSocket( } previous.inputStream.close() + if (previous.outputShutdown) previous.connection.close() break } } @@ -158,6 +167,7 @@ internal class FakeSocket( } previous.outputStream.close() + if (previous.inputShutdown) previous.connection.close() break } } @@ -173,15 +183,16 @@ internal class FakeSocket( State.New -> { } - is State.Connected -> { - previous.inputStream.close() - previous.outputStream.close() - } - is State.Connecting -> { previous.attempt.cancel(SocketException("client closed")) } + is State.Connected -> { + previous.source.cancel() + previous.sink.cancel() + previous.connection.close() + } + is State.Closed -> { } } @@ -249,6 +260,11 @@ internal class FakeSocket( override fun toString() = "FakeSocket" + /** + * This represents the lifecycle state of the TCP socket, which includes a nested lifecycle for + * the TLS handshake. Tracking the TLS lifecycle here is a layering violation, but it lets us + * easily keep a single atomic state for both layers. + */ sealed interface State { val localAddress: InetSocketAddress? get() = null @@ -262,6 +278,8 @@ internal class FakeSocket( get() = false val outputShutdown: Boolean get() = false + val handshakeState: HandshakeState + get() = HandshakeState.New object New : State @@ -270,34 +288,64 @@ internal class FakeSocket( ) : State class Connected( + val connection: FakeConnection, override val localAddress: InetSocketAddress, override val remoteAddress: InetSocketAddress, - val source: SocketSource, - val sink: SocketSink, + override val handshakeState: HandshakeState = HandshakeState.New, + val socket: OkioSocket, + val source: SocketSource = SocketSource(socket), + val sink: SocketSink = SocketSink(socket), + val inputStream: InputStream = source.buffer().inputStream(), + val outputStream: OutputStream = sink.buffer().outputStream(), ) : State { - val inputStream = source.buffer().inputStream() - val outputStream = sink.buffer().outputStream() - override val bound: Boolean get() = true override val connected: Boolean get() = true override val inputShutdown: Boolean - get() = source.delegate == null + get() = source.closed override val outputShutdown: Boolean - get() = sink.delegate == null + get() = sink.closed + + /** Note that this yields a new [inputStream] and [outputStream]. */ + fun withHandshakeSuccess( + handshakeState: HandshakeState.Success, + socket: OkioSocket, + ) = Connected( + connection = connection, + localAddress = localAddress, + remoteAddress = remoteAddress, + handshakeState = handshakeState, + socket = socket, + ) + + /** Note that this retains the previous [inputStream] and [outputStream]. */ + fun withHandshakeState(handshakeState: HandshakeState) = + Connected( + connection = connection, + localAddress = localAddress, + remoteAddress = remoteAddress, + handshakeState = handshakeState, + socket = socket, + source = source, + sink = sink, + inputStream = inputStream, + outputStream = outputStream, + ) } /** A closed socket remembers what happened before it was closed. */ class Closed( override val localAddress: InetSocketAddress?, override val remoteAddress: InetSocketAddress?, + override val handshakeState: HandshakeState, override val bound: Boolean, override val connected: Boolean, ) : State { constructor(previous: State) : this( localAddress = previous.localAddress, remoteAddress = previous.remoteAddress, + handshakeState = previous.handshakeState, bound = previous.bound, connected = previous.connected, ) @@ -310,55 +358,100 @@ internal class FakeSocket( } } -internal class SocketSource( - delegate: Source, -) : Source { - private val timeout = delegate.timeout() +/** + * This wraps an `okio.Socket` and exposes it as either the source or sink of a `java.net.Socket`. + * + * It's made complicated by how [okio.inMemorySocketPair] handles asynchronous cancellation. + * Canceling an in-memory socket cancels both streams for both peers. In-flight operations + * immediately fail and all following operations throw an [IOException]. + * + * To contrast, closing a [java.net.Socket] immediately fails local operations only. The peer can + * read the remainder of the stream until it is exhausted. + * + * Our mitigation is straightforward enough: if the [FakeSocket] is closed while a local operation + * is in-flight, we trigger a maximally invasive cancel. Otherwise, we do a graceful close. + */ +internal open class SocketStream( + val socket: OkioSocket, + val stream: T, +) : Closeable { + val closed: Boolean + get() = state.get() == StreamState.Closed + + private val state = AtomicReference(StreamState.Ready) + + override fun close() { + val previous = state.getAndSet(StreamState.Closed) + if (previous != StreamState.Closed) { + stream.close() + } + } - @Volatile var delegate: Source? = delegate - private set + /** + * Equivalent to [close] unless there's a local operation in-flight, in which case the entire + * socket is interrupted. + */ + fun cancel() { + val previous = state.getAndSet(StreamState.Closed) + if (previous != StreamState.Closed) { + stream.close() + } + if (previous == StreamState.Blocked) { + socket.cancel() + } + } + protected inline fun blockingOp(block: () -> T): T { + if (!state.compareAndSet(StreamState.Ready, StreamState.Blocked)) { + throw IOException("closed") + } + try { + return block() + } finally { + // If the state is since closed, that's okay. + state.compareAndSet(StreamState.Blocked, StreamState.Ready) + } + } + + internal enum class StreamState { + Ready, + Blocked, + Closed + } +} + +internal class SocketSource( + socket: OkioSocket, +) : SocketStream(socket, socket.source), Source { override fun read( sink: Buffer, byteCount: Long, ): Long { - val delegate = this.delegate ?: throw IOException("closed") - return delegate.read(sink, byteCount) + blockingOp { + return stream.read(sink, byteCount) + } } - override fun timeout() = timeout - - override fun close() { - delegate?.close() - delegate = null - } + override fun timeout() = stream.timeout() } internal class SocketSink( - delegate: Sink, -) : Sink { - private val timeout = delegate.timeout() - - @Volatile var delegate: Sink? = delegate - private set - + socket: OkioSocket, +) : SocketStream(socket, socket.sink), Sink { override fun write( source: Buffer, byteCount: Long, ) { - val delegate = this.delegate ?: throw IOException("closed") - delegate.write(source, byteCount) + blockingOp { + stream.write(source, byteCount) + } } override fun flush() { - val delegate = this.delegate ?: throw IOException("closed") - delegate.flush() + blockingOp { + stream.flush() + } } - override fun timeout() = timeout - - override fun close() { - delegate?.close() - delegate = null - } + override fun timeout() = stream.timeout() } diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSslSocket.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSslSocket.kt new file mode 100644 index 000000000000..2276bc5e2b22 --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeSslSocket.kt @@ -0,0 +1,468 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("Since15") + +package okhttp3.sockets + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.SocketAddress +import java.net.SocketException +import java.net.SocketOption +import java.nio.channels.SocketChannel +import java.util.concurrent.TimeUnit +import java.util.function.BiFunction +import javax.net.ssl.HandshakeCompletedListener +import javax.net.ssl.SSLParameters +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSession +import javax.net.ssl.SSLSocket +import okhttp3.CipherSuite +import okhttp3.Handshake +import okhttp3.Protocol +import okhttp3.TlsVersion +import okio.ByteString +import okio.Timeout + +/** + * The TLS layer on top of socket. + * + * The [java.net.Socket] API uses inheritance rather than composition for TLS, but this uses + * composition, and delegates socket methods to its underlying [FakeSocket]. It also relies on that + * class to hold the state of the TLS handshake. + */ +internal class FakeSslSocket( + val tls: FakeTls, + val socket: FakeSocket, + val hostname: String?, + tlsVersions: List, + cipherSuites: List, +) : SSLSocket() { + private var sslParameters = + SSLParameters().apply { + this.protocols = tlsVersions.map { it.javaName }.toTypedArray() + this.cipherSuites = cipherSuites.map { it.javaName }.toTypedArray() + } + private var enableSessionCreation = true + private var useClientMode = true + private var echConfigList: ByteString? = null + + override fun getEnabledProtocols(): Array = sslParameters.protocols + + override fun setEnabledProtocols(protocols: Array) { + sslParameters.protocols = protocols + } + + override fun getSupportedProtocols() = tls.supportedTlsVersions.map { it.javaName }.toTypedArray() + + override fun getSupportedCipherSuites() = tls.supportedCipherSuites.map { it.javaName }.toTypedArray() + + override fun getEnabledCipherSuites(): Array = sslParameters.cipherSuites + + override fun setEnabledCipherSuites(cipherSuites: Array) { + sslParameters.cipherSuites = cipherSuites + } + + override fun setNeedClientAuth(needClientAuth: Boolean) { + sslParameters.needClientAuth = needClientAuth + } + + override fun setWantClientAuth(wantClientAuth: Boolean) { + this.sslParameters.wantClientAuth = wantClientAuth + } + + override fun getNeedClientAuth() = sslParameters.needClientAuth + + override fun getWantClientAuth() = sslParameters.wantClientAuth + + override fun setEnableSessionCreation(flag: Boolean) { + this.enableSessionCreation = flag + } + + override fun getEnableSessionCreation() = enableSessionCreation + + override fun getSSLParameters() = sslParameters + + override fun setSSLParameters(sslParameters: SSLParameters) { + this.sslParameters = sslParameters + } + + override fun setUseClientMode(useClientMode: Boolean) { + this.useClientMode = useClientMode + } + + override fun getUseClientMode() = useClientMode + + override fun getSession(): SSLSession = requireSession() + + override fun startHandshake() { + requireSession() + } + + private fun requireSession(): SSLSession { + val tlsVersions = sslParameters.protocols.map { TlsVersion.forJavaName(it) } + val cipherSuites = sslParameters.cipherSuites.map { CipherSuite.forJavaName(it) } + val protocols = sslParameters.applicationProtocols.map { Protocol.get(it) } + + val inputs = + when { + useClientMode -> { + Handshaker.ClientInputs( + tlsVersions = tlsVersions, + cipherSuites = cipherSuites, + protocols = protocols, + handshakeCertificates = tls.handshakeCertificates, + hostname = hostname, + echConfigList = echConfigList, + ) + } + + else -> { + Handshaker.ServerInputs( + tlsVersions = tlsVersions, + cipherSuites = cipherSuites, + protocols = protocols, + handshakeCertificates = tls.handshakeCertificates, + clientAuth = + when { + sslParameters.needClientAuth -> Handshaker.ClientAuth.Required + sslParameters.wantClientAuth -> Handshaker.ClientAuth.Requested + else -> Handshaker.ClientAuth.None + }, + ) + } + } + + val previous = socket.state + if (previous.handshakeState == HandshakeState.New) { + val connection = + (previous as? FakeSocket.State.Connected)?.connection + ?: throw SocketException("not connected") + + val handshaking = + previous.withHandshakeState( + handshakeState = HandshakeState.Handshaking, + ) + + if (!socket.atomicState.compareAndSet(previous, handshaking)) { + throw SocketException("not ready") + } + + val handshakeTimeout = Timeout() + val soTimeout = soTimeout + if (soTimeout != 0) { + handshakeTimeout.deadline(soTimeout.toLong(), TimeUnit.MILLISECONDS) + } + + val next = + try { + when (inputs) { + is Handshaker.ClientInputs -> { + val result = connection.handshake(tls.handshaker, inputs, handshakeTimeout) + previous.withHandshakeSuccess( + socket = result.clientSocket, + handshakeState = + HandshakeState.Success( + session = + FakeSslSession( + peerAddress = remoteSocketAddress, + handshake = result.clientHandshake, + selectedProtocol = result.selectedProtocol, + ), + ), + ) + } + + is Handshaker.ServerInputs -> { + val result = connection.handshake(inputs, handshakeTimeout) + previous.withHandshakeSuccess( + socket = result.serverSocket, + handshakeState = + HandshakeState.Success( + session = + FakeSslSession( + peerAddress = remoteSocketAddress, + handshake = result.serverHandshake, + selectedProtocol = result.selectedProtocol, + ), + ), + ) + } + } + } catch (e: IOException) { + previous.withHandshakeState( + handshakeState = + HandshakeState.Failed( + exception = e, + session = FakeSslSession(), + ), + ) + } + + // If the state changed while we were connecting, the other state wins. + socket.atomicState.compareAndSet(handshaking, next) + } + + val state = socket.state + if (state is FakeSocket.State.Closed) throw SocketException("closed") + + return when (val handshakeState = state.handshakeState) { + is HandshakeState.Failed -> throw handshakeState.exception + else -> handshakeState.session ?: error("unexpected state") + } + } + + override fun getApplicationProtocol(): String? { + val session = socket.state.handshakeState.session ?: return null + return session.selectedProtocol?.toString() ?: "" + } + + override fun getInputStream(): InputStream { + val state = socket.state + if (state !is FakeSocket.State.Connected) throw IOException("not connected") + if (state.handshakeState.session == null) throw IOException("no handshake") + return state.inputStream + } + + override fun getOutputStream(): OutputStream { + val state = socket.state + if (state !is FakeSocket.State.Connected) throw IOException("not connected") + if (state.handshakeState.session == null) throw IOException("no handshake") + return state.outputStream + } + + override fun isInputShutdown() = socket.isInputShutdown() + + override fun shutdownInput() { + socket.shutdownInput() + } + + override fun isOutputShutdown() = socket.isOutputShutdown + + override fun shutdownOutput() { + socket.shutdownOutput() + } + + override fun close() { + socket.close() + } + + override fun isClosed() = socket.isClosed + + override fun getLocalSocketAddress() = socket.localSocketAddress + + override fun getLocalAddress() = socket.localAddress + + override fun getLocalPort() = socket.localPort + + override fun getRemoteSocketAddress() = socket.remoteSocketAddress + + override fun getInetAddress() = socket.inetAddress + + override fun getPort() = socket.port + + override fun getKeepAlive() = socket.keepAlive + + override fun getSoLinger() = socket.soLinger + + override fun getReceiveBufferSize() = socket.receiveBufferSize + + override fun getSendBufferSize() = socket.sendBufferSize + + override fun getSoTimeout() = socket.soTimeout + + override fun getTcpNoDelay() = socket.tcpNoDelay + + override fun setKeepAlive(keepAlive: Boolean) { + socket.setKeepAlive(keepAlive) + } + + override fun setSendBufferSize(sendBufferSize: Int) { + socket.setSendBufferSize(sendBufferSize) + } + + override fun setReceiveBufferSize(receiveBufferSize: Int) { + socket.setReceiveBufferSize(receiveBufferSize) + } + + override fun setSoLinger( + on: Boolean, + timeout: Int, + ) { + socket.setSoLinger(on, timeout) + } + + override fun setSoTimeout(soTimeout: Int) { + socket.soTimeout = soTimeout + } + + override fun setTcpNoDelay(tcpNoDelay: Boolean) { + socket.setTcpNoDelay(tcpNoDelay) + } + + override fun isBound() = socket.isBound + + override fun isConnected() = socket.isConnected + + override fun bind(localAddr: SocketAddress) { + socket.bind(localAddr) + } + + override fun connect(remoteAddr: SocketAddress) { + socket.connect(remoteAddr) + } + + override fun connect( + remoteAddr: SocketAddress, + timeout: Int, + ) { + socket.connect(remoteAddr, timeout) + } + + override fun setReuseAddress(reuseAddress: Boolean) { + socket.setReuseAddress(reuseAddress) + } + + override fun getReuseAddress() = socket.reuseAddress + + override fun setOOBInline(oobInline: Boolean) { + socket.setOOBInline(oobInline) + } + + override fun getOOBInline() = socket.oobInline + + override fun setTrafficClass(trafficClass: Int) { + socket.setTrafficClass(trafficClass) + } + + override fun getTrafficClass() = socket.trafficClass + + override fun getChannel(): SocketChannel = socket.channel + + override fun sendUrgentData(data: Int) = socket.sendUrgentData(data) + + override fun setPerformancePreferences( + connectionTime: Int, + latency: Int, + bandwidth: Int, + ) = socket.setPerformancePreferences(connectionTime, latency, bandwidth) + + override fun setOption( + name: SocketOption, + value: T?, + ) = socket.setOption(name, value) + + override fun getOption(name: SocketOption) = socket.getOption(name) + + override fun supportedOptions(): Set> = socket.supportedOptions() + + override fun addHandshakeCompletedListener(listener: HandshakeCompletedListener) = error("unsupported") + + override fun removeHandshakeCompletedListener(listener: HandshakeCompletedListener) = error("unsupported") + + override fun getHandshakeSession() = error("unsupported") + + override fun getHandshakeApplicationProtocol() = error("unsupported") + + override fun setHandshakeApplicationProtocolSelector(selector: BiFunction, String>) = error("unsupported") + + override fun getHandshakeApplicationProtocolSelector() = error("unsupported") + + override fun toString() = "FakeSslSocket" + + internal sealed interface HandshakeState { + val session: FakeSslSession? + get() = null + + object New : HandshakeState + + object Handshaking : HandshakeState + + data class Success( + override val session: FakeSslSession, + ) : HandshakeState + + class Failed( + val exception: IOException, + override val session: FakeSslSession, + ) : HandshakeState + } + + /** + * For OkHttp this is useful as a holder for the handshake. + * + * It's particularly awkward to use because it returns dummy values when used without an actual + * TLS handshake. + */ + internal class FakeSslSession( + val peerAddress: InetSocketAddress? = null, + val handshake: Handshake? = null, + val selectedProtocol: Protocol? = null, + ) : SSLSession { + override fun getPeerCertificates() = + handshake?.peerCertificates?.toTypedArray() + ?: throw SSLPeerUnverifiedException("no handshake") + + override fun getLocalCertificates() = + handshake?.localCertificates?.toTypedArray() + ?: throw SSLPeerUnverifiedException("no handshake") + + override fun getPeerPrincipal() = + handshake?.peerPrincipal + ?: throw SSLPeerUnverifiedException("no handshake") + + override fun getLocalPrincipal() = + handshake?.localPrincipal + ?: throw SSLPeerUnverifiedException("no handshake") + + override fun getCipherSuite() = handshake?.cipherSuite?.javaName ?: "TLS_NULL_WITH_NULL_NULL" + + override fun getProtocol() = handshake?.tlsVersion?.javaName ?: "NONE" + + override fun getPeerHost() = peerAddress?.hostName + + override fun getPeerPort() = peerAddress?.port ?: -1 + + override fun getId() = error("unsupported") + + override fun getSessionContext() = error("unsupported") + + override fun getCreationTime() = error("unsupported") + + override fun getLastAccessedTime() = error("unsupported") + + override fun invalidate() = error("unsupported") + + override fun isValid() = error("unsupported") + + override fun putValue( + name: String, + value: Any, + ) = error("unsupported") + + override fun getValue(name: String) = error("unsupported") + + override fun removeValue(name: String) = error("unsupported") + + override fun getValueNames() = error("unsupported") + + override fun getPacketBufferSize() = error("unsupported") + + override fun getApplicationBufferSize() = error("unsupported") + } +} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeTls.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeTls.kt new file mode 100644 index 000000000000..bba0da254459 --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/FakeTls.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.sockets + +import java.io.InputStream +import java.net.InetAddress +import java.net.Socket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager +import okhttp3.CipherSuite +import okhttp3.TlsVersion +import okhttp3.tls.HandshakeCertificates + +/** + * A fake TLS stack to accompany our fake network. + * + * Unlike [FakeNetwork] which is a natural singleton, we expect each peer to have their own + * independent instances of [FakeTls]. This allows us to simulate different configurations for + * each peer. + * + * Note that only the client's [handshaker] is used, and its result is used by both client and + * server. + */ +class FakeTls( + val handshaker: Handshaker, + val handshakeCertificates: HandshakeCertificates, + val supportedTlsVersions: List = + listOf( + TlsVersion.TLS_1_3, + TlsVersion.TLS_1_2, + ), + val enabledTlsVersions: List = supportedTlsVersions, + val supportedCipherSuites: List = + listOf( + CipherSuite.TLS_AES_128_GCM_SHA256, // TLSv1.3. + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, // TLSv1.2 + ), + val defaultCipherSuites: List = supportedCipherSuites, +) { + val trustManager: X509TrustManager + get() = handshakeCertificates.trustManager + + val sslSocketFactory = + object : SSLSocketFactory() { + override fun getDefaultCipherSuites() = this@FakeTls.defaultCipherSuites.map { it.javaName }.toTypedArray() + + override fun getSupportedCipherSuites() = this@FakeTls.supportedCipherSuites.map { it.javaName }.toTypedArray() + + override fun createSocket( + socket: Socket, + consumed: InputStream, + autoClose: Boolean, + ) = error("unsupported") + + override fun createSocket() = error("unsupported") + + override fun createSocket( + socket: Socket, + host: String?, + port: Int, + autoClose: Boolean, + ): FakeSslSocket { + require(autoClose) + return FakeSslSocket( + tls = this@FakeTls, + socket = socket as FakeSocket, + hostname = host, + tlsVersions = this@FakeTls.enabledTlsVersions, + cipherSuites = this@FakeTls.defaultCipherSuites, + ) + } + + override fun createSocket( + host: String?, + port: Int, + ) = error("unsupported") + + override fun createSocket( + host: String?, + port: Int, + localHost: InetAddress, + localPort: Int, + ) = error("unsupported") + + override fun createSocket( + host: InetAddress?, + port: Int, + ) = error("unsupported") + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress?, + localPort: Int, + ) = error("unsupported") + } +} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/Handshaker.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/Handshaker.kt new file mode 100644 index 000000000000..a0f832f0c3ef --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/Handshaker.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.sockets + +import java.io.IOException +import okhttp3.CipherSuite +import okhttp3.Handshake +import okhttp3.Protocol +import okhttp3.TlsVersion +import okhttp3.tls.HandshakeCertificates +import okio.ByteString +import okio.Socket + +/** + * This implements the policy of a TLS handshake, deciding which [TlsVersion], [CipherSuite], and + * [Protocol] to negotiate. + * + * In a real TLS handshake the client and server are mutually-distrusting parties, and they each + * implement their own handshaking logic. For this fake, we use a single handshaker that makes + * decisions on behalf of both parties. + */ +interface Handshaker { + /** + * Returns a two-element array containing the client result and the server result. + * + * @throws [EchRejectedException] if that is why this handshake was rejected. + */ + @Throws(IOException::class) + fun handshake( + client: ClientInputs, + server: ServerInputs, + ): Result + + sealed interface Inputs { + val tlsVersions: List + val cipherSuites: List + val protocols: List? + val handshakeCertificates: HandshakeCertificates + } + + class ClientInputs( + override val tlsVersions: List, + override val cipherSuites: List, + override val protocols: List?, + override val handshakeCertificates: HandshakeCertificates, + val hostname: String?, + val echConfigList: ByteString?, + ) : Inputs + + class ServerInputs( + override val tlsVersions: List, + override val cipherSuites: List, + override val protocols: List?, + override val handshakeCertificates: HandshakeCertificates, + val clientAuth: ClientAuth, + ) : Inputs + + enum class ClientAuth { + None, + Requested, + Required, + } + + class Result( + val clientSocket: Socket, + val serverSocket: Socket, + val clientHandshake: Handshake, + val serverHandshake: Handshake, + val selectedProtocol: Protocol?, + ) +} diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/InsecureHandshaker.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/InsecureHandshaker.kt new file mode 100644 index 000000000000..c9211ef615f1 --- /dev/null +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/sockets/InsecureHandshaker.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.sockets + +import java.security.cert.X509Certificate +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.X509KeyManager +import okhttp3.Handshake +import okhttp3.internal.connection.asBufferedSocket +import okio.inMemorySocketPair + +/** + * A basic handshaker that makes policy decisions without doing any useful cryptography. + */ +class InsecureHandshaker : Handshaker { + override fun handshake( + client: Handshaker.ClientInputs, + server: Handshaker.ServerInputs, + ): Handshaker.Result { + val tlsVersion = + server.tlsVersions.firstOrNull { it in client.tlsVersions } + ?: throw SSLHandshakeException("no matching TLS version") + val cipherSuite = + server.cipherSuites.firstOrNull { it in client.cipherSuites } + ?: throw SSLHandshakeException("no matching cipher suite") + val protocol = + server.protocols + .orEmpty() + .firstOrNull { it in client.protocols.orEmpty() } + + // For more accuracy, we should pick the key type based on the cipher suite. + val keyType = "EC" + + val clientCertificates = + when (server.clientAuth) { + Handshaker.ClientAuth.Required -> { + client.handshakeCertificates.keyManager.clientCertificatesOrNull(keyType) + ?: throw SSLHandshakeException("required client certificates not sent") + } + + Handshaker.ClientAuth.Requested -> { + client.handshakeCertificates.keyManager.clientCertificatesOrNull(keyType) + ?: listOf() + } + + Handshaker.ClientAuth.None -> { + listOf() + } + } + + val serverCertificates = server.handshakeCertificates.keyManager.serverCertificates(keyType) + + val (clientSocket, serverSocket) = inMemorySocketPair(maxBufferSize = 1024 * 1024) + + return Handshaker.Result( + clientSocket = clientSocket.asBufferedSocket(), + serverSocket = serverSocket.asBufferedSocket(), + clientHandshake = + Handshake.get( + tlsVersion = tlsVersion, + cipherSuite = cipherSuite, + peerCertificates = serverCertificates, + localCertificates = clientCertificates, + ), + serverHandshake = + Handshake.get( + tlsVersion = tlsVersion, + cipherSuite = cipherSuite, + peerCertificates = clientCertificates, + localCertificates = serverCertificates, + ), + selectedProtocol = protocol, + ) + } + + private fun X509KeyManager.serverCertificates(keyType: String): List { + val alias = + getServerAliases(keyType, null) + .firstOrNull() + ?: throw SSLHandshakeException("no server aliases for $keyType") + + return getCertificateChain(alias).toList() + } + + private fun X509KeyManager.clientCertificatesOrNull(keyType: String): List? { + val alias = + getClientAliases(keyType, null) + .firstOrNull() + ?: return null + + return getCertificateChain(alias).toList() + } +} diff --git a/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeNetworkTest.kt b/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeNetworkTest.kt index f99749bcd6f5..cfdea7c38872 100644 --- a/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeNetworkTest.kt +++ b/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeNetworkTest.kt @@ -22,11 +22,11 @@ import assertk.assertions.isEqualTo import java.io.InterruptedIOException import java.net.SocketException import kotlin.test.assertFailsWith -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.measureTime import okhttp3.OkHttpClientTestRule import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.concurrent.schedule import okio.buffer import okio.sink import okio.source @@ -263,15 +263,4 @@ class FakeNetworkTest { assertThat(e).hasMessage("server closed") } } - - private fun TaskRunner.schedule( - name: String, - delay: Duration = 0.milliseconds, - block: () -> Unit, - ) { - newQueue().schedule(name, delay.inWholeNanoseconds) { - block() - -1L - } - } } diff --git a/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeTlsTest.kt b/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeTlsTest.kt new file mode 100644 index 000000000000..8db021b5c0c2 --- /dev/null +++ b/okhttp-testing-support/src/test/kotlin/okhttp3/sockets/FakeTlsTest.kt @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.sockets + +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isBetween +import assertk.assertions.isEqualTo +import java.io.InterruptedIOException +import java.net.SocketException +import javax.net.ssl.SSLHandshakeException +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.measureTime +import okhttp3.OkHttpClientTestRule +import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.concurrent.schedule +import okhttp3.sockets.Handshaker.ClientInputs +import okhttp3.sockets.Handshaker.ServerInputs +import okhttp3.tls.internal.TlsUtil +import okio.buffer +import okio.sink +import okio.source +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@Tag("Slowish") +class FakeTlsTest { + @RegisterExtension + @JvmField + val clientTestRule = OkHttpClientTestRule() + + val taskRunner = TaskRunner.INSTANCE + val network = FakeNetwork() + + val clientTls = + FakeTls( + handshaker = InsecureHandshaker(), + handshakeCertificates = TlsUtil.localhost(), + ) + val serverTls = + FakeTls( + handshaker = InsecureHandshaker(), + handshakeCertificates = TlsUtil.localhost(), + ) + + @Test + fun `happy path`() { + val (clientSocket, serverSocket) = network.socketPair() + val clientSslSocket = clientTls.clientSocket(clientSocket) + val serverSslSocket = serverTls.serverSocket(serverSocket) + + taskRunner.schedule("client") { + clientSslSocket.startHandshake() + clientSslSocket.use { socket -> + val sink = socket.getOutputStream().sink().buffer() + sink.writeUtf8("hello from client\n") + sink.flush() + + val source = socket.getInputStream().source().buffer() + assertThat(source.readUtf8Line()).isEqualTo("hello from server") + } + } + + serverSslSocket.startHandshake() + serverSslSocket.use { socket -> + val source = socket.getInputStream().source().buffer() + assertThat(source.readUtf8Line()).isEqualTo("hello from client") + + val sink = socket.getOutputStream().sink().buffer() + sink.writeUtf8("hello from server\n") + sink.flush() + } + } + + @Test + fun `handshake fails`() { + val (clientSocket, serverSocket) = network.socketPair() + + val clientTls = + FakeTls( + handshaker = + object : Handshaker { + private var handshakeCount = 0 + + override fun handshake( + client: ClientInputs, + server: ServerInputs, + ): Handshaker.Result { + assertThat(handshakeCount++).isEqualTo(0) + throw SSLHandshakeException("boom!") + } + }, + handshakeCertificates = TlsUtil.localhost(), + ) + + val clientSslSocket = clientTls.clientSocket(clientSocket) + val serverSslSocket = serverTls.serverSocket(serverSocket) + + taskRunner.schedule("client") { + val e = + assertFailsWith { + clientSslSocket.startHandshake() + } + assertThat(e).hasMessage("boom!") + } + + val e = + assertFailsWith { + serverSslSocket.startHandshake() + } + assertThat(e).hasMessage("boom!") + + // Exception is memoized. + assertFailsWith { + serverSslSocket.getSession() + } + + clientSslSocket.close() + serverSslSocket.close() + } + + @Test + fun `connection closed during successful handshake`() { + val (clientSocket, serverSocket) = network.socketPair() + + val clientTls = + FakeTls( + handshaker = + object : Handshaker { + override fun handshake( + client: ClientInputs, + server: ServerInputs, + ): Handshaker.Result { + clientSocket.close() + return InsecureHandshaker().handshake(client, server) + } + }, + handshakeCertificates = TlsUtil.localhost(), + ) + + val clientSslSocket = clientTls.clientSocket(clientSocket) + val serverSslSocket = serverTls.serverSocket(serverSocket) + + taskRunner.schedule("server") { + val e = + assertFailsWith { + serverSslSocket.startHandshake() + } + assertThat(e).hasMessage("closed") + } + + val e = + assertFailsWith { + clientSslSocket.startHandshake() + } + assertThat(e).hasMessage("closed") + } + + @Test + fun `client handshake timeout`() { + val (clientSocket, _) = network.socketPair() + clientSocket.soTimeout = 250 + + val clientSslSocket = clientTls.clientSocket(clientSocket) + + val elapsed = + measureTime { + assertFailsWith { + clientSslSocket.startHandshake() + } + } + assertThat(elapsed).isBetween(200.milliseconds, 350.milliseconds) + } + + @Test + fun `client handshake fails because server is closed`() { + val (clientSocket, serverSocket) = network.socketPair() + clientSocket.soTimeout = 5_000 + + val clientSslSocket = clientTls.clientSocket(clientSocket) + + taskRunner.schedule("close server later", 250.milliseconds) { + serverSocket.close() + } + + val elapsed = + measureTime { + assertFailsWith { + clientSslSocket.startHandshake() + } + } + assertThat(elapsed).isBetween(200.milliseconds, 350.milliseconds) + } + + @Test + fun `client handshake fails because client is closed`() { + val (clientSocket, _) = network.socketPair() + clientSocket.soTimeout = 5_000 + + val clientSslSocket = clientTls.clientSocket(clientSocket) + + taskRunner.schedule("close client later", 250.milliseconds) { + clientSocket.close() + } + + val elapsed = + measureTime { + assertFailsWith { + clientSslSocket.startHandshake() + } + } + assertThat(elapsed).isBetween(200.milliseconds, 350.milliseconds) + } + + @Test + fun `server handshake timeout`() { + val (_, serverSocket) = network.socketPair() + serverSocket.soTimeout = 250 + + val serverSslSocket = serverTls.clientSocket(serverSocket) + + val elapsed = + measureTime { + assertFailsWith { + serverSslSocket.startHandshake() + } + } + assertThat(elapsed).isBetween(200.milliseconds, 350.milliseconds) + } + + @Test + fun `server handshake fails because server is closed`() { + val (_, serverSocket) = network.socketPair() + serverSocket.soTimeout = 5_000 + + val serverSslSocket = serverTls.clientSocket(serverSocket) + + taskRunner.schedule("close server later", 250.milliseconds) { + serverSocket.close() + } + + val elapsed = + measureTime { + assertFailsWith { + serverSslSocket.startHandshake() + } + } + assertThat(elapsed).isBetween(200.milliseconds, 350.milliseconds) + } + + @Test + fun `server handshake fails because client is closed`() { + val (clientSocket, serverSocket) = network.socketPair() + serverSocket.soTimeout = 5_000 + + val serverSslSocket = serverTls.clientSocket(serverSocket) + + taskRunner.schedule("close server later", 250.milliseconds) { + clientSocket.close() + } + + val elapsed = + measureTime { + assertFailsWith { + serverSslSocket.startHandshake() + } + } + assertThat(elapsed).isBetween(200.milliseconds, 350.milliseconds) + } +} diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt index e00c97a4e8e1..db781e6bb81e 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt @@ -21,8 +21,6 @@ import assertk.assertions.contains import assertk.assertions.containsExactly import assertk.assertions.doesNotContain import assertk.assertions.hasMessage -import assertk.assertions.hasSize -import assertk.assertions.index import assertk.assertions.isCloseTo import assertk.assertions.isEqualTo import assertk.assertions.isFalse @@ -33,8 +31,9 @@ import assertk.assertions.isNotNull import assertk.assertions.isNotSameAs import assertk.assertions.isNull import assertk.assertions.isTrue -import assertk.assertions.matches +import assertk.assertions.matchesPredicate import assertk.assertions.prop +import assertk.assertions.single import assertk.assertions.startsWith import assertk.fail import java.io.FileNotFoundException @@ -117,6 +116,10 @@ import okhttp3.internal.http.RecordingProxySelector import okhttp3.java.net.cookiejar.JavaNetCookieJar import okhttp3.okio.LoggingFilesystem import okhttp3.sockets.DelegatingSSLSocketFactory +import okhttp3.sockets.FakeNetwork +import okhttp3.sockets.FakeTls +import okhttp3.sockets.Handshaker +import okhttp3.sockets.InsecureHandshaker import okhttp3.testing.Flaky import okhttp3.testing.PlatformRule import okhttp3.tls.HandshakeCertificates @@ -154,17 +157,34 @@ open class CallTest { @RegisterExtension val testLogHandler = TestLogHandler(OkHttpClient::class.java) + private val network = FakeNetwork() + + private val serverTls = FakeTls( + handshakeCertificates = platform.localhostHandshakeCertificates(), + handshaker = InsecureHandshaker(), + ) + private val clientTls = FakeTls( + handshakeCertificates = platform.localhostHandshakeCertificates(), + handshaker = InsecureHandshaker(), + ) + @StartStop private val server = MockWebServer() + .apply { + serverSocketFactory = network.serverSocketFactory + } @StartStop private val server2 = MockWebServer() + .apply { + serverSocketFactory = network.serverSocketFactory + } private var eventRecorder = EventRecorder() - private val handshakeCertificates = platform.localhostHandshakeCertificates() private var client = clientTestRule .newClientBuilder() + .socketFactory(network.socketFactory) .eventListenerFactory(clientTestRule.wrap(eventRecorder)) .build() private val callback = RecordingCallback() @@ -888,6 +908,7 @@ open class CallTest { client = OkHttpClient .Builder() + .socketFactory(network.socketFactory) .connectionPool(client.connectionPool) .proxy(server.proxyAddress) .build() @@ -895,6 +916,7 @@ open class CallTest { client = OkHttpClient .Builder() + .socketFactory(network.socketFactory) .connectionPool(client.connectionPool) .proxy(server.proxyAddress) .build() @@ -902,6 +924,7 @@ open class CallTest { client = OkHttpClient .Builder() + .socketFactory(network.socketFactory) .connectionPool(client.connectionPool) .proxy(server.proxyAddress) .build() @@ -916,6 +939,7 @@ open class CallTest { client = OkHttpClient .Builder() + .socketFactory(network.socketFactory) .proxy(server.proxyAddress) .build() server.enqueue(MockResponse(body = "abc")) @@ -1066,9 +1090,10 @@ open class CallTest { .assertBody("success!") assertThat(proxySelector.failures) - .all { - hasSize(1) - index(0).matches(".* Connect timed out".toRegex(RegexOption.IGNORE_CASE)) + .single() + .matchesPredicate { + it.matches(".* Connect timed out".toRegex(RegexOption.IGNORE_CASE)) + || it.matches(".* Failed to connect to .*".toRegex(RegexOption.IGNORE_CASE)) } } @@ -1104,6 +1129,7 @@ open class CallTest { client = clientTestRule .newClientBuilder() + .socketFactory(network.socketFactory) .addInterceptor( Interceptor { chain: Interceptor.Chain -> val response = @@ -1333,6 +1359,7 @@ open class CallTest { executeSynchronously("/") .assertFailure(IOException::class.java) .assertFailureMatches( + "canceled", "stream was reset: CANCEL", "unexpected end of stream on " + server.url("/").redact(), ) @@ -1349,7 +1376,14 @@ open class CallTest { fun tlsHandshakeFailure_noFallbackByDefault() { platform.assumeNotBouncyCastle() - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) + server.handshakeFailureSslSocketFactory = serverTls.sslSocketFactory + + val clientTls = failHandshakeClientTls(count = 1) + client = client.newBuilder() + .sslSocketFactory(clientTls.sslSocketFactory, clientTls.trustManager) + .build() + server.enqueue(MockResponse.Builder().failHandshake().build()) server.enqueue(MockResponse(body = "response that will never be received")) val response = executeSynchronously("/") @@ -1368,7 +1402,11 @@ open class CallTest { fun recoverFromTlsHandshakeFailure() { platform.assumeNotBouncyCastle() - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) + server.handshakeFailureSslSocketFactory = serverTls.sslSocketFactory + + val clientTls = failHandshakeClientTls(count = 1) + server.enqueue(MockResponse.Builder().failHandshake().build()) server.enqueue(MockResponse(body = "abc")) client = @@ -1382,33 +1420,54 @@ open class CallTest { ConnectionSpec.MODERN_TLS, ), ).sslSocketFactory( - suppressTlsFallbackClientSocketFactory(), - handshakeCertificates.trustManager, + FallbackTestClientSocketFactory(clientTls.sslSocketFactory), + clientTls.trustManager, ).build() executeSynchronously("/").assertBody("abc") } + private fun failHandshakeClientTls(count: Int = Int.MAX_VALUE): FakeTls { + return FakeTls( + object : Handshaker { + var nextId = 0 + override fun handshake( + client: Handshaker.ClientInputs, + server: Handshaker.ServerInputs + ): Handshaker.Result { + val id = nextId++ + if (id < count) throw SSLHandshakeException("boom!") + return InsecureHandshaker().handshake(client, server) + } + }, + platform.localhostHandshakeCertificates(), + ) + } + @Test fun recoverFromTlsHandshakeFailure_tlsFallbackScsvEnabled() { platform.assumeNotConscrypt() val tlsFallbackScsv = "TLS_FALLBACK_SCSV" - val supportedCiphers = listOf(*handshakeCertificates.sslSocketFactory().supportedCipherSuites) + val supportedCiphers = listOf(*clientTls.sslSocketFactory.supportedCipherSuites) if (!supportedCiphers.contains(tlsFallbackScsv)) { // This only works if the client socket supports TLS_FALLBACK_SCSV. return } - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) + server.handshakeFailureSslSocketFactory = serverTls.sslSocketFactory + + val clientTls = failHandshakeClientTls(count = 1) + server.enqueue(MockResponse.Builder().failHandshake().build()) - val clientSocketFactory = + val clientSslSocketFactory = RecordingSSLSocketFactory( - handshakeCertificates.sslSocketFactory(), + clientTls.sslSocketFactory, ) client = client .newBuilder() .sslSocketFactory( - clientSocketFactory, - handshakeCertificates.trustManager, + clientSslSocketFactory, + clientTls.trustManager, ) // Attempt RESTRICTED_TLS then fall back to MODERN_TLS. .connectionSpecs(listOf(ConnectionSpec.RESTRICTED_TLS, ConnectionSpec.MODERN_TLS)) .hostnameVerifier(RecordingHostnameVerifier()) @@ -1417,10 +1476,10 @@ open class CallTest { assertFailsWith { client.newCall(request).execute() } - val firstSocket = clientSocketFactory.socketsCreated[0] + val firstSocket = clientSslSocketFactory.socketsCreated[0] assertThat(firstSocket.enabledCipherSuites) .doesNotContain(tlsFallbackScsv) - val secondSocket = clientSocketFactory.socketsCreated[1] + val secondSocket = clientSslSocketFactory.socketsCreated[1] assertThat(secondSocket.enabledCipherSuites) .contains(tlsFallbackScsv) } @@ -1429,7 +1488,11 @@ open class CallTest { fun recoverFromTlsHandshakeFailure_Async() { platform.assumeNotBouncyCastle() - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) + server.handshakeFailureSslSocketFactory = serverTls.sslSocketFactory + + val clientTls = failHandshakeClientTls(count = 1) + server.enqueue(MockResponse.Builder().failHandshake().build()) server.enqueue(MockResponse(body = "abc")) client = @@ -1440,8 +1503,8 @@ open class CallTest { ) // Attempt RESTRICTED_TLS then fall back to MODERN_TLS. .connectionSpecs(listOf(ConnectionSpec.RESTRICTED_TLS, ConnectionSpec.MODERN_TLS)) .sslSocketFactory( - suppressTlsFallbackClientSocketFactory(), - handshakeCertificates.trustManager, + FallbackTestClientSocketFactory(clientTls.sslSocketFactory), + clientTls.trustManager, ).build() val request = Request(server.url("/")) client.newCall(request).enqueue(callback) @@ -1452,16 +1515,21 @@ open class CallTest { fun noRecoveryFromTlsHandshakeFailureWhenTlsFallbackIsDisabled() { platform.assumeNotBouncyCastle() + + val clientTls = failHandshakeClientTls(count = 1) + client = client .newBuilder() .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT)) .hostnameVerifier(RecordingHostnameVerifier()) .sslSocketFactory( - suppressTlsFallbackClientSocketFactory(), - handshakeCertificates.trustManager, + FallbackTestClientSocketFactory(clientTls.sslSocketFactory), + clientTls.trustManager, ).build() - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) + server.handshakeFailureSslSocketFactory = serverTls.sslSocketFactory + server.enqueue(MockResponse.Builder().failHandshake().build()) val request = Request.Builder().url(server.url("/")).build() assertFailsWith { @@ -1511,12 +1579,14 @@ open class CallTest { .Builder() .addTrustedCertificate(serverCertificate.certificate) .build() + val clientTls = FakeTls(InsecureHandshaker(), clientCertificates) + val serverTls = FakeTls(InsecureHandshaker(), serverCertificates) client = client .newBuilder() - .sslSocketFactory(clientCertificates.sslSocketFactory(), clientCertificates.trustManager) + .sslSocketFactory(clientTls.sslSocketFactory, clientTls.trustManager) .build() - server.useHttps(serverCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) executeSynchronously("/") .assertFailureMatches("(?s)Hostname localhost not verified.*") } @@ -1540,12 +1610,13 @@ open class CallTest { HandshakeCertificates .Builder() .build() + val clientTls = FakeTls(InsecureHandshaker(), clientCertificates) client = client .newBuilder() .sslSocketFactory( - socketFactoryWithCipherSuite(clientCertificates.sslSocketFactory(), cipherSuite), - clientCertificates.trustManager, + socketFactoryWithCipherSuite(clientTls.sslSocketFactory, cipherSuite), + clientTls.trustManager, ).connectionSpecs( listOf( ConnectionSpec @@ -1558,8 +1629,9 @@ open class CallTest { HandshakeCertificates .Builder() .build() + val serverTls = FakeTls(InsecureHandshaker(), serverCertificates) server.useHttps( - socketFactoryWithCipherSuite(serverCertificates.sslSocketFactory(), cipherSuite), + socketFactoryWithCipherSuite(serverTls.sslSocketFactory, cipherSuite), ) executeSynchronously("/") .assertFailure(SSLHandshakeException::class.java) @@ -1591,7 +1663,7 @@ open class CallTest { .newBuilder() .protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE)) .build() - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue(MockResponse()) val call = client.newCall(Request(server.url("/"))) assertFailsWith { @@ -3059,6 +3131,7 @@ open class CallTest { call.enqueue(callback) assertThat(server.takeRequest().url.encodedPath).isEqualTo("/a") callback.await(requestA.url).assertFailure( + "canceled", "Canceled", "stream was reset: CANCEL", "Socket closed", @@ -3725,7 +3798,7 @@ open class CallTest { /** Test which headers are sent unencrypted to the HTTP proxy. */ @Test fun proxyConnectOmitsApplicationHeaders() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue( MockResponse .Builder() @@ -3740,8 +3813,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .hostnameVerifier(hostnameVerifier) .build() @@ -3786,8 +3859,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .hostnameVerifier(hostnameVerifier) .build() @@ -3802,7 +3875,7 @@ open class CallTest { /** Respond to a proxy authorization challenge. */ @Test fun proxyAuthenticateOnConnect() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue( MockResponse .Builder() @@ -3824,8 +3897,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .proxyAuthenticator(RecordingOkAuthenticator("password", "Basic")) .hostnameVerifier(RecordingHostnameVerifier()) @@ -3879,7 +3952,7 @@ open class CallTest { */ @Test fun proxyAuthenticateOnConnectWithConnectionClose() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.protocols = listOf(Protocol.HTTP_1_1) server.enqueue( MockResponse @@ -3908,8 +3981,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .proxyAuthenticator(RecordingOkAuthenticator("password", "Basic")) .hostnameVerifier(RecordingHostnameVerifier()) @@ -3930,7 +4003,7 @@ open class CallTest { @Test fun tooManyProxyAuthFailuresWithConnectionClose() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.protocols = listOf(Protocol.HTTP_1_1) for (i in 0..20) { server.enqueue( @@ -3952,8 +4025,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .proxyAuthenticator(RecordingOkAuthenticator("password", "Basic")) .hostnameVerifier(RecordingHostnameVerifier()) @@ -3971,7 +4044,7 @@ open class CallTest { */ @Test fun noPreemptiveProxyAuthorization() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue( MockResponse .Builder() @@ -3983,8 +4056,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .hostnameVerifier(RecordingHostnameVerifier()) .build() @@ -4005,7 +4078,7 @@ open class CallTest { /** Confirm that we can send authentication information without being prompted first. */ @Test fun preemptiveProxyAuthentication() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue( MockResponse .Builder() @@ -4018,8 +4091,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .hostnameVerifier(RecordingHostnameVerifier()) .proxyAuthenticator { _: Route?, response: Response? -> @@ -4047,7 +4120,7 @@ open class CallTest { @Test fun preemptiveThenReactiveProxyAuthentication() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue( MockResponse .Builder() @@ -4070,8 +4143,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .hostnameVerifier(RecordingHostnameVerifier()) .proxyAuthenticator { _: Route?, response: Response -> @@ -4097,7 +4170,7 @@ open class CallTest { @Test @Disabled fun proxyDisconnectsAfterRequest() { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.enqueue( MockResponse .Builder() @@ -4109,8 +4182,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).proxy(server.proxyAddress) .build() val request = Request(server.url("/")) @@ -4514,7 +4587,7 @@ open class CallTest { /** Use a proxy to fake IPv6 connectivity, even if localhost doesn't have IPv6. */ private fun configureClientAndServerProxies(http2: Boolean) { - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) server.protocols = when { http2 -> listOf(Protocol.HTTP_2, Protocol.HTTP_1_1) @@ -4530,8 +4603,8 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).hostnameVerifier(RecordingHostnameVerifier()) .proxy(server.proxyAddress) .build() @@ -4578,6 +4651,7 @@ open class CallTest { client = clientTestRule .newClientBuilder() + .socketFactory(network.socketFactory) .connectionPool(ConnectionPool(0, 10, TimeUnit.MILLISECONDS)) .build() val request = Request(server.url("/")) @@ -4596,6 +4670,7 @@ open class CallTest { client = clientTestRule .newClientBuilder() + .socketFactory(network.socketFactory) .connectionPool(ConnectionPool(0, 10, TimeUnit.MILLISECONDS)) .build() val request = Request(server.url("/")) @@ -4679,15 +4754,17 @@ open class CallTest { .heldCertificate(heldCertificate) .addTrustedCertificate(heldCertificate.certificate) .build() + val clientTls = FakeTls(InsecureHandshaker(), handshakeCertificates) + val serverTls = FakeTls(InsecureHandshaker(), handshakeCertificates) // Use that certificate on the server and trust it on the client. - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) client = client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).hostnameVerifier(RecordingHostnameVerifier()) .protocols(listOf(Protocol.HTTP_1_1)) .build() @@ -4803,7 +4880,7 @@ open class CallTest { .build() executeSynchronously("/") .assertFailure(IOException::class.java) - .assertFailure("canceled", "Canceled", "Socket closed", "Socket is closed") + .assertFailure("closed", "canceled", "Canceled", "Socket closed", "Socket is closed") } @Test @@ -4981,7 +5058,7 @@ open class CallTest { val call = client.newCall(request) return try { val response = call.execute() - val bodyString = response.body.string() + val bodyString = response.body.string() RecordedResponse(request, response, null, bodyString, null) } catch (e: IOException) { RecordedResponse(request, null, null, null, e) @@ -5006,11 +5083,11 @@ open class CallTest { client .newBuilder() .sslSocketFactory( - handshakeCertificates.sslSocketFactory(), - handshakeCertificates.trustManager, + clientTls.sslSocketFactory, + clientTls.trustManager, ).hostnameVerifier(RecordingHostnameVerifier()) .build() - server.useHttps(handshakeCertificates.sslSocketFactory()) + server.useHttps(serverTls.sslSocketFactory) } private fun gzip(data: String): Buffer { @@ -5069,5 +5146,5 @@ open class CallTest { * for details. */ private fun suppressTlsFallbackClientSocketFactory(): FallbackTestClientSocketFactory = - FallbackTestClientSocketFactory(handshakeCertificates.sslSocketFactory()) + FallbackTestClientSocketFactory(clientTls.sslSocketFactory) } diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/FakeNetworkOkHttpTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/FakeNetworkOkHttpTest.kt index fec608987f8c..fed84f814a26 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/FakeNetworkOkHttpTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/FakeNetworkOkHttpTest.kt @@ -22,12 +22,29 @@ import mockwebserver3.MockResponse import mockwebserver3.MockWebServer import mockwebserver3.junit5.StartStop import okhttp3.sockets.FakeNetwork +import okhttp3.sockets.FakeTls +import okhttp3.sockets.InsecureHandshaker +import okhttp3.tls.internal.TlsUtil import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension open class FakeNetworkOkHttpTest { private val network = FakeNetwork() + private val handshaker = InsecureHandshaker() + + private val clientTls = + FakeTls( + handshaker = handshaker, + handshakeCertificates = TlsUtil.localhost(), + ) + + private val serverTls = + FakeTls( + handshaker = handshaker, + handshakeCertificates = TlsUtil.localhost(), + ) + @RegisterExtension val clientTestRule = OkHttpClientTestRule() @@ -46,6 +63,16 @@ open class FakeNetworkOkHttpTest { @Test fun `happy path`() { + makeRequest() + } + + @Test + fun `happy path with TLS`() { + enableTls() + makeRequest() + } + + fun makeRequest() { server.enqueue( MockResponse .Builder() @@ -68,4 +95,13 @@ open class FakeNetworkOkHttpTest { assertThat(recordedRequest.method).isEqualTo("GET") assertThat(recordedRequest.body).isNull() } + + private fun enableTls() { + client = + client + .newBuilder() + .sslSocketFactory(clientTls.sslSocketFactory, clientTls.trustManager) + .build() + server.useHttps(serverTls.sslSocketFactory) + } }