/** * Copyright 2016 LinkedIn Corp. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ package com.github.ambry.rest; import com.codahale.metrics.MetricRegistry; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.SslHandler; import io.netty.handler.ssl.util.SelfSignedCertificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Random; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import org.junit.Assert; import org.junit.Test; /** * Unit tests for {@link PublicAccessLogHandler} */ public class PublicAccessLogHandlerTest { private MockPublicAccessLogger publicAccessLogger; private static final String REQUEST_HEADERS = HttpHeaderNames.HOST + "," + HttpHeaderNames.CONTENT_LENGTH + ",x-ambry-content-type"; private static final String RESPONSE_HEADERS = EchoMethodHandler.RESPONSE_HEADER_KEY_1 + "," + EchoMethodHandler.RESPONSE_HEADER_KEY_2; private static final String NOT_LOGGED_HEADER_KEY = "headerKey"; private static final X509Certificate PEER_CERT; private static final SslContext SSL_CONTEXT; static { try { PEER_CERT = new SelfSignedCertificate().cert(); SelfSignedCertificate localCert = new SelfSignedCertificate(); SSL_CONTEXT = SslContextBuilder.forServer(localCert.certificate(), localCert.privateKey()).build(); } catch (CertificateException | SSLException e) { throw new IllegalStateException(e); } } /** * Sets up the mock public access logger that {@link PublicAccessLogHandler} can use. */ public PublicAccessLogHandlerTest() { publicAccessLogger = new MockPublicAccessLogger(REQUEST_HEADERS.split(","), RESPONSE_HEADERS.split(",")); } /** * Tests for the common case request handling flow. * @throws Exception */ @Test public void requestHandleWithGoodInputTest() throws Exception { doRequestHandleTest(HttpMethod.POST, "POST", false, false); doRequestHandleTest(HttpMethod.GET, "GET", false, false); doRequestHandleTest(HttpMethod.DELETE, "DELETE", false, false); // SSL enabled doRequestHandleTest(HttpMethod.POST, "POST", false, true); doRequestHandleTest(HttpMethod.GET, "GET", false, true); doRequestHandleTest(HttpMethod.DELETE, "DELETE", false, true); } /** * Tests for multiple requests with keep alive. * @throws Exception */ @Test public void requestHandleWithGoodInputTestWithKeepAlive() throws Exception { doRequestHandleWithKeepAliveTest(HttpMethod.POST, "POST", false); doRequestHandleWithKeepAliveTest(HttpMethod.GET, "GET", false); doRequestHandleWithKeepAliveTest(HttpMethod.DELETE, "DELETE", false); // SSL enabled doRequestHandleWithKeepAliveTest(HttpMethod.POST, "POST", true); doRequestHandleWithKeepAliveTest(HttpMethod.GET, "GET", true); doRequestHandleWithKeepAliveTest(HttpMethod.DELETE, "DELETE", true); } /** * Tests two successive request without completing first request * @throws Exception */ @Test public void requestHandleWithTwoSuccessiveRequest() throws Exception { doRequestHandleWithMultipleRequest(HttpMethod.POST, "POST", false); doRequestHandleWithMultipleRequest(HttpMethod.GET, "GET", false); doRequestHandleWithMultipleRequest(HttpMethod.DELETE, "DELETE", false); // SSL enabled doRequestHandleWithMultipleRequest(HttpMethod.POST, "POST", true); doRequestHandleWithMultipleRequest(HttpMethod.GET, "GET", true); doRequestHandleWithMultipleRequest(HttpMethod.DELETE, "DELETE", true); } /** * Tests for the request handling flow for close * @throws Exception */ @Test public void requestHandleOnCloseTest() throws Exception { doRequestHandleTest(HttpMethod.POST, EchoMethodHandler.CLOSE_URI, true, false); doRequestHandleTest(HttpMethod.GET, EchoMethodHandler.CLOSE_URI, true, false); doRequestHandleTest(HttpMethod.DELETE, EchoMethodHandler.CLOSE_URI, true, false); // SSL enabled doRequestHandleTest(HttpMethod.POST, EchoMethodHandler.CLOSE_URI, true, true); doRequestHandleTest(HttpMethod.GET, EchoMethodHandler.CLOSE_URI, true, true); doRequestHandleTest(HttpMethod.DELETE, EchoMethodHandler.CLOSE_URI, true, true); } /** * Tests for the request handling flow on disconnect * @throws Exception */ @Test public void requestHandleOnDisconnectTest() throws Exception { // disonnecting the embedded channel, calls close of PubliAccessLogRequestHandler doRequestHandleTest(HttpMethod.POST, EchoMethodHandler.DISCONNECT_URI, true, false); doRequestHandleTest(HttpMethod.GET, EchoMethodHandler.DISCONNECT_URI, true, false); doRequestHandleTest(HttpMethod.DELETE, EchoMethodHandler.DISCONNECT_URI, true, false); // SSL enabled doRequestHandleTest(HttpMethod.POST, EchoMethodHandler.DISCONNECT_URI, true, true); doRequestHandleTest(HttpMethod.GET, EchoMethodHandler.DISCONNECT_URI, true, true); doRequestHandleTest(HttpMethod.DELETE, EchoMethodHandler.DISCONNECT_URI, true, true); } /** * Tests for the request handling flow with transfer encoding chunked */ @Test public void requestHandleWithChunkedResponse() throws Exception { doRequestHandleWithChunkedResponse(false); // SSL enabled doRequestHandleWithChunkedResponse(true); } // requestHandleTest() helpers /** * Does a test to see that request handling results in expected entries in public access log * @param httpMethod the {@link HttpMethod} for the request. * @param uri Uri to be used during the request * @param testErrorCase true if error case has to be tested, false otherwise * @param useSSL {@code true} to test SSL logging. * @throws Exception */ private void doRequestHandleTest(HttpMethod httpMethod, String uri, boolean testErrorCase, boolean useSSL) throws Exception { EmbeddedChannel channel = createChannel(useSSL); List<HttpHeaders> httpHeadersList = getHeadersList(); for (HttpHeaders headers : httpHeadersList) { HttpRequest request = RestTestUtils.createRequest(httpMethod, uri, headers); HttpUtil.setKeepAlive(request, true); sendRequestCheckResponse(channel, request, uri, headers, testErrorCase, false, useSSL); if (!testErrorCase) { Assert.assertTrue("Channel should not be closed ", channel.isOpen()); } else { Assert.assertFalse("Channel should have been closed ", channel.isOpen()); channel = createChannel(useSSL); } } channel.close(); } /** * Does a test to see that two consecutive request handling results in expected entries in public access log * with keep alive * @param httpMethod the {@link HttpMethod} for the request. * @param uri Uri to be used during the request * @param useSSL {@code true} to test SSL logging. * @throws Exception */ private void doRequestHandleWithKeepAliveTest(HttpMethod httpMethod, String uri, boolean useSSL) throws Exception { EmbeddedChannel channel = createChannel(useSSL); // contains one logged request header HttpHeaders headers = new DefaultHttpHeaders(); headers.add(HttpHeaderNames.CONTENT_LENGTH, new Random().nextLong()); HttpRequest request = RestTestUtils.createRequest(httpMethod, uri, headers); HttpUtil.setKeepAlive(request, true); sendRequestCheckResponse(channel, request, uri, headers, false, false, useSSL); Assert.assertTrue("Channel should not be closed ", channel.isOpen()); // contains one logged and not logged header headers = new DefaultHttpHeaders(); headers.add(NOT_LOGGED_HEADER_KEY + "1", "headerValue1"); headers.add(HttpHeaderNames.CONTENT_LENGTH, new Random().nextLong()); request = RestTestUtils.createRequest(httpMethod, uri, headers); HttpUtil.setKeepAlive(request, true); sendRequestCheckResponse(channel, request, uri, headers, false, false, useSSL); Assert.assertTrue("Channel should not be closed ", channel.isOpen()); channel.close(); } /** * Does a test to see that two consecutive requests without sending last http content for first request fails * @param httpMethod the {@link HttpMethod} for the request. * @param uri Uri to be used during the request * @param useSSL {@code true} to test SSL logging. * @throws Exception */ private void doRequestHandleWithMultipleRequest(HttpMethod httpMethod, String uri, boolean useSSL) throws Exception { EmbeddedChannel channel = createChannel(useSSL); // contains one logged request header HttpHeaders headers1 = new DefaultHttpHeaders(); headers1.add(HttpHeaderNames.CONTENT_LENGTH, new Random().nextLong()); HttpRequest request = RestTestUtils.createRequest(httpMethod, uri, headers1); HttpUtil.setKeepAlive(request, true); channel.writeInbound(request); // contains one logged and not logged header HttpHeaders headers2 = new DefaultHttpHeaders(); headers2.add(NOT_LOGGED_HEADER_KEY + "1", "headerValue1"); headers2.add(HttpHeaderNames.CONTENT_LENGTH, new Random().nextLong()); // sending another request w/o sending last http content request = RestTestUtils.createRequest(httpMethod, uri, headers2); HttpUtil.setKeepAlive(request, true); sendRequestCheckResponse(channel, request, uri, headers2, false, false, useSSL); Assert.assertTrue("Channel should not be closed ", channel.isOpen()); // verify that headers from first request is not found in public access log String lastLogEntry = publicAccessLogger.getLastPublicAccessLogEntry(); // verify request headers verifyPublicAccessLogEntryForRequestHeaders(lastLogEntry, headers1, request.method(), false); channel.close(); } /** * Sends the provided {@code httpRequest} and verifies that the response is as expected. * @param channel the {@link EmbeddedChannel} to send the request over. * @param httpRequest the {@link HttpRequest} that has to be sent * @param uri, Uri to be used for the request * @param headers {@link HttpHeaders} that is set in the request to be used for verification purposes * @param testErrorCase true if error case has to be tested, false otherwise * @param sslUsed true if SSL was used for this request. */ private void sendRequestCheckResponse(EmbeddedChannel channel, HttpRequest httpRequest, String uri, HttpHeaders headers, boolean testErrorCase, boolean chunkedResponse, boolean sslUsed) throws Exception { channel.writeInbound(httpRequest); if (uri.equals(EchoMethodHandler.DISCONNECT_URI)) { channel.disconnect(); } else { channel.writeInbound(new DefaultLastHttpContent()); } String lastLogEntry = publicAccessLogger.getLastPublicAccessLogEntry(); // verify remote host, http method and uri String subString = testErrorCase ? "Error" : "Info" + ":embedded" + " " + httpRequest.method() + " " + uri; Assert.assertTrue("Public Access log entry doesn't have expected remote host/method/uri ", lastLogEntry.startsWith(subString)); // verify SSL-related info subString = "SSL ([used=" + sslUsed + "]"; if (sslUsed) { subString += ", [principal=" + PEER_CERT.getSubjectX500Principal() + "]"; subString += ", [san=" + PEER_CERT.getSubjectAlternativeNames() + "]"; } subString += ")"; Assert.assertTrue("Public Access log entry doesn't have SSL info set correctly", lastLogEntry.contains(subString)); // verify request headers verifyPublicAccessLogEntryForRequestHeaders(lastLogEntry, headers, httpRequest.method(), true); // verify response subString = "Response ("; for (String responseHeader : RESPONSE_HEADERS.split(",")) { if (headers.contains(responseHeader)) { subString += "[" + responseHeader + "=" + headers.get(responseHeader) + "] "; } } subString += "[isChunked=" + chunkedResponse + "]), status=" + HttpResponseStatus.OK.code(); if (!testErrorCase) { Assert.assertTrue("Public Access log entry doesn't have response set correctly", lastLogEntry.contains(subString)); } else { Assert.assertTrue("Public Access log entry doesn't have error set correctly ", lastLogEntry.contains(": Channel closed while request in progress.")); } } /** * Does a test for the request handling flow with transfer encoding chunked */ private void doRequestHandleWithChunkedResponse(boolean useSSL) throws Exception { EmbeddedChannel channel = createChannel(useSSL); HttpHeaders headers = new DefaultHttpHeaders(); headers.add(EchoMethodHandler.IS_CHUNKED, "true"); HttpRequest request = RestTestUtils.createRequest(HttpMethod.POST, "POST", headers); HttpUtil.setKeepAlive(request, true); sendRequestCheckResponse(channel, request, "POST", headers, false, true, useSSL); Assert.assertTrue("Channel should not be closed ", channel.isOpen()); channel.close(); } // helpers // general /** * Creates an {@link EmbeddedChannel} that incorporates an instance of {@link PublicAccessLogHandler} * and {@link EchoMethodHandler}. * @param useSSL {@code true} to add an {@link SslHandler} to the pipeline. * @return an {@link EmbeddedChannel} that incorporates an instance of {@link PublicAccessLogHandler} * and {@link EchoMethodHandler}, and an {@link SslHandler} if needed. */ private EmbeddedChannel createChannel(boolean useSSL) { EmbeddedChannel channel = new EmbeddedChannel(); if (useSSL) { SSLEngine sslEngine = SSL_CONTEXT.newEngine(channel.alloc()); // HttpRequests pass through the SslHandler without a handshake (it only operates on ByteBuffers) so we have // to mock certain methods of SSLEngine and SSLSession to ensure that we can test certificate logging. SSLEngine mockSSLEngine = new MockSSLEngine(sslEngine, new MockSSLSession(sslEngine.getSession(), PEER_CERT)); channel.pipeline().addLast(new SslHandler(mockSSLEngine)); } channel.pipeline() .addLast(new PublicAccessLogHandler(publicAccessLogger, new NettyMetrics(new MetricRegistry()))) .addLast(new EchoMethodHandler()); return channel; } /** * Prepares a list of HttpHeaders for test purposes * @return */ private List<HttpHeaders> getHeadersList() { List<HttpHeaders> headersList = new ArrayList<HttpHeaders>(); // contains one logged request header HttpHeaders headers = new DefaultHttpHeaders(); headers.add(HttpHeaderNames.CONTENT_TYPE, "content-type1"); headersList.add(headers); // contains one logged and not logged header headers = new DefaultHttpHeaders(); headers.add(NOT_LOGGED_HEADER_KEY + "1", "headerValue1"); headers.add(HttpHeaderNames.CONTENT_TYPE, "content-type2"); headersList.add(headers); // contains all not logged headers headers = new DefaultHttpHeaders(); headers.add(NOT_LOGGED_HEADER_KEY + "1", "headerValue1"); headers.add(NOT_LOGGED_HEADER_KEY + "2", "headerValue2"); headersList.add(headers); // contains all the logged headers headers = new DefaultHttpHeaders(); headers.add(HttpHeaderNames.HOST, "host1"); headers.add(RestUtils.Headers.CONTENT_TYPE, "content-type3"); headers.add(EchoMethodHandler.RESPONSE_HEADER_KEY_1, "responseHeaderValue1"); headers.add(EchoMethodHandler.RESPONSE_HEADER_KEY_2, "responseHeaderValue1"); headersList.add(headers); return headersList; } /** * Verifies either the expected request headers are found or not found (based on the parameter passed) in the * public access log entry * @param logEntry the public access log entry * @param headers expected headers * @param httpMethod HttpMethod type * @param expected, true if the headers are expected, false otherwise */ private void verifyPublicAccessLogEntryForRequestHeaders(String logEntry, HttpHeaders headers, HttpMethod httpMethod, boolean expected) { Iterator<Map.Entry<String, String>> itr = headers.iteratorAsString(); while (itr.hasNext()) { Map.Entry<String, String> entry = itr.next(); if (!entry.getKey().startsWith(NOT_LOGGED_HEADER_KEY) && !entry.getKey() .startsWith(EchoMethodHandler.RESPONSE_HEADER_KEY_PREFIX)) { if (httpMethod == HttpMethod.GET && !entry.getKey().equalsIgnoreCase(HttpHeaderNames.CONTENT_TYPE.toString())) { String subString = "[" + entry.getKey() + "=" + entry.getValue() + "]"; boolean actual = logEntry.contains(subString); if (expected) { Assert.assertTrue("Public Access log entry does not have expected header " + entry.getKey(), actual); } else { Assert.assertFalse("Public Access log entry has unexpected header " + entry.getKey(), actual); } } } } } }