ExchangeAttributeReadTest.java

package io.undertow.attribute;

import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.entity.StringEntity;

import static org.junit.Assert.fail;

import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.text.ParseException;
import java.text.SimpleDateFormat;

import org.apache.http.HttpResponse;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.xnio.OptionMap;

import io.undertow.UndertowOptions;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.BlockingHandler;
import io.undertow.testutils.DefaultServer;
import io.undertow.testutils.HttpClientUtils;
import io.undertow.testutils.TestHttpClient;
import io.undertow.util.NetworkUtils;
import io.undertow.util.StatusCodes;

/**
 * Tests for read-only ExchangeAttribute implementations.
 * These tests use direct attribute instances/factory methods rather than the parser
 * to test the attribute logic itself, not the parsing logic.
 */
@RunWith(DefaultServer.class)
public class ExchangeAttributeReadTest {

    /**
     * Test for RelativePathAttribute, which stores the relative path of the request.
     * @throws IOException
     */
    @Test
    public void testRelativePathAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.relativePath());

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/some/path"),
            "/some/path"
        );
    }

    /**
     * Test for RequestMethodAttribute, which stores the HTTP method of the request.
     * @throws IOException
     */
    @Test
    public void testRequestMethodAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.requestMethod());

        doSuccessfulRequest(
            new HttpPost(DefaultServer.getDefaultServerURL() + "/test") ,
            "POST"
        );
    }

    /**
     * Test for LocalIPAttribute, which stores the local IP address of the server.
     * @throws IOException
     */
    @Test
    public void testLocalIPAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.localIp());

        // retrieve the expected local IP address from the server configuration
        String expectedLocalIp = DefaultServer.getDefaultServerAddress().getAddress().getHostAddress();
        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            expectedLocalIp
        );
    }

    /**
     * Test for RemoteIPAttribute, which stores the remote IP address of the client.
     * @throws IOException
     */
    @Test
    public void testRemoteIPAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.remoteIp());

        // we expect the remote IP to be the same as the local IP, as the requests comes from the same machine
        String expectedIp = DefaultServer.getDefaultServerAddress().getAddress().getHostAddress();
        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), expectedIp);
    }

    /**
     * Test for LocalPortAttribute, which stores the local port of the server.
     * @throws IOException
     */
    @Test
    public void testLocalPortAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.localPort());

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            String.valueOf(
                (DefaultServer.isProxy() ? DefaultServer.getProxyPort() : DefaultServer.getHostPort()) // use proxy port if testing with proxy
            )
        );
    }

    /**
     * Test for RequestProtocolAttribute, which stores the protocol of the request (e.g. HTTP/1.1).
     * @throws IOException
     */
    @Test
    public void testRequestProtocolAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.requestProtocol());

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            (DefaultServer.isH2() ? "HTTP/2.0" : "HTTP/1.1")
        );
    }

    /**
     * Test for RequestLineAttribute, which stores the full request line (method, URI, protocol).
     * @throws IOException
     */
    @Test
    public void testRequestLineAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.requestList());

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test/path?foo=bar"),
            "GET /test/path?foo=bar " + (DefaultServer.isH2() ? "HTTP/2.0" : "HTTP/1.1")
        );
    }

    /**
     * Test for QueryStringAttribute, which stores the query string of the request.
     * @throws IOException
     */
    @Test
    public void testQueryStringAttribute() throws IOException {
        HttpGet get = new HttpGet(DefaultServer.getDefaultServerURL() + "/test?foo=bar&baz=qux");

        setRootHandler(ExchangeAttributes.queryString()); // includes question mark

        doSuccessfulRequest(get, "?foo=bar&baz=qux");

        setRootHandler(QueryStringAttribute.BARE_INSTANCE); // excludes question mark

        doSuccessfulRequest(get, "foo=bar&baz=qux");
    }

    /**
     * Test for RequestURLAttribute, which stores the request URL, without the query string.
     * @throws IOException
     */
    @Test
    public void testRequestURLAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.requestURL());

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test/path"),
            "/test/path"
        );

        // uri should not include query string
        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test/path?foo=bar"),
            "/test/path"
        );
    }

    /**
     * Test for RemoteHostAttribute, which stores the remote host name.
     * @throws IOException
     */
    @Test
    public void testRemoteHostAttribute() throws IOException {
        setRootHandler(RemoteHostAttribute.INSTANCE);

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            DefaultServer.getDefaultServerAddress().getAddress().getHostAddress() // host of this machine
        );
    }

    /**
     * Test for RemoteObfuscatedIpAttribute, which stores an obfuscated version of the remote IP address.
     * @throws IOException
     */
    @Test
    public void testRemoteObfuscatedIPAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.remoteObfuscatedIp());

        // obfustocate the full ip
        String obfuscatedIp = NetworkUtils.toObfuscatedString(DefaultServer.getDefaultServerAddress().getAddress());
        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), obfuscatedIp);
    }

    /**
     * Test for IdentUsernameAttribute, which stores the username obtained from identd.
     * @throws IOException
     */
    @Test
    public void testIdentUsernameAttribute() throws IOException {
        setRootHandler(IdentUsernameAttribute.INSTANCE);

        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), ""); // not used
    }

    /**
     * Test for DateTimeAttribute, which stores the current date and time in a specific format.
     * @throws Exception
     */
    @Test
    public void testDateTimeAttribute() throws Exception {
        String dateFormatPattern = "yyyy-MM-dd HH:mm:ss";
        SimpleDateFormat dateFormat = new SimpleDateFormat(dateFormatPattern, java.util.Locale.ENGLISH);

        setRootHandler(new DateTimeAttribute(dateFormatPattern));

        TestHttpClient client = new TestHttpClient();
        try {
            HttpResponse result = client.execute(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"));
            Assert.assertEquals(StatusCodes.OK, result.getStatusLine().getStatusCode());
            String response = HttpClientUtils.readResponse(result);

            // verify the response is a valid date in the specified format
            Assert.assertNotNull("DateTime should not be null", response);
            Assert.assertFalse("DateTime should not be empty", response.isEmpty());

            // parse the date to verify it's valid
            try {
                dateFormat.parse(response);
            } catch (ParseException e) {
                fail("Returned Date does not Follow the Expected Format");
            }
        } finally {
            client.getConnectionManager().shutdown();
            client.close();
        }
    }

    /**
     * Test for BytesSentAttribute, which stores the number of bytes sent in the response.
     * @throws IOException
     */
    @Test
    public void testBytesSentAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.bytesSent(false));

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            "0" // we expect 0 bytes sent since the root handler first reads the attribute and then writes a response
        );

        // also check the case where dashIfZero is true, which should return "-" instead of "0"
        setRootHandler(ExchangeAttributes.bytesSent(true));

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            "-"
        );
    }

    /**
     * Test for BytesReadAttribute, which stores the number of bytes read from the request.
     * @throws IOException
     */
    @Test
    public void testBytesReadAttribute() throws IOException {
        ExchangeAttribute attribute = new BytesReadAttribute(false);

        DefaultServer.setRootHandler(new BlockingHandler((HttpServerExchange exchange) -> {

            InputStreamReader reader = new InputStreamReader(exchange.getInputStream());

            // consume the whole stream
            while (reader.read() != -1) {}

            reader.close();

            exchange.getResponseSender().send(attribute.readAttribute(exchange));
        }));

        HttpPost post = new HttpPost(DefaultServer.getDefaultServerURL() + "/test");
        post.setEntity(new StringEntity("hello"));

        doSuccessfulRequest(
            post,
            String.valueOf("hello".getBytes().length)
        );
    }

    /**
     * Test for ConstantExchangeAttribute, which stores a constant value.
     * @throws IOException
     */
    @Test
    public void testConstantAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.constant("CONSTANT_VALUE"));

        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), "CONSTANT_VALUE");
    }

    /**
     * Test for RemoteUserAttribute, which stores the remote authenticated user.
     * @throws IOException
     */
    @Test
    public void testRemoteUserAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.remoteUser());

        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), ""); // we expect an empty string since security context is null
    }

    /**
     * Test for AuthenticationTypeExchangeAttribute, which stores the authentication type used.
     * @throws IOException
     */
    @Test
    public void testAuthenticationTypeAttribute() throws IOException {
        setRootHandler(ExchangeAttributes.authenticationType());

        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), ""); // we expect an empty string since security context is null
    }

    /**
     * Test for HostAndPortAttribute, which stores the host + port of the destination
     * @throws IOException
     */
    @Test
    public void testHostAndPortAttribute() throws IOException {
        setRootHandler(HostAndPortAttribute.INSTANCE);

        String host = DefaultServer.getHostAddress();
        int port = DefaultServer.getHostPort();
        doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), host + ":" + port);
    }

    /**
     * Test for QuotingExchangeAttribute, which wraps another attribute with appropriate quoting.
     * @throws IOException
     */
    @Test
    public void testQuotingExchangeAttribute() throws IOException {

        Map<String, String> expectedConstantTransformations = Map.of(
            "Hello", "'Hello'",
            "Hello 'W'orld", "'Hello \"'\"W\"'\"orld'",
            "Hello 'World'", "'Hello \"'\"World\"'\"'",
            "'Hello World'", "'\"'\"Hello World\"'\"'"
        );
        for (Map.Entry<String, String> constant : expectedConstantTransformations.entrySet()) {
            // create the constant attribute to wrap in quotes
            ExchangeAttribute constantAttribute = ExchangeAttributes.constant(constant.getKey());

            setRootHandler(QuotingExchangeAttribute.WRAPPER.wrap(constantAttribute));

            doSuccessfulRequest(new HttpGet(DefaultServer.getDefaultServerURL() + "/test"), constant.getValue());
        }
    }

    /**
     * Test for ResponseTimeAttribute, which stores the response time for the request.
     * @throws IOException
     */
    @Test
    public void testResponseTimeAttribute() throws IOException {
        setRootHandler(new ResponseTimeAttribute(TimeUnit.SECONDS));

        // enable the recording of the request start time, in order for ResponseTimeAttribute to calculate the total time
        DefaultServer.setUndertowOptions(OptionMap.create(UndertowOptions.RECORD_REQUEST_START_TIME, true));

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            "\\d+\\.\\d+", // assert that the response matches a digits.digits format
            false,
            true
        );
    }

    /**
     * Test for SecureProtocolAttribute, which stores the SSL/TLS protocol version.
     * @throws IOException
     */
    @Test
    public void testSecureProtocolAttribute() throws IOException {

        setRootHandler(SecureProtocolAttribute.INSTANCE);
        DefaultServer.startSSLServer();

        try {
            doSuccessfulRequest(
                new HttpGet(DefaultServer.getDefaultServerSSLAddress() + "/test"), // use https instead of http
                // we dont set ssl session info for ajp. See DefaultServer#setRootHandler
                // and the emitted setupProxyHandlerForSSL() call on the ajp proxy definition in DefaultServer#startServer
                (DefaultServer.isAjp() ? "" : "TLSv1.2"),
                true
            );
        } finally {
            DefaultServer.stopSSLServer();
        }
    }

    /**
     * Test for SslCipherAttribute, which stores the SSL cipher suite used.
     * @throws IOException
     */
    @Test
    public void testSslCipherAttribute() throws IOException {
        setRootHandler(SslCipherAttribute.INSTANCE);
        DefaultServer.startSSLServer();

        try {
            doSuccessfulRequest(
                new HttpGet(DefaultServer.getDefaultServerSSLAddress() + "/test"), // use https instead of http
                "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
                true
            );
        } finally {
            DefaultServer.stopSSLServer();
        }
    }

    /**
     * Test for SslClientCertAttribute, which stores the client SSL certificate.
     * @throws IOException
     */
    @Test
    public void testSslClientCertAttribute() throws IOException {
        setRootHandler(SslClientCertAttribute.INSTANCE);
        DefaultServer.startSSLServer();

        try {
            doSuccessfulRequest(
                new HttpGet(DefaultServer.getDefaultServerSSLAddress() + "/test"),
                "-----BEGIN CERTIFICATE-----[\\s\\S]+-----END CERTIFICATE-----", // match everything in between, since the cert has many distinct chars (like .+ but also include \n)
                true,
                true
            );
        } finally {
            DefaultServer.stopSSLServer();
        }
    }

    /**
     * Test for SslSessionIdAttribute, which stores the SSL session ID
     * @throws IOException
     */
    @Test
    public void testSslSessionIdAttribute() throws IOException {
        setRootHandler(SslSessionIdAttribute.INSTANCE);
        DefaultServer.startSSLServer();

        try {
            doSuccessfulRequest(
                new HttpGet(DefaultServer.getDefaultServerSSLAddress() + "/test"),
                "[0-9A-Fa-f]+", // match a hex string
                true,
                true
            );
        } finally {
            DefaultServer.stopSSLServer();
        }
    }

    /**
     * Test for CompositeExchangeAttribute, which combines multiple attributes into one.
     * @throws IOException
     */
    @Test
    public void testCompositeExchangeAttribute() throws IOException {
        setRootHandler(new CompositeExchangeAttribute(
            new ExchangeAttribute[] {
                ExchangeAttributes.requestMethod(),
                ExchangeAttributes.requestURL(),
                ExchangeAttributes.constant("CONSTANT_VALUE"),
            }
        ));

        doSuccessfulRequest(
            new HttpGet(DefaultServer.getDefaultServerURL() + "/test"),
            "GET" + "/test" + "CONSTANT_VALUE"
        );
    }

    /**
     * Helper method to set the root handler to one that reads the specified attribute and sends it in the response.
     * @param attribute the attribute to read and send in the response
     */
    private void setRootHandler(ExchangeAttribute attribute) {
        DefaultServer.setRootHandler((HttpServerExchange exchange) -> {
            String value = attribute.readAttribute(exchange);
            exchange.getResponseSender().send(value != null ? value : "");
        });
    }

    /**
     * Helper method to execute a request and assert the response matches the expected value.
     * @param method the HTTP method to execute
     * @param expectedResponse the expected response body
     * @throws IOException
     */
    private void doSuccessfulRequest(HttpRequestBase method, String expectedResponse) throws IOException {
        doSuccessfulRequest(method, expectedResponse, false);
    }

    /**
     * Helper method to execute a request and assert the response matches the expected value.
     * @param method the HTTP method to execute
     * @param expectedResponse the expected response body
     * @param setSSLContext whether to set an SSLContext to the client before the request
     * @throws IOException
     */
    private void doSuccessfulRequest(HttpRequestBase method, String expectedResponse, boolean setSSLContext) throws IOException {
        doSuccessfulRequest(method, expectedResponse, setSSLContext, false);
    }

    /**
     * Helper method to execute a request and assert the response matches the expected value.
     * @param method the HTTP method to execute
     * @param expectedResponse the expected response body
     * @param setSSLContext whether to set an SSLContext to the client before the request
     * @param isRegex whether the expected response should be treaded as a regex to be matched
     * @throws IOException
     */
    private void doSuccessfulRequest(HttpRequestBase method, String expectedResponse, boolean setSSLContext, boolean isRegex) throws IOException {
        TestHttpClient client = new TestHttpClient();
        if (setSSLContext) {
            client.setSSLContext(DefaultServer.getClientSSLContext());
        }
        try {
            HttpResponse result = client.execute(method);
            Assert.assertEquals(StatusCodes.OK, result.getStatusLine().getStatusCode());
            String response = HttpClientUtils.readResponse(result);
            if (isRegex) {
                Assert.assertTrue(response.matches(expectedResponse));
            } else {
                Assert.assertEquals(expectedResponse, response);
            }
        } finally {
            client.getConnectionManager().shutdown();
            client.close();
        }
    }
}