001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.internal.test.util.http;
020
021import javax.net.ssl.KeyManagerFactory;
022import javax.net.ssl.SSLContext;
023import javax.net.ssl.TrustManagerFactory;
024
025import java.io.File;
026import java.io.FileNotFoundException;
027import java.io.IOException;
028import java.io.InputStream;
029import java.io.UncheckedIOException;
030import java.net.ServerSocket;
031import java.net.URI;
032import java.nio.charset.StandardCharsets;
033import java.nio.file.Files;
034import java.nio.file.Path;
035import java.nio.file.Paths;
036import java.nio.file.StandardCopyOption;
037import java.security.KeyStore;
038import java.security.NoSuchAlgorithmException;
039import java.time.Duration;
040import java.util.Enumeration;
041import java.util.HashMap;
042import java.util.List;
043import java.util.Map;
044import java.util.concurrent.TimeUnit;
045import java.util.concurrent.atomic.AtomicReference;
046import java.util.function.Supplier;
047import java.util.regex.Pattern;
048import java.util.stream.Stream;
049
050import org.awaitility.Awaitility;
051import org.eclipse.aether.ConfigurationProperties;
052import org.eclipse.aether.DefaultRepositoryCache;
053import org.eclipse.aether.DefaultRepositorySystemSession;
054import org.eclipse.aether.DefaultSessionData;
055import org.eclipse.aether.internal.impl.transport.http.DefaultChecksumExtractor;
056import org.eclipse.aether.internal.impl.transport.http.Nx2ChecksumExtractor;
057import org.eclipse.aether.internal.impl.transport.http.XChecksumExtractor;
058import org.eclipse.aether.internal.test.util.TestFileUtils;
059import org.eclipse.aether.internal.test.util.TestLocalRepositoryManager;
060import org.eclipse.aether.repository.Authentication;
061import org.eclipse.aether.repository.Proxy;
062import org.eclipse.aether.repository.RemoteRepository;
063import org.eclipse.aether.spi.connector.transport.GetTask;
064import org.eclipse.aether.spi.connector.transport.PeekTask;
065import org.eclipse.aether.spi.connector.transport.PutTask;
066import org.eclipse.aether.spi.connector.transport.Transporter;
067import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
068import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractorStrategy;
069import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
070import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
071import org.eclipse.aether.spi.connector.transport.http.HttpTransporterFactory;
072import org.eclipse.aether.spi.connector.transport.http.RFC9457.HttpRFC9457Exception;
073import org.eclipse.aether.transfer.HttpTransportProperty;
074import org.eclipse.aether.transfer.NoTransporterException;
075import org.eclipse.aether.transfer.TransferCancelledException;
076import org.eclipse.aether.util.repository.AuthenticationBuilder;
077import org.eclipse.jetty.http.HttpVersion;
078import org.junit.jupiter.api.AfterAll;
079import org.junit.jupiter.api.AfterEach;
080import org.junit.jupiter.api.BeforeAll;
081import org.junit.jupiter.api.BeforeEach;
082import org.junit.jupiter.api.MethodOrderer;
083import org.junit.jupiter.api.Test;
084import org.junit.jupiter.api.TestInfo;
085import org.junit.jupiter.api.TestMethodOrder;
086import org.junit.jupiter.api.Timeout;
087import org.junit.jupiter.params.ParameterizedTest;
088import org.junit.jupiter.params.provider.ValueSource;
089
090import static java.util.Objects.requireNonNull;
091import static org.junit.jupiter.api.Assertions.assertEquals;
092import static org.junit.jupiter.api.Assertions.assertLinesMatch;
093import static org.junit.jupiter.api.Assertions.assertNotNull;
094import static org.junit.jupiter.api.Assertions.assertNull;
095import static org.junit.jupiter.api.Assertions.assertThrows;
096import static org.junit.jupiter.api.Assertions.assertTrue;
097import static org.junit.jupiter.api.Assertions.fail;
098import static org.junit.jupiter.api.Assumptions.assumeTrue;
099
100/**
101 * Common set of tests against Http transporter.
102 */
103@SuppressWarnings({"checkstyle:methodname", "checkstyle:sizes"})
104@TestMethodOrder(MethodOrderer.MethodName.class)
105public abstract class HttpTransporterTest {
106
107    protected static final Path SERVER_STORE_PATH = Paths.get("target/server-store");
108    protected static final Path CLIENT_STORE_PATH = Paths.get("target/client-store");
109    protected static final Path PEM_QUICHE_SERVER_PATH = Paths.get("target/pems");
110
111    protected static SSLContext defaultSslContext;
112
113    static {
114        // uncomment to enable SSL debugging for easier troubleshooting of SSL related test failures
115        // System.setProperty("javax.net.debug", "all");
116    }
117
118    @BeforeAll
119    protected static void beforeAll() throws NoSuchAlgorithmException {
120        // populate custom keystore and truststore
121        try {
122            try (InputStream keyStoreStream =
123                            HttpTransporterTest.class.getClassLoader().getResourceAsStream("ssl/server-store");
124                    InputStream trustStoreStream =
125                            HttpTransporterTest.class.getClassLoader().getResourceAsStream("ssl/client-store"); ) {
126                Files.copy(keyStoreStream, SERVER_STORE_PATH, StandardCopyOption.REPLACE_EXISTING);
127                Files.copy(trustStoreStream, CLIENT_STORE_PATH, StandardCopyOption.REPLACE_EXISTING);
128                Files.createDirectories(PEM_QUICHE_SERVER_PATH);
129            }
130        } catch (IOException e) {
131            throw new UncheckedIOException(e);
132        }
133        // override default SSLContext to include our custom keystore and truststore (which are "cross connected" with
134        // HttpServer)
135        defaultSslContext = SSLContext.getDefault();
136        SSLContext.setDefault(createClientSSLContext());
137    }
138
139    @AfterAll
140    protected static void afterAll() {
141        if (defaultSslContext != null) {
142            SSLContext.setDefault(defaultSslContext);
143        }
144    }
145
146    /**
147     * Creates an {@link SSLContext} for the client that extends the default keystore and truststore with the entries
148     * from {@link #SERVER_STORE_PATH} (password {@code "server-pwd"}) and {@link #CLIENT_STORE_PATH}
149     * (password {@code "client-pwd"}).
150     *
151     * @return an {@link SSLContext} combining default and custom key/trust material
152     */
153    protected static SSLContext createClientSSLContext() {
154        try {
155            // Load custom key store (KEY_STORE_PATH acts as truststore in "cross connected" setup)
156            KeyStore customTrustStore = KeyStore.getInstance("pkcs12");
157            try (InputStream is = Files.newInputStream(SERVER_STORE_PATH)) {
158                customTrustStore.load(is, "server-pwd".toCharArray());
159            }
160
161            // Load custom trust store (TRUST_STORE_PATH acts as keystore in "cross connected" setup)
162            KeyStore customKeyStore = KeyStore.getInstance("pkcs12");
163            try (InputStream is = Files.newInputStream(CLIENT_STORE_PATH)) {
164                customKeyStore.load(is, "client-pwd".toCharArray());
165            }
166
167            // Load default truststore and merge custom entries
168            KeyStore defaultTrustStore = KeyStore.getInstance(KeyStore.getDefaultType());
169            Path defaultTrustStorePath = Path.of(System.getProperty("java.home"), "lib", "security", "cacerts");
170            if (Files.exists(defaultTrustStorePath)) {
171                try (InputStream is = Files.newInputStream(defaultTrustStorePath)) {
172                    defaultTrustStore.load(is, "changeit".toCharArray());
173                }
174            } else {
175                defaultTrustStore.load(null, null);
176            }
177            Enumeration<String> aliases = customTrustStore.aliases();
178            while (aliases.hasMoreElements()) {
179                String alias = aliases.nextElement();
180                defaultTrustStore.setCertificateEntry("custom-trust-" + alias, customTrustStore.getCertificate(alias));
181            }
182
183            // Load default keystore and merge custom entries
184            KeyStore mergedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
185            mergedKeyStore.load(null, null);
186            Enumeration<String> keyAliases = customKeyStore.aliases();
187            while (keyAliases.hasMoreElements()) {
188                String alias = keyAliases.nextElement();
189                if (customKeyStore.isKeyEntry(alias)) {
190                    mergedKeyStore.setKeyEntry(
191                            alias,
192                            customKeyStore.getKey(alias, "client-pwd".toCharArray()),
193                            "client-pwd".toCharArray(),
194                            customKeyStore.getCertificateChain(alias));
195                } else {
196                    mergedKeyStore.setCertificateEntry(alias, customKeyStore.getCertificate(alias));
197                }
198            }
199
200            TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
201            tmf.init(defaultTrustStore);
202
203            KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
204            kmf.init(mergedKeyStore, "client-pwd".toCharArray());
205
206            SSLContext sslContext = SSLContext.getInstance("TLS");
207            sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
208            return sslContext;
209        } catch (Exception e) {
210            throw new RuntimeException("Failed to create SSLContext", e);
211        }
212    }
213
214    private final Supplier<HttpTransporterFactory> transporterFactorySupplier;
215
216    protected DefaultRepositorySystemSession session;
217
218    protected HttpTransporterFactory factory;
219
220    protected HttpTransporter transporter;
221
222    protected Runnable closer;
223
224    protected File repoDir;
225
226    protected HttpServer httpServer;
227
228    protected Authentication auth;
229
230    protected Proxy proxy;
231
232    protected HttpTransporterTest(Supplier<HttpTransporterFactory> transporterFactorySupplier) {
233        this.transporterFactorySupplier = requireNonNull(transporterFactorySupplier);
234    }
235
236    protected static ChecksumExtractor standardChecksumExtractor() {
237        HashMap<String, ChecksumExtractorStrategy> strategies = new HashMap<>();
238        strategies.put("1", new Nx2ChecksumExtractor());
239        strategies.put("2", new XChecksumExtractor());
240        return new DefaultChecksumExtractor(strategies);
241    }
242
243    protected RemoteRepository newRepo(String url) {
244        return new RemoteRepository.Builder("test", "default", url)
245                .setAuthentication(auth)
246                .setProxy(proxy)
247                .build();
248    }
249
250    protected void newTransporter(String url) throws Exception {
251        if (transporter != null) {
252            transporter.close();
253            transporter = null;
254        }
255        if (closer != null) {
256            closer.run();
257            closer = null;
258        }
259        session = new DefaultRepositorySystemSession(session);
260        session.setData(new DefaultSessionData());
261        transporter = factory.newInstance(session, newRepo(url));
262    }
263
264    protected static final long OLD_FILE_TIMESTAMP = 160660800000L;
265
266    /** HTTP status code for "Too Many Requests". */
267    private static final int SC_TOO_MANY_REQUESTS = 429;
268
269    @BeforeEach
270    protected void setUp(TestInfo testInfo) throws Exception {
271        System.out.println("=== " + testInfo.getDisplayName() + " ===");
272        session = new DefaultRepositorySystemSession(h -> {
273            this.closer = h;
274            return true;
275        });
276        session.setLocalRepositoryManager(new TestLocalRepositoryManager());
277        factory = transporterFactorySupplier.get();
278        repoDir = TestFileUtils.createTempDir();
279        TestFileUtils.writeString(new File(repoDir, "file.txt"), "test");
280        TestFileUtils.writeString(new File(repoDir, "artifact.pom"), "<xml>pom</xml>");
281        TestFileUtils.writeString(new File(repoDir, "dir/file.txt"), "test");
282        TestFileUtils.writeString(new File(repoDir, "dir/oldFile.txt"), "oldTest", OLD_FILE_TIMESTAMP);
283        TestFileUtils.writeString(new File(repoDir, "empty.txt"), "");
284        TestFileUtils.writeString(new File(repoDir, "some space.txt"), "space");
285        try (InputStream is = getCompressibleFileStream()) {
286            Files.copy(is, repoDir.toPath().resolve("compressible-file.xml"));
287        }
288        File resumable = new File(repoDir, "resume.txt");
289        TestFileUtils.writeString(resumable, "resumable");
290        resumable.setLastModified(System.currentTimeMillis() - 90 * 1000);
291        httpServer = new HttpServer().setRepoDir(repoDir).start();
292        // always create a transporter connecting to the Http URL
293        newTransporter(httpServer.getHttpUrl());
294    }
295
296    private static InputStream getCompressibleFileStream() {
297        return HttpTransporterTest.class.getClassLoader().getResourceAsStream("compressible-file.xml");
298    }
299
300    @AfterEach
301    protected void tearDown() throws Exception {
302        if (transporter != null) {
303            transporter.close();
304            transporter = null;
305        }
306        if (closer != null) {
307            closer.run();
308            closer = null;
309        }
310        if (httpServer != null) {
311            if (closesAllConnectionsOnTransporterClose()) {
312                // check for leaked connections (e.g., due to not closing response body streams)
313                Awaitility.await().atMost(3, TimeUnit.SECONDS).until(() -> httpServer.getNumConnectedEndPoints() == 0);
314            }
315            httpServer.stop();
316            httpServer = null;
317        }
318        factory = null;
319        session = null;
320    }
321
322    /**
323     * Indicates whether the transporter implementation closes all connections when the transporter is closed.
324     * @return {@code true} if all connections are closed on transporter close, {@code false} otherwise.
325     */
326    protected boolean closesAllConnectionsOnTransporterClose() {
327        return true;
328    }
329
330    /**
331     * Indicates whether the transporter implementation supports preemptive authentication (i.e., sending credentials with the first request).
332     * @return {@code true} if preemptive authentication is supported, {@code false} otherwise.
333     */
334    protected boolean supportsPreemptiveAuth() {
335        return true;
336    }
337
338    /**
339     * Indicates whether the transporter implementation supports HTTP/3.
340     * @return {@code true} if HTTP/3 is supported, {@code false} otherwise.
341     */
342    protected boolean supportsHttp3() {
343        return true;
344    }
345
346    /**
347     * Indicates whether the transporter implementation supports HTTP/2.
348     * @return {@code true} if HTTP/2 is supported, {@code false} otherwise.
349     */
350    protected boolean supportsHttp2() {
351        return true;
352    }
353
354    @Test
355    protected void testClassify() {
356        assertEquals(Transporter.ERROR_OTHER, transporter.classify(new FileNotFoundException()));
357        assertEquals(Transporter.ERROR_OTHER, transporter.classify(new HttpTransporterException(403)));
358        assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(new HttpTransporterException(404)));
359        assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(new HttpTransporterException(410)));
360    }
361
362    @Test
363    protected void testPeek() throws Exception {
364        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
365    }
366
367    @Test
368    protected void testPeek_DoesNotAcceptRfc9457() throws Exception {
369        // peek is HEAD request, therefore cannot support RFC 9457
370        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
371        String accept = httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept");
372        assertNull(accept, "No accept header expected for HEAD request, but was: " + accept);
373    }
374
375    @Test
376    protected void testRetryHandler_defaultCount_positive() throws Exception {
377        httpServer.setConnectionsToClose(3);
378        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
379    }
380
381    protected boolean supportsRetryLogging() {
382        return true;
383    }
384
385    @ParameterizedTest
386    @ValueSource(ints = {429, 503})
387    protected void testRetryLogging_HttpStatus(int status) throws Exception {
388        assumeTrue(supportsRetryLogging());
389        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 2);
390        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, 1);
391        newTransporter(httpServer.getHttpUrl());
392        httpServer.setServerErrorsBeforeWorks(2, status);
393
394        try (RetryLogCapture capture = new RetryLogCapture(transporter.getClass())) {
395            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
396            String request = "HEAD request to " + httpServer.getHttpUrl() + "/repo/file.txt";
397            assertLinesMatch(
398                    List.of(
399                            Pattern.quote(request + " failed (attempt 1 of 3) due to status " + status
400                                            + ". Retrying in 1 ms")
401                                    + ".*\\.\\.\\.",
402                            Pattern.quote(request + " failed (attempt 2 of 3) due to status " + status
403                                            + ". Retrying in 2 ms")
404                                    + ".*\\.\\.\\."),
405                    capture.messages());
406        }
407    }
408
409    @ParameterizedTest
410    @ValueSource(ints = {429, 503})
411    protected void testRetryLogging_HttpStatusWithRetryAfter(int status) throws Exception {
412        assumeTrue(supportsRetryLogging());
413        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
414        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, 1);
415        newTransporter(httpServer.getHttpUrl());
416        httpServer.setServerErrorsBeforeWorks(1, status, Map.of("Retry-After", "1"));
417
418        try (RetryLogCapture capture = new RetryLogCapture(transporter.getClass())) {
419            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
420            String request = "HEAD request to " + httpServer.getHttpUrl() + "/repo/file.txt";
421            assertLinesMatch(
422                    List.of(Pattern.quote(request + " failed (attempt 1 of 2) due to status " + status
423                                    + ". Retrying in 1000 ms")
424                            + ".*\\.\\.\\."),
425                    capture.messages());
426        }
427    }
428
429    @Test
430    protected void testRetryLogging_IoException() throws Exception {
431        assumeTrue(supportsRetryLogging());
432        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
433        newTransporter(httpServer.getHttpUrl());
434        // Exhaust the JDK client's internal retries as well as the transporter's retries.
435        httpServer.setConnectionsToClose(100);
436
437        try (RetryLogCapture capture = new RetryLogCapture(transporter.getClass())) {
438            assertThrows(IOException.class, () -> transporter.peek(new PeekTask(URI.create("repo/file.txt"))));
439            String request = "HEAD request to " + httpServer.getHttpUrl() + "/repo/file.txt";
440            assertLinesMatch(
441                    List.of(Pattern.quote(request + " failed (attempt 1 of 2) due to ") + ".*"
442                            + Pattern.quote("Retrying immediately...")),
443                    capture.messages());
444        }
445    }
446
447    @ParameterizedTest
448    @ValueSource(ints = {0, 1})
449    protected void testRetryLogging_NoRetry(int retryCount) throws Exception {
450        assumeTrue(supportsRetryLogging());
451        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, retryCount);
452        newTransporter(httpServer.getHttpUrl());
453        httpServer.setServerErrorsBeforeWorks(1, retryCount == 0 ? 503 : 404);
454
455        try (RetryLogCapture capture = new RetryLogCapture(transporter.getClass())) {
456            assertThrows(
457                    HttpTransporterException.class, () -> transporter.peek(new PeekTask(URI.create("repo/file.txt"))));
458            assertEquals(List.of(), capture.messages());
459        }
460    }
461
462    @Test
463    protected void testRetryLogging_IntervalTooLong() throws Exception {
464        assumeTrue(supportsRetryLogging());
465        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
466        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL_MAX, 1);
467        newTransporter(httpServer.getHttpUrl());
468        httpServer.setServerErrorsBeforeWorks(1, 503, Map.of("Retry-After", "1"));
469
470        try (RetryLogCapture capture = new RetryLogCapture(transporter.getClass())) {
471            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
472            String request = "HEAD request to " + httpServer.getHttpUrl() + "/repo/file.txt";
473            assertEquals(
474                    List.of(
475                            request + " failed (attempt 1 of 2) due to status " + 503
476                                    + ". Retrying in 1 ms (due to Retry-After header with seconds value 1, capped at max value)..."),
477                    capture.messages());
478        }
479    }
480
481    @Test
482    protected void testRetryHandler_defaultCount_negative() throws Exception {
483        httpServer.setConnectionsToClose(4);
484        try {
485            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
486            fail("Expected error");
487        } catch (Exception expected) {
488        }
489    }
490
491    @Test
492    protected void testRetryHandler_tooManyRequests_explicitCount_positive() throws Exception {
493        // set low retry count as this involves back off delays
494        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
495        int retryIntervalMs = 500;
496        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, retryIntervalMs);
497        newTransporter(httpServer.getHttpUrl());
498        httpServer.setServerErrorsBeforeWorks(1, SC_TOO_MANY_REQUESTS);
499        long startTime = System.currentTimeMillis();
500        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
501        assertTrue(
502                System.currentTimeMillis() - startTime >= retryIntervalMs,
503                "Expected back off delay of at least " + retryIntervalMs);
504    }
505
506    @Test
507    protected void testRetryHandler_tooManyRequests_withRetryAfterHeader_explicitCount_positive() throws Exception {
508        // set low retry count as this involves back off delays
509        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
510        int retryIntervalMs = 5000;
511        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, retryIntervalMs);
512        newTransporter(httpServer.getHttpUrl());
513        Duration retryAfterDuration = Duration.ofSeconds(1);
514        httpServer.setServerErrorsBeforeWorks(
515                1, SC_TOO_MANY_REQUESTS, Map.of("Retry-After", String.valueOf(retryAfterDuration.getSeconds())));
516        long startTime = System.currentTimeMillis();
517        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
518        long elapsedTimeMs = System.currentTimeMillis() - startTime;
519        assertTrue(
520                elapsedTimeMs >= retryAfterDuration.toMillis(),
521                "Expected back off delay of at least " + retryAfterDuration.toMillis() + " ms");
522        assertTrue(elapsedTimeMs < retryIntervalMs, "Expected back off delay of less than " + retryIntervalMs + " ms");
523    }
524
525    @Test
526    protected void testRetryHandler_tooManyRequests_explicitCount_negative() throws Exception {
527        // set low retry count as this involves back off delays
528        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 3);
529        int retryIntervalMs = 100;
530        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, retryIntervalMs);
531        newTransporter(httpServer.getHttpUrl());
532        httpServer.setServerErrorsBeforeWorks(4, SC_TOO_MANY_REQUESTS);
533        long startTime = System.currentTimeMillis();
534        try {
535            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
536            fail("Expected error");
537        } catch (Exception expected) {
538        }
539        // linear backoff: 1x + 2x + 3x
540        long expectedMinimumDuration = retryIntervalMs * (1 + 2 + 3);
541        assertTrue(
542                System.currentTimeMillis() - startTime >= expectedMinimumDuration,
543                "Expected back off delay of at least " + expectedMinimumDuration);
544    }
545
546    @Test
547    protected void testRetryHandler_explicitCount_positive() throws Exception {
548        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 10);
549        newTransporter(httpServer.getHttpUrl());
550        httpServer.setConnectionsToClose(10);
551        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
552    }
553
554    @Test
555    protected void testRetryHandler_disabled() throws Exception {
556        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 0);
557        newTransporter(httpServer.getHttpUrl());
558        httpServer.setConnectionsToClose(1);
559        try {
560            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
561        } catch (Exception expected) {
562        }
563    }
564
565    @Test
566    protected void testPeek_NotFound() throws Exception {
567        try {
568            transporter.peek(new PeekTask(URI.create("repo/missing.txt")));
569            fail("Expected error");
570        } catch (HttpTransporterException e) {
571            assertEquals(404, e.getStatusCode());
572            assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(e));
573        }
574    }
575
576    @Test
577    protected void testPeek_Closed() throws Exception {
578        transporter.close();
579        try {
580            transporter.peek(new PeekTask(URI.create("repo/missing.txt")));
581            fail("Expected error");
582        } catch (IllegalStateException e) {
583            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
584        }
585    }
586
587    @Test
588    protected void testPeek_Authenticated() throws Exception {
589        httpServer.setAuthentication("testuser", "testpass");
590        auth = new AuthenticationBuilder()
591                .addUsername("testuser")
592                .addPassword("testpass")
593                .build();
594        newTransporter(httpServer.getHttpUrl());
595        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
596    }
597
598    @Test
599    protected void testPeek_Unauthenticated() throws Exception {
600        httpServer.setAuthentication("testuser", "testpass");
601        try {
602            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
603            fail("Expected error");
604        } catch (HttpTransporterException e) {
605            assertEquals(401, e.getStatusCode());
606            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
607        }
608    }
609
610    @Test
611    protected void testPeek_ProxyAuthenticated() throws Exception {
612        httpServer.setProxyAuthentication("testuser", "testpass");
613        auth = new AuthenticationBuilder()
614                .addUsername("testuser")
615                .addPassword("testpass")
616                .build();
617        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
618        newTransporter("http://bad.localhost:1/");
619        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
620    }
621
622    @Test
623    protected void testPeek_ProxyUnauthenticated() throws Exception {
624        httpServer.setProxyAuthentication("testuser", "testpass");
625        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
626        newTransporter("http://bad.localhost:1/");
627        try {
628            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
629            fail("Expected error");
630        } catch (HttpTransporterException e) {
631            assertEquals(407, e.getStatusCode());
632            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
633        }
634    }
635
636    @Test
637    protected void testPeek_SSL() throws Exception {
638        httpServer.addHttp2ConnectorWithMutualTLS();
639        newTransporter(httpServer.getHttpsUrl());
640        RecordingTransportListener listener = new RecordingTransportListener();
641        PeekTask task = new PeekTask(URI.create("repo/file.txt")).setListener(listener);
642        transporter.peek(task);
643        if (exposeContentCodingInTransportProperties()) {
644            assertEquals(
645                    HttpTransportProperty.SslProtocol.TLS_1_3,
646                    listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_PROTOCOL));
647        }
648    }
649
650    @Test
651    protected void testPeek_Redirect() throws Exception {
652        httpServer.addHttp2ConnectorWithMutualTLS();
653        transporter.peek(new PeekTask(URI.create("redirect/file.txt")));
654        transporter.peek(new PeekTask(URI.create("redirect/file.txt?scheme=https")));
655    }
656
657    @Test
658    protected void testGet_ToMemory() throws Exception {
659        RecordingTransportListener listener = new RecordingTransportListener();
660        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
661        transporter.get(task);
662        assertEquals("test", task.getDataString());
663        assertEquals(0L, listener.getDataOffset());
664        assertEquals(4L, listener.getDataLength());
665        assertEquals(1, listener.getStartedCount());
666        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
667        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
668    }
669
670    @Test
671    protected void testGet_ToFile() throws Exception {
672        File file = TestFileUtils.createTempFile("failure");
673        RecordingTransportListener listener = new RecordingTransportListener();
674        GetTask task = new GetTask(URI.create("repo/file.txt"))
675                .setDataPath(file.toPath())
676                .setListener(listener);
677        transporter.get(task);
678        assertEquals("test", TestFileUtils.readString(file));
679        assertEquals(0L, listener.getDataOffset());
680        assertEquals(4L, listener.getDataLength());
681        assertEquals(1, listener.getStartedCount());
682        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
683        assertEquals("test", listener.getBaos().toString(StandardCharsets.UTF_8));
684    }
685
686    @Test
687    protected void testGet_ToFileTimestamp() throws Exception {
688        File file = TestFileUtils.createTempFile("failure");
689        RecordingTransportListener listener = new RecordingTransportListener();
690        GetTask task = new GetTask(URI.create("repo/dir/oldFile.txt"))
691                .setDataPath(file.toPath())
692                .setListener(listener);
693        transporter.get(task);
694        assertEquals("oldTest", TestFileUtils.readString(file));
695        assertEquals(0L, listener.getDataOffset());
696        assertEquals(7L, listener.getDataLength());
697        assertEquals(1, listener.getStartedCount());
698        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
699        assertEquals("oldTest", listener.getBaos().toString(StandardCharsets.UTF_8));
700        assertEquals(OLD_FILE_TIMESTAMP, file.lastModified());
701    }
702
703    @Test
704    protected void testGet_AcceptsRfc9457() throws Exception {
705        GetTask task = new GetTask(URI.create("repo/file.txt"));
706        transporter.get(task);
707        String accept = httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept");
708        assertNotNull(accept, "Missing Accept header when retrieving artifact");
709        assertTrue(
710                accept.contains("application/problem+json"),
711                "Expected Accept header to contain application/problem+json, but was: " + accept);
712    }
713
714    @Test
715    protected void testGet_ParseRfc9457() throws Exception {
716        // use Maven Central (Cloudflare CDN) as endpoints that return RFC 9457 responses
717        newTransporter("https://repo.maven.apache.org");
718        try {
719            // https://blog.cloudflare.com/rfc-9457-agent-error-pages/#how-to-use-it
720            GetTask task = new GetTask(URI.create("cdn-cgi/error/1020"));
721            transporter.get(task);
722            fail("Should have throw HttpRFC9457Exception");
723        } catch (HttpRFC9457Exception e) {
724            // Expected exception, verify the content of the RFC 9457 message.
725            assertEquals(403, e.getStatusCode());
726            assertEquals("Error 1020: Access denied", e.getPayload().getTitle());
727            assertEquals(
728                    "The request was blocked by a Cloudflare firewall rule configured by the site owner.",
729                    e.getPayload().getDetail());
730        }
731    }
732
733    /**
734     * Provides compression algorithms supported by the transporter implementation.
735     * This should be the string value passed in the {@code Accept-Encoding} header.
736     *
737     * @return stream of supported compression algorithm names
738     * @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#directives">Accept-Encoding directives</a>
739     */
740    protected abstract Stream<String> supportedCompressionAlgorithms();
741
742    protected boolean exposeContentCodingInTransportProperties() {
743        return true;
744    }
745
746    @ParameterizedTest
747    // DEFLATE isn't supported by Jetty server (https://github.com/jetty/jetty.project/issues/280)
748    @ValueSource(strings = {"br", "gzip", "zstd"})
749    protected void testGet_WithCompression(String encoding) throws Exception {
750        assumeTrue(
751                supportedCompressionAlgorithms().anyMatch(supported -> supported.equals(encoding)),
752                () -> "Transporter does not support compression algorithm: " + encoding);
753        RecordingTransportListener listener = new RecordingTransportListener();
754        // requires a file with at least 48/50 bytes (otherwise compression is disabled,
755        // https://github.com/jetty/jetty.project/blob/2264d3d9f9586f3e5e9040fba779ed72e931cb46/jetty-core/jetty-compression/jetty-compression-brotli/src/main/java/org/eclipse/jetty/compression/brotli/BrotliCompression.java#L61)
756        GetTask task = new GetTask(URI.create(encoding + "/repo/compressible-file.xml")).setListener(listener);
757        transporter.get(task);
758        String acceptEncoding =
759                httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept-Encoding");
760        assertNotNull(acceptEncoding, "Missing Accept-Encoding header when retrieving pom");
761        assertTrue(acceptEncoding.contains(encoding));
762        // check original response header sent by server (client transparently handles compression and removes it)
763        // see https://issues.apache.org/jira/browse/HTTPCORE-792
764        // and https://github.com/mizosoft/methanol/issues/182
765        for (HttpServer.LogEntry log : httpServer.getLogEntries()) {
766            assertEquals(encoding, log.getResponseHeaders().get("Content-Encoding"));
767        }
768        String expectedResourceData;
769        try (InputStream is = getCompressibleFileStream()) {
770            expectedResourceData = new String(is.readAllBytes(), StandardCharsets.UTF_8);
771        }
772        assertEquals(expectedResourceData, task.getDataString());
773        assertEquals(0L, listener.getDataOffset());
774        // data length is unknown as chunked transfer encoding is used with compression
775        assertEquals(-1, listener.getDataLength());
776        assertEquals(1, listener.getStartedCount());
777        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
778        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
779        if (exposeContentCodingInTransportProperties()) {
780            assertEquals(encoding, listener.getTransportProperties().get(HttpTransportProperty.Key.CONTENT_CODING));
781        }
782    }
783
784    @Test
785    protected void testGet_EmptyResource() throws Exception {
786        File file = TestFileUtils.createTempFile("failure");
787        RecordingTransportListener listener = new RecordingTransportListener();
788        GetTask task = new GetTask(URI.create("repo/empty.txt"))
789                .setDataPath(file.toPath())
790                .setListener(listener);
791        transporter.get(task);
792        assertEquals("", TestFileUtils.readString(file));
793        assertEquals(0L, listener.getDataOffset());
794        assertEquals(0L, listener.getDataLength());
795        assertEquals(1, listener.getStartedCount());
796        assertEquals(0, listener.getProgressedCount());
797        assertEquals("", listener.getBaos().toString(StandardCharsets.UTF_8));
798    }
799
800    @Test
801    protected void testGet_EncodedResourcePath() throws Exception {
802        GetTask task = new GetTask(URI.create("repo/some%20space.txt"));
803        transporter.get(task);
804        assertEquals("space", task.getDataString());
805    }
806
807    @Test
808    protected void testGet_Authenticated() throws Exception {
809        httpServer.setAuthentication("testuser", "testpass");
810        auth = new AuthenticationBuilder()
811                .addUsername("testuser")
812                .addPassword("testpass")
813                .build();
814        newTransporter(httpServer.getHttpUrl());
815        RecordingTransportListener listener = new RecordingTransportListener();
816        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
817        transporter.get(task);
818        assertEquals("test", task.getDataString());
819        assertEquals(0L, listener.getDataOffset());
820        assertEquals(4L, listener.getDataLength());
821        assertEquals(1, listener.getStartedCount());
822        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
823        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
824    }
825
826    @Test
827    protected void testGet_Unauthenticated() throws Exception {
828        httpServer.setAuthentication("testuser", "testpass");
829        try {
830            transporter.get(new GetTask(URI.create("repo/file.txt")));
831            fail("Expected error");
832        } catch (HttpTransporterException e) {
833            assertEquals(401, e.getStatusCode());
834            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
835        }
836    }
837
838    @Test
839    protected void testGet_ProxyAuthenticated() throws Exception {
840        httpServer.setProxyAuthentication("testuser", "testpass");
841        Authentication auth = new AuthenticationBuilder()
842                .addUsername("testuser")
843                .addPassword("testpass")
844                .build();
845        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
846        newTransporter("http://bad.localhost:1/");
847        RecordingTransportListener listener = new RecordingTransportListener();
848        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
849        transporter.get(task);
850        assertEquals("test", task.getDataString());
851        assertEquals(0L, listener.getDataOffset());
852        assertEquals(4L, listener.getDataLength());
853        assertEquals(1, listener.getStartedCount());
854        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
855        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
856    }
857
858    @Test
859    protected void testGet_ProxyUnauthenticated() throws Exception {
860        httpServer.setProxyAuthentication("testuser", "testpass");
861        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
862        newTransporter("http://bad.localhost:1/");
863        try {
864            transporter.get(new GetTask(URI.create("repo/file.txt")));
865            fail("Expected error");
866        } catch (HttpTransporterException e) {
867            assertEquals(407, e.getStatusCode());
868            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
869        }
870    }
871
872    @Test
873    protected void testGet_RFC9457Response() throws Exception {
874        try {
875            transporter.get(new GetTask(URI.create("rfc9457/file.txt")));
876            fail("Expected error");
877        } catch (HttpRFC9457Exception e) {
878            assertEquals(403, e.getStatusCode());
879            assertEquals(e.getPayload().getType(), URI.create("https://example.com/probs/out-of-credit"));
880            assertEquals(403, e.getPayload().getStatus());
881            assertEquals("You do not have enough credit.", e.getPayload().getTitle());
882            assertEquals(
883                    "Your current balance is 30, but that costs 50.",
884                    e.getPayload().getDetail());
885            assertEquals(URI.create("/account/12345/msgs/abc"), e.getPayload().getInstance());
886        }
887    }
888
889    @Test
890    protected void testGet_RFC9457Response_with_missing_fields() throws Exception {
891        try {
892            transporter.get(new GetTask(URI.create("rfc9457/missing_fields.txt")));
893            fail("Expected error");
894        } catch (HttpRFC9457Exception e) {
895            assertEquals(403, e.getStatusCode());
896            assertEquals(e.getPayload().getType(), URI.create("about:blank"));
897            assertNull(e.getPayload().getStatus());
898            assertNull(e.getPayload().getTitle());
899            assertNull(e.getPayload().getDetail());
900            assertNull(e.getPayload().getInstance());
901        }
902    }
903
904    @Test
905    protected void testGet_SSL() throws Exception {
906        httpServer.addHttp2ConnectorWithMutualTLS();
907        newTransporter(httpServer.getHttpsUrl());
908        RecordingTransportListener listener = new RecordingTransportListener();
909        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
910        transporter.get(task);
911        assertEquals("test", task.getDataString());
912        assertEquals(0L, listener.getDataOffset());
913        assertEquals(4L, listener.getDataLength());
914        assertEquals(1, listener.getStartedCount());
915        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
916        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
917        if (exposeContentCodingInTransportProperties()) {
918            assertEquals(
919                    HttpTransportProperty.SslProtocol.TLS_1_3,
920                    listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_PROTOCOL));
921            assertEquals(
922                    "TLS_AES_256_GCM_SHA384",
923                    listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_CIPHER_SUITE));
924        }
925    }
926
927    @Test
928    protected void testGet_SSL_WithServerErrors() throws Exception {
929        httpServer.setServerErrorsBeforeWorks(1);
930        httpServer.addHttp2ConnectorWithMutualTLS();
931        newTransporter(httpServer.getHttpsUrl());
932        for (int i = 1; i < 3; i++) {
933            try {
934                RecordingTransportListener listener = new RecordingTransportListener();
935                GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
936                transporter.get(task);
937                assertEquals("test", task.getDataString());
938                assertEquals(0L, listener.getDataOffset());
939                assertEquals(4L, listener.getDataLength());
940                assertEquals(1, listener.getStartedCount());
941                assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
942                assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
943            } catch (HttpTransporterException e) {
944                assertEquals(500, e.getStatusCode());
945            }
946        }
947    }
948
949    @Test
950    protected void testGet_HTTPS_Unknown_SecurityMode() throws Exception {
951        session.setConfigProperty(ConfigurationProperties.HTTPS_SECURITY_MODE, "unknown");
952        httpServer.addHttp2Connector();
953        try {
954            newTransporter(httpServer.getHttpsUrl());
955            fail("Unsupported security mode");
956        } catch (IllegalArgumentException a) {
957            // good
958        }
959    }
960
961    @Test
962    protected void testGet_HTTPS_Insecure_SecurityMode() throws Exception {
963        // we have to reset the default ssl context to avoid the default truststore being used, which would make the
964        // test pass even if the security mode is not set to insecure
965        SSLContext.setDefault(defaultSslContext);
966        try {
967            session.setConfigProperty(
968                    ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE);
969            httpServer.addHttp2Connector();
970            newTransporter(httpServer.getHttpsUrl());
971            RecordingTransportListener listener = new RecordingTransportListener();
972            GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
973            transporter.get(task);
974            assertEquals("test", task.getDataString());
975            assertEquals(0L, listener.getDataOffset());
976            assertEquals(4L, listener.getDataLength());
977            assertEquals(1, listener.getStartedCount());
978            assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
979            assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
980        } finally {
981            // restore the default SSL context used for all other tests
982            SSLContext.setDefault(createClientSSLContext());
983        }
984    }
985
986    @Test
987    protected void testGet_HTTPS_HTTP2Only_Insecure_SecurityMode() throws Exception {
988        assumeTrue(supportsHttp2(), "Transporter does not support HTTP/2");
989        // we have to reset the default ssl context to avoid the default truststore being used, which would make the
990        // test pass even if the security mode is not set to insecure
991        SSLContext.setDefault(defaultSslContext);
992        try {
993            session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_2);
994            session.setConfigProperty(
995                    ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE);
996            httpServer.addHttp2OnlyConnector();
997            newTransporter(httpServer.getHttpsUrl());
998            RecordingTransportListener listener = new RecordingTransportListener();
999            GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1000            transporter.get(task);
1001            assertEquals("test", task.getDataString());
1002            assertEquals(0L, listener.getDataOffset());
1003            assertEquals(4L, listener.getDataLength());
1004            assertEquals(1, listener.getStartedCount());
1005            assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1006            assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
1007            httpServer.getLogEntries().forEach(log -> {
1008                assertEquals(HttpVersion.HTTP_2, log.getVersion());
1009            });
1010        } finally {
1011            // restore the default SSL context used for all other tests
1012            SSLContext.setDefault(createClientSSLContext());
1013        }
1014    }
1015
1016    @Test
1017    protected void testGet_HTTP3Only() throws Exception {
1018        assumeTrue(supportsHttp3(), "Transporter does not support HTTP/3");
1019        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3);
1020        httpServer.addHttp3Connector(false);
1021        httpServer.start();
1022        newTransporter(httpServer.getHttp3Url());
1023        RecordingTransportListener listener = new RecordingTransportListener();
1024        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1025        transporter.get(task);
1026        assertEquals("test", task.getDataString());
1027        assertEquals(0L, listener.getDataOffset());
1028        assertEquals(4L, listener.getDataLength());
1029        assertEquals(1, listener.getStartedCount());
1030        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1031        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
1032        httpServer.getLogEntries().forEach(log -> {
1033            assertEquals(HttpVersion.HTTP_3, log.getVersion());
1034        });
1035    }
1036
1037    @Test
1038    protected void testGet_HTTP3() throws Exception {
1039        assumeTrue(supportsHttp3(), "Transporter does not support HTTP/3");
1040        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3);
1041        // both HTTP2 and HTTP3 endpoints are available at the same port
1042        // reserve a port free for both TCP and UDP, as the same-numbered UDP port of an OS-assigned TCP port may be
1043        // taken (https://github.com/apache/maven-resolver/issues/2036)
1044        int port = httpServer.findFreeTcpAndUdpPort();
1045        httpServer.addHttp2OnlyConnectorWithMutualTLS(port);
1046        httpServer.addHttp3Connector(false, port);
1047        // alt-svc header should point to http/3
1048        httpServer.start();
1049        newTransporter(httpServer.getHttp3Url());
1050        RecordingTransportListener listener = new RecordingTransportListener();
1051        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1052        // issue 2 requests to ensure that the second request is HTTP/3 (the first one may be HTTP/2 if the TCP
1053        // connection is faster)
1054        transporter.get(task);
1055        transporter.get(task);
1056        assertEquals("test", task.getDataString());
1057        assertEquals(0L, listener.getDataOffset());
1058        assertEquals(4L, listener.getDataLength());
1059        assertEquals(2, listener.getStartedCount());
1060        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1061        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
1062        // the last request should be HTTP/3 finally
1063        assertEquals(
1064                HttpVersion.HTTP_3,
1065                httpServer
1066                        .getLogEntries()
1067                        .get(httpServer.getLogEntries().size() - 1)
1068                        .getVersion());
1069    }
1070
1071    @Test
1072    protected void testGet_HTTP3FallbackToHTTP2() throws Exception {
1073        assumeTrue(supportsHttp3(), "Transporter does not support HTTP/3");
1074        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3);
1075        httpServer.addHttp2OnlyConnectorWithMutualTLS();
1076        httpServer.start();
1077        newTransporter(httpServer.getHttpsUrl());
1078        RecordingTransportListener listener = new RecordingTransportListener();
1079        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1080        transporter.get(task);
1081        assertEquals("test", task.getDataString());
1082        assertEquals(0L, listener.getDataOffset());
1083        assertEquals(4L, listener.getDataLength());
1084        assertEquals(1, listener.getStartedCount());
1085        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1086        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
1087        assertEquals(
1088                HttpTransportProperty.HttpVersion.HTTP_2,
1089                listener.getTransportProperties().get(HttpTransportProperty.Key.HTTP_VERSION));
1090        httpServer.getLogEntries().forEach(log -> {
1091            assertEquals(HttpVersion.HTTP_2, log.getVersion());
1092        });
1093    }
1094
1095    @Test
1096    protected void testGet_Redirect() throws Exception {
1097        httpServer.addHttp2ConnectorWithMutualTLS();
1098        RecordingTransportListener listener = new RecordingTransportListener();
1099        GetTask task = new GetTask(URI.create("redirect/file.txt?scheme=https")).setListener(listener);
1100        transporter.get(task);
1101        assertEquals("test", task.getDataString());
1102        assertEquals(0L, listener.getDataOffset());
1103        assertEquals(4L, listener.getDataLength());
1104        assertEquals(1, listener.getStartedCount());
1105        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1106        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
1107    }
1108
1109    @Test
1110    protected void testGet_Redirect_ModeNone() throws Exception {
1111        session.setConfigProperty(
1112                ConfigurationProperties.HTTP_REDIRECT_MODE + ".test", ConfigurationProperties.RedirectMode.NONE);
1113        newTransporter(httpServer.getHttpUrl());
1114
1115        assertThrows(Exception.class, () -> transporter.get(new GetTask(URI.create("redirect/file.txt"))));
1116        assertEquals(1, httpServer.getLogEntries().size());
1117    }
1118
1119    @Test
1120    protected void testGet_Redirect_ModeSameAuthority() throws Exception {
1121        session.setConfigProperty(
1122                ConfigurationProperties.HTTP_REDIRECT_MODE + ".test",
1123                ConfigurationProperties.RedirectMode.SAME_AUTHORITY);
1124        newTransporter(httpServer.getHttpUrl());
1125
1126        GetTask task = new GetTask(URI.create("redirect/file.txt"));
1127        transporter.get(task);
1128
1129        assertEquals("test", task.getDataString());
1130        assertEquals(2, httpServer.getLogEntries().size());
1131    }
1132
1133    @Test
1134    protected void testGet_Redirect_ModeSameAuthorityRefusesDifferentAuthority() throws Exception {
1135        session.setConfigProperty(
1136                ConfigurationProperties.HTTP_REDIRECT_MODE + ".test",
1137                ConfigurationProperties.RedirectMode.SAME_AUTHORITY);
1138        newTransporter(httpServer.getHttpUrl());
1139
1140        assertThrows(
1141                Exception.class, () -> transporter.get(new GetTask(URI.create("redirect/file.txt?host=127.0.0.1"))));
1142        assertEquals(1, httpServer.getLogEntries().size());
1143    }
1144
1145    @Test
1146    protected void testGet_Redirect_ModeAnyAllowsDifferentAuthority() throws Exception {
1147        session.setConfigProperty(ConfigurationProperties.HTTP_REDIRECT_MODE, ConfigurationProperties.RedirectMode.ANY);
1148        newTransporter(httpServer.getHttpUrl());
1149
1150        GetTask task = new GetTask(URI.create("redirect/file.txt?host=127.0.0.1"));
1151        transporter.get(task);
1152
1153        assertEquals("test", task.getDataString());
1154        assertEquals(2, httpServer.getLogEntries().size());
1155    }
1156
1157    @Test
1158    protected void testGet_Redirect_OriginScopedHeadersByDefault() throws Exception {
1159        Map<String, String> headers = Map.of("X-Secret", "secret");
1160        session.setConfigProperty(ConfigurationProperties.HTTP_HEADERS + ".test", headers);
1161        newTransporter(httpServer.getHttpUrl());
1162
1163        transporter.get(new GetTask(URI.create("redirect/file.txt?host=127.0.0.1")));
1164
1165        assertEquals(2, httpServer.getLogEntries().size());
1166        assertEquals(
1167                "secret", httpServer.getLogEntries().get(0).getRequestHeaders().get("X-Secret"));
1168        assertNull(httpServer.getLogEntries().get(1).getRequestHeaders().get("X-Secret"));
1169    }
1170
1171    @Test
1172    protected void testGet_Redirect_OriginScopedHeadersDisabled() throws Exception {
1173        Map<String, String> headers = Map.of("X-Secret", "secret");
1174        session.setConfigProperty(ConfigurationProperties.HTTP_HEADERS + ".test", headers);
1175        session.setConfigProperty(ConfigurationProperties.HTTP_ORIGIN_SCOPED_HEADERS, false);
1176        newTransporter(httpServer.getHttpUrl());
1177
1178        transporter.get(new GetTask(URI.create("redirect/file.txt?host=127.0.0.1")));
1179
1180        assertEquals(2, httpServer.getLogEntries().size());
1181        assertEquals(
1182                "secret", httpServer.getLogEntries().get(0).getRequestHeaders().get("X-Secret"));
1183        assertEquals(
1184                "secret", httpServer.getLogEntries().get(1).getRequestHeaders().get("X-Secret"));
1185    }
1186
1187    @Test
1188    protected void testGet_Redirect_OriginScopedAuthenticationNotReplayed() throws Exception {
1189        httpServer.setAuthentication("testuser", "testpass");
1190        auth = new AuthenticationBuilder()
1191                .addUsername("testuser")
1192                .addPassword("testpass")
1193                .build();
1194        newTransporter(httpServer.getHttpUrl());
1195
1196        assertThrows(
1197                Exception.class, () -> transporter.get(new GetTask(URI.create("redirect/file.txt?host=127.0.0.1"))));
1198
1199        HttpServer.LogEntry redirectTarget =
1200                httpServer.getLogEntries().get(httpServer.getLogEntries().size() - 1);
1201        assertTrue(redirectTarget.getPath().startsWith("/repo/file.txt"));
1202        assertNull(redirectTarget.getRequestHeaders().get("Authorization"));
1203    }
1204
1205    @Test
1206    protected void testGet_Redirect_InsecureRedirectRefusedByDefault() throws Exception {
1207        httpServer.addHttp2ConnectorWithMutualTLS();
1208        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_1_1);
1209        newTransporter(httpServer.getHttpsUrl());
1210
1211        assertThrows(Exception.class, () -> transporter.get(new GetTask(URI.create("redirect/file.txt?scheme=http"))));
1212
1213        assertEquals(1, httpServer.getLogEntries().size());
1214    }
1215
1216    @Test
1217    protected void testGet_Redirect_InsecureRedirectAllowedWhenConfigured() throws Exception {
1218        httpServer.addHttp2ConnectorWithMutualTLS();
1219        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_1_1);
1220        session.setConfigProperty(ConfigurationProperties.HTTP_FOLLOW_INSECURE_REDIRECT, true);
1221        newTransporter(httpServer.getHttpsUrl());
1222
1223        GetTask task = new GetTask(URI.create("redirect/file.txt?scheme=http"));
1224        transporter.get(task);
1225
1226        assertEquals("test", task.getDataString());
1227        assertEquals(2, httpServer.getLogEntries().size());
1228    }
1229
1230    @Test
1231    protected void testGet_Resume() throws Exception {
1232        File file = TestFileUtils.createTempFile("re");
1233        RecordingTransportListener listener = new RecordingTransportListener();
1234        GetTask task = new GetTask(URI.create("repo/resume.txt"))
1235                .setDataPath(file.toPath(), true)
1236                .setListener(listener);
1237        transporter.get(task);
1238        assertEquals("resumable", TestFileUtils.readString(file));
1239        assertEquals(1L, listener.getStartedCount());
1240        assertEquals(2L, listener.getDataOffset());
1241        assertEquals(9, listener.getDataLength());
1242        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1243        assertEquals("sumable", listener.getBaos().toString(StandardCharsets.UTF_8));
1244    }
1245
1246    @Test
1247    protected void testGet_ResumeLocalContentsOutdated() throws Exception {
1248        File file = TestFileUtils.createTempFile("re");
1249        file.setLastModified(System.currentTimeMillis() - 5 * 60 * 1000);
1250        RecordingTransportListener listener = new RecordingTransportListener();
1251        GetTask task = new GetTask(URI.create("repo/resume.txt"))
1252                .setDataPath(file.toPath(), true)
1253                .setListener(listener);
1254        transporter.get(task);
1255        assertEquals("resumable", TestFileUtils.readString(file));
1256        assertEquals(1L, listener.getStartedCount());
1257        assertEquals(0L, listener.getDataOffset());
1258        assertEquals(9, listener.getDataLength());
1259        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1260        assertEquals("resumable", listener.getBaos().toString(StandardCharsets.UTF_8));
1261    }
1262
1263    @Test
1264    protected void testGet_ResumeRangesNotSupportedByServer() throws Exception {
1265        httpServer.setRangeSupport(false);
1266        File file = TestFileUtils.createTempFile("re");
1267        RecordingTransportListener listener = new RecordingTransportListener();
1268        GetTask task = new GetTask(URI.create("repo/resume.txt"))
1269                .setDataPath(file.toPath(), true)
1270                .setListener(listener);
1271        transporter.get(task);
1272        assertEquals("resumable", TestFileUtils.readString(file));
1273        assertEquals(1L, listener.getStartedCount());
1274        assertEquals(0L, listener.getDataOffset());
1275        assertEquals(9, listener.getDataLength());
1276        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1277        assertEquals("resumable", listener.getBaos().toString(StandardCharsets.UTF_8));
1278    }
1279
1280    @Test
1281    protected void testGet_Checksums_Nexus() throws Exception {
1282        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.NEXUS);
1283        GetTask task = new GetTask(URI.create("repo/file.txt"));
1284        transporter.get(task);
1285        assertEquals("test", task.getDataString());
1286        assertEquals(
1287                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1288    }
1289
1290    @Test
1291    protected void testGet_Checksums_XChecksum() throws Exception {
1292        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.XCHECKSUM);
1293        GetTask task = new GetTask(URI.create("repo/file.txt"));
1294        transporter.get(task);
1295        assertEquals("test", task.getDataString());
1296        assertEquals(
1297                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1298    }
1299
1300    @Test
1301    protected void testGet_Checksums_XChecksum_Google() throws Exception {
1302        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.XCHECKSUM_GOOGLE);
1303        GetTask task = new GetTask(URI.create("repo/file.txt"));
1304        transporter.get(task);
1305        assertEquals("test", task.getDataString());
1306        assertEquals(
1307                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1308    }
1309
1310    @Test
1311    protected void testGet_Checksums_XChecksum_Amazon() throws Exception {
1312        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.XCHECKSUM_AMAZON);
1313        GetTask task = new GetTask(URI.create("repo/file.txt"));
1314        transporter.get(task);
1315        assertEquals("test", task.getDataString());
1316        assertEquals(
1317                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1318    }
1319
1320    @Test
1321    protected void testGet_FileHandleLeak() throws Exception {
1322        for (int i = 0; i < 100; i++) {
1323            File file = TestFileUtils.createTempFile("failure");
1324            transporter.get(new GetTask(URI.create("repo/file.txt")).setDataPath(file.toPath()));
1325            assertTrue(file.delete(), i + ", " + file.getAbsolutePath());
1326        }
1327    }
1328
1329    @Test
1330    protected void testGet_NotFound() throws Exception {
1331        try {
1332            transporter.get(new GetTask(URI.create("repo/missing.txt")));
1333            fail("Expected error");
1334        } catch (HttpTransporterException e) {
1335            assertEquals(404, e.getStatusCode());
1336            assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(e));
1337        }
1338    }
1339
1340    @Test
1341    protected void testGet_Closed() throws Exception {
1342        transporter.close();
1343        try {
1344            transporter.get(new GetTask(URI.create("repo/file.txt")));
1345            fail("Expected error");
1346        } catch (IllegalStateException e) {
1347            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1348        }
1349    }
1350
1351    @Test
1352    protected void testGet_StartCancelled() throws Exception {
1353        RecordingTransportListener listener = new RecordingTransportListener();
1354        listener.cancelStart();
1355        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1356        try {
1357            transporter.get(task);
1358            fail("Expected error");
1359        } catch (TransferCancelledException e) {
1360            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1361        }
1362        assertEquals(0L, listener.getDataOffset());
1363        assertEquals(4L, listener.getDataLength());
1364        assertEquals(1, listener.getStartedCount());
1365        assertEquals(0, listener.getProgressedCount());
1366    }
1367
1368    @Test
1369    protected void testGet_ProgressCancelled() throws Exception {
1370        RecordingTransportListener listener = new RecordingTransportListener();
1371        listener.cancelProgress();
1372        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1373        try {
1374            transporter.get(task);
1375            fail("Expected error");
1376        } catch (TransferCancelledException e) {
1377            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1378        }
1379        assertEquals(0L, listener.getDataOffset());
1380        assertEquals(4L, listener.getDataLength());
1381        assertEquals(1, listener.getStartedCount());
1382        assertEquals(1, listener.getProgressedCount());
1383    }
1384
1385    @Test
1386    protected void testPut_FromMemory() throws Exception {
1387        RecordingTransportListener listener = new RecordingTransportListener();
1388        String payload = "upload";
1389        PutTask task =
1390                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString(payload);
1391        transporter.put(task);
1392        assertEquals(0L, listener.getDataOffset());
1393        assertEquals(6L, listener.getDataLength());
1394        assertEquals(1, listener.getStartedCount());
1395        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1396        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1397        assertEquals(
1398                String.valueOf(payload.getBytes(StandardCharsets.UTF_8).length),
1399                httpServer.getLogEntries().get(0).getRequestHeaders().get("Content-Length"));
1400    }
1401
1402    @Test
1403    protected void testPut_AcceptsRfc9457() throws Exception {
1404        String payload = "upload";
1405        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString(payload);
1406        transporter.put(task);
1407        String accept = httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept");
1408        assertNotNull(accept, "Missing Accept header when retrieving artifact");
1409        assertTrue(
1410                accept.contains("application/problem+json"),
1411                "Expected Accept header to contain application/problem+json, but was: " + accept);
1412    }
1413
1414    @Test
1415    protected void testPut_FromFile() throws Exception {
1416        File file = TestFileUtils.createTempFile("upload");
1417        RecordingTransportListener listener = new RecordingTransportListener();
1418        PutTask task =
1419                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataPath(file.toPath());
1420        transporter.put(task);
1421        assertEquals(0L, listener.getDataOffset());
1422        assertEquals(6L, listener.getDataLength());
1423        assertEquals(1, listener.getStartedCount());
1424        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1425        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1426        assertEquals(
1427                String.valueOf(file.length()),
1428                httpServer.getLogEntries().get(0).getRequestHeaders().get("Content-Length"));
1429    }
1430
1431    @Test
1432    protected void testPut_EmptyResource() throws Exception {
1433        RecordingTransportListener listener = new RecordingTransportListener();
1434        PutTask task = new PutTask(URI.create("repo/file.txt")).setListener(listener);
1435        transporter.put(task);
1436        assertEquals(0L, listener.getDataOffset());
1437        assertEquals(0L, listener.getDataLength());
1438        // some transports may skip the upload for empty resources
1439        assertTrue(
1440                listener.getStartedCount() <= 1,
1441                "The transport should be started at most once but was started " + listener.getStartedCount()
1442                        + " times");
1443        assertEquals(0, listener.getProgressedCount());
1444        assertEquals("", TestFileUtils.readString(new File(repoDir, "file.txt")));
1445    }
1446
1447    @Test
1448    protected void testPut_EncodedResourcePath() throws Exception {
1449        RecordingTransportListener listener = new RecordingTransportListener();
1450        PutTask task = new PutTask(URI.create("repo/some%20space.txt"))
1451                .setListener(listener)
1452                .setDataString("OK");
1453        transporter.put(task);
1454        assertEquals(0L, listener.getDataOffset());
1455        assertEquals(2L, listener.getDataLength());
1456        assertEquals(1, listener.getStartedCount());
1457        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1458        assertEquals("OK", TestFileUtils.readString(new File(repoDir, "some space.txt")));
1459    }
1460
1461    @Test
1462    protected void testPut_Authenticated_ExpectContinue() throws Exception {
1463        httpServer.setAuthentication("testuser", "testpass");
1464        auth = new AuthenticationBuilder()
1465                .addUsername("testuser")
1466                .addPassword("testpass")
1467                .build();
1468        newTransporter(httpServer.getHttpUrl());
1469        RecordingTransportListener listener = new RecordingTransportListener();
1470        PutTask task =
1471                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1472        transporter.put(task);
1473        assertEquals(0L, listener.getDataOffset());
1474        assertEquals(6L, listener.getDataLength());
1475        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1476        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1477        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1478    }
1479
1480    @Test
1481    protected void testPut_Authenticated_ExpectContinueBroken() throws Exception {
1482        // this makes OPTIONS recover, and have only 1 PUT (startedCount=1 as OPTIONS is not counted)
1483        session.setConfigProperty(ConfigurationProperties.HTTP_SUPPORT_WEBDAV, true);
1484        httpServer.setAuthentication("testuser", "testpass");
1485        httpServer.setExpectSupport(HttpServer.ExpectContinue.BROKEN);
1486        auth = new AuthenticationBuilder()
1487                .addUsername("testuser")
1488                .addPassword("testpass")
1489                .build();
1490        newTransporter(httpServer.getHttpUrl());
1491        RecordingTransportListener listener = new RecordingTransportListener();
1492        PutTask task =
1493                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1494        transporter.put(task);
1495        assertEquals(0L, listener.getDataOffset());
1496        assertEquals(6L, listener.getDataLength());
1497        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1498        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1499        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1500    }
1501
1502    @Test
1503    protected void testPut_Authenticated_ExpectContinueRejected() throws Exception {
1504        httpServer.setAuthentication("testuser", "testpass");
1505        httpServer.setExpectSupport(HttpServer.ExpectContinue.FAIL);
1506        auth = new AuthenticationBuilder()
1507                .addUsername("testuser")
1508                .addPassword("testpass")
1509                .build();
1510        newTransporter(httpServer.getHttpUrl());
1511        RecordingTransportListener listener = new RecordingTransportListener();
1512        PutTask task =
1513                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1514        transporter.put(task);
1515        assertEquals(0L, listener.getDataOffset());
1516        assertEquals(6L, listener.getDataLength());
1517        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1518        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1519        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1520    }
1521
1522    @Test
1523    protected void testPut_Authenticated_ExpectContinueDisabled() throws Exception {
1524        session.setConfigProperty(ConfigurationProperties.HTTP_EXPECT_CONTINUE, false);
1525        httpServer.setAuthentication("testuser", "testpass");
1526        httpServer.setExpectSupport(HttpServer.ExpectContinue.FAIL); // if transport tries Expect/Continue explode
1527        auth = new AuthenticationBuilder()
1528                .addUsername("testuser")
1529                .addPassword("testpass")
1530                .build();
1531        newTransporter(httpServer.getHttpUrl());
1532        RecordingTransportListener listener = new RecordingTransportListener();
1533        PutTask task =
1534                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1535        transporter.put(task);
1536        assertEquals(0L, listener.getDataOffset());
1537        assertEquals(6L, listener.getDataLength());
1538        assertEquals(
1539                supportsPreemptiveAuth() ? 1 : 2,
1540                listener.getStartedCount()); // w/ expectContinue enabled would have here 2/3
1541        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1542        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1543    }
1544
1545    @Test
1546    protected void testPut_Authenticated_ExpectContinueRejected_ExplicitlyConfiguredHeader() throws Exception {
1547        Map<String, String> headers = new HashMap<>();
1548        headers.put("Expect", "100-continue");
1549        session.setConfigProperty(ConfigurationProperties.HTTP_HEADERS + ".test", headers);
1550        httpServer.setAuthentication("testuser", "testpass");
1551        httpServer.setExpectSupport(HttpServer.ExpectContinue.FAIL);
1552        auth = new AuthenticationBuilder()
1553                .addUsername("testuser")
1554                .addPassword("testpass")
1555                .build();
1556        newTransporter(httpServer.getHttpUrl());
1557        RecordingTransportListener listener = new RecordingTransportListener();
1558        PutTask task =
1559                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1560        transporter.put(task);
1561        assertEquals(0L, listener.getDataOffset());
1562        assertEquals(6L, listener.getDataLength());
1563        assertEquals(1, listener.getStartedCount());
1564        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1565        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1566    }
1567
1568    @Test
1569    protected void testPut_Unauthenticated() throws Exception {
1570        httpServer.setAuthentication("testuser", "testpass");
1571        RecordingTransportListener listener = new RecordingTransportListener();
1572        PutTask task =
1573                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1574        try {
1575            transporter.put(task);
1576            fail("Expected error");
1577        } catch (HttpTransporterException e) {
1578            assertEquals(401, e.getStatusCode());
1579            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1580        }
1581        assertEquals(0, listener.getStartedCount());
1582        assertEquals(0, listener.getProgressedCount());
1583    }
1584
1585    @Test
1586    protected void testPut_ProxyAuthenticated() throws Exception {
1587        httpServer.setProxyAuthentication("testuser", "testpass");
1588        Authentication auth = new AuthenticationBuilder()
1589                .addUsername("testuser")
1590                .addPassword("testpass")
1591                .build();
1592        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
1593        newTransporter("http://bad.localhost:1/");
1594        RecordingTransportListener listener = new RecordingTransportListener();
1595        PutTask task =
1596                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1597        transporter.put(task);
1598        assertEquals(0L, listener.getDataOffset());
1599        assertEquals(6L, listener.getDataLength());
1600        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1601        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1602        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1603    }
1604
1605    @Test
1606    protected void testPut_ProxyUnauthenticated() throws Exception {
1607        httpServer.setProxyAuthentication("testuser", "testpass");
1608        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
1609        newTransporter("http://bad.localhost:1/");
1610        RecordingTransportListener listener = new RecordingTransportListener();
1611        PutTask task =
1612                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1613        try {
1614            transporter.put(task);
1615            fail("Expected error");
1616        } catch (HttpTransporterException e) {
1617            assertEquals(407, e.getStatusCode());
1618            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1619        }
1620        assertEquals(0, listener.getStartedCount());
1621        assertEquals(0, listener.getProgressedCount());
1622    }
1623
1624    @Test
1625    protected void testPut_SSL() throws Exception {
1626        httpServer.addHttp2ConnectorWithMutualTLS();
1627        httpServer.setAuthentication("testuser", "testpass");
1628        auth = new AuthenticationBuilder()
1629                .addUsername("testuser")
1630                .addPassword("testpass")
1631                .build();
1632        newTransporter(httpServer.getHttpsUrl());
1633        RecordingTransportListener listener = new RecordingTransportListener();
1634        PutTask task =
1635                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1636        transporter.put(task);
1637        assertEquals(0L, listener.getDataOffset());
1638        assertEquals(6L, listener.getDataLength());
1639        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1640        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1641        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1642        assertEquals(
1643                HttpTransportProperty.SslProtocol.TLS_1_3,
1644                listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_PROTOCOL));
1645    }
1646
1647    @Test
1648    protected void testPut_FileHandleLeak() throws Exception {
1649        for (int i = 0; i < 100; i++) {
1650            File src = TestFileUtils.createTempFile("upload");
1651            File dst = new File(repoDir, "file.txt");
1652            transporter.put(new PutTask(URI.create("repo/file.txt")).setDataPath(src.toPath()));
1653            assertTrue(src.delete(), i + ", " + src.getAbsolutePath());
1654            assertTrue(dst.delete(), i + ", " + dst.getAbsolutePath());
1655        }
1656    }
1657
1658    @Test
1659    protected void testPut_Closed() throws Exception {
1660        transporter.close();
1661        try {
1662            transporter.put(new PutTask(URI.create("repo/missing.txt")));
1663            fail("Expected error");
1664        } catch (IllegalStateException e) {
1665            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1666        }
1667    }
1668
1669    @Test
1670    protected void testPut_StartCancelled() throws Exception {
1671        RecordingTransportListener listener = new RecordingTransportListener();
1672        listener.cancelStart();
1673        PutTask task =
1674                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1675        try {
1676            transporter.put(task);
1677            fail("Expected error");
1678        } catch (TransferCancelledException e) {
1679            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1680        }
1681        assertEquals(0L, listener.getDataOffset());
1682        assertEquals(6L, listener.getDataLength());
1683        assertEquals(1, listener.getStartedCount());
1684        assertEquals(0, listener.getProgressedCount());
1685    }
1686
1687    @Test
1688    protected void testPut_ProgressCancelled() throws Exception {
1689        RecordingTransportListener listener = new RecordingTransportListener();
1690        listener.cancelProgress();
1691        PutTask task =
1692                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1693        try {
1694            transporter.put(task);
1695            fail("Expected error");
1696        } catch (TransferCancelledException e) {
1697            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1698        }
1699        assertEquals(0L, listener.getDataOffset());
1700        assertEquals(6L, listener.getDataLength());
1701        assertEquals(1, listener.getStartedCount());
1702        assertEquals(1, listener.getProgressedCount());
1703    }
1704
1705    @Test
1706    protected void testGetPut_AuthCache() throws Exception {
1707        httpServer.setAuthentication("testuser", "testpass");
1708        auth = new AuthenticationBuilder()
1709                .addUsername("testuser")
1710                .addPassword("testpass")
1711                .build();
1712        newTransporter(httpServer.getHttpUrl());
1713        GetTask get = new GetTask(URI.create("repo/file.txt"));
1714        transporter.get(get);
1715        RecordingTransportListener listener = new RecordingTransportListener();
1716        PutTask task =
1717                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1718        transporter.put(task);
1719        assertEquals(1, listener.getStartedCount());
1720    }
1721
1722    @Test
1723    protected void testPut_PreemptiveIsDefault() throws Exception {
1724        httpServer.setAuthentication("testuser", "testpass");
1725        auth = new AuthenticationBuilder()
1726                .addUsername("testuser")
1727                .addPassword("testpass")
1728                .build();
1729        newTransporter(httpServer.getHttpUrl());
1730        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1731        transporter.put(task);
1732        assertEquals(
1733                supportsPreemptiveAuth() ? 1 : 2, httpServer.getLogEntries().size()); // put w/ auth
1734    }
1735
1736    @Test
1737    protected void testPut_AuthCache() throws Exception {
1738        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH, false);
1739        httpServer.setAuthentication("testuser", "testpass");
1740        auth = new AuthenticationBuilder()
1741                .addUsername("testuser")
1742                .addPassword("testpass")
1743                .build();
1744        newTransporter(httpServer.getHttpUrl());
1745        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1746        transporter.put(task);
1747        assertEquals(2, httpServer.getLogEntries().size()); // put (challenged) + put w/ auth
1748        httpServer.getLogEntries().clear();
1749        task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1750        transporter.put(task);
1751        assertEquals(1, httpServer.getLogEntries().size()); // put w/ auth
1752    }
1753
1754    @Test
1755    protected void testPut_AuthCache_Preemptive() throws Exception {
1756        httpServer.setAuthentication("testuser", "testpass");
1757        auth = new AuthenticationBuilder()
1758                .addUsername("testuser")
1759                .addPassword("testpass")
1760                .build();
1761        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_AUTH, true);
1762        newTransporter(httpServer.getHttpUrl());
1763        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1764        transporter.put(task);
1765        assertEquals(
1766                supportsPreemptiveAuth() ? 1 : 2, httpServer.getLogEntries().size()); // put w/ auth
1767        httpServer.getLogEntries().clear();
1768        task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1769        transporter.put(task);
1770        assertEquals(1, httpServer.getLogEntries().size()); // put w/ auth
1771    }
1772
1773    @Test
1774    protected void testPut_WithResponseBody() throws Exception {
1775        httpServer.setAuthentication("testuser", "testpass");
1776        httpServer.setResponseBodyForPut("Some dummy response body");
1777        auth = new AuthenticationBuilder()
1778                .addUsername("testuser")
1779                .addPassword("testpass")
1780                .build();
1781        newTransporter(httpServer.getHttpUrl());
1782        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1783        transporter.put(task);
1784        // this leads to stuck threads in some transporters if the response body is not consumed
1785    }
1786
1787    @Test
1788    @Timeout(20)
1789    protected void testConcurrency() throws Exception {
1790        httpServer.setAuthentication("testuser", "testpass");
1791        auth = new AuthenticationBuilder()
1792                .addUsername("testuser")
1793                .addPassword("testpass")
1794                .build();
1795        newTransporter(httpServer.getHttpUrl());
1796        final AtomicReference<Throwable> error = new AtomicReference<>();
1797        Thread[] threads = new Thread[20];
1798        for (int i = 0; i < threads.length; i++) {
1799            final String path = "repo/file.txt?i=" + i;
1800            threads[i] = new Thread(() -> {
1801                try {
1802                    for (int j = 0; j < 100; j++) {
1803                        GetTask task = new GetTask(URI.create(path));
1804                        transporter.get(task);
1805                        assertEquals("test", task.getDataString());
1806                    }
1807                } catch (Throwable t) {
1808                    error.compareAndSet(null, t);
1809                    System.err.println(path);
1810                    t.printStackTrace();
1811                }
1812            });
1813            threads[i].setName("Task-" + i);
1814        }
1815        for (Thread thread : threads) {
1816            thread.start();
1817        }
1818        for (Thread thread : threads) {
1819            thread.join();
1820        }
1821        assertNull(error.get(), String.valueOf(error.get()));
1822    }
1823
1824    @Test
1825    @Timeout(10)
1826    protected void testConnectTimeout() throws Exception {
1827        session.setConfigProperty(ConfigurationProperties.CONNECT_TIMEOUT, 100);
1828        int port = 1;
1829        newTransporter("http://localhost:" + port);
1830        try {
1831            transporter.get(new GetTask(URI.create("repo/file.txt")));
1832            fail("Expected error");
1833        } catch (Exception e) {
1834            // impl specific "timeout" exception
1835            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1836        }
1837    }
1838
1839    @Test
1840    @Timeout(10)
1841    protected void testRequestTimeout() throws Exception {
1842        session.setConfigProperty(ConfigurationProperties.REQUEST_TIMEOUT, 100);
1843        ServerSocket server = new ServerSocket(0);
1844        try (server) {
1845            newTransporter("http://localhost:" + server.getLocalPort());
1846            try {
1847                transporter.get(new GetTask(URI.create("repo/file.txt")));
1848                fail("Expected error");
1849            } catch (Exception e) {
1850                assertTrue(e.getClass().getSimpleName().contains("Timeout"));
1851                assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1852            }
1853        }
1854    }
1855
1856    @Test
1857    protected void testUserAgent() throws Exception {
1858        session.setConfigProperty(ConfigurationProperties.USER_AGENT, "SomeTest/1.0");
1859        newTransporter(httpServer.getHttpUrl());
1860        transporter.get(new GetTask(URI.create("repo/file.txt")));
1861        assertEquals(1, httpServer.getLogEntries().size());
1862        for (HttpServer.LogEntry log : httpServer.getLogEntries()) {
1863            assertEquals("SomeTest/1.0", log.getRequestHeaders().get("User-Agent"));
1864        }
1865    }
1866
1867    @Test
1868    protected void testCustomHeaders() throws Exception {
1869        Map<String, String> headers = new HashMap<>();
1870        headers.put("User-Agent", "Custom/1.0");
1871        headers.put("X-CustomHeader", "Custom-Value");
1872        session.setConfigProperty(ConfigurationProperties.USER_AGENT, "SomeTest/1.0");
1873        session.setConfigProperty(ConfigurationProperties.HTTP_HEADERS + ".test", headers);
1874        newTransporter(httpServer.getHttpUrl());
1875        transporter.get(new GetTask(URI.create("repo/file.txt")));
1876        assertEquals(1, httpServer.getLogEntries().size());
1877        for (HttpServer.LogEntry log : httpServer.getLogEntries()) {
1878            for (Map.Entry<String, String> entry : headers.entrySet()) {
1879                assertEquals(entry.getValue(), log.getRequestHeaders().get(entry.getKey()), entry.getKey());
1880            }
1881        }
1882    }
1883
1884    @Test
1885    protected void testServerAuthScope_NotUsedForProxy() throws Exception {
1886        String username = "testuser", password = "testpass";
1887        httpServer.setProxyAuthentication(username, password);
1888        auth = new AuthenticationBuilder()
1889                .addUsername(username)
1890                .addPassword(password)
1891                .build();
1892        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
1893        newTransporter("http://" + httpServer.getHost() + ":12/");
1894        try {
1895            transporter.get(new GetTask(URI.create("repo/file.txt")));
1896            fail("Server auth must not be used as proxy auth");
1897        } catch (HttpTransporterException e) {
1898            assertEquals(407, e.getStatusCode());
1899        } catch (IOException e) {
1900            // accepted as well: point is to fail
1901        }
1902    }
1903
1904    @Test
1905    protected void testProxyAuthScope_NotUsedForServer() throws Exception {
1906        String username = "testuser", password = "testpass";
1907        httpServer.setAuthentication(username, password);
1908        Authentication auth = new AuthenticationBuilder()
1909                .addUsername(username)
1910                .addPassword(password)
1911                .build();
1912        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
1913        newTransporter("http://" + httpServer.getHost() + ":12/");
1914        try {
1915            transporter.get(new GetTask(URI.create("repo/file.txt")));
1916            fail("Proxy auth must not be used as server auth");
1917        } catch (HttpTransporterException e) {
1918            assertEquals(401, e.getStatusCode());
1919        } catch (IOException e) {
1920            // accepted as well: point is to fail
1921        }
1922    }
1923
1924    @Test
1925    protected void testAuthSchemeReuse() throws Exception {
1926        httpServer.setAuthentication("testuser", "testpass");
1927        httpServer.setProxyAuthentication("proxyuser", "proxypass");
1928        session.setCache(new DefaultRepositoryCache());
1929        auth = new AuthenticationBuilder()
1930                .addUsername("testuser")
1931                .addPassword("testpass")
1932                .build();
1933        Authentication auth = new AuthenticationBuilder()
1934                .addUsername("proxyuser")
1935                .addPassword("proxypass")
1936                .build();
1937        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
1938        newTransporter("http://bad.localhost:1/");
1939        GetTask task = new GetTask(URI.create("repo/file.txt"));
1940        transporter.get(task);
1941        assertEquals("test", task.getDataString());
1942        assertEquals(3, httpServer.getLogEntries().size());
1943        httpServer.getLogEntries().clear();
1944        newTransporter("http://bad.localhost:1/");
1945        task = new GetTask(URI.create("repo/file.txt"));
1946        transporter.get(task);
1947        assertEquals("test", task.getDataString());
1948        assertEquals(1, httpServer.getLogEntries().size());
1949        assertNotNull(httpServer.getLogEntries().get(0).getRequestHeaders().get("Authorization"));
1950        assertNotNull(httpServer.getLogEntries().get(0).getRequestHeaders().get("Proxy-Authorization"));
1951    }
1952
1953    @Test
1954    protected void testAuthSchemePreemptive() throws Exception {
1955        httpServer.setAuthentication("testuser", "testpass");
1956        session.setCache(new DefaultRepositoryCache());
1957        auth = new AuthenticationBuilder()
1958                .addUsername("testuser")
1959                .addPassword("testpass")
1960                .build();
1961
1962        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_AUTH, false);
1963        newTransporter(httpServer.getHttpUrl());
1964        GetTask task = new GetTask(URI.create("repo/file.txt"));
1965        transporter.get(task);
1966        assertEquals("test", task.getDataString());
1967        // there ARE challenge round-trips
1968        assertEquals(2, httpServer.getLogEntries().size());
1969        httpServer.getLogEntries().clear();
1970        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_AUTH, true);
1971        newTransporter(httpServer.getHttpUrl());
1972        task = new GetTask(URI.create("repo/file.txt"));
1973        transporter.get(task);
1974        assertEquals("test", task.getDataString());
1975        // there are (potentially) NO challenge round-trips, all goes through at first
1976        assertEquals(
1977                supportsPreemptiveAuth() ? 1 : 2, httpServer.getLogEntries().size());
1978    }
1979
1980    @Test
1981    void testInit_BadProtocol() {
1982        assertThrows(NoTransporterException.class, () -> newTransporter("bad:/void"));
1983    }
1984
1985    @Test
1986    void testInit_BadUrl() {
1987        assertThrows(NoTransporterException.class, () -> newTransporter("http://localhost:NaN"));
1988    }
1989
1990    @Test
1991    void testInit_CaseInsensitiveProtocol() throws Exception {
1992        newTransporter("http://localhost");
1993        newTransporter("HTTP://localhost");
1994        newTransporter("Http://localhost");
1995        newTransporter("https://localhost");
1996        newTransporter("HTTPS://localhost");
1997        newTransporter("HttpS://localhost");
1998    }
1999}