InboundHeadersProviderLeakTest.java

/*
 * Copyright (c) 2026 Contributors to the Eclipse Foundation.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0, which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the
 * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
 * version 2 with the GNU Classpath Exception, which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 */

package org.glassfish.jersey.test.microprofile.restclient;

import java.net.URI;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;

import org.eclipse.microprofile.rest.client.RestClientBuilder;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory;
import org.glassfish.jersey.microprofile.restclient.InboundHeadersProvider;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
 * Regression for issue #6101: application {@link InboundHeadersProvider} headers must not be
 * copied into the outbound request unless an explicit {@link ClientHeadersFactory} is used.
 */
public class InboundHeadersProviderLeakTest {

    private static final String INBOUND_ONLY = "X-Inbound-Only";
    private static final String BUILDER_HEADER = "X-Builder-Header";
    private static final String RESULT = "X-Test-Leaked";
    private static final String BUILDER_PRESENT = "X-Builder-Present";
    private static final String FACTORY_SAW_INBOUND = "X-Factory-Saw-Inbound";

    @Test
    public void inboundHeadersMustNotBeCopiedAutomatically() throws Exception {
        Set<Boolean> observed = new HashSet<>();

        for (int i = 0; i < 100; i++) {
            TestClient client = RestClientBuilder.newBuilder()
                    .baseUri(URI.create("http://example.invalid"))
                    .register(new TestInboundHeadersProvider())
                    .register(new InspectingFilter())
                    .build(TestClient.class);

            try {
                try (Response response = client.invoke()) {
                    observed.add(Boolean.parseBoolean(response.getHeaderString(RESULT)));
                }
            } finally {
                ((AutoCloseable) client).close();
            }
        }

        assertEquals(Set.of(false), observed, () -> "observed=" + observed);
    }

    @Test
    public void restClientBuilderHeaderStillAppliedWithoutClientHeadersFactory() throws Exception {
        TestClient client = RestClientBuilder.newBuilder()
                .baseUri(URI.create("http://example.invalid"))
                .header(BUILDER_HEADER, "from-builder")
                .register(new TestInboundHeadersProvider())
                .register(new InspectingFilter())
                .build(TestClient.class);

        try (Response response = client.invoke()) {
            assertFalse(Boolean.parseBoolean(response.getHeaderString(RESULT)));
            assertTrue(Boolean.parseBoolean(response.getHeaderString(BUILDER_PRESENT)));
        } finally {
            ((AutoCloseable) client).close();
        }
    }

    @Test
    public void inboundHeadersAvailableToExplicitClientHeadersFactory() throws Exception {
        TestClientWithFactory client = RestClientBuilder.newBuilder()
                .baseUri(URI.create("http://example.invalid"))
                .register(new TestInboundHeadersProvider())
                .register(new InspectingFilter())
                .build(TestClientWithFactory.class);

        try (Response response = client.invoke()) {
            // Explicit factory may propagate inbound headers; InspectingFilter reports leak as true.
            assertTrue(Boolean.parseBoolean(response.getHeaderString(RESULT)));
            assertTrue(Boolean.parseBoolean(response.getHeaderString(FACTORY_SAW_INBOUND)));
        } finally {
            ((AutoCloseable) client).close();
        }
    }

    @Path("/")
    public interface TestClient {
        @GET
        Response invoke();
    }

    @Path("/")
    @RegisterClientHeaders(PropagatingClientHeadersFactory.class)
    public interface TestClientWithFactory {
        @GET
        Response invoke();
    }

    public static final class TestInboundHeadersProvider implements InboundHeadersProvider {
        @Override
        public Map<String, List<String>> inboundHeaders() {
            return Map.of(INBOUND_ONLY, List.of("must-not-be-sent"));
        }

        @Override
        public int hashCode() {
            // Matches issue reproducer: equal hash codes stress HashSet iteration order.
            return 0;
        }
    }

    public static final class PropagatingClientHeadersFactory implements ClientHeadersFactory {
        @Override
        public MultivaluedMap<String, String> update(MultivaluedMap<String, String> incomingHeaders,
                                                     MultivaluedMap<String, String> clientOutgoingHeaders) {
            MultivaluedMap<String, String> result = new MultivaluedHashMap<>();
            result.putAll(clientOutgoingHeaders);
            result.putAll(incomingHeaders);
            result.putSingle(FACTORY_SAW_INBOUND, String.valueOf(incomingHeaders.containsKey(INBOUND_ONLY)));
            return result;
        }
    }

    public static final class InspectingFilter implements ClientRequestFilter {
        @Override
        public void filter(ClientRequestContext context) {
            boolean leaked = context.getHeaders().containsKey(INBOUND_ONLY);
            boolean builderPresent = context.getHeaders().containsKey(BUILDER_HEADER);
            Object factorySaw = context.getHeaders().getFirst(FACTORY_SAW_INBOUND);
            context.abortWith(Response.ok()
                    .header(RESULT, leaked)
                    .header(BUILDER_PRESENT, builderPresent)
                    .header(FACTORY_SAW_INBOUND, factorySaw != null ? factorySaw : "false")
                    .build());
        }
    }
}