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.Map;
043import java.util.concurrent.TimeUnit;
044import java.util.concurrent.atomic.AtomicReference;
045import java.util.function.Supplier;
046import java.util.stream.Stream;
047
048import org.awaitility.Awaitility;
049import org.eclipse.aether.ConfigurationProperties;
050import org.eclipse.aether.DefaultRepositoryCache;
051import org.eclipse.aether.DefaultRepositorySystemSession;
052import org.eclipse.aether.DefaultSessionData;
053import org.eclipse.aether.internal.impl.transport.http.DefaultChecksumExtractor;
054import org.eclipse.aether.internal.impl.transport.http.Nx2ChecksumExtractor;
055import org.eclipse.aether.internal.impl.transport.http.XChecksumExtractor;
056import org.eclipse.aether.internal.test.util.TestFileUtils;
057import org.eclipse.aether.internal.test.util.TestLocalRepositoryManager;
058import org.eclipse.aether.repository.Authentication;
059import org.eclipse.aether.repository.Proxy;
060import org.eclipse.aether.repository.RemoteRepository;
061import org.eclipse.aether.spi.connector.transport.GetTask;
062import org.eclipse.aether.spi.connector.transport.PeekTask;
063import org.eclipse.aether.spi.connector.transport.PutTask;
064import org.eclipse.aether.spi.connector.transport.Transporter;
065import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
066import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractorStrategy;
067import org.eclipse.aether.spi.connector.transport.http.HttpTransporter;
068import org.eclipse.aether.spi.connector.transport.http.HttpTransporterException;
069import org.eclipse.aether.spi.connector.transport.http.HttpTransporterFactory;
070import org.eclipse.aether.spi.connector.transport.http.RFC9457.HttpRFC9457Exception;
071import org.eclipse.aether.transfer.HttpTransportProperty;
072import org.eclipse.aether.transfer.NoTransporterException;
073import org.eclipse.aether.transfer.TransferCancelledException;
074import org.eclipse.aether.util.repository.AuthenticationBuilder;
075import org.eclipse.jetty.http.HttpVersion;
076import org.junit.jupiter.api.AfterAll;
077import org.junit.jupiter.api.AfterEach;
078import org.junit.jupiter.api.BeforeAll;
079import org.junit.jupiter.api.BeforeEach;
080import org.junit.jupiter.api.MethodOrderer;
081import org.junit.jupiter.api.Test;
082import org.junit.jupiter.api.TestInfo;
083import org.junit.jupiter.api.TestMethodOrder;
084import org.junit.jupiter.api.Timeout;
085import org.junit.jupiter.params.ParameterizedTest;
086import org.junit.jupiter.params.provider.ValueSource;
087
088import static java.util.Objects.requireNonNull;
089import static org.junit.jupiter.api.Assertions.assertEquals;
090import static org.junit.jupiter.api.Assertions.assertNotNull;
091import static org.junit.jupiter.api.Assertions.assertNull;
092import static org.junit.jupiter.api.Assertions.assertThrows;
093import static org.junit.jupiter.api.Assertions.assertTrue;
094import static org.junit.jupiter.api.Assertions.fail;
095import static org.junit.jupiter.api.Assumptions.assumeFalse;
096import static org.junit.jupiter.api.Assumptions.assumeTrue;
097
098/**
099 * Common set of tests against Http transporter.
100 */
101@SuppressWarnings({"checkstyle:MethodName"})
102@TestMethodOrder(MethodOrderer.MethodName.class)
103public abstract class HttpTransporterTest {
104
105    protected static final Path SERVER_STORE_PATH = Paths.get("target/server-store");
106
107    protected static final Path CLIENT_STORE_PATH = Paths.get("target/client-store");
108
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        // skip on ASF Jenkins due to incompatible GLIBC version
344        // (https://github.com/jetty-project/jetty-quiche-native/issues/180 and
345        // https://issues.apache.org/jira/browse/INFRA-28128)
346        // identified via property "os.version" exposed in https://ci-maven.apache.org/computer/maven6/systemInfo
347        assumeFalse(
348                System.getProperty("os.version").equals("5.15.0-1089-azure"),
349                "Skipping HTTP/3 tests on ASF Jenkins Linux Nodes");
350        return true;
351    }
352
353    /**
354     * Indicates whether the transporter implementation supports HTTP/2.
355     * @return {@code true} if HTTP/2 is supported, {@code false} otherwise.
356     */
357    protected boolean supportsHttp2() {
358        return true;
359    }
360
361    @Test
362    protected void testClassify() {
363        assertEquals(Transporter.ERROR_OTHER, transporter.classify(new FileNotFoundException()));
364        assertEquals(Transporter.ERROR_OTHER, transporter.classify(new HttpTransporterException(403)));
365        assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(new HttpTransporterException(404)));
366        assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(new HttpTransporterException(410)));
367    }
368
369    @Test
370    protected void testPeek() throws Exception {
371        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
372    }
373
374    @Test
375    protected void testPeek_DoesNotAcceptRfc9457() throws Exception {
376        // peek is HEAD request, therefore cannot support RFC 9457
377        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
378        String accept = httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept");
379        assertNull(accept, "No accept header expected for HEAD request, but was: " + accept);
380    }
381
382    @Test
383    protected void testRetryHandler_defaultCount_positive() throws Exception {
384        httpServer.setConnectionsToClose(3);
385        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
386    }
387
388    @Test
389    protected void testRetryHandler_defaultCount_negative() throws Exception {
390        httpServer.setConnectionsToClose(4);
391        try {
392            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
393            fail("Expected error");
394        } catch (Exception expected) {
395        }
396    }
397
398    @Test
399    protected void testRetryHandler_tooManyRequests_explicitCount_positive() throws Exception {
400        // set low retry count as this involves back off delays
401        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
402        int retryIntervalMs = 500;
403        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, retryIntervalMs);
404        newTransporter(httpServer.getHttpUrl());
405        httpServer.setServerErrorsBeforeWorks(1, SC_TOO_MANY_REQUESTS);
406        long startTime = System.currentTimeMillis();
407        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
408        assertTrue(
409                System.currentTimeMillis() - startTime >= retryIntervalMs,
410                "Expected back off delay of at least " + retryIntervalMs);
411    }
412
413    @Test
414    protected void testRetryHandler_tooManyRequests_withRetryAfterHeader_explicitCount_positive() throws Exception {
415        // set low retry count as this involves back off delays
416        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 1);
417        int retryIntervalMs = 5000;
418        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, retryIntervalMs);
419        newTransporter(httpServer.getHttpUrl());
420        Duration retryAfterDuration = Duration.ofSeconds(1);
421        httpServer.setServerErrorsBeforeWorks(
422                1, SC_TOO_MANY_REQUESTS, Map.of("Retry-After", String.valueOf(retryAfterDuration.getSeconds())));
423        long startTime = System.currentTimeMillis();
424        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
425        long elapsedTimeMs = System.currentTimeMillis() - startTime;
426        assertTrue(
427                elapsedTimeMs >= retryAfterDuration.toMillis(),
428                "Expected back off delay of at least " + retryAfterDuration.toMillis() + " ms");
429        assertTrue(elapsedTimeMs < retryIntervalMs, "Expected back off delay of less than " + retryIntervalMs + " ms");
430    }
431
432    @Test
433    protected void testRetryHandler_tooManyRequests_explicitCount_negative() throws Exception {
434        // set low retry count as this involves back off delays
435        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 3);
436        int retryIntervalMs = 100;
437        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_INTERVAL, retryIntervalMs);
438        newTransporter(httpServer.getHttpUrl());
439        httpServer.setServerErrorsBeforeWorks(4, SC_TOO_MANY_REQUESTS);
440        long startTime = System.currentTimeMillis();
441        try {
442            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
443            fail("Expected error");
444        } catch (Exception expected) {
445        }
446        // linear backoff: 1x + 2x + 3x
447        long expectedMinimumDuration = retryIntervalMs * (1 + 2 + 3);
448        assertTrue(
449                System.currentTimeMillis() - startTime >= expectedMinimumDuration,
450                "Expected back off delay of at least " + expectedMinimumDuration);
451    }
452
453    @Test
454    protected void testRetryHandler_explicitCount_positive() throws Exception {
455        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 10);
456        newTransporter(httpServer.getHttpUrl());
457        httpServer.setConnectionsToClose(10);
458        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
459    }
460
461    @Test
462    protected void testRetryHandler_disabled() throws Exception {
463        session.setConfigProperty(ConfigurationProperties.HTTP_RETRY_HANDLER_COUNT, 0);
464        newTransporter(httpServer.getHttpUrl());
465        httpServer.setConnectionsToClose(1);
466        try {
467            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
468        } catch (Exception expected) {
469        }
470    }
471
472    @Test
473    protected void testPeek_NotFound() throws Exception {
474        try {
475            transporter.peek(new PeekTask(URI.create("repo/missing.txt")));
476            fail("Expected error");
477        } catch (HttpTransporterException e) {
478            assertEquals(404, e.getStatusCode());
479            assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(e));
480        }
481    }
482
483    @Test
484    protected void testPeek_Closed() throws Exception {
485        transporter.close();
486        try {
487            transporter.peek(new PeekTask(URI.create("repo/missing.txt")));
488            fail("Expected error");
489        } catch (IllegalStateException e) {
490            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
491        }
492    }
493
494    @Test
495    protected void testPeek_Authenticated() throws Exception {
496        httpServer.setAuthentication("testuser", "testpass");
497        auth = new AuthenticationBuilder()
498                .addUsername("testuser")
499                .addPassword("testpass")
500                .build();
501        newTransporter(httpServer.getHttpUrl());
502        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
503    }
504
505    @Test
506    protected void testPeek_Unauthenticated() throws Exception {
507        httpServer.setAuthentication("testuser", "testpass");
508        try {
509            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
510            fail("Expected error");
511        } catch (HttpTransporterException e) {
512            assertEquals(401, e.getStatusCode());
513            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
514        }
515    }
516
517    @Test
518    protected void testPeek_ProxyAuthenticated() throws Exception {
519        httpServer.setProxyAuthentication("testuser", "testpass");
520        auth = new AuthenticationBuilder()
521                .addUsername("testuser")
522                .addPassword("testpass")
523                .build();
524        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
525        newTransporter("http://bad.localhost:1/");
526        transporter.peek(new PeekTask(URI.create("repo/file.txt")));
527    }
528
529    @Test
530    protected void testPeek_ProxyUnauthenticated() throws Exception {
531        httpServer.setProxyAuthentication("testuser", "testpass");
532        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
533        newTransporter("http://bad.localhost:1/");
534        try {
535            transporter.peek(new PeekTask(URI.create("repo/file.txt")));
536            fail("Expected error");
537        } catch (HttpTransporterException e) {
538            assertEquals(407, e.getStatusCode());
539            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
540        }
541    }
542
543    @Test
544    protected void testPeek_SSL() throws Exception {
545        httpServer.addHttp2ConnectorWithMutualTLS();
546        newTransporter(httpServer.getHttpsUrl());
547        RecordingTransportListener listener = new RecordingTransportListener();
548        PeekTask task = new PeekTask(URI.create("repo/file.txt")).setListener(listener);
549        transporter.peek(task);
550        if (exposeContentCodingInTransportProperties()) {
551            assertEquals(
552                    HttpTransportProperty.SslProtocol.TLS_1_3,
553                    listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_PROTOCOL));
554        }
555    }
556
557    @Test
558    protected void testPeek_Redirect() throws Exception {
559        httpServer.addHttp2ConnectorWithMutualTLS();
560        transporter.peek(new PeekTask(URI.create("redirect/file.txt")));
561        transporter.peek(new PeekTask(URI.create("redirect/file.txt?scheme=https")));
562    }
563
564    @Test
565    protected void testGet_ToMemory() throws Exception {
566        RecordingTransportListener listener = new RecordingTransportListener();
567        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
568        transporter.get(task);
569        assertEquals("test", task.getDataString());
570        assertEquals(0L, listener.getDataOffset());
571        assertEquals(4L, listener.getDataLength());
572        assertEquals(1, listener.getStartedCount());
573        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
574        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
575    }
576
577    @Test
578    protected void testGet_ToFile() throws Exception {
579        File file = TestFileUtils.createTempFile("failure");
580        RecordingTransportListener listener = new RecordingTransportListener();
581        GetTask task = new GetTask(URI.create("repo/file.txt"))
582                .setDataPath(file.toPath())
583                .setListener(listener);
584        transporter.get(task);
585        assertEquals("test", TestFileUtils.readString(file));
586        assertEquals(0L, listener.getDataOffset());
587        assertEquals(4L, listener.getDataLength());
588        assertEquals(1, listener.getStartedCount());
589        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
590        assertEquals("test", listener.getBaos().toString(StandardCharsets.UTF_8));
591    }
592
593    @Test
594    protected void testGet_ToFileTimestamp() throws Exception {
595        File file = TestFileUtils.createTempFile("failure");
596        RecordingTransportListener listener = new RecordingTransportListener();
597        GetTask task = new GetTask(URI.create("repo/dir/oldFile.txt"))
598                .setDataPath(file.toPath())
599                .setListener(listener);
600        transporter.get(task);
601        assertEquals("oldTest", TestFileUtils.readString(file));
602        assertEquals(0L, listener.getDataOffset());
603        assertEquals(7L, listener.getDataLength());
604        assertEquals(1, listener.getStartedCount());
605        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
606        assertEquals("oldTest", listener.getBaos().toString(StandardCharsets.UTF_8));
607        assertEquals(OLD_FILE_TIMESTAMP, file.lastModified());
608    }
609
610    @Test
611    protected void testGet_AcceptsRfc9457() throws Exception {
612        GetTask task = new GetTask(URI.create("repo/file.txt"));
613        transporter.get(task);
614        String accept = httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept");
615        assertNotNull(accept, "Missing Accept header when retrieving artifact");
616        assertTrue(
617                accept.contains("application/problem+json"),
618                "Expected Accept header to contain application/problem+json, but was: " + accept);
619    }
620
621    @Test
622    protected void testGet_ParseRfc9457() throws Exception {
623        // use Maven Central (Cloudflare CDN) as endpoints that return RFC 9457 responses
624        newTransporter("https://repo.maven.apache.org");
625        try {
626            // https://blog.cloudflare.com/rfc-9457-agent-error-pages/#how-to-use-it
627            GetTask task = new GetTask(URI.create("cdn-cgi/error/1020"));
628            transporter.get(task);
629            fail("Should have throw HttpRFC9457Exception");
630        } catch (HttpRFC9457Exception e) {
631            // Expected exception, verify the content of the RFC 9457 message.
632            assertEquals(403, e.getStatusCode());
633            assertEquals("Error 1020: Access denied", e.getPayload().getTitle());
634            assertEquals(
635                    "The request was blocked by a Cloudflare firewall rule configured by the site owner.",
636                    e.getPayload().getDetail());
637        }
638    }
639
640    /**
641     * Provides compression algorithms supported by the transporter implementation.
642     * This should be the string value passed in the {@code Accept-Encoding} header.
643     *
644     * @return stream of supported compression algorithm names
645     * @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#directives">Accept-Encoding directives</a>
646     */
647    protected abstract Stream<String> supportedCompressionAlgorithms();
648
649    protected boolean exposeContentCodingInTransportProperties() {
650        return true;
651    }
652
653    @ParameterizedTest
654    // DEFLATE isn't supported by Jetty server (https://github.com/jetty/jetty.project/issues/280)
655    @ValueSource(strings = {"br", "gzip", "zstd"})
656    protected void testGet_WithCompression(String encoding) throws Exception {
657        assumeTrue(
658                supportedCompressionAlgorithms().anyMatch(supported -> supported.equals(encoding)),
659                () -> "Transporter does not support compression algorithm: " + encoding);
660        RecordingTransportListener listener = new RecordingTransportListener();
661        // requires a file with at least 48/50 bytes (otherwise compression is disabled,
662        // 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)
663        GetTask task = new GetTask(URI.create(encoding + "/repo/compressible-file.xml")).setListener(listener);
664        transporter.get(task);
665        String acceptEncoding =
666                httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept-Encoding");
667        assertNotNull(acceptEncoding, "Missing Accept-Encoding header when retrieving pom");
668        assertTrue(acceptEncoding.contains(encoding));
669        // check original response header sent by server (client transparently handles compression and removes it)
670        // see https://issues.apache.org/jira/browse/HTTPCORE-792
671        // and https://github.com/mizosoft/methanol/issues/182
672        for (HttpServer.LogEntry log : httpServer.getLogEntries()) {
673            assertEquals(encoding, log.getResponseHeaders().get("Content-Encoding"));
674        }
675        String expectedResourceData;
676        try (InputStream is = getCompressibleFileStream()) {
677            expectedResourceData = new String(is.readAllBytes(), StandardCharsets.UTF_8);
678        }
679        assertEquals(expectedResourceData, task.getDataString());
680        assertEquals(0L, listener.getDataOffset());
681        // data length is unknown as chunked transfer encoding is used with compression
682        assertEquals(-1, listener.getDataLength());
683        assertEquals(1, listener.getStartedCount());
684        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
685        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
686        if (exposeContentCodingInTransportProperties()) {
687            assertEquals(encoding, listener.getTransportProperties().get(HttpTransportProperty.Key.CONTENT_CODING));
688        }
689    }
690
691    @Test
692    protected void testGet_EmptyResource() throws Exception {
693        File file = TestFileUtils.createTempFile("failure");
694        RecordingTransportListener listener = new RecordingTransportListener();
695        GetTask task = new GetTask(URI.create("repo/empty.txt"))
696                .setDataPath(file.toPath())
697                .setListener(listener);
698        transporter.get(task);
699        assertEquals("", TestFileUtils.readString(file));
700        assertEquals(0L, listener.getDataOffset());
701        assertEquals(0L, listener.getDataLength());
702        assertEquals(1, listener.getStartedCount());
703        assertEquals(0, listener.getProgressedCount());
704        assertEquals("", listener.getBaos().toString(StandardCharsets.UTF_8));
705    }
706
707    @Test
708    protected void testGet_EncodedResourcePath() throws Exception {
709        GetTask task = new GetTask(URI.create("repo/some%20space.txt"));
710        transporter.get(task);
711        assertEquals("space", task.getDataString());
712    }
713
714    @Test
715    protected void testGet_Authenticated() throws Exception {
716        httpServer.setAuthentication("testuser", "testpass");
717        auth = new AuthenticationBuilder()
718                .addUsername("testuser")
719                .addPassword("testpass")
720                .build();
721        newTransporter(httpServer.getHttpUrl());
722        RecordingTransportListener listener = new RecordingTransportListener();
723        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
724        transporter.get(task);
725        assertEquals("test", task.getDataString());
726        assertEquals(0L, listener.getDataOffset());
727        assertEquals(4L, listener.getDataLength());
728        assertEquals(1, listener.getStartedCount());
729        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
730        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
731    }
732
733    @Test
734    protected void testGet_Unauthenticated() throws Exception {
735        httpServer.setAuthentication("testuser", "testpass");
736        try {
737            transporter.get(new GetTask(URI.create("repo/file.txt")));
738            fail("Expected error");
739        } catch (HttpTransporterException e) {
740            assertEquals(401, e.getStatusCode());
741            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
742        }
743    }
744
745    @Test
746    protected void testGet_ProxyAuthenticated() throws Exception {
747        httpServer.setProxyAuthentication("testuser", "testpass");
748        Authentication auth = new AuthenticationBuilder()
749                .addUsername("testuser")
750                .addPassword("testpass")
751                .build();
752        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
753        newTransporter("http://bad.localhost:1/");
754        RecordingTransportListener listener = new RecordingTransportListener();
755        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
756        transporter.get(task);
757        assertEquals("test", task.getDataString());
758        assertEquals(0L, listener.getDataOffset());
759        assertEquals(4L, listener.getDataLength());
760        assertEquals(1, listener.getStartedCount());
761        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
762        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
763    }
764
765    @Test
766    protected void testGet_ProxyUnauthenticated() throws Exception {
767        httpServer.setProxyAuthentication("testuser", "testpass");
768        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
769        newTransporter("http://bad.localhost:1/");
770        try {
771            transporter.get(new GetTask(URI.create("repo/file.txt")));
772            fail("Expected error");
773        } catch (HttpTransporterException e) {
774            assertEquals(407, e.getStatusCode());
775            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
776        }
777    }
778
779    @Test
780    protected void testGet_RFC9457Response() throws Exception {
781        try {
782            transporter.get(new GetTask(URI.create("rfc9457/file.txt")));
783            fail("Expected error");
784        } catch (HttpRFC9457Exception e) {
785            assertEquals(403, e.getStatusCode());
786            assertEquals(e.getPayload().getType(), URI.create("https://example.com/probs/out-of-credit"));
787            assertEquals(403, e.getPayload().getStatus());
788            assertEquals("You do not have enough credit.", e.getPayload().getTitle());
789            assertEquals(
790                    "Your current balance is 30, but that costs 50.",
791                    e.getPayload().getDetail());
792            assertEquals(URI.create("/account/12345/msgs/abc"), e.getPayload().getInstance());
793        }
794    }
795
796    @Test
797    protected void testGet_RFC9457Response_with_missing_fields() throws Exception {
798        try {
799            transporter.get(new GetTask(URI.create("rfc9457/missing_fields.txt")));
800            fail("Expected error");
801        } catch (HttpRFC9457Exception e) {
802            assertEquals(403, e.getStatusCode());
803            assertEquals(e.getPayload().getType(), URI.create("about:blank"));
804            assertNull(e.getPayload().getStatus());
805            assertNull(e.getPayload().getTitle());
806            assertNull(e.getPayload().getDetail());
807            assertNull(e.getPayload().getInstance());
808        }
809    }
810
811    @Test
812    protected void testGet_SSL() throws Exception {
813        httpServer.addHttp2ConnectorWithMutualTLS();
814        newTransporter(httpServer.getHttpsUrl());
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        if (exposeContentCodingInTransportProperties()) {
825            assertEquals(
826                    HttpTransportProperty.SslProtocol.TLS_1_3,
827                    listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_PROTOCOL));
828            assertEquals(
829                    "TLS_AES_256_GCM_SHA384",
830                    listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_CIPHER_SUITE));
831        }
832    }
833
834    @Test
835    protected void testGet_SSL_WithServerErrors() throws Exception {
836        httpServer.setServerErrorsBeforeWorks(1);
837        httpServer.addHttp2ConnectorWithMutualTLS();
838        newTransporter(httpServer.getHttpsUrl());
839        for (int i = 1; i < 3; i++) {
840            try {
841                RecordingTransportListener listener = new RecordingTransportListener();
842                GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
843                transporter.get(task);
844                assertEquals("test", task.getDataString());
845                assertEquals(0L, listener.getDataOffset());
846                assertEquals(4L, listener.getDataLength());
847                assertEquals(1, listener.getStartedCount());
848                assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
849                assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
850            } catch (HttpTransporterException e) {
851                assertEquals(500, e.getStatusCode());
852            }
853        }
854    }
855
856    @Test
857    protected void testGet_HTTPS_Unknown_SecurityMode() throws Exception {
858        session.setConfigProperty(ConfigurationProperties.HTTPS_SECURITY_MODE, "unknown");
859        httpServer.addHttp2Connector();
860        try {
861            newTransporter(httpServer.getHttpsUrl());
862            fail("Unsupported security mode");
863        } catch (IllegalArgumentException a) {
864            // good
865        }
866    }
867
868    @Test
869    protected void testGet_HTTPS_Insecure_SecurityMode() throws Exception {
870        // we have to reset the default ssl context to avoid the default truststore being used, which would make the
871        // test pass even if the security mode is not set to insecure
872        SSLContext.setDefault(defaultSslContext);
873        try {
874            session.setConfigProperty(
875                    ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE);
876            httpServer.addHttp2Connector();
877            newTransporter(httpServer.getHttpsUrl());
878            RecordingTransportListener listener = new RecordingTransportListener();
879            GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
880            transporter.get(task);
881            assertEquals("test", task.getDataString());
882            assertEquals(0L, listener.getDataOffset());
883            assertEquals(4L, listener.getDataLength());
884            assertEquals(1, listener.getStartedCount());
885            assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
886            assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
887        } finally {
888            // restore the default SSL context used for all other tests
889            SSLContext.setDefault(createClientSSLContext());
890        }
891    }
892
893    @Test
894    protected void testGet_HTTPS_HTTP2Only_Insecure_SecurityMode() throws Exception {
895        assumeTrue(supportsHttp2(), "Transporter does not support HTTP/2");
896        // we have to reset the default ssl context to avoid the default truststore being used, which would make the
897        // test pass even if the security mode is not set to insecure
898        SSLContext.setDefault(defaultSslContext);
899        try {
900            session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_2);
901            session.setConfigProperty(
902                    ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE);
903            httpServer.addHttp2OnlyConnector();
904            newTransporter(httpServer.getHttpsUrl());
905            RecordingTransportListener listener = new RecordingTransportListener();
906            GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
907            transporter.get(task);
908            assertEquals("test", task.getDataString());
909            assertEquals(0L, listener.getDataOffset());
910            assertEquals(4L, listener.getDataLength());
911            assertEquals(1, listener.getStartedCount());
912            assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
913            assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
914            httpServer.getLogEntries().forEach(log -> {
915                assertEquals(HttpVersion.HTTP_2, log.getVersion());
916            });
917        } finally {
918            // restore the default SSL context used for all other tests
919            SSLContext.setDefault(createClientSSLContext());
920        }
921    }
922
923    @Test
924    protected void testGet_HTTP3Only() throws Exception {
925        assumeTrue(supportsHttp3(), "Transporter does not support HTTP/3");
926        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3);
927        httpServer.addHttp3Connector(false);
928        httpServer.start();
929        newTransporter(httpServer.getHttp3Url());
930        RecordingTransportListener listener = new RecordingTransportListener();
931        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
932        transporter.get(task);
933        assertEquals("test", task.getDataString());
934        assertEquals(0L, listener.getDataOffset());
935        assertEquals(4L, listener.getDataLength());
936        assertEquals(1, listener.getStartedCount());
937        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
938        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
939        httpServer.getLogEntries().forEach(log -> {
940            assertEquals(HttpVersion.HTTP_3, log.getVersion());
941        });
942    }
943
944    @Test
945    protected void testGet_HTTP3() throws Exception {
946        assumeTrue(supportsHttp3(), "Transporter does not support HTTP/3");
947        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3);
948        // both HTTP2 and HTTP3 endpoints are available at the same port
949        // reserve a port free for both TCP and UDP, as the same-numbered UDP port of an OS-assigned TCP port may be
950        // taken (https://github.com/apache/maven-resolver/issues/2036)
951        int port = httpServer.findFreeTcpAndUdpPort();
952        httpServer.addHttp2OnlyConnectorWithMutualTLS(port);
953        httpServer.addHttp3Connector(false, port);
954        // alt-svc header should point to http/3
955        httpServer.start();
956        newTransporter(httpServer.getHttp3Url());
957        RecordingTransportListener listener = new RecordingTransportListener();
958        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
959        // issue 2 requests to ensure that the second request is HTTP/3 (the first one may be HTTP/2 if the TCP
960        // connection is faster)
961        transporter.get(task);
962        transporter.get(task);
963        assertEquals("test", task.getDataString());
964        assertEquals(0L, listener.getDataOffset());
965        assertEquals(4L, listener.getDataLength());
966        assertEquals(2, listener.getStartedCount());
967        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
968        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
969        // the last request should be HTTP/3 finally
970        assertEquals(
971                HttpVersion.HTTP_3,
972                httpServer
973                        .getLogEntries()
974                        .get(httpServer.getLogEntries().size() - 1)
975                        .getVersion());
976    }
977
978    @Test
979    protected void testGet_HTTP3FallbackToHTTP2() throws Exception {
980        assumeTrue(supportsHttp3(), "Transporter does not support HTTP/3");
981        session.setConfigProperty(ConfigurationProperties.HTTP_VERSION, ConfigurationProperties.HttpVersion.HTTP_3);
982        httpServer.addHttp2OnlyConnectorWithMutualTLS();
983        httpServer.start();
984        newTransporter(httpServer.getHttpsUrl());
985        RecordingTransportListener listener = new RecordingTransportListener();
986        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
987        transporter.get(task);
988        assertEquals("test", task.getDataString());
989        assertEquals(0L, listener.getDataOffset());
990        assertEquals(4L, listener.getDataLength());
991        assertEquals(1, listener.getStartedCount());
992        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
993        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
994        assertEquals(
995                HttpTransportProperty.HttpVersion.HTTP_2,
996                listener.getTransportProperties().get(HttpTransportProperty.Key.HTTP_VERSION));
997        httpServer.getLogEntries().forEach(log -> {
998            assertEquals(HttpVersion.HTTP_2, log.getVersion());
999        });
1000    }
1001
1002    @Test
1003    protected void testGet_Redirect() throws Exception {
1004        httpServer.addHttp2ConnectorWithMutualTLS();
1005        RecordingTransportListener listener = new RecordingTransportListener();
1006        GetTask task = new GetTask(URI.create("redirect/file.txt?scheme=https")).setListener(listener);
1007        transporter.get(task);
1008        assertEquals("test", task.getDataString());
1009        assertEquals(0L, listener.getDataOffset());
1010        assertEquals(4L, listener.getDataLength());
1011        assertEquals(1, listener.getStartedCount());
1012        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1013        assertEquals(task.getDataString(), listener.getBaos().toString(StandardCharsets.UTF_8));
1014    }
1015
1016    @Test
1017    protected void testGet_Resume() throws Exception {
1018        File file = TestFileUtils.createTempFile("re");
1019        RecordingTransportListener listener = new RecordingTransportListener();
1020        GetTask task = new GetTask(URI.create("repo/resume.txt"))
1021                .setDataPath(file.toPath(), true)
1022                .setListener(listener);
1023        transporter.get(task);
1024        assertEquals("resumable", TestFileUtils.readString(file));
1025        assertEquals(1L, listener.getStartedCount());
1026        assertEquals(2L, listener.getDataOffset());
1027        assertEquals(9, listener.getDataLength());
1028        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1029        assertEquals("sumable", listener.getBaos().toString(StandardCharsets.UTF_8));
1030    }
1031
1032    @Test
1033    protected void testGet_ResumeLocalContentsOutdated() throws Exception {
1034        File file = TestFileUtils.createTempFile("re");
1035        file.setLastModified(System.currentTimeMillis() - 5 * 60 * 1000);
1036        RecordingTransportListener listener = new RecordingTransportListener();
1037        GetTask task = new GetTask(URI.create("repo/resume.txt"))
1038                .setDataPath(file.toPath(), true)
1039                .setListener(listener);
1040        transporter.get(task);
1041        assertEquals("resumable", TestFileUtils.readString(file));
1042        assertEquals(1L, listener.getStartedCount());
1043        assertEquals(0L, listener.getDataOffset());
1044        assertEquals(9, listener.getDataLength());
1045        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1046        assertEquals("resumable", listener.getBaos().toString(StandardCharsets.UTF_8));
1047    }
1048
1049    @Test
1050    protected void testGet_ResumeRangesNotSupportedByServer() throws Exception {
1051        httpServer.setRangeSupport(false);
1052        File file = TestFileUtils.createTempFile("re");
1053        RecordingTransportListener listener = new RecordingTransportListener();
1054        GetTask task = new GetTask(URI.create("repo/resume.txt"))
1055                .setDataPath(file.toPath(), true)
1056                .setListener(listener);
1057        transporter.get(task);
1058        assertEquals("resumable", TestFileUtils.readString(file));
1059        assertEquals(1L, listener.getStartedCount());
1060        assertEquals(0L, listener.getDataOffset());
1061        assertEquals(9, listener.getDataLength());
1062        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1063        assertEquals("resumable", listener.getBaos().toString(StandardCharsets.UTF_8));
1064    }
1065
1066    @Test
1067    protected void testGet_Checksums_Nexus() throws Exception {
1068        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.NEXUS);
1069        GetTask task = new GetTask(URI.create("repo/file.txt"));
1070        transporter.get(task);
1071        assertEquals("test", task.getDataString());
1072        assertEquals(
1073                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1074    }
1075
1076    @Test
1077    protected void testGet_Checksums_XChecksum() throws Exception {
1078        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.XCHECKSUM);
1079        GetTask task = new GetTask(URI.create("repo/file.txt"));
1080        transporter.get(task);
1081        assertEquals("test", task.getDataString());
1082        assertEquals(
1083                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1084    }
1085
1086    @Test
1087    protected void testGet_Checksums_XChecksum_Google() throws Exception {
1088        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.XCHECKSUM_GOOGLE);
1089        GetTask task = new GetTask(URI.create("repo/file.txt"));
1090        transporter.get(task);
1091        assertEquals("test", task.getDataString());
1092        assertEquals(
1093                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1094    }
1095
1096    @Test
1097    protected void testGet_Checksums_XChecksum_Amazon() throws Exception {
1098        httpServer.setChecksumHeader(HttpServer.ChecksumHeader.XCHECKSUM_AMAZON);
1099        GetTask task = new GetTask(URI.create("repo/file.txt"));
1100        transporter.get(task);
1101        assertEquals("test", task.getDataString());
1102        assertEquals(
1103                "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", task.getChecksums().get("SHA-1"));
1104    }
1105
1106    @Test
1107    protected void testGet_FileHandleLeak() throws Exception {
1108        for (int i = 0; i < 100; i++) {
1109            File file = TestFileUtils.createTempFile("failure");
1110            transporter.get(new GetTask(URI.create("repo/file.txt")).setDataPath(file.toPath()));
1111            assertTrue(file.delete(), i + ", " + file.getAbsolutePath());
1112        }
1113    }
1114
1115    @Test
1116    protected void testGet_NotFound() throws Exception {
1117        try {
1118            transporter.get(new GetTask(URI.create("repo/missing.txt")));
1119            fail("Expected error");
1120        } catch (HttpTransporterException e) {
1121            assertEquals(404, e.getStatusCode());
1122            assertEquals(Transporter.ERROR_NOT_FOUND, transporter.classify(e));
1123        }
1124    }
1125
1126    @Test
1127    protected void testGet_Closed() throws Exception {
1128        transporter.close();
1129        try {
1130            transporter.get(new GetTask(URI.create("repo/file.txt")));
1131            fail("Expected error");
1132        } catch (IllegalStateException e) {
1133            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1134        }
1135    }
1136
1137    @Test
1138    protected void testGet_StartCancelled() throws Exception {
1139        RecordingTransportListener listener = new RecordingTransportListener();
1140        listener.cancelStart();
1141        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1142        try {
1143            transporter.get(task);
1144            fail("Expected error");
1145        } catch (TransferCancelledException e) {
1146            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1147        }
1148        assertEquals(0L, listener.getDataOffset());
1149        assertEquals(4L, listener.getDataLength());
1150        assertEquals(1, listener.getStartedCount());
1151        assertEquals(0, listener.getProgressedCount());
1152    }
1153
1154    @Test
1155    protected void testGet_ProgressCancelled() throws Exception {
1156        RecordingTransportListener listener = new RecordingTransportListener();
1157        listener.cancelProgress();
1158        GetTask task = new GetTask(URI.create("repo/file.txt")).setListener(listener);
1159        try {
1160            transporter.get(task);
1161            fail("Expected error");
1162        } catch (TransferCancelledException e) {
1163            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1164        }
1165        assertEquals(0L, listener.getDataOffset());
1166        assertEquals(4L, listener.getDataLength());
1167        assertEquals(1, listener.getStartedCount());
1168        assertEquals(1, listener.getProgressedCount());
1169    }
1170
1171    @Test
1172    protected void testPut_FromMemory() throws Exception {
1173        RecordingTransportListener listener = new RecordingTransportListener();
1174        String payload = "upload";
1175        PutTask task =
1176                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString(payload);
1177        transporter.put(task);
1178        assertEquals(0L, listener.getDataOffset());
1179        assertEquals(6L, listener.getDataLength());
1180        assertEquals(1, listener.getStartedCount());
1181        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1182        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1183        assertEquals(
1184                String.valueOf(payload.getBytes(StandardCharsets.UTF_8).length),
1185                httpServer.getLogEntries().get(0).getRequestHeaders().get("Content-Length"));
1186    }
1187
1188    @Test
1189    protected void testPut_AcceptsRfc9457() throws Exception {
1190        String payload = "upload";
1191        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString(payload);
1192        transporter.put(task);
1193        String accept = httpServer.getLogEntries().get(0).getRequestHeaders().get("Accept");
1194        assertNotNull(accept, "Missing Accept header when retrieving artifact");
1195        assertTrue(
1196                accept.contains("application/problem+json"),
1197                "Expected Accept header to contain application/problem+json, but was: " + accept);
1198    }
1199
1200    @Test
1201    protected void testPut_FromFile() throws Exception {
1202        File file = TestFileUtils.createTempFile("upload");
1203        RecordingTransportListener listener = new RecordingTransportListener();
1204        PutTask task =
1205                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataPath(file.toPath());
1206        transporter.put(task);
1207        assertEquals(0L, listener.getDataOffset());
1208        assertEquals(6L, listener.getDataLength());
1209        assertEquals(1, listener.getStartedCount());
1210        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1211        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1212        assertEquals(
1213                String.valueOf(file.length()),
1214                httpServer.getLogEntries().get(0).getRequestHeaders().get("Content-Length"));
1215    }
1216
1217    @Test
1218    protected void testPut_EmptyResource() throws Exception {
1219        RecordingTransportListener listener = new RecordingTransportListener();
1220        PutTask task = new PutTask(URI.create("repo/file.txt")).setListener(listener);
1221        transporter.put(task);
1222        assertEquals(0L, listener.getDataOffset());
1223        assertEquals(0L, listener.getDataLength());
1224        // some transports may skip the upload for empty resources
1225        assertTrue(
1226                listener.getStartedCount() <= 1,
1227                "The transport should be started at most once but was started " + listener.getStartedCount()
1228                        + " times");
1229        assertEquals(0, listener.getProgressedCount());
1230        assertEquals("", TestFileUtils.readString(new File(repoDir, "file.txt")));
1231    }
1232
1233    @Test
1234    protected void testPut_EncodedResourcePath() throws Exception {
1235        RecordingTransportListener listener = new RecordingTransportListener();
1236        PutTask task = new PutTask(URI.create("repo/some%20space.txt"))
1237                .setListener(listener)
1238                .setDataString("OK");
1239        transporter.put(task);
1240        assertEquals(0L, listener.getDataOffset());
1241        assertEquals(2L, listener.getDataLength());
1242        assertEquals(1, listener.getStartedCount());
1243        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1244        assertEquals("OK", TestFileUtils.readString(new File(repoDir, "some space.txt")));
1245    }
1246
1247    @Test
1248    protected void testPut_Authenticated_ExpectContinue() throws Exception {
1249        httpServer.setAuthentication("testuser", "testpass");
1250        auth = new AuthenticationBuilder()
1251                .addUsername("testuser")
1252                .addPassword("testpass")
1253                .build();
1254        newTransporter(httpServer.getHttpUrl());
1255        RecordingTransportListener listener = new RecordingTransportListener();
1256        PutTask task =
1257                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1258        transporter.put(task);
1259        assertEquals(0L, listener.getDataOffset());
1260        assertEquals(6L, listener.getDataLength());
1261        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1262        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1263        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1264    }
1265
1266    @Test
1267    protected void testPut_Authenticated_ExpectContinueBroken() throws Exception {
1268        // this makes OPTIONS recover, and have only 1 PUT (startedCount=1 as OPTIONS is not counted)
1269        session.setConfigProperty(ConfigurationProperties.HTTP_SUPPORT_WEBDAV, true);
1270        httpServer.setAuthentication("testuser", "testpass");
1271        httpServer.setExpectSupport(HttpServer.ExpectContinue.BROKEN);
1272        auth = new AuthenticationBuilder()
1273                .addUsername("testuser")
1274                .addPassword("testpass")
1275                .build();
1276        newTransporter(httpServer.getHttpUrl());
1277        RecordingTransportListener listener = new RecordingTransportListener();
1278        PutTask task =
1279                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1280        transporter.put(task);
1281        assertEquals(0L, listener.getDataOffset());
1282        assertEquals(6L, listener.getDataLength());
1283        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1284        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1285        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1286    }
1287
1288    @Test
1289    protected void testPut_Authenticated_ExpectContinueRejected() throws Exception {
1290        httpServer.setAuthentication("testuser", "testpass");
1291        httpServer.setExpectSupport(HttpServer.ExpectContinue.FAIL);
1292        auth = new AuthenticationBuilder()
1293                .addUsername("testuser")
1294                .addPassword("testpass")
1295                .build();
1296        newTransporter(httpServer.getHttpUrl());
1297        RecordingTransportListener listener = new RecordingTransportListener();
1298        PutTask task =
1299                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1300        transporter.put(task);
1301        assertEquals(0L, listener.getDataOffset());
1302        assertEquals(6L, listener.getDataLength());
1303        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1304        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1305        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1306    }
1307
1308    @Test
1309    protected void testPut_Authenticated_ExpectContinueDisabled() throws Exception {
1310        session.setConfigProperty(ConfigurationProperties.HTTP_EXPECT_CONTINUE, false);
1311        httpServer.setAuthentication("testuser", "testpass");
1312        httpServer.setExpectSupport(HttpServer.ExpectContinue.FAIL); // if transport tries Expect/Continue explode
1313        auth = new AuthenticationBuilder()
1314                .addUsername("testuser")
1315                .addPassword("testpass")
1316                .build();
1317        newTransporter(httpServer.getHttpUrl());
1318        RecordingTransportListener listener = new RecordingTransportListener();
1319        PutTask task =
1320                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1321        transporter.put(task);
1322        assertEquals(0L, listener.getDataOffset());
1323        assertEquals(6L, listener.getDataLength());
1324        assertEquals(
1325                supportsPreemptiveAuth() ? 1 : 2,
1326                listener.getStartedCount()); // w/ expectContinue enabled would have here 2/3
1327        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1328        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1329    }
1330
1331    @Test
1332    protected void testPut_Authenticated_ExpectContinueRejected_ExplicitlyConfiguredHeader() throws Exception {
1333        Map<String, String> headers = new HashMap<>();
1334        headers.put("Expect", "100-continue");
1335        session.setConfigProperty(ConfigurationProperties.HTTP_HEADERS + ".test", headers);
1336        httpServer.setAuthentication("testuser", "testpass");
1337        httpServer.setExpectSupport(HttpServer.ExpectContinue.FAIL);
1338        auth = new AuthenticationBuilder()
1339                .addUsername("testuser")
1340                .addPassword("testpass")
1341                .build();
1342        newTransporter(httpServer.getHttpUrl());
1343        RecordingTransportListener listener = new RecordingTransportListener();
1344        PutTask task =
1345                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1346        transporter.put(task);
1347        assertEquals(0L, listener.getDataOffset());
1348        assertEquals(6L, listener.getDataLength());
1349        assertEquals(1, listener.getStartedCount());
1350        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1351        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1352    }
1353
1354    @Test
1355    protected void testPut_Unauthenticated() throws Exception {
1356        httpServer.setAuthentication("testuser", "testpass");
1357        RecordingTransportListener listener = new RecordingTransportListener();
1358        PutTask task =
1359                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1360        try {
1361            transporter.put(task);
1362            fail("Expected error");
1363        } catch (HttpTransporterException e) {
1364            assertEquals(401, e.getStatusCode());
1365            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1366        }
1367        assertEquals(0, listener.getStartedCount());
1368        assertEquals(0, listener.getProgressedCount());
1369    }
1370
1371    @Test
1372    protected void testPut_ProxyAuthenticated() throws Exception {
1373        httpServer.setProxyAuthentication("testuser", "testpass");
1374        Authentication auth = new AuthenticationBuilder()
1375                .addUsername("testuser")
1376                .addPassword("testpass")
1377                .build();
1378        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
1379        newTransporter("http://bad.localhost:1/");
1380        RecordingTransportListener listener = new RecordingTransportListener();
1381        PutTask task =
1382                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1383        transporter.put(task);
1384        assertEquals(0L, listener.getDataOffset());
1385        assertEquals(6L, listener.getDataLength());
1386        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1387        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1388        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1389    }
1390
1391    @Test
1392    protected void testPut_ProxyUnauthenticated() throws Exception {
1393        httpServer.setProxyAuthentication("testuser", "testpass");
1394        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
1395        newTransporter("http://bad.localhost:1/");
1396        RecordingTransportListener listener = new RecordingTransportListener();
1397        PutTask task =
1398                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1399        try {
1400            transporter.put(task);
1401            fail("Expected error");
1402        } catch (HttpTransporterException e) {
1403            assertEquals(407, e.getStatusCode());
1404            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1405        }
1406        assertEquals(0, listener.getStartedCount());
1407        assertEquals(0, listener.getProgressedCount());
1408    }
1409
1410    @Test
1411    protected void testPut_SSL() throws Exception {
1412        httpServer.addHttp2ConnectorWithMutualTLS();
1413        httpServer.setAuthentication("testuser", "testpass");
1414        auth = new AuthenticationBuilder()
1415                .addUsername("testuser")
1416                .addPassword("testpass")
1417                .build();
1418        newTransporter(httpServer.getHttpsUrl());
1419        RecordingTransportListener listener = new RecordingTransportListener();
1420        PutTask task =
1421                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1422        transporter.put(task);
1423        assertEquals(0L, listener.getDataOffset());
1424        assertEquals(6L, listener.getDataLength());
1425        assertEquals(supportsPreemptiveAuth() ? 1 : 2, listener.getStartedCount());
1426        assertTrue(listener.getProgressedCount() > 0, "Count: " + listener.getProgressedCount());
1427        assertEquals("upload", TestFileUtils.readString(new File(repoDir, "file.txt")));
1428        assertEquals(
1429                HttpTransportProperty.SslProtocol.TLS_1_3,
1430                listener.getTransportProperties().get(HttpTransportProperty.Key.SSL_PROTOCOL));
1431    }
1432
1433    @Test
1434    protected void testPut_FileHandleLeak() throws Exception {
1435        for (int i = 0; i < 100; i++) {
1436            File src = TestFileUtils.createTempFile("upload");
1437            File dst = new File(repoDir, "file.txt");
1438            transporter.put(new PutTask(URI.create("repo/file.txt")).setDataPath(src.toPath()));
1439            assertTrue(src.delete(), i + ", " + src.getAbsolutePath());
1440            assertTrue(dst.delete(), i + ", " + dst.getAbsolutePath());
1441        }
1442    }
1443
1444    @Test
1445    protected void testPut_Closed() throws Exception {
1446        transporter.close();
1447        try {
1448            transporter.put(new PutTask(URI.create("repo/missing.txt")));
1449            fail("Expected error");
1450        } catch (IllegalStateException e) {
1451            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1452        }
1453    }
1454
1455    @Test
1456    protected void testPut_StartCancelled() throws Exception {
1457        RecordingTransportListener listener = new RecordingTransportListener();
1458        listener.cancelStart();
1459        PutTask task =
1460                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1461        try {
1462            transporter.put(task);
1463            fail("Expected error");
1464        } catch (TransferCancelledException e) {
1465            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1466        }
1467        assertEquals(0L, listener.getDataOffset());
1468        assertEquals(6L, listener.getDataLength());
1469        assertEquals(1, listener.getStartedCount());
1470        assertEquals(0, listener.getProgressedCount());
1471    }
1472
1473    @Test
1474    protected void testPut_ProgressCancelled() throws Exception {
1475        RecordingTransportListener listener = new RecordingTransportListener();
1476        listener.cancelProgress();
1477        PutTask task =
1478                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1479        try {
1480            transporter.put(task);
1481            fail("Expected error");
1482        } catch (TransferCancelledException e) {
1483            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1484        }
1485        assertEquals(0L, listener.getDataOffset());
1486        assertEquals(6L, listener.getDataLength());
1487        assertEquals(1, listener.getStartedCount());
1488        assertEquals(1, listener.getProgressedCount());
1489    }
1490
1491    @Test
1492    protected void testGetPut_AuthCache() throws Exception {
1493        httpServer.setAuthentication("testuser", "testpass");
1494        auth = new AuthenticationBuilder()
1495                .addUsername("testuser")
1496                .addPassword("testpass")
1497                .build();
1498        newTransporter(httpServer.getHttpUrl());
1499        GetTask get = new GetTask(URI.create("repo/file.txt"));
1500        transporter.get(get);
1501        RecordingTransportListener listener = new RecordingTransportListener();
1502        PutTask task =
1503                new PutTask(URI.create("repo/file.txt")).setListener(listener).setDataString("upload");
1504        transporter.put(task);
1505        assertEquals(1, listener.getStartedCount());
1506    }
1507
1508    @Test
1509    protected void testPut_PreemptiveIsDefault() throws Exception {
1510        httpServer.setAuthentication("testuser", "testpass");
1511        auth = new AuthenticationBuilder()
1512                .addUsername("testuser")
1513                .addPassword("testpass")
1514                .build();
1515        newTransporter(httpServer.getHttpUrl());
1516        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1517        transporter.put(task);
1518        assertEquals(
1519                supportsPreemptiveAuth() ? 1 : 2, httpServer.getLogEntries().size()); // put w/ auth
1520    }
1521
1522    @Test
1523    protected void testPut_AuthCache() throws Exception {
1524        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_PUT_AUTH, false);
1525        httpServer.setAuthentication("testuser", "testpass");
1526        auth = new AuthenticationBuilder()
1527                .addUsername("testuser")
1528                .addPassword("testpass")
1529                .build();
1530        newTransporter(httpServer.getHttpUrl());
1531        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1532        transporter.put(task);
1533        assertEquals(2, httpServer.getLogEntries().size()); // put (challenged) + put w/ auth
1534        httpServer.getLogEntries().clear();
1535        task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1536        transporter.put(task);
1537        assertEquals(1, httpServer.getLogEntries().size()); // put w/ auth
1538    }
1539
1540    @Test
1541    protected void testPut_AuthCache_Preemptive() throws Exception {
1542        httpServer.setAuthentication("testuser", "testpass");
1543        auth = new AuthenticationBuilder()
1544                .addUsername("testuser")
1545                .addPassword("testpass")
1546                .build();
1547        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_AUTH, true);
1548        newTransporter(httpServer.getHttpUrl());
1549        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1550        transporter.put(task);
1551        assertEquals(
1552                supportsPreemptiveAuth() ? 1 : 2, httpServer.getLogEntries().size()); // put w/ auth
1553        httpServer.getLogEntries().clear();
1554        task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1555        transporter.put(task);
1556        assertEquals(1, httpServer.getLogEntries().size()); // put w/ auth
1557    }
1558
1559    @Test
1560    protected void testPut_WithResponseBody() throws Exception {
1561        httpServer.setAuthentication("testuser", "testpass");
1562        httpServer.setResponseBodyForPut("Some dummy response body");
1563        auth = new AuthenticationBuilder()
1564                .addUsername("testuser")
1565                .addPassword("testpass")
1566                .build();
1567        newTransporter(httpServer.getHttpUrl());
1568        PutTask task = new PutTask(URI.create("repo/file.txt")).setDataString("upload");
1569        transporter.put(task);
1570        // this leads to stuck threads in some transporters if the response body is not consumed
1571    }
1572
1573    @Test
1574    @Timeout(20)
1575    protected void testConcurrency() throws Exception {
1576        httpServer.setAuthentication("testuser", "testpass");
1577        auth = new AuthenticationBuilder()
1578                .addUsername("testuser")
1579                .addPassword("testpass")
1580                .build();
1581        newTransporter(httpServer.getHttpUrl());
1582        final AtomicReference<Throwable> error = new AtomicReference<>();
1583        Thread[] threads = new Thread[20];
1584        for (int i = 0; i < threads.length; i++) {
1585            final String path = "repo/file.txt?i=" + i;
1586            threads[i] = new Thread(() -> {
1587                try {
1588                    for (int j = 0; j < 100; j++) {
1589                        GetTask task = new GetTask(URI.create(path));
1590                        transporter.get(task);
1591                        assertEquals("test", task.getDataString());
1592                    }
1593                } catch (Throwable t) {
1594                    error.compareAndSet(null, t);
1595                    System.err.println(path);
1596                    t.printStackTrace();
1597                }
1598            });
1599            threads[i].setName("Task-" + i);
1600        }
1601        for (Thread thread : threads) {
1602            thread.start();
1603        }
1604        for (Thread thread : threads) {
1605            thread.join();
1606        }
1607        assertNull(error.get(), String.valueOf(error.get()));
1608    }
1609
1610    @Test
1611    @Timeout(10)
1612    protected void testConnectTimeout() throws Exception {
1613        session.setConfigProperty(ConfigurationProperties.CONNECT_TIMEOUT, 100);
1614        int port = 1;
1615        newTransporter("http://localhost:" + port);
1616        try {
1617            transporter.get(new GetTask(URI.create("repo/file.txt")));
1618            fail("Expected error");
1619        } catch (Exception e) {
1620            // impl specific "timeout" exception
1621            assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1622        }
1623    }
1624
1625    @Test
1626    @Timeout(10)
1627    protected void testRequestTimeout() throws Exception {
1628        session.setConfigProperty(ConfigurationProperties.REQUEST_TIMEOUT, 100);
1629        ServerSocket server = new ServerSocket(0);
1630        try (server) {
1631            newTransporter("http://localhost:" + server.getLocalPort());
1632            try {
1633                transporter.get(new GetTask(URI.create("repo/file.txt")));
1634                fail("Expected error");
1635            } catch (Exception e) {
1636                assertTrue(e.getClass().getSimpleName().contains("Timeout"));
1637                assertEquals(Transporter.ERROR_OTHER, transporter.classify(e));
1638            }
1639        }
1640    }
1641
1642    @Test
1643    protected void testUserAgent() throws Exception {
1644        session.setConfigProperty(ConfigurationProperties.USER_AGENT, "SomeTest/1.0");
1645        newTransporter(httpServer.getHttpUrl());
1646        transporter.get(new GetTask(URI.create("repo/file.txt")));
1647        assertEquals(1, httpServer.getLogEntries().size());
1648        for (HttpServer.LogEntry log : httpServer.getLogEntries()) {
1649            assertEquals("SomeTest/1.0", log.getRequestHeaders().get("User-Agent"));
1650        }
1651    }
1652
1653    @Test
1654    protected void testCustomHeaders() throws Exception {
1655        Map<String, String> headers = new HashMap<>();
1656        headers.put("User-Agent", "Custom/1.0");
1657        headers.put("X-CustomHeader", "Custom-Value");
1658        session.setConfigProperty(ConfigurationProperties.USER_AGENT, "SomeTest/1.0");
1659        session.setConfigProperty(ConfigurationProperties.HTTP_HEADERS + ".test", headers);
1660        newTransporter(httpServer.getHttpUrl());
1661        transporter.get(new GetTask(URI.create("repo/file.txt")));
1662        assertEquals(1, httpServer.getLogEntries().size());
1663        for (HttpServer.LogEntry log : httpServer.getLogEntries()) {
1664            for (Map.Entry<String, String> entry : headers.entrySet()) {
1665                assertEquals(entry.getValue(), log.getRequestHeaders().get(entry.getKey()), entry.getKey());
1666            }
1667        }
1668    }
1669
1670    @Test
1671    protected void testServerAuthScope_NotUsedForProxy() throws Exception {
1672        String username = "testuser", password = "testpass";
1673        httpServer.setProxyAuthentication(username, password);
1674        auth = new AuthenticationBuilder()
1675                .addUsername(username)
1676                .addPassword(password)
1677                .build();
1678        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort());
1679        newTransporter("http://" + httpServer.getHost() + ":12/");
1680        try {
1681            transporter.get(new GetTask(URI.create("repo/file.txt")));
1682            fail("Server auth must not be used as proxy auth");
1683        } catch (HttpTransporterException e) {
1684            assertEquals(407, e.getStatusCode());
1685        } catch (IOException e) {
1686            // accepted as well: point is to fail
1687        }
1688    }
1689
1690    @Test
1691    protected void testProxyAuthScope_NotUsedForServer() throws Exception {
1692        String username = "testuser", password = "testpass";
1693        httpServer.setAuthentication(username, password);
1694        Authentication auth = new AuthenticationBuilder()
1695                .addUsername(username)
1696                .addPassword(password)
1697                .build();
1698        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
1699        newTransporter("http://" + httpServer.getHost() + ":12/");
1700        try {
1701            transporter.get(new GetTask(URI.create("repo/file.txt")));
1702            fail("Proxy auth must not be used as server auth");
1703        } catch (HttpTransporterException e) {
1704            assertEquals(401, e.getStatusCode());
1705        } catch (IOException e) {
1706            // accepted as well: point is to fail
1707        }
1708    }
1709
1710    @Test
1711    protected void testAuthSchemeReuse() throws Exception {
1712        httpServer.setAuthentication("testuser", "testpass");
1713        httpServer.setProxyAuthentication("proxyuser", "proxypass");
1714        session.setCache(new DefaultRepositoryCache());
1715        auth = new AuthenticationBuilder()
1716                .addUsername("testuser")
1717                .addPassword("testpass")
1718                .build();
1719        Authentication auth = new AuthenticationBuilder()
1720                .addUsername("proxyuser")
1721                .addPassword("proxypass")
1722                .build();
1723        proxy = new Proxy(Proxy.TYPE_HTTP, httpServer.getHost(), httpServer.getHttpPort(), auth);
1724        newTransporter("http://bad.localhost:1/");
1725        GetTask task = new GetTask(URI.create("repo/file.txt"));
1726        transporter.get(task);
1727        assertEquals("test", task.getDataString());
1728        assertEquals(3, httpServer.getLogEntries().size());
1729        httpServer.getLogEntries().clear();
1730        newTransporter("http://bad.localhost:1/");
1731        task = new GetTask(URI.create("repo/file.txt"));
1732        transporter.get(task);
1733        assertEquals("test", task.getDataString());
1734        assertEquals(1, httpServer.getLogEntries().size());
1735        assertNotNull(httpServer.getLogEntries().get(0).getRequestHeaders().get("Authorization"));
1736        assertNotNull(httpServer.getLogEntries().get(0).getRequestHeaders().get("Proxy-Authorization"));
1737    }
1738
1739    @Test
1740    protected void testAuthSchemePreemptive() throws Exception {
1741        httpServer.setAuthentication("testuser", "testpass");
1742        session.setCache(new DefaultRepositoryCache());
1743        auth = new AuthenticationBuilder()
1744                .addUsername("testuser")
1745                .addPassword("testpass")
1746                .build();
1747
1748        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_AUTH, false);
1749        newTransporter(httpServer.getHttpUrl());
1750        GetTask task = new GetTask(URI.create("repo/file.txt"));
1751        transporter.get(task);
1752        assertEquals("test", task.getDataString());
1753        // there ARE challenge round-trips
1754        assertEquals(2, httpServer.getLogEntries().size());
1755
1756        httpServer.getLogEntries().clear();
1757
1758        session.setConfigProperty(ConfigurationProperties.HTTP_PREEMPTIVE_AUTH, true);
1759        newTransporter(httpServer.getHttpUrl());
1760        task = new GetTask(URI.create("repo/file.txt"));
1761        transporter.get(task);
1762        assertEquals("test", task.getDataString());
1763        // there are (potentially) NO challenge round-trips, all goes through at first
1764        assertEquals(
1765                supportsPreemptiveAuth() ? 1 : 2, httpServer.getLogEntries().size());
1766    }
1767
1768    @Test
1769    void testInit_BadProtocol() {
1770        assertThrows(NoTransporterException.class, () -> newTransporter("bad:/void"));
1771    }
1772
1773    @Test
1774    void testInit_BadUrl() {
1775        assertThrows(NoTransporterException.class, () -> newTransporter("http://localhost:NaN"));
1776    }
1777
1778    @Test
1779    void testInit_CaseInsensitiveProtocol() throws Exception {
1780        newTransporter("http://localhost");
1781        newTransporter("HTTP://localhost");
1782        newTransporter("Http://localhost");
1783        newTransporter("https://localhost");
1784        newTransporter("HTTPS://localhost");
1785        newTransporter("HttpS://localhost");
1786    }
1787}