Refactor transport-http response delivery
[netconf.git] / transport / transport-http / src / test / java / org / opendaylight / netconf / transport / http / HttpClientServerTest.java
1 /*
2  * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6  * and is available at http://www.eclipse.org/legal/epl-v10.html
7  */
8 package org.opendaylight.netconf.transport.http;
9
10 import static io.netty.buffer.Unpooled.wrappedBuffer;
11 import static io.netty.handler.codec.http.HttpHeaderNames.CONNECTION;
12 import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
13 import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
14 import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE;
15 import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_PLAIN;
16 import static io.netty.handler.codec.http.HttpResponseStatus.OK;
17 import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
18 import static org.junit.jupiter.api.Assertions.assertEquals;
19 import static org.junit.jupiter.api.Assertions.assertNotNull;
20 import static org.mockito.ArgumentMatchers.any;
21 import static org.mockito.Mockito.doReturn;
22 import static org.mockito.Mockito.timeout;
23 import static org.mockito.Mockito.verify;
24 import static org.opendaylight.netconf.transport.http.ConfigUtils.clientTransportTcp;
25 import static org.opendaylight.netconf.transport.http.ConfigUtils.clientTransportTls;
26 import static org.opendaylight.netconf.transport.http.ConfigUtils.serverTransportTcp;
27 import static org.opendaylight.netconf.transport.http.ConfigUtils.serverTransportTls;
28
29 import com.google.common.util.concurrent.FutureCallback;
30 import com.google.common.util.concurrent.SettableFuture;
31 import io.netty.handler.codec.http.DefaultFullHttpRequest;
32 import io.netty.handler.codec.http.DefaultFullHttpResponse;
33 import io.netty.handler.codec.http.FullHttpResponse;
34 import io.netty.handler.codec.http.HttpMethod;
35 import java.io.IOException;
36 import java.math.BigInteger;
37 import java.net.InetAddress;
38 import java.net.ServerSocket;
39 import java.nio.charset.StandardCharsets;
40 import java.security.KeyPair;
41 import java.security.KeyPairGenerator;
42 import java.security.PrivateKey;
43 import java.security.SecureRandom;
44 import java.security.cert.X509Certificate;
45 import java.security.spec.ECGenParameterSpec;
46 import java.security.spec.RSAKeyGenParameterSpec;
47 import java.time.Duration;
48 import java.time.Instant;
49 import java.util.Date;
50 import java.util.Map;
51 import java.util.concurrent.Executors;
52 import java.util.concurrent.ScheduledExecutorService;
53 import java.util.concurrent.TimeUnit;
54 import java.util.concurrent.atomic.AtomicInteger;
55 import org.bouncycastle.asn1.x500.X500Name;
56 import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
57 import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
58 import org.bouncycastle.jce.provider.BouncyCastleProvider;
59 import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
60 import org.junit.jupiter.api.AfterAll;
61 import org.junit.jupiter.api.BeforeAll;
62 import org.junit.jupiter.api.extension.ExtendWith;
63 import org.junit.jupiter.params.ParameterizedTest;
64 import org.junit.jupiter.params.provider.ValueSource;
65 import org.mockito.Mock;
66 import org.mockito.junit.jupiter.MockitoExtension;
67 import org.opendaylight.netconf.transport.api.TransportChannelListener;
68 import org.opendaylight.netconf.transport.tcp.BootstrapFactory;
69 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientStackGrouping;
70 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerStackGrouping;
71
72 @ExtendWith(MockitoExtension.class)
73 public class HttpClientServerTest {
74
75     private static final String USERNAME = "username";
76     private static final String PASSWORD = "pa$$W0rd";
77     private static final Map<String, String> USER_HASHES_MAP = Map.of(USERNAME, "$0$" + PASSWORD);
78     private static final AtomicInteger COUNTER = new AtomicInteger(0);
79     private static final SecureRandom SECURE_RANDOM = new SecureRandom();
80     private static final String[] METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"};
81     private static final String RESPONSE_TEMPLATE = "Method: %s URI: %s Payload: %s";
82
83     private static ScheduledExecutorService scheduledExecutor;
84     private static RequestDispatcher requestDispatcher;
85     private static BootstrapFactory bootstrapFactory;
86     private static String localAddress;
87
88     @Mock
89     private HttpServerStackGrouping serverConfig;
90     @Mock
91     private HttpClientStackGrouping clientConfig;
92     @Mock
93     private TransportChannelListener serverTransportListener;
94     @Mock
95     private TransportChannelListener clientTransportListener;
96
97     @BeforeAll
98     static void beforeAll() {
99         bootstrapFactory = new BootstrapFactory("IntegrationTest", 0);
100         localAddress = InetAddress.getLoopbackAddress().getHostAddress();
101         scheduledExecutor = Executors.newSingleThreadScheduledExecutor();
102
103         requestDispatcher = (request, callback) -> {
104             // emulate asynchronous server request processing - run in separate thread with 100 millis delay
105             scheduledExecutor.schedule(() -> {
106                 // return 200 response with a content built from request parameters
107                 final var method = request.method().name();
108                 final var uri = request.uri();
109                 final var payload = request.content().readableBytes() > 0
110                     ? request.content().toString(StandardCharsets.UTF_8) : "";
111                 final var responseMessage = RESPONSE_TEMPLATE.formatted(method, uri, payload);
112                 final var response = new DefaultFullHttpResponse(request.protocolVersion(), OK,
113                     wrappedBuffer(responseMessage.getBytes(StandardCharsets.UTF_8)));
114                 response.headers().set(CONTENT_TYPE, TEXT_PLAIN)
115                     .setInt(CONTENT_LENGTH, response.content().readableBytes());
116                 callback.onSuccess(response);
117             }, 100, TimeUnit.MILLISECONDS);
118         };
119     }
120
121     @AfterAll
122     static void afterAll() {
123         bootstrapFactory.close();
124         scheduledExecutor.shutdown();
125     }
126
127     @ParameterizedTest(name = "TCP with no authorization, HTTP/2: {0}")
128     @ValueSource(booleans = {false, true})
129     void noAuthTcp(final boolean http2) throws Exception {
130         final var localPort = freePort();
131         doReturn(serverTransportTcp(localAddress, localPort)).when(serverConfig).getTransport();
132         doReturn(clientTransportTcp(localAddress, localPort)).when(clientConfig).getTransport();
133         integrationTest(http2);
134     }
135
136     @ParameterizedTest(name = "TCP with Basic authorization, HTTP/2: {0}")
137     @ValueSource(booleans = {false, true})
138     void basicAuthTcp(final boolean http2) throws Exception {
139         final var localPort = freePort();
140         doReturn(serverTransportTcp(localAddress, localPort, USER_HASHES_MAP))
141             .when(serverConfig).getTransport();
142         doReturn(clientTransportTcp(localAddress, localPort, USERNAME, PASSWORD))
143             .when(clientConfig).getTransport();
144         integrationTest(http2);
145     }
146
147     @ParameterizedTest(name = "TLS with no authorization, HTTP/2: {0}")
148     @ValueSource(booleans = {false, true})
149     void noAuthTls(final boolean http2) throws Exception {
150         final var certData = generateX509CertData("RSA");
151         final var localPort = freePort();
152         doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey()))
153             .when(serverConfig).getTransport();
154         doReturn(clientTransportTls(localAddress, localPort, certData.certificate())).when(clientConfig).getTransport();
155         integrationTest(http2);
156     }
157
158     @ParameterizedTest(name = "TLS with Basic authorization, HTTP/2: {0}")
159     @ValueSource(booleans = {false, true})
160     void basicAuthTls(final boolean http2) throws Exception {
161         final var certData = generateX509CertData("EC");
162         final var localPort = freePort();
163         doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey(),
164             USER_HASHES_MAP)).when(serverConfig).getTransport();
165         doReturn(clientTransportTls(localAddress, localPort, certData.certificate(), USERNAME, PASSWORD))
166             .when(clientConfig).getTransport();
167         integrationTest(http2);
168     }
169
170     private void integrationTest(final boolean http2) throws Exception {
171         final var server = HTTPServer.listen(serverTransportListener, bootstrapFactory.newServerBootstrap(),
172             serverConfig, requestDispatcher).get(2, TimeUnit.SECONDS);
173         try {
174             final var client = HTTPClient.connect(clientTransportListener, bootstrapFactory.newBootstrap(),
175                     clientConfig, http2).get(2, TimeUnit.SECONDS);
176             try {
177                 verify(serverTransportListener, timeout(2000)).onTransportChannelEstablished(any());
178                 verify(clientTransportListener, timeout(2000)).onTransportChannelEstablished(any());
179
180                 for (var method : METHODS) {
181                     final var uri = nextValue("URI");
182                     final var payload = nextValue("PAYLOAD");
183                     final var request = new DefaultFullHttpRequest(HTTP_1_1, HttpMethod.valueOf(method),
184                         uri, wrappedBuffer(payload.getBytes(StandardCharsets.UTF_8)));
185                     request.headers().set(CONTENT_TYPE, TEXT_PLAIN)
186                         .setInt(CONTENT_LENGTH, request.content().readableBytes())
187                         // allow multiple requests on same connections
188                         .set(CONNECTION, KEEP_ALIVE);
189
190                     final var future = SettableFuture.<FullHttpResponse>create();
191                     client.invoke(request, new FutureCallback<>() {
192                         @Override
193                         public void onSuccess(final FullHttpResponse result) {
194                             future.set(result.copy());
195                         }
196
197                         @Override
198                         public void onFailure(final Throwable cause) {
199                             future.setException(cause);
200                         }
201                     });
202
203                     final var response = future.get(2, TimeUnit.SECONDS);
204                     assertNotNull(response);
205                     assertEquals(OK, response.status());
206                     final var expected = RESPONSE_TEMPLATE.formatted(method, uri, payload);
207                     assertEquals(expected, response.content().toString(StandardCharsets.UTF_8));
208                 }
209             } finally {
210                 client.shutdown().get(2, TimeUnit.SECONDS);
211             }
212         } finally {
213             server.shutdown().get(2, TimeUnit.SECONDS);
214         }
215     }
216
217     private static int freePort() throws IOException {
218         // find free port
219         final var socket = new ServerSocket(0);
220         final var localPort = socket.getLocalPort();
221         socket.close();
222         return localPort;
223     }
224
225     private static String nextValue(final String prefix) {
226         return prefix + COUNTER.incrementAndGet();
227     }
228
229     private static X509CertData generateX509CertData(final String algorithm) throws Exception {
230         final var keyPairGenerator = KeyPairGenerator.getInstance(algorithm);
231         if (isRSA(algorithm)) {
232             keyPairGenerator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4), SECURE_RANDOM);
233         } else {
234             keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"), SECURE_RANDOM);
235         }
236         final var keyPair = keyPairGenerator.generateKeyPair();
237         final var certificate = generateCertificate(keyPair, isRSA(algorithm) ? "SHA256withRSA" : "SHA256withECDSA");
238         return new X509CertData(certificate, keyPair.getPrivate());
239     }
240
241     private static X509Certificate generateCertificate(final KeyPair keyPair, final String hashAlgorithm)
242             throws Exception {
243         final var now = Instant.now();
244         final var contentSigner = new JcaContentSignerBuilder(hashAlgorithm).build(keyPair.getPrivate());
245
246         final var x500Name = new X500Name("CN=TestCertificate");
247         final var certificateBuilder = new JcaX509v3CertificateBuilder(x500Name,
248             BigInteger.valueOf(now.toEpochMilli()),
249             Date.from(now), Date.from(now.plus(Duration.ofDays(365))),
250             x500Name,
251             keyPair.getPublic());
252         return new JcaX509CertificateConverter()
253             .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner));
254     }
255
256     private static boolean isRSA(final String algorithm) {
257         return "RSA".equals(algorithm);
258     }
259
260     private record X509CertData(X509Certificate certificate, PrivateKey privateKey) {
261     }
262 }