diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 8e95e9e..3799db3 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -79,7 +79,8 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddress - let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) try await client.executeThenClose { inbound, outbound in try await outbound.write(Self.reqHead) @@ -160,7 +161,8 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddress - let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) try await client.executeThenClose { inbound, outbound in try await outbound.write(Self.reqHead) @@ -191,7 +193,6 @@ struct NIOHTTPServiceLifecycleTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testActiveHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { let serverChain = try TestCA.makeSelfSignedChain() - let clientChain = try TestCA.makeSelfSignedChain() let server = NIOHTTPServer( logger: self.serverLogger, @@ -235,34 +236,41 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddress - let client = try await setUpClientWithMTLS( - at: serverAddress, - chain: clientChain, - trustRoots: [serverChain.ca], - applicationProtocol: "h2" - ) - - try await client.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) - try await outbound.write(Self.reqBody) - - // Wait until the server has received the request. - try await firstChunkReadPromise.futureResult.get() - - // Now trigger graceful shutdown. This should propagate down to the server. The server will - // start the 500ms grace timer after which all connections that are still open will be - // forcefully closed. - trigger.triggerGracefulShutdown() - - // The server should shut down after 500ms. Wait for this. - try await group.waitForAll() + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: [serverChain.ca], + applicationProtocol: "h2" + ) + + switch client { + case .http1: + Issue.record("Unexpectedly negotiated a HTTP/2 connection") + + case .http2(let streamManager): + let streamChannel = try await streamManager.openStream() + try await streamChannel.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + + // Wait until the server has received the request. + try await firstChunkReadPromise.futureResult.get() + + // Now trigger graceful shutdown. This should propagate down to the server. The server will + // start the 500ms grace timer after which all connections that are still open will be + // forcefully closed. + trigger.triggerGracefulShutdown() + + // The server should shut down after 500ms. Wait for this. + try await group.waitForAll() + + // The connection should have been closed: we should get an `ioOnClosedChannel` error. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(Self.reqEnd) + } - // The connection should have been closed: we should get an `ioOnClosedChannel` error. - await #expect(throws: ChannelError.ioOnClosedChannel) { - try await outbound.write(Self.reqEnd) + connectionForcefullyShutdown() } - - connectionForcefullyShutdown() } } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index de71195..481a131 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -14,6 +14,7 @@ import HTTPServer import HTTPTypes +import Logging import NIOCore import NIOEmbedded import NIOHTTP1 @@ -31,7 +32,8 @@ struct NIOHTTPServerEndToEndTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) @Test("HTTP/1.1 request and response") func testHTTP1_1() async throws { - try await HTTP1ClientServerProvider.withProvider( + try await TestingChannelHTTP1Server.serve( + logger: Logger(label: "NIOHTTPServerEndToEndTests"), handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in let sender = try await resSender.send(.init(status: .ok)) @@ -41,9 +43,9 @@ struct NIOHTTPServerEndToEndTests { return [.serverTiming: "test"] } } - ) { clientServerProvider in - try await clientServerProvider.withConnectedClient { client in - try await client.executeThenClose { inbound, outbound in + ) { server in + try await server.withConnectedClient { connectionChannel in + try await connectionChannel.executeThenClose { inbound, outbound in try await outbound.write(.head(.init(method: .get, scheme: "", authority: "", path: "/"))) try await outbound.write(.end(nil)) @@ -77,7 +79,7 @@ struct NIOHTTPServerEndToEndTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) @Test("HTTP/2 negotiation") - func testSecureUpgradeNegotiation() async throws { + func testHTTP2Negotiation() async throws { let serverChain = try TestCA.makeSelfSignedChain() var serverTLSConfig = TLSConfiguration.makeServerConfiguration( certificateChain: [try .init(serverChain.leaf)], @@ -90,7 +92,8 @@ struct NIOHTTPServerEndToEndTests { clientTLSConfig.certificateVerification = .noHostnameVerification clientTLSConfig.applicationProtocols = ["h2"] - try await HTTPSecureUpgradeClientServerProvider.withProvider( + try await TestingChannelSecureUpgradeServer.serve( + logger: Logger(label: "NIOHTTPServerEndToEndTests"), tlsConfiguration: serverTLSConfig, handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in let sender = try await resSender.send(.init(status: .ok)) @@ -101,12 +104,12 @@ struct NIOHTTPServerEndToEndTests { return [.serverTiming: "test"] } } - ) { clientServerProvider in - try await clientServerProvider.withConnectedClient(clientTLSConfiguration: clientTLSConfig) { - negotiatedConnection in - switch negotiatedConnection { + ) { server in + try await server.withConnectedClient(clientTLSConfig: clientTLSConfig) { negotiatedConnectionChannel in + switch negotiatedConnectionChannel { case .http1(_): Issue.record("Failed to negotiate HTTP/2 despite the client requiring HTTP/2.") + case .http2(let http2StreamManager): let http2AsyncChannel = try await http2StreamManager.openStream() diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index a5a3dda..a78fd76 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -12,10 +12,12 @@ // //===----------------------------------------------------------------------===// +import HTTPServer import HTTPTypes import Logging import NIOCore import NIOHTTPTypes +import NIOPosix import Testing import X509 @@ -53,23 +55,19 @@ struct NIOHTTPServerTests { @Test("Obtain the listening address correctly") func testListeningAddress() async throws { let server = NIOHTTPServer( - logger: Logger(label: "Test"), + logger: Logger(label: "NIOHTTPServerTests"), configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 1234)) ) - try await withThrowingTaskGroup { group in - group.addTask { - try await server.serve { _, _, _, _ in } + try await Self.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in }, + body: { serverAddress in + let address = try #require(serverAddress.ipv4) + #expect(address.host == "127.0.0.1") + #expect(address.port == 1234) } - - let serverAddress = try await server.listeningAddress - - let address = try #require(serverAddress.ipv4) - #expect(address.host == "127.0.0.1") - #expect(address.port == 1234) - - group.cancelAll() - } + ) // Now that the server has shut down, try obtaining the listening address. This should result in an error. await #expect(throws: ListeningAddressError.serverClosed) { @@ -81,55 +79,53 @@ struct NIOHTTPServerTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testPlaintext() async throws { let server = NIOHTTPServer( - logger: Logger(label: "Test"), + logger: Logger(label: "NIOHTTPServerTests"), configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) ) - try await withThrowingTaskGroup { group in - group.addTask { - try await server.serve { request, requestContext, reader, responseWriter in - #expect(request.method == .post) - #expect(request.path == "/") + try await Self.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in + #expect(request.method == .post) + #expect(request.path == "/") - var buffer = ByteBuffer() - let (_, finalElement) = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - return try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in - buffer.writeBytes(body.bytes) - } - } - #expect(buffer == Self.bodyData) - #expect(finalElement == Self.trailer) - - let responseBodySender = try await responseWriter.send(.init(status: .ok)) - try await responseBodySender.produceAndConclude { responseBodyWriter in - var responseBodyWriter = responseBodyWriter - try await responseBodyWriter.write([1, 2].span) - return Self.trailer + var buffer = ByteBuffer() + let (_, finalElement) = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + return try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + buffer.writeBytes(body.bytes) } } - } + #expect(buffer == Self.bodyData) + #expect(finalElement == Self.trailer) - let serverAddress = try await server.listeningAddress + let responseBodySender = try await responseWriter.send(.init(status: .ok)) + try await responseBodySender.produceAndConclude { responseBodyWriter in + var responseBodyWriter = responseBodyWriter + try await responseBodyWriter.write([1, 2].span) + return Self.trailer + } + }, + body: { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) - let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) - for try await response in inbound { - try await Self.clientResponseHandler( - response, - expectedStatus: .ok, - expectedBody: .init([1, 2]), - expectedTrailers: Self.trailer - ) + for try await response in inbound { + try await Self.clientResponseHandler( + response, + expectedStatus: .ok, + expectedBody: .init([1, 2]), + expectedTrailers: Self.trailer + ) + } } } - - group.cancelAll() - } + ) } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) @@ -143,7 +139,7 @@ struct NIOHTTPServerTests { let clientChain = try TestCA.makeSelfSignedChain() let server = NIOHTTPServer( - logger: Logger(label: "Test"), + logger: Logger(label: "NIOHTTPServerTests"), configuration: .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), transportSecurity: .mTLS( @@ -158,63 +154,104 @@ struct NIOHTTPServerTests { ) ) - try await withThrowingTaskGroup { group in - group.addTask { - try await server.serve { request, requestContext, reader, responseWriter in - #expect(request.method == .post) - #expect(request.path == "/") - - do { - let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) - #expect(Array(peerChain) == [clientChain.leaf]) - } catch { - Issue.record("Could not obtain the peer's certificate chain: \(error)") + try await Self.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in + #expect(request.method == .post) + #expect(request.path == "/") + + do { + let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) + #expect(Array(peerChain) == [clientChain.leaf]) + } catch { + Issue.record("Could not obtain the peer's certificate chain: \(error)") + } + + let (buffer, finalElement) = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + var buffer = ByteBuffer() + _ = try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + buffer.writeBytes(body.bytes) } + return buffer + } + #expect(buffer == Self.bodyData) + #expect(finalElement == Self.trailer) + + let sender = try await responseWriter.send(.init(status: .ok)) + try await sender.produceAndConclude { bodyWriter in + var bodyWriter = bodyWriter + try await bodyWriter.write([1, 2].span) + return Self.trailer + } + }, + body: { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServerOverMTLS( + at: serverAddress, + clientChain: clientChain, + trustRoots: [serverChain.ca], + applicationProtocol: applicationProtocol + ) - let (buffer, finalElement) = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - var buffer = ByteBuffer() - _ = try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in - buffer.writeBytes(body.bytes) - } - return buffer + let clientChannel: NIOAsyncChannel + switch client { + case .http1(let http1ClientChannel): + guard applicationProtocol == "http/1.1" else { + Issue.record("Unexpectedly negotiated a HTTP/1.1 connection") + return } - #expect(buffer == Self.bodyData) - #expect(finalElement == Self.trailer) - - let sender = try await responseWriter.send(.init(status: .ok)) - try await sender.produceAndConclude { bodyWriter in - var bodyWriter = bodyWriter - try await bodyWriter.write([1, 2].span) - return Self.trailer + clientChannel = http1ClientChannel + + case .http2(let streamManager): + guard applicationProtocol == "h2" else { + Issue.record("Unexpectedly negotiated a HTTP/2 connection") + return } + clientChannel = try await streamManager.openStream() } - } - - let serverAddress = try await server.listeningAddress - let clientChannel = try await setUpClientWithMTLS( - at: serverAddress, - chain: clientChain, - trustRoots: [serverChain.ca], - applicationProtocol: applicationProtocol - ) + try await clientChannel.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) - try await clientChannel.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) - try await outbound.write(Self.reqBody) - try await outbound.write(Self.reqEnd) - - for try await response in inbound { - try await Self.clientResponseHandler( - response, - expectedStatus: .ok, - expectedBody: .init([1, 2]), - expectedTrailers: Self.trailer - ) + for try await response in inbound { + try await Self.clientResponseHandler( + response, + expectedStatus: .ok, + expectedBody: .init([1, 2]), + expectedTrailers: Self.trailer + ) + } } } - // Cancel the server and client task once we know the client has received the response + ) + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerTests { + static func withServer( + server: NIOHTTPServer, + serverHandler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestConcludingReader, + NIOHTTPServer.ResponseConcludingWriter + >, + body: (NIOHTTPServer.SocketAddress) async throws -> Void + ) async throws { + try await withThrowingTaskGroup { group in + // Add the server task to the group + group.addTask { + try await server.serve(handler: serverHandler) + } + + // Wait for the server to start listening before running the body closure + let listeningAddress = try await server.listeningAddress + + try await body(listeningAddress) + + // Shut the server down group.cancelAll() } } diff --git a/Tests/NIOHTTPServerTests/Utilities/Client.swift b/Tests/NIOHTTPServerTests/Utilities/Client.swift deleted file mode 100644 index 95d71a8..0000000 --- a/Tests/NIOHTTPServerTests/Utilities/Client.swift +++ /dev/null @@ -1,101 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import NIOCore -import NIOHTTPTypes -import NIOHTTPTypesHTTP1 -import NIOHTTPTypesHTTP2 -import NIOPosix -import NIOSSL -import X509 - -@testable import NIOHTTPServer - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -func setUpClient(host: String, port: Int) async throws -> NIOAsyncChannel { - try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .connect(to: try .init(ipAddress: host, port: port)) { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHTTPClientHandlers() - try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: .init() - ) - } - } -} - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -func setUpClientWithMTLS( - at address: NIOHTTPServer.SocketAddress, - chain: ChainPrivateKeyPair, - trustRoots: [Certificate], - applicationProtocol: String, -) async throws -> NIOAsyncChannel { - var clientTLSConfig = TLSConfiguration.makeClientConfiguration() - clientTLSConfig.certificateChain = [try NIOSSLCertificateSource(chain.leaf)] - clientTLSConfig.privateKey = .privateKey(try .init(chain.privateKey)) - clientTLSConfig.trustRoots = .certificates(try trustRoots.map { try NIOSSLCertificate($0) }) - clientTLSConfig.certificateVerification = .noHostnameVerification - clientTLSConfig.applicationProtocols = [applicationProtocol] - - let sslContext = try NIOSSLContext(configuration: clientTLSConfig) - - let clientNegotiatedChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .connect(to: try .init(ipAddress: address.host, port: address.port)) { channel in - channel.eventLoop.makeCompletedFuture { - let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: nil) - try channel.pipeline.syncOperations.addHandler(sslHandler) - }.flatMap { - channel.configureHTTP2AsyncSecureUpgrade( - http1ConnectionInitializer: { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHTTPClientHandlers() - try channel.pipeline.syncOperations.addHandlers(HTTP1ToHTTPClientCodec()) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: .init() - ) - } - }, - http2ConnectionInitializer: { channel in - channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } - } - ) - } - }.get() - - switch clientNegotiatedChannel { - case .http1_1(let http1Channel): - precondition(applicationProtocol == "http/1.1", "Unexpectedly established a HTTP 1.1 channel") - return http1Channel - - case .http2(let http2Channel): - precondition(applicationProtocol == "h2", "Unexpectedly established a HTTP 2 channel") - return try await http2Channel.openStream { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: .init() - ) - } - } - } -} diff --git a/Tests/NIOHTTPServerTests/Utilities/HTTP1ClientServerProvider.swift b/Tests/NIOHTTPServerTests/Utilities/HTTP1ClientServerProvider.swift deleted file mode 100644 index a5ecf0c..0000000 --- a/Tests/NIOHTTPServerTests/Utilities/HTTP1ClientServerProvider.swift +++ /dev/null @@ -1,139 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import NIOCore -import NIOEmbedded -import NIOHTTP1 -import NIOHTTPTypes -import NIOHTTPTypesHTTP1 -import X509 - -@testable import HTTPServer -@testable import NIOHTTPServer - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -struct HTTP1ClientServerProvider { - let server: NIOHTTPServer - let serverTestChannel: NIOAsyncTestingChannel - - static func withProvider( - handler: some HTTPServerRequestHandler, - body: (HTTP1ClientServerProvider) async throws -> Void - ) async throws { - let server = NIOHTTPServer( - logger: .init(label: "test"), - // The server won't actually be bound to this host and port; we just have to pass this argument - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) - ) - // Create a test channel. We will run the server on this channel. - let serverTestChannel = NIOAsyncTestingChannel() - - try await withThrowingTaskGroup { group in - // We are ready now. Start the server with the test channel. - group.addTask { - try await server.serveInsecureHTTP1_1WithTestChannel(testChannel: serverTestChannel, handler: handler) - } - - // Execute the provided closure with a `HTTP1ClientServerProvider` instance created from the server - // instance and the test channel instance - try await body( - HTTP1ClientServerProvider(server: server, serverTestChannel: serverTestChannel) - ) - - group.cancelAll() - } - } - - /// Starts a new connection with the server and executes the provided `body` closure. - /// - Parameter body: A closure that should send a request using the provided client instance and validate - /// the received response. - func withConnectedClient( - body: (NIOAsyncChannel) async throws -> Void - ) async throws { - // Create a test connection channel - let serverTestConnectionChannel = NIOAsyncTestingChannel() - - let connectionPromise = serverTestConnectionChannel.eventLoop.makePromise(of: Void.self) - serverTestConnectionChannel.connect( - to: try .init(ipAddress: "127.0.0.1", port: 8000), - promise: connectionPromise - ) - try await connectionPromise.futureResult.get() - - // Set up the required channel handlers on `serverTestConnectionChannel` - let serverAsyncConnectionChannel = try await self.server.setupHTTP1_1ConnectionChildChannel( - channel: serverTestConnectionChannel, - asyncChannelConfiguration: .init() - ).get() - - // Write the connection channel to the server channel to simulate an incoming connection - try await self.serverTestChannel.writeInbound(serverAsyncConnectionChannel) - - // Now, we could write requests directly to `serverAsyncConnectionChannel`, but it expects `ByteBuffer` inputs. - // This is cumbersome to work with in tests. - // So, we create a client channel, and use it to send requests and observe responses in terms of HTTP types. - let (clientTestChannel, clientAsyncChannel) = try await self.setUpClientConnection() - - try await withThrowingDiscardingTaskGroup { group in - // We must forward all client outbound writes to the server and vice-versa. - group.addTask { try await clientTestChannel.glueTo(serverTestConnectionChannel) } - - try await body(clientAsyncChannel) - - try await serverTestConnectionChannel.close() - } - } - - private func setUpClientConnection() async throws -> ( - NIOAsyncTestingChannel, NIOAsyncChannel - ) { - let clientTestChannel = try await NIOAsyncTestingChannel { channel in - try channel.pipeline.syncOperations.addHTTPClientHandlers() - try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) - } - - // Wrap the client channel in a NIOAsyncChannel for convenience - let clientAsyncChannel = try await clientTestChannel.eventLoop.submit { - try NIOAsyncChannel( - wrappingChannelSynchronously: clientTestChannel, - configuration: .init(isOutboundHalfClosureEnabled: true) - ) - }.get() - - return (clientTestChannel, clientAsyncChannel) - } -} - -extension NIOAsyncTestingChannel { - /// Forwards all of our outbound writes to `other` and vice-versa. - func glueTo(_ other: NIOAsyncTestingChannel) async throws { - await withThrowingTaskGroup { group in - // 1. Forward all `self` writes to `other` - group.addTask { - while !Task.isCancelled { - let ourPart = try await self.waitForOutboundWrite(as: ByteBuffer.self) - try await other.writeInbound(ourPart) - } - } - - // 2. Forward all `other` writes to `self` - group.addTask { - while !Task.isCancelled { - let otherPart = try await other.waitForOutboundWrite(as: ByteBuffer.self) - try await self.writeInbound(otherPart) - } - } - } - } -} diff --git a/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift b/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift deleted file mode 100644 index 4b0e844..0000000 --- a/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift +++ /dev/null @@ -1,200 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import NIOCore -import NIOEmbedded -import NIOHTTP2 -import NIOHTTPTypes -import NIOHTTPTypesHTTP1 -import NIOHTTPTypesHTTP2 -import NIOSSL -import X509 - -@testable import HTTPServer -@testable import NIOHTTPServer - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -struct HTTPSecureUpgradeClientServerProvider { - let server: NIOHTTPServer - let serverTestChannel: NIOAsyncTestingChannel - - let serverTLSConfiguration: TLSConfiguration - let verificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? - - let http2Configuration: NIOHTTPServerConfiguration.HTTP2 - - static func withProvider( - tlsConfiguration: TLSConfiguration, - tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? = nil, - http2Configuration: NIOHTTPServerConfiguration.HTTP2 = .defaults, - handler: some HTTPServerRequestHandler, - body: (HTTPSecureUpgradeClientServerProvider) async throws -> Void - ) async throws { - let server = NIOHTTPServer( - logger: .init(label: "test"), - // The server won't actually be bound to this host and port; we just have to pass this argument - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) - ) - - // Create a test channel. We will run the server on this channel. - let serverTestChannel = NIOAsyncTestingChannel() - - try await withThrowingTaskGroup { group in - // We are ready now. Start the server with the test channel. - group.addTask { - try await server.serveSecureUpgradeWithTestChannel(testChannel: serverTestChannel, handler: handler) - } - - // Execute the provided closure with a `HTTPSecureUpgradeClientServerProvider` instance - try await body( - HTTPSecureUpgradeClientServerProvider( - server: server, - serverTestChannel: serverTestChannel, - serverTLSConfiguration: tlsConfiguration, - verificationCallback: tlsVerificationCallback, - http2Configuration: http2Configuration - ) - ) - - group.cancelAll() - } - } - - func withConnectedClient( - clientTLSConfiguration: TLSConfiguration, - body: (NegotiatedConnection) async throws -> Void - ) async throws { - // Create a test connection channel - let serverTestConnectionChannel = NIOAsyncTestingChannel() - - let connectionPromise = serverTestConnectionChannel.eventLoop.makePromise(of: Void.self) - serverTestConnectionChannel.connect( - to: try .init(ipAddress: "127.0.0.1", port: 8000), - promise: connectionPromise - ) - try await connectionPromise.futureResult.get() - - // Set up the required channel handlers on `serverTestConnectionChannel` - let negotiatedServerConnectionFuture = try await serverTestConnectionChannel.eventLoop.flatSubmit { - self.server.setupSecureUpgradeConnectionChildChannel( - channel: serverTestConnectionChannel, - tlsConfiguration: self.serverTLSConfiguration, - asyncChannelConfiguration: .init(), - http2Configuration: self.http2Configuration, - verificationCallback: self.verificationCallback - ) - }.get() - - // Write the connection channel to the server channel to simulate an incoming connection - try await self.serverTestChannel.writeInbound(negotiatedServerConnectionFuture) - - // Now we could write requests directly to the server channle, but it expects `ByteBuffer` inputs. This is - // cumbersome to work with in tests. - // So, we create a client channel, and use it to send requests and observe responses in terms of HTTP types. - let (clientTestChannel, clientNegotiatedConnectionFuture) = try await self.setUpClientConnection( - tlsConfiguration: clientTLSConfiguration - ) - - try await withThrowingDiscardingTaskGroup { group in - // We must forward all client outbound writes to the server and vice-versa. - group.addTask { try await clientTestChannel.glueTo(serverTestConnectionChannel) } - - try await body(.init(negotiationResult: try await clientNegotiatedConnectionFuture.get())) - - try await serverTestConnectionChannel.close() - } - } - - private func setUpClientConnection( - tlsConfiguration: TLSConfiguration - ) async throws -> ( - NIOAsyncTestingChannel, - EventLoopFuture< - NIONegotiatedHTTPVersion< - NIOAsyncChannel, NIOHTTP2Handler.AsyncStreamMultiplexer - > - > - ) { - let clientTestChannel = try await NIOAsyncTestingChannel { channel in - _ = channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler( - try NIOSSLClientHandler(context: .init(configuration: tlsConfiguration), serverHostname: nil) - ) - } - } - - let clientNegotiatedConnection = try await clientTestChannel.eventLoop.flatSubmit { - clientTestChannel.configureHTTP2AsyncSecureUpgrade( - http1ConnectionInitializer: { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHTTPClientHandlers() - try channel.pipeline.syncOperations.addHandlers(HTTP1ToHTTPClientCodec()) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: .init(isOutboundHalfClosureEnabled: true) - ) - } - }, - http2ConnectionInitializer: { channel in - channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } - } - ) - }.get() - - let connectionPromise = clientTestChannel.eventLoop.makePromise(of: Void.self) - clientTestChannel.connect(to: try .init(ipAddress: "127.0.0.1", port: 8000), promise: connectionPromise) - try await connectionPromise.futureResult.get() - - return (clientTestChannel, clientNegotiatedConnection) - } -} - -enum NegotiatedConnection { - case http1(NIOAsyncChannel) - case http2(HTTP2StreamManager) - - init( - negotiationResult: NIONegotiatedHTTPVersion< - NIOAsyncChannel, NIOHTTP2Handler.AsyncStreamMultiplexer - > - ) async throws { - switch negotiationResult { - case .http1_1(let http1AsyncChannel): - self = .http1(http1AsyncChannel) - - case .http2(let http2StreamMultiplexer): - self = .http2(.init(http2StreamMultiplexer: http2StreamMultiplexer)) - } - } - - struct HTTP2StreamManager { - let http2StreamMultiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer - - /// A wrapper over `NIOHTTP2Handler/AsyncStreamMultiplexer/openStream(_:)` that first initializes the stream - /// channel with the `HTTP2FramePayloadToHTTPClientCodec` channel handler, and wraps it in a `NIOAsyncChannel` - /// (with outbound half closure enabled). - func openStream() async throws -> NIOAsyncChannel { - try await self.http2StreamMultiplexer.openStream { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: .init(isOutboundHalfClosureEnabled: true) - ) - } - } - } - } -} diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift new file mode 100644 index 0000000..3a33e42 --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOEmbedded + +extension NIOAsyncTestingChannel { + /// Forwards all of our outbound writes to `other` and vice-versa. + func glueTo(_ other: NIOAsyncTestingChannel) async throws { + try await withThrowingDiscardingTaskGroup { group in + // 1. Forward all `self` writes to `other` + group.addTask { + while !Task.isCancelled { + do { + let ourPart = try await self.waitForOutboundWrite(as: ByteBuffer.self) + try await other.writeInbound(ourPart) + } catch ChannelError.ioOnClosedChannel { + // We only reach here if the channel has closed. `waitForOutboundWrite` uses a continuation + // without `withTaskCancellationHandler`, so this error is the only shutdown signal; returning + // allows the task group and `glueTo` to complete cleanly. + return + } + } + } + + // 2. Forward all `other` writes to `self` + group.addTask { + while !Task.isCancelled { + do { + let otherPart = try await other.waitForOutboundWrite(as: ByteBuffer.self) + try await self.writeInbound(otherPart) + } catch ChannelError.ioOnClosedChannel { + // Same reasoning as above: the channel has closed, and returning allows the task group and + // `glueTo` to complete cleanly. + return + } + } + } + } + } + + /// Returns a `NIOAsyncTestingChannel` that is set to the `active` state. + static func createActiveChannel() async throws -> NIOAsyncTestingChannel { + let channel = NIOAsyncTestingChannel() + + let setToActivePromise = channel.eventLoop.makePromise(of: Void.self) + // The `to` address has no significance here: it is just a random address. We are only interested in making the + // channel *active*; calling `connect` is the way to achieve that. + channel.connect( + to: try .init(ipAddress: "127.0.0.1", port: 8000), + promise: setToActivePromise + ) + try await setToActivePromise.futureResult.get() + + return channel + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift new file mode 100644 index 0000000..99bb74f --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTPServer +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOPosix + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Channel { + /// Adds HTTP/1.1 client handlers to the pipeline. + func configureTestHTTP1ClientPipeline() -> EventLoopFuture> { + self.eventLoop.makeCompletedFuture { + try self.pipeline.syncOperations.addHTTPClientHandlers() + try self.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: self, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension ClientBootstrap { + /// Connects to the provided `serverAddress` and provides a `NIOAsyncChannel`. With this ``NIOAsyncChannel``, one + /// can write `HTTPRequestPart`s to the server and observe `HTTPResponsePart`s from the inbound stream of the + /// channel. + func connectToTestHTTP1Server( + at serverAddress: NIOHTTPServer.SocketAddress + ) async throws -> NIOAsyncChannel { + try await self.connect(to: try .init(ipAddress: serverAddress.host, port: serverAddress.port)) { channel in + channel.configureTestHTTP1ClientPipeline() + } + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift new file mode 100644 index 0000000..6631a0d --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -0,0 +1,127 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP2 +import NIOHTTPTypes +import NIOPosix +import NIOSSL +import X509 + +@testable import NIOHTTPServer + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +/// Provides a HTTP client with ALPN negotiation. +extension Channel { + /// Adds a ``NIOSSLClientHandler`` configured with the provided `TLSConfiguration` to the pipeline. + func configureTestClientSSLPipeline(tlsConfig: TLSConfiguration) -> EventLoopFuture { + self.eventLoop.makeCompletedFuture { + let sslContext = try NIOSSLContext(configuration: tlsConfig) + let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: nil) + try self.pipeline.syncOperations.addHandler(sslHandler) + } + } + + /// Adds an ALPN handler (configured with both HTTP/1.1 and HTTP/2 channel initializers) to the pipeline. + func configureTestSecureUpgradeClientPipeline() -> EventLoopFuture< + EventLoopFuture< + NIONegotiatedHTTPVersion< + NIOAsyncChannel, + NIOHTTP2Handler.AsyncStreamMultiplexer + > + > + > { + self.configureHTTP2AsyncSecureUpgrade( + http1ConnectionInitializer: { channel in + channel.configureTestHTTP1ClientPipeline() + }, + http2ConnectionInitializer: { channel in + channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } + } + ) + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension ClientBootstrap { + /// Connects the client to the specified address using the provided TLS configuration. + func connectToTestSecureUpgradeHTTPServer( + at serverAddress: NIOHTTPServer.SocketAddress, + tlsConfig: TLSConfiguration + ) async throws -> NegotiatedClientConnection { + let clientNegotiatedChannel = try await self.connect( + to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) + ) { channel in + channel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { + channel.configureTestSecureUpgradeClientPipeline() + } + }.get() + + switch clientNegotiatedChannel { + case .http1_1(let http1Channel): + return .http1(http1Channel) + + case .http2(let http2Channel): + return .http2(.init(http2StreamMultiplexer: http2Channel)) + } + } + + /// Creates and connects a TLS-enabled client to the specified address. + func connectToTestSecureUpgradeHTTPServer( + at serverAddress: NIOHTTPServer.SocketAddress, + trustRoots: [Certificate], + applicationProtocol: String + ) async throws -> NegotiatedClientConnection { + let tlsConfig = try TLSConfiguration.makeTestClientConfiguration( + testTrustRoots: trustRoots, + applicationProtocol: applicationProtocol + ) + + return try await self.connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: tlsConfig) + } + + /// Exactly like ``connectToTestSecureUpgradeHTTPServerOverMTLS(at:trustRoots:applicationProtocol:)`` but over mTLS + /// instead. + func connectToTestSecureUpgradeHTTPServerOverMTLS( + at serverAddress: NIOHTTPServer.SocketAddress, + clientChain: ChainPrivateKeyPair, + trustRoots: [Certificate], + applicationProtocol: String + ) async throws -> NegotiatedClientConnection { + var mTLSConfig = try TLSConfiguration.makeTestClientConfiguration( + testTrustRoots: trustRoots, + applicationProtocol: applicationProtocol + ) + mTLSConfig.certificateChain = [try NIOSSLCertificateSource(clientChain.leaf)] + mTLSConfig.privateKey = .privateKey(try .init(clientChain.privateKey)) + + return try await self.connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: mTLSConfig) + } +} + +extension TLSConfiguration { + /// Valid `applicationProtocol` values are `"http/1.1"` (forces HTTP/1.1), `"h2"` (forces HTTP/2), or a + /// comma-separated combination of both in order of preference, e.g. `"http/1.1, h2"`. + static func makeTestClientConfiguration( + testTrustRoots: [Certificate], + applicationProtocol: String + ) throws -> TLSConfiguration { + var clientTLSConfig = TLSConfiguration.makeClientConfiguration() + clientTLSConfig.trustRoots = .certificates(try testTrustRoots.map { try NIOSSLCertificate($0) }) + clientTLSConfig.certificateVerification = .noHostnameVerification + clientTLSConfig.applicationProtocols = [applicationProtocol] + + return clientTLSConfig + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NegotiatedClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/NegotiatedClientConnection.swift new file mode 100644 index 0000000..5292c6c --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/NegotiatedClientConnection.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP2 + +/// A testing utility that wraps the result of ALPN negotiation for HTTP/1.1 or HTTP/2 client connections. +/// +/// - If HTTP/1.1 is negotiated, this type vends the underlying client connection channel. +/// - If HTTP/2 is negotiated, this type vends a ``HTTP2StreamManager``. In tests, you can then call the +/// ``HTTP2StreamManager/openStream()`` method, which will create a stream channel, set it up with a channel handler, +/// and return a ``NIOAsyncChannel`` from which you can send/observe requests/responses in terms of HTTP types. +enum NegotiatedClientConnection { + case http1(NIOAsyncChannel) + case http2(HTTP2StreamManager) + + init( + negotiationResult: NIONegotiatedHTTPVersion< + NIOAsyncChannel, NIOHTTP2Handler.AsyncStreamMultiplexer + > + ) async throws { + switch negotiationResult { + case .http1_1(let http1AsyncChannel): + self = .http1(http1AsyncChannel) + + case .http2(let http2StreamMultiplexer): + self = .http2(.init(http2StreamMultiplexer: http2StreamMultiplexer)) + } + } + + /// Provides utilities for managing HTTP/2 streams. + struct HTTP2StreamManager { + let http2StreamMultiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer + + /// A wrapper over `NIOHTTP2Handler/AsyncStreamMultiplexer/openStream(_:)` that first initializes the stream + /// channel with the `HTTP2FramePayloadToHTTPClientCodec` channel handler, and wraps it in a `NIOAsyncChannel` + /// (with outbound half closure enabled). + func openStream() async throws -> NIOAsyncChannel { + try await self.http2StreamMultiplexer.openStream { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + } + } + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift similarity index 92% rename from Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift rename to Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift index 96b78d8..9a3e137 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift @@ -21,6 +21,8 @@ import NIOHTTPTypes @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { + /// Starts serving plaintext HTTP/1.1 using the provided testing channel instead of using `ServerBootstrap` as + /// `NIOHTTPServer` normally does. func serveInsecureHTTP1_1WithTestChannel( testChannel: NIOAsyncTestingChannel, handler: some HTTPServerRequestHandler diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift similarity index 91% rename from Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift rename to Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift index 2c6288b..dd4c657 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift @@ -21,6 +21,8 @@ import NIOHTTPTypes @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { + /// Starts serving with the Secure Upgrade transport using the provided testing channel instead of using + /// `ServerBootstrap` as `NIOHTTPServer` normally does. func serveSecureUpgradeWithTestChannel( testChannel: NIOAsyncTestingChannel, handler: some HTTPServerRequestHandler diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift new file mode 100644 index 0000000..a79ee41 --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPServer +import Logging +import NIOCore +import NIOEmbedded +import NIOHTTPTypes + +@testable import NIOHTTPServer + +/// A testing utility that sets up a `NIOHTTPServer` instance based on `NIOAsyncTestingChannel` (instead of the +/// `ServerSocketChannel` that `NIOHTTPServer` normally uses) and vends a client instance for sending requests and +/// observing responses. +/// +/// This provider creates a `NIOHTTPServer` instance using a `NIOAsyncTestingChannel` as its listening channel. Since no +/// network socket is actually listening for incoming connections, client connections are simulated by *writing* a +/// connection channel to the server channel. This connection channel is set up with the same handlers that +/// `ServerBootstrap` would set up and vend to `NIOHTTPServer` on an incoming connection. +/// +/// This provider vends a HTTP client channel (also backed by a `NIOAsyncTestingChannel`) that can be used to send +/// requests and observe responses in terms of HTTP types (`HTTPRequestPart` and `HTTPResponsePart`) to the server +/// connection channel. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +struct TestingChannelHTTP1Server { + let server: NIOHTTPServer + let serverTestChannel: NIOAsyncTestingChannel + + /// Creates a `NIOHTTPServer` backed by a `NIOAsyncTestingChannel` and the provided request handler, starts it, and + /// provides `Self` to the `body` closure. + static func serve( + logger: Logger, + handler: some HTTPServerRequestHandler, + body: (Self) async throws -> Void + ) async throws { + let server = NIOHTTPServer( + logger: logger, + // The server won't actually be bound to this host and port; we just have to pass this argument. + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) + ) + // Create a test channel. We will run the server on this channel. + let serverTestChannel = NIOAsyncTestingChannel() + + try await withThrowingTaskGroup { group in + // We are ready now. Start the server with the test channel. + group.addTask { + try await server.serveInsecureHTTP1_1WithTestChannel(testChannel: serverTestChannel, handler: handler) + } + + // Execute the provided closure with `Self`. + try await body(Self(server: server, serverTestChannel: serverTestChannel)) + + group.cancelAll() + } + } + + /// Starts a new connection with the server and executes the provided `body` closure. + /// - Parameter body: A closure that should send a request using the provided client channel and validate the + /// received response. + func withConnectedClient( + body: (_ connectionChannel: NIOAsyncChannel) async throws -> Void + ) async throws { + // Create a connection channel: we will write this to the server channel to simulate an incoming connection + let serverTestConnectionChannel = try await NIOAsyncTestingChannel.createActiveChannel() + + // Set up the required channel handlers on `serverTestConnectionChannel` + let serverAsyncConnectionChannel = try await self.server.setupHTTP1_1ConnectionChildChannel( + channel: serverTestConnectionChannel, + asyncChannelConfiguration: .init() + ).get() + + // Write the connection channel to the server channel to simulate an incoming connection + try await self.serverTestChannel.writeInbound(serverAsyncConnectionChannel) + + let clientTestingChannel = try await NIOAsyncTestingChannel.createActiveChannel() + let clientAsyncChannel = try await clientTestingChannel.eventLoop.flatSubmit { + clientTestingChannel.configureTestHTTP1ClientPipeline() + }.get() + + try await withThrowingDiscardingTaskGroup { group in + // We must forward all client outbound writes to the server and vice-versa. + group.addTask { try await clientTestingChannel.glueTo(serverTestConnectionChannel) } + + try await body(clientAsyncChannel) + + try await serverTestConnectionChannel.close() + } + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift new file mode 100644 index 0000000..ac623d2 --- /dev/null +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPServer +import Logging +import NIOCore +import NIOEmbedded +import NIOHTTP2 +import NIOHTTPTypes +import NIOSSL +import X509 + +@testable import NIOHTTPServer + +/// Like ``TestingChannelHTTP1Server``, but for Secure Upgrade. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +struct TestingChannelSecureUpgradeServer { + let server: NIOHTTPServer + let serverTestChannel: NIOAsyncTestingChannel + + let tlsConfiguration: TLSConfiguration + let tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? + let http2Configuration: NIOHTTPServerConfiguration.HTTP2 + + /// Sets up the server with a testing channel and the provided request handler, starts the server, and provides + /// `Self` to the `body` closure. Call `withConnection(clientTLSConfiguration:body:)` on the provided instance to + /// simulate incoming connections. + static func serve( + logger: Logger, + tlsConfiguration: TLSConfiguration, + tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? = nil, + http2Configuration: NIOHTTPServerConfiguration.HTTP2 = .init(), + handler: some HTTPServerRequestHandler, + body: (Self) async throws -> Void + ) async throws { + let server = NIOHTTPServer( + logger: logger, + // The server won't actually be bound to this host and port; we just have to pass this argument + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) + ) + + // Create a test channel. We will run the server on this channel. + let serverTestChannel = NIOAsyncTestingChannel() + + try await withThrowingTaskGroup { group in + // We are ready now. Start the server with the test channel. + group.addTask { + try await server.serveSecureUpgradeWithTestChannel(testChannel: serverTestChannel, handler: handler) + } + + // Execute the provided closure. + try await body( + Self( + server: server, + serverTestChannel: serverTestChannel, + tlsConfiguration: tlsConfiguration, + tlsVerificationCallback: tlsVerificationCallback, + http2Configuration: http2Configuration + ) + ) + + group.cancelAll() + } + } + + /// Starts a new TLS connection with ALPN negotiation to the server and executes the provided `body` closure + /// with the negotiated ALPN result as an argument. + func withConnectedClient( + clientTLSConfig: TLSConfiguration, + body: (_ negotiatedConnectionChannel: NegotiatedClientConnection) async throws -> Void + ) async throws { + // Create a connection channel: we will write this to the server channel to simulate an incoming connection. + let serverTestConnectionChannel = try await NIOAsyncTestingChannel.createActiveChannel() + + // Set up the required channel handlers on `serverTestConnectionChannel` + let negotiatedServerConnectionFuture = try await serverTestConnectionChannel.eventLoop.flatSubmit { + self.server.setupSecureUpgradeConnectionChildChannel( + channel: serverTestConnectionChannel, + tlsConfiguration: self.tlsConfiguration, + asyncChannelConfiguration: .init(), + http2Configuration: self.http2Configuration, + verificationCallback: self.tlsVerificationCallback + ) + }.get() + + // Write the connection channel to the server channel to simulate an incoming connection + try await self.serverTestChannel.writeInbound(negotiatedServerConnectionFuture) + + let clientTestingChannel = try await NIOAsyncTestingChannel.createActiveChannel() + let clientNegotiatedConnectionFuture = try await clientTestingChannel.eventLoop.flatSubmit { + clientTestingChannel.configureTestClientSSLPipeline(tlsConfig: clientTLSConfig).flatMap { + clientTestingChannel.configureTestSecureUpgradeClientPipeline() + } + }.get() + + try await withThrowingDiscardingTaskGroup { group in + // We must forward all client outbound writes to the server and vice-versa. + group.addTask { try await clientTestingChannel.glueTo(serverTestConnectionChannel) } + + try await body(.init(negotiationResult: try await clientNegotiatedConnectionFuture.get())) + + try await serverTestConnectionChannel.close() + } + } +}