From d6d9fdda9e35bf231e9035211044adfe1dcf3666 Mon Sep 17 00:00:00 2001 From: Johannes Brodwall Date: Sat, 2 Sep 2023 12:53:13 +0200 Subject: [PATCH] Add support for dynamic MDC with jakarta (modern javax) --- logevents/pom.xml | 6 + .../optional/jakarta/HttpServletMDC.java | 63 +++ .../jakarta/HttpServletRequestMDC.java | 87 ++++ .../jakarta/HttpServletResponseMDC.java | 63 +++ .../LogEventsConfigurationServlet.java | 71 ++++ .../optional/jakarta/LogEventsServlet.java | 335 ++++++++++++++++ .../optional/jakarta/HttpServletMDCTest.java | 42 ++ .../jakarta/HttpServletRequestMDCTest.java | 77 ++++ .../jakarta/HttpServletResponseMDCTest.java | 61 +++ .../LogEventsConfigurationServletTest.java | 60 +++ .../jakarta/LogEventsServletTest.java | 370 ++++++++++++++++++ 11 files changed, 1235 insertions(+) create mode 100644 logevents/src/main/java/org/logevents/optional/jakarta/HttpServletMDC.java create mode 100644 logevents/src/main/java/org/logevents/optional/jakarta/HttpServletRequestMDC.java create mode 100644 logevents/src/main/java/org/logevents/optional/jakarta/HttpServletResponseMDC.java create mode 100644 logevents/src/main/java/org/logevents/optional/jakarta/LogEventsConfigurationServlet.java create mode 100644 logevents/src/main/java/org/logevents/optional/jakarta/LogEventsServlet.java create mode 100644 logevents/src/test/java/org/logevents/optional/jakarta/HttpServletMDCTest.java create mode 100644 logevents/src/test/java/org/logevents/optional/jakarta/HttpServletRequestMDCTest.java create mode 100644 logevents/src/test/java/org/logevents/optional/jakarta/HttpServletResponseMDCTest.java create mode 100644 logevents/src/test/java/org/logevents/optional/jakarta/LogEventsConfigurationServletTest.java create mode 100644 logevents/src/test/java/org/logevents/optional/jakarta/LogEventsServletTest.java diff --git a/logevents/pom.xml b/logevents/pom.xml index 68fa02d1..4b14b219 100644 --- a/logevents/pom.xml +++ b/logevents/pom.xml @@ -54,6 +54,12 @@ 3.1.0 true + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + true + com.microsoft.azure applicationinsights-core diff --git a/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletMDC.java b/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletMDC.java new file mode 100644 index 00000000..e1d0c65e --- /dev/null +++ b/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletMDC.java @@ -0,0 +1,63 @@ +package org.logevents.optional.jakarta; + +import org.logevents.config.MdcFilter; +import org.logevents.formatters.exceptions.ExceptionFormatter; +import org.logevents.mdc.DynamicMDC; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Populates MDC or JSON format according to Elastic + * Common Schema guidelines: + * + * + */ +public class HttpServletMDC implements DynamicMDC { + + private final HttpServletRequest request; + private final HttpServletResponse response; + private final long duration; + + public HttpServletMDC(HttpServletRequest request, HttpServletResponse response, long duration) { + this.request = request; + this.response = response; + this.duration = duration; + } + + public static Supplier supplier(HttpServletRequest request, HttpServletResponse response) { + long start = System.currentTimeMillis(); + return () -> new HttpServletMDC(request, response, System.currentTimeMillis() - start); + } + + @Override + public Iterable> entrySet() { + Map result = new java.util.LinkedHashMap<>(); + HttpServletResponseMDC.addMdcVariables(result, response); + HttpServletRequestMDC.addMdcVariables(result, request); + result.put("event.time", String.format("%.04f", duration / 1000.0)); + return result.entrySet(); + } + + @Override + public void populateJsonEvent(Map jsonPayload, MdcFilter mdcFilter, ExceptionFormatter exceptionFormatter) { + HttpServletResponseMDC.populateJson(jsonPayload, response); + HttpServletRequestMDC.populateJson(jsonPayload, exceptionFormatter, request); + jsonPayload.put("event.time", String.format("%.04f", duration / 1000.0)); + } +} diff --git a/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletRequestMDC.java b/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletRequestMDC.java new file mode 100644 index 00000000..9df7731e --- /dev/null +++ b/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletRequestMDC.java @@ -0,0 +1,87 @@ +package org.logevents.optional.jakarta; + +import org.logevents.config.MdcFilter; +import org.logevents.formatters.JsonLogEventFormatter; +import org.logevents.formatters.exceptions.ExceptionFormatter; +import org.logevents.mdc.DynamicMDC; +import org.logevents.mdc.DynamicMDCAdapter; +import org.logevents.util.JsonUtil; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Populates MDC or JSON format according to Elastic + * Common Schema guidelines: + * + *
    + *
  • url.original: The URL as the user entered it
  • + *
  • http.request.method: GET, PUT, POST, DELETE etc
  • + *
  • user.name: {@link HttpServletRequest#getRemoteUser()}
  • + *
  • client.address: {@link HttpServletRequest#getRemoteAddr()}
  • + *
  • event.time: The number of seconds since the request started
  • + *
  • + * error.{class, message, stack_trace} (only JSON, not MDC): + * The exception in "javax.servlet.error.exception" (if any) + *
  • + *
+ */ +public class HttpServletRequestMDC implements DynamicMDC { + + public static void addMdcVariables(Map result, HttpServletRequest request) { + result.put("http.request.method", request.getMethod()); + result.put("url.original", request.getRequestURL().toString()); + result.put("user.name", request.getRemoteUser()); + result.put("client.address", request.getRemoteHost()); + } + + public static void populateJson(Map jsonPayload, ExceptionFormatter exceptionFormatter, HttpServletRequest request) { + jsonPayload.put("url.original", request.getRequestURL().toString()); + jsonPayload.put("user.name", request.getRemoteUser()); + jsonPayload.put("client.address", request.getRemoteHost()); + + if (jsonPayload.containsKey("http")) { + JsonUtil.getObject(jsonPayload, "http").put("request.method", request.getMethod()); + } else { + jsonPayload.put("http.request.method", request.getMethod()); + } + + if (request.getAttribute("javax.servlet.error.exception") instanceof Throwable) { + Throwable exception = (Throwable) request.getAttribute("javax.servlet.error.exception"); + jsonPayload.put("error", JsonLogEventFormatter.toJsonObject(exception, exceptionFormatter)); + } + } + + private final HttpServletRequest request; + private final long duration; + + private HttpServletRequestMDC(HttpServletRequest request, long duration) { + this.request = request; + this.duration = duration; + } + + public static Supplier supplier(HttpServletRequest request) { + long start = System.currentTimeMillis(); + return () -> new HttpServletRequestMDC(request, System.currentTimeMillis() - start); + } + + public static DynamicMDCAdapter.Cleanup put(ServletRequest request) { + return DynamicMDC.putDynamic("request", supplier((HttpServletRequest) request)); + } + + @Override + public Iterable> entrySet() { + Map result = new java.util.LinkedHashMap<>(); + addMdcVariables(result, request); + result.put("event.time", String.format("%.04f", duration / 1000.0)); + return result.entrySet(); + } + + @Override + public void populateJsonEvent(Map jsonPayload, MdcFilter mdcFilter, ExceptionFormatter exceptionFormatter) { + populateJson(jsonPayload, exceptionFormatter, request); + jsonPayload.put("event.time", String.format("%.04f", duration / 1000.0)); + } +} diff --git a/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletResponseMDC.java b/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletResponseMDC.java new file mode 100644 index 00000000..ba5911bd --- /dev/null +++ b/logevents/src/main/java/org/logevents/optional/jakarta/HttpServletResponseMDC.java @@ -0,0 +1,63 @@ +package org.logevents.optional.jakarta; + +import org.logevents.config.MdcFilter; +import org.logevents.formatters.exceptions.ExceptionFormatter; +import org.logevents.mdc.DynamicMDC; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Populates MDC or JSON format according to Elastic + * Common Schema guidelines: + * + *
    + *
  • http.response.status_code: {@link HttpServletResponse#getStatus()}
  • + *
  • http.response.mime_type: {@link HttpServletResponse#getContentType()}
  • + *
  • http.response.redirect: "Location" response header
  • + *
+ */ +public class HttpServletResponseMDC implements DynamicMDC { + + public static void populateJson(Map jsonPayload, HttpServletResponse response) { + Map http = new HashMap<>(); + + Map httpResponse = new HashMap<>(); + httpResponse.put("status_code", response.getStatus()); + httpResponse.put("mime_type", response.getContentType()); + httpResponse.put("redirect", response.getHeader("Location")); + + http.put("response", httpResponse); + jsonPayload.put("http", http); + } + + public static void addMdcVariables(Map result, HttpServletResponse response) { + result.put("http.response.status_code", String.valueOf(response.getStatus())); + result.put("http.response.mime_type", response.getContentType()); + result.put("http.response.redirect", response.getHeader("Location")); + } + + private final HttpServletResponse response; + + public HttpServletResponseMDC(HttpServletResponse response) { + this.response = response; + } + + public static Supplier supplier(HttpServletResponse response) { + return () -> new HttpServletResponseMDC(response); + } + + @Override + public Iterable> entrySet() { + Map result = new java.util.LinkedHashMap<>(); + addMdcVariables(result, response); + return result.entrySet(); + } + + @Override + public void populateJsonEvent(Map jsonPayload, MdcFilter mdcFilter, ExceptionFormatter exceptionFormatter) { + populateJson(jsonPayload, response); + } +} diff --git a/logevents/src/main/java/org/logevents/optional/jakarta/LogEventsConfigurationServlet.java b/logevents/src/main/java/org/logevents/optional/jakarta/LogEventsConfigurationServlet.java new file mode 100644 index 00000000..f369e176 --- /dev/null +++ b/logevents/src/main/java/org/logevents/optional/jakarta/LogEventsConfigurationServlet.java @@ -0,0 +1,71 @@ +package org.logevents.optional.jakarta; + +import org.logevents.LogEventFactory; +import org.logevents.LogEventLogger; +import org.logevents.core.LogEventFilter; +import org.logevents.util.JsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class LogEventsConfigurationServlet extends HttpServlet { + + private static final Logger logger = LoggerFactory.getLogger(LogEventsConfigurationServlet.class); + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + Map configuration = logConfigurationToJson(LogEventFactory.getInstance()); + resp.setContentType("application/json"); + resp.getWriter().println(JsonUtil.toIndentedJson(configuration)); + } + + Map logConfigurationToJson(LogEventFactory logEventFactory) { + Map loggers = logEventFactory.getLoggers(); + + List loggerNames = new ArrayList<>(loggers.keySet()); + Collections.sort(loggerNames); + + Map logLevels = new LinkedHashMap<>(); + // TODO: null check? + logLevels.put("/", logEventFactory.getRootLogger().getOwnFilter().toString()); + for (String loggerName : loggerNames) { + LogEventFilter threshold = logEventFactory.getLogger(loggerName).getOwnFilter(); + logLevels.put(loggerName, threshold != null ? threshold.toString() : ""); + } + + Map observers = new LinkedHashMap<>(); + observers.put("/", logEventFactory.getRootLogger().getObserver()); + for (String loggerName : loggerNames) { + observers.put(loggerName, logEventFactory.getLogger(loggerName).getObserver()); + } + + Map configuration = new LinkedHashMap<>(); + configuration.put("logLevels", logLevels); + configuration.put("observers", observers); + return configuration; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + setLogLevel(req.getParameter("loggerName"), req.getParameter("level")); + resp.sendRedirect(req.getContextPath() + req.getServletPath() + req.getPathInfo()); + } + + void setLogLevel(String loggerName, String levelName) { + Level level = levelName == null || levelName.equals("null") ? null : Level.valueOf(levelName); + logger.info("Changing log level for {} to {}", loggerName, level); + + LogEventFactory.getInstance().setLevel(loggerName, level); + } + +} diff --git a/logevents/src/main/java/org/logevents/optional/jakarta/LogEventsServlet.java b/logevents/src/main/java/org/logevents/optional/jakarta/LogEventsServlet.java new file mode 100644 index 00000000..e5ec8e92 --- /dev/null +++ b/logevents/src/main/java/org/logevents/optional/jakarta/LogEventsServlet.java @@ -0,0 +1,335 @@ +package org.logevents.optional.jakarta; + +import org.logevents.LogEventFactory; +import org.logevents.LogEventLogger; +import org.logevents.LogEventObserver; +import org.logevents.observers.LogEventSource; +import org.logevents.observers.WebLogEventObserver; +import org.logevents.observers.web.CryptoVault; +import org.logevents.query.LogEventQuery; +import org.logevents.query.LogEventQueryResult; +import org.logevents.status.LogEventStatus; +import org.logevents.util.JsonParser; +import org.logevents.util.JsonUtil; +import org.logevents.util.openid.OpenIdConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A servlet that exposes log information to administrative users via a built in web page. To use, you need to: + *
    + *
  • + * Run your application in a servlet container: Add LogEventsServlet as a servlet in web.xml, add in a ServletContextListener or in Spring, add a ServletRegistrationBean + *
  • + *
  • + * You need an Identity Provider that supports OpenID Connect to authorize administrative users. + * If you don't have any existing options, I suggest creating a (free!) Azure Active Directory + * and adding users that should have access as guest users. See {@link OpenIdConfiguration} + * to learn how to set this up. + *
  • + *
  • + * In order to run LogEventsServlet needs security configuration in your logevents*.properties. + * You need to set observer.servlet.openIdIssuer, observer.servlet.clientId + * and observer.servlet.clientSecret. See {@link WebLogEventObserver} + *
  • + *
  • + * If you mount LogEventsServlet on "/logs", the API will be at "/logs/events", the OpenAPI documentation + * will be at "/logs/openapi.json" and a simple client web page will be at "/logs/"". + *
  • + *
+ * + *

Example configuration:

+ * + *
+ * observer.servlet=WebLogEventObserver
+ * observer.servlet.openIdIssuer=https://login.microsoftonline.com/common
+ * observer.servlet.clientId=12345678-abcd-pqrs-9876-9abcdef01234
+ * observer.servlet.clientSecret=3¤..¤!?qwer
+ * observer.servlet.redirectUri=https://my-server.example.com/logs/oauth2callback
+ * observer.servlet.requiredClaim.username=johannes@brodwall.com,someone@brodwall.com
+ * observer.servlet.requiredClaim.roles=admin
+ * 
+ * + *

Register LogEventsServlet in your servlet container

+ * + *

Example web.xml-file

+ * + *
+ * <servlet>
+ *     <servlet-name>LogEvents</servlet-name>
+ *     <servlet-class>org.logevents.extend.servlets.LogEventsServlet</servlet-class>
+ * </servlet>
+ * <servlet-mapping>
+ *   <servlet-name>LogEvents</servlet-name>
+ *   <url-pattern>/*</url-pattern>
+ * </servlet-mapping>
+ * 
+ * + *

Example ServletContextListener

+ * + *
+ * public class ApplicationContext implements ServletContextListener {
+ *     public void contextInitialized(ServletContextEvent sce) {
+ *        sce.getServletContext().addServlet("logs", new LogEventsServlet()).addMapping("/logs/*");
+ *    }
+ *    public void contextDestroyed(ServletContextEvent sce) {
+ *    }
+ * }
+ * 
+ * + *

Example Spring ServletRegistrationBean

+ * + *
+ * @Bean
+ * public ServletRegistrationBean servletRegistrationBean(){
+ *     return new ServletRegistrationBean(new LogEventsServlet(), "/logs/*");
+ * }
+ * 
+ * + * @see WebLogEventObserver + * @see OpenIdConfiguration + * @see LogEventQuery + * + */ +public class LogEventsServlet extends HttpServlet { + + private final static Logger logger = LoggerFactory.getLogger(LogEventsServlet.class); + private static final Marker AUDIT = MarkerFactory.getMarker("AUDIT"); + private static final String LOGEVENTS_API = "/org/logevents/openapi.json"; + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String path = req.getPathInfo(); + String contextPath = req.getContextPath() != null ? req.getContextPath() : ""; + if (path == null) { + resp.sendRedirect(contextPath + req.getServletPath() + "/" + + (req.getQueryString() != null ? "?" + req.getQueryString() : "")); + } else if (path.equals("/")) { + resp.setContentType("text/html"); + copyResource(resp, getLogEventsHtml()); + } else if (path.matches("/[a-zA-Z._-]+\\.css")) { + resp.setContentType("text/css"); + copyResource(resp, "/org/logevents" + path); + } else if (path.matches("/[a-zA-Z._-]+\\.js")) { + resp.setContentType("text/javascript"); + copyResource(resp, "/org/logevents" + path); + } else if (path.equals("/openapi.json")) { + resp.setContentType("application/json"); + Map api = JsonParser.parseObject(getClass().getResourceAsStream(LOGEVENTS_API)); + HashMap localServer = new HashMap<>(); + localServer.put("url", contextPath + req.getServletPath()); + api.put("servers", Collections.singletonList(localServer)); + resp.getWriter().write(JsonUtil.toIndentedJson(api)); + } else if (path.equals("/login")) { + String state = OpenIdConfiguration.randomString(50); + resp.sendRedirect(getOpenIdConfiguration().getAuthorizationUrl( + state, getServletUrl(req) + "/oauth2callback" + )); + } else if (path.equals("/oauth2callback")) { + establishSession(req, resp); + } else if (!authenticated(resp, req.getCookies())) { + resp.sendError(401, "Please log in"); + } else if (path.equals("/events")) { + LogEventQuery query = new LogEventQuery(req.getParameterMap()); + LogEventQueryResult queryResult = getLogEventSource().query(query); + + Map result = new LinkedHashMap<>(); + result.put("facets", queryResult.getSummary().toJson()); + result.put("events", queryResult.getEventsAsJson()); + + resp.setContentType("application/json"); + resp.getWriter().write(JsonUtil.toIndentedJson(result)); + } else if (path.equals("/loggers")) { + Map result = loggersAsJson(LogEventFactory.getInstance()); + resp.setContentType("application/json"); + resp.getWriter().write(JsonUtil.toIndentedJson(result)); + } else { + resp.sendError(404, "Not found " + path); + } + } + + protected String getLogEventsHtml() { + return getObserver().getLogEventsHtml(); + } + + Map loggersAsJson(LogEventFactory factory) { + Map configuration = new HashMap<>(); + List> loggers = new ArrayList<>(); + + List loggerNames = new ArrayList<>(); + loggerNames.add(Logger.ROOT_LOGGER_NAME); + factory.getLoggers().entrySet().stream() + .filter(entry -> entry.getValue().isConfigured()) + .map(Map.Entry::getKey) + .sorted() + .forEach(loggerNames::add); + + for (String loggerName : loggerNames) { + Map loggerJson = new LinkedHashMap<>(); + loggerJson.put("loggerName", loggerName); + LogEventLogger logger = factory.getLogger(loggerName); + loggerJson.put("trace", observersAsJson(logger.getTraceObservers())); + loggerJson.put("debug", observersAsJson(logger.getDebugObservers())); + loggerJson.put("info", observersAsJson(logger.getInfoObservers())); + loggerJson.put("warn", observersAsJson(logger.getWarnObservers())); + loggerJson.put("error", observersAsJson(logger.getErrorObservers())); + loggers.add(loggerJson); + } + + configuration.put("loggers", loggers); + return configuration; + } + + private List> observersAsJson(LogEventObserver observers) { + return observers.stream() + .map(o -> { + Map observer = new HashMap<>(); + observer.put("observerClass", o.getClass().getName()); + observer.put("observerDescription", o.toString()); + return observer; + }) + .collect(Collectors.toList()); + } + + protected void establishSession(HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (req.getParameter("error_description") != null) { + resp.getWriter().write("Login failed\n\n"); + resp.getWriter().write(req.getParameter("error_description")); + return; + } + + Map idToken = getOpenIdConfiguration() + .fetchIdToken(req.getParameter("code"), getServletUrl(req) + "/oauth2callback"); + + if (!getOpenIdConfiguration().isAuthorizedToken(idToken)) { + logger.warn(AUDIT, "Unknown user tried to log in {}", idToken); + resp.sendError(403, "Unauthorized"); + return; + } + + logger.warn(AUDIT, "User logged in {}", idToken); + LogEventStatus.getInstance().addConfig(this, "User logged in " + idToken); + + resp.addCookie(createSessionCookie(idToken)); + String location = req.getContextPath() + req.getServletPath() + "/"; + String redirectTo = findCookie(req.getCookies(), "logevents.query") + .map(query -> location + "?" + query) + .orElse(location); + resp.sendRedirect(redirectTo); + } + + protected LogEventSource getLogEventSource() { + return getObserver().getLogEventSource(); + } + + protected OpenIdConfiguration getOpenIdConfiguration() { + return getObserver().getOpenIdConfiguration(); + } + + protected Optional findCookie(Cookie[] reqCookies, String name) { + return Optional.ofNullable(reqCookies) + .flatMap(cookies -> Stream.of(cookies) + .filter(c -> c.getName().equals(name)) + .map(Cookie::getValue) + .findAny() + ); + } + + private String getServletUrl(HttpServletRequest req) { + return getServerUrl(req) + req.getContextPath() + req.getServletPath(); + } + + protected Cookie createSessionCookie(Map idToken) { + String session = "subject=" + idToken.get("sub") + "\n" + + "sessionTime=" + Instant.ofEpochSecond(Long.parseLong(idToken.get("iat").toString())); + return new Cookie("logevents.session", encrypt(session)); + } + + private String encrypt(String session) { + return getCookieVault().encrypt(session); + } + + protected String decrypt(String value) throws GeneralSecurityException { + return getCookieVault().decrypt(value); + } + + protected synchronized CryptoVault getCookieVault() { + return getObserver().getCookieVault(); + } + + protected boolean authenticated(HttpServletResponse resp, Cookie[] cookies) { + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("logevents.session")) { + try { + Map session = Stream.of(decrypt(cookie.getValue()).split("\n")) + .collect(Collectors.toMap( + s -> s.split("=")[0], + s -> s.split("=")[1] + )); + if (session.containsKey("sessionTime")) { + Instant sessionTime = Instant.parse(session.get("sessionTime")); + if (Instant.now().isBefore(sessionTime.plusSeconds(60*60))) { + return true; + } + } + } catch (GeneralSecurityException|IllegalArgumentException|ArrayIndexOutOfBoundsException e) { + LogEventStatus.getInstance().addInfo(this, "Decoding session failed, invalidating session " + e); + } + cookie.setValue(""); + cookie.setMaxAge(0); + resp.addCookie(cookie); + return false; + } + } + } + return false; + } + + protected void copyResource(HttpServletResponse resp, String resource) throws IOException { + InputStream resourceAsStream = getClass().getResourceAsStream(resource); + if (resourceAsStream == null) { + resp.sendError(404); + return; + } + try (Reader html = new InputStreamReader(resourceAsStream)) { + int c; + while ((c = html.read()) != -1) { + resp.getWriter().write((char) c); + } + } + } + + protected String getServerUrl(HttpServletRequest req) { + String scheme = Optional.ofNullable(req.getHeader("X-Forwarded-Proto")).orElse(req.getScheme()); + String host = Optional.ofNullable(req.getHeader("X-Forwarded-Host")).orElse(req.getHeader("Host")); + return scheme + "://" + host; + } + + public WebLogEventObserver getObserver() { + return (WebLogEventObserver) LogEventFactory.getInstance().tryGetObserver("servlet"); + } + +} diff --git a/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletMDCTest.java b/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletMDCTest.java new file mode 100644 index 00000000..d541d477 --- /dev/null +++ b/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletMDCTest.java @@ -0,0 +1,42 @@ +package org.logevents.optional.jakarta; + +import org.junit.Test; +import org.logevents.LogEvent; +import org.logevents.formatters.JsonLogEventFormatter; +import org.logevents.mdc.DynamicMDCAdapter; +import org.logevents.mdc.DynamicMDCAdapterImplementation; +import org.logevents.optional.junit.LogEventSampler; +import org.logevents.util.JsonUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.logevents.optional.jakarta.HttpServletRequestMDCTest.createMockRequest; +import static org.logevents.optional.jakarta.HttpServletResponseMDCTest.createMockResponse; + +public class HttpServletMDCTest { + + private final JsonLogEventFormatter jsonFormatter = new JsonLogEventFormatter(); + private final DynamicMDCAdapter mdcAdapter = new DynamicMDCAdapterImplementation(); + + @Test + public void shouldFormatRequestAndResponseInJson() { + HttpServletRequest mockRequest = createMockRequest(); + HttpServletResponse mockResponse = createMockResponse(); + try (DynamicMDCAdapter.Cleanup ignored = mdcAdapter.putDynamic("request", HttpServletMDC.supplier(mockRequest, mockResponse))) { + LogEvent event = new LogEventSampler().build(mdcAdapter); + + Map jsonLogEvent = jsonFormatter.toJsonObject(event); + + Map http = JsonUtil.getObject(jsonLogEvent, "http"); + Map httpResponse = JsonUtil.getObject(http, "response"); + assertEquals(mockResponse.getStatus(), httpResponse.get("status_code")); + assertEquals(mockResponse.getContentType(), httpResponse.get("mime_type")); + + assertEquals("GET", JsonUtil.getField(http, "request.method")); + } + } + +} diff --git a/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletRequestMDCTest.java b/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletRequestMDCTest.java new file mode 100644 index 00000000..6c4c6cc0 --- /dev/null +++ b/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletRequestMDCTest.java @@ -0,0 +1,77 @@ +package org.logevents.optional.jakarta; + +import org.junit.Test; +import org.logevents.LogEvent; +import org.logevents.config.MdcFilter; +import org.logevents.formatters.JsonLogEventFormatter; +import org.logevents.mdc.DynamicMDCAdapter; +import org.logevents.mdc.DynamicMDCAdapterImplementation; +import org.logevents.optional.junit.LogEventSampler; +import org.logevents.util.JsonUtil; +import org.mockito.Mockito; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class HttpServletRequestMDCTest { + + private final JsonLogEventFormatter jsonFormatter = new JsonLogEventFormatter(); + private final DynamicMDCAdapter mdcAdapter = new DynamicMDCAdapterImplementation(); + + @Test + public void shouldFormatRequestInJson() { + HttpServletRequest mockRequest = createMockRequest(); + try (DynamicMDCAdapter.Cleanup ignored = mdcAdapter.putDynamic("request", HttpServletRequestMDC.supplier(mockRequest))) { + LogEvent event = new LogEventSampler().build(mdcAdapter); + Map jsonLogEvent = jsonFormatter.toJsonObject(event); + assertNull(jsonLogEvent.get("mdc")); + assertEquals("GET", JsonUtil.getField(jsonLogEvent, "http.request.method")); + assertEquals("http://localhost:8080/test", JsonUtil.getField(jsonLogEvent, "url.original")); + } + } + + @Test + public void shouldProvideMdcForRequest() { + HttpServletRequest mockRequest = createMockRequest(); + try (DynamicMDCAdapter.Cleanup ignored = mdcAdapter.putDynamic("request", HttpServletRequestMDC.supplier(mockRequest))) { + LogEvent event = new LogEventSampler().build(mdcAdapter); + + assertEquals( + " {http.request.method=GET, url.original=http://localhost:8080/test}", + event.getMdcString(new MdcFilter.IncludedMdcKeys(new HashSet<>(Arrays.asList("something", "url.original", "http.request.method")))) + ); + } + } + + @Test + public void shouldIncludeExceptionIfAvailable() { + HttpServletRequest mockRequest = createMockRequest(); + Throwable exception = new IOException("Here it is!"); + Mockito.when(mockRequest.getAttribute("javax.servlet.error.exception")).thenReturn(exception); + + try (DynamicMDCAdapter.Cleanup ignored = mdcAdapter.putDynamic("request", HttpServletRequestMDC.supplier(mockRequest))) { + LogEvent event = new LogEventSampler().build(mdcAdapter); + + Map jsonLogEvent = jsonFormatter.toJsonObject(event); + assertNull(jsonLogEvent.get("mdc")); + + Map error = JsonUtil.getObject(jsonLogEvent, "error"); + assertEquals(exception.getClass().getName(), error.get("class")); + assertEquals(exception.getMessage(), error.get("message")); + } + } + + static HttpServletRequest createMockRequest() { + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + Mockito.when(mockRequest.getRequestURL()).thenReturn(new StringBuffer("http://localhost:8080/test")); + Mockito.when(mockRequest.getMethod()).thenReturn("GET"); + return mockRequest; + } + +} \ No newline at end of file diff --git a/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletResponseMDCTest.java b/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletResponseMDCTest.java new file mode 100644 index 00000000..b44f94f6 --- /dev/null +++ b/logevents/src/test/java/org/logevents/optional/jakarta/HttpServletResponseMDCTest.java @@ -0,0 +1,61 @@ +package org.logevents.optional.jakarta; + +import org.junit.Test; +import org.logevents.LogEvent; +import org.logevents.config.MdcFilter; +import org.logevents.formatters.JsonLogEventFormatter; +import org.logevents.mdc.DynamicMDCAdapter; +import org.logevents.mdc.DynamicMDCAdapterImplementation; +import org.logevents.optional.junit.LogEventSampler; +import org.logevents.util.JsonUtil; +import org.mockito.Mockito; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class HttpServletResponseMDCTest { + + private final JsonLogEventFormatter jsonFormatter = new JsonLogEventFormatter(); + private final DynamicMDCAdapter mdcAdapter = new DynamicMDCAdapterImplementation(); + + @Test + public void shouldFormatRequestInJson() { + HttpServletResponse mockResponse = createMockResponse(); + try (DynamicMDCAdapter.Cleanup ignored = mdcAdapter.putDynamic("response", HttpServletResponseMDC.supplier(mockResponse))) { + LogEvent event = new LogEventSampler().build(mdcAdapter); + Map jsonLogEvent = jsonFormatter.toJsonObject(event); + assertNull(jsonLogEvent.get("mdc")); + Map http = JsonUtil.getObject(jsonLogEvent, "http"); + Map httpResponse = JsonUtil.getObject(http, "response"); + + assertEquals(401, httpResponse.get("status_code")); + assertEquals("text/html", httpResponse.get("mime_type")); + } + } + + @Test + public void shouldProvideMdcForResponse() { + HttpServletResponse mockResponse = createMockResponse(); + try (DynamicMDCAdapter.Cleanup ignored = mdcAdapter.putDynamic("response", HttpServletResponseMDC.supplier(mockResponse))) { + LogEvent event = new LogEventSampler().build(mdcAdapter); + + assertEquals( + " {http.response.status_code=401, http.response.mime_type=text/html}", + event.getMdcString(new MdcFilter.IncludedMdcKeys(new HashSet<>(Arrays.asList("http.response.status_code", "http.response.mime_type")))) + ); + } + } + + static HttpServletResponse createMockResponse() { + HttpServletResponse mockResponse = Mockito.mock(HttpServletResponse.class); + Mockito.when(mockResponse.getStatus()).thenReturn(401); + Mockito.when(mockResponse.getContentType()).thenReturn("text/html"); + return mockResponse; + } + +} \ No newline at end of file diff --git a/logevents/src/test/java/org/logevents/optional/jakarta/LogEventsConfigurationServletTest.java b/logevents/src/test/java/org/logevents/optional/jakarta/LogEventsConfigurationServletTest.java new file mode 100644 index 00000000..d9a05e34 --- /dev/null +++ b/logevents/src/test/java/org/logevents/optional/jakarta/LogEventsConfigurationServletTest.java @@ -0,0 +1,60 @@ +package org.logevents.optional.jakarta; + +import org.junit.Test; +import org.logevents.LogEventFactory; +import org.logevents.observers.CircularBufferLogEventObserver; +import org.logevents.util.JsonUtil; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.logevents.util.JsonUtil.getField; +import static org.logevents.util.JsonUtil.getObject; + +public class LogEventsConfigurationServletTest { + + private LogEventsConfigurationServlet servlet = new LogEventsConfigurationServlet(); + private LogEventFactory factory = LogEventFactory.getInstance(); + + @Test + public void shouldTranslateLogLevelsToJson() { + servlet.setLogLevel("org.example", "TRACE"); + + Map json = servlet.logConfigurationToJson(factory); + Map levels = JsonUtil.getObject(json, "logLevels"); + + assertEquals(factory.getRootLogger().getOwnFilter().toString(), + JsonUtil.getField(levels, "/")); + assertEquals("LogEventFilter{ERROR,WARN,INFO,DEBUG,TRACE}", JsonUtil.getField(levels, "org.example")); + assertEquals("", JsonUtil.getField(levels, "org")); + } + + @Test + public void shouldResetLogLevel() { + servlet.setLogLevel("org.example", "TRACE"); + + assertEquals("LogEventFilter{ERROR,WARN,INFO,DEBUG,TRACE}", + getField(getObject(servlet.logConfigurationToJson(factory), "logLevels"), "org.example")); + + servlet.setLogLevel("org.example", "null"); + assertEquals("", + getField(getObject(servlet.logConfigurationToJson(factory), "logLevels"), "org.example")); + + } + + @Test + public void shouldTranslateLogObserversToJson() { + factory.setRootObserver(new CircularBufferLogEventObserver()); + factory.setObserver("org.example", new CircularBufferLogEventObserver()); + Map json = servlet.logConfigurationToJson(factory); + + Map observers = JsonUtil.getObject(json, "observers"); + assertEquals("CircularBufferLogEventObserver{size=0,capacity=200}", + JsonUtil.getField(observers, "/")); + assertEquals("CompositeLogEventObserver{[CircularBufferLogEventObserver{size=0,capacity=200}, CircularBufferLogEventObserver{size=0,capacity=200}]}", + JsonUtil.getField(observers, "org.example")); + assertEquals("CircularBufferLogEventObserver{size=0,capacity=200}", + JsonUtil.getField(observers, "org")); + } + +} diff --git a/logevents/src/test/java/org/logevents/optional/jakarta/LogEventsServletTest.java b/logevents/src/test/java/org/logevents/optional/jakarta/LogEventsServletTest.java new file mode 100644 index 00000000..753e52fe --- /dev/null +++ b/logevents/src/test/java/org/logevents/optional/jakarta/LogEventsServletTest.java @@ -0,0 +1,370 @@ +package org.logevents.optional.jakarta; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; +import org.logevents.LogEvent; +import org.logevents.LogEventFactory; +import org.logevents.LogEventObserver; +import org.logevents.config.Configuration; +import org.logevents.core.CompositeLogEventObserver; +import org.logevents.core.FixedLevelThresholdConditionalObserver; +import org.logevents.core.LevelThresholdConditionalObserver; +import org.logevents.formatters.messages.MessageFormatter; +import org.logevents.observers.LogEventBuffer; +import org.logevents.observers.TestObserver; +import org.logevents.observers.WebLogEventObserver; +import org.logevents.optional.junit.LogEventSampler; +import org.logevents.status.LogEventStatus; +import org.logevents.status.StatusEvent; +import org.logevents.util.JsonParser; +import org.logevents.util.JsonUtil; +import org.logevents.util.openid.OpenIdConfiguration; +import org.mockito.ArgumentCaptor; +import org.slf4j.event.Level; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.UnknownHostException; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class LogEventsServletTest extends LogEventsServlet { + + private final LogEventsServlet servlet = new LogEventsServlet(); + private final Random random = new Random(); + private final HttpServletResponse response = mock(HttpServletResponse.class); + private final HttpServletRequest request = mock(HttpServletRequest.class); + + @Before + public void setupRequest() { + when(request.getScheme()).thenReturn("https"); + when(request.getServerName()).thenReturn("www.example.com"); + when(request.getContextPath()).thenReturn(""); + when(request.getServletPath()).thenReturn("/logs"); + when(request.getHeader("Host")).thenReturn("www.example.com"); + } + + @Test + public void usersShouldBeAuthenticated() { + Map idToken = createSessionCookieToken(System.currentTimeMillis()); + Cookie sessionCookie = servlet.createSessionCookie(idToken); + + boolean authenticated = servlet.authenticated(response, + new Cookie[]{sessionCookie} + ); + assertTrue(sessionCookie + " should be authenticated", authenticated); + } + + @Test + public void shouldRemoveExpiredCookie() { + Cookie sessionCookie = servlet.createSessionCookie( + createSessionCookieToken(Instant.now().minusSeconds(2 * 60 * 60).getEpochSecond()) + ); + assertEquals(-1, sessionCookie.getMaxAge()); + + boolean authenticated = servlet.authenticated(response, new Cookie[] { sessionCookie }); + assertFalse(sessionCookie + " should be expired", authenticated); + verify(response).addCookie(sessionCookie); + assertEquals(0, sessionCookie.getMaxAge()); + } + + @Test + public void shouldRemoveTamperedCookie() { + LogEventStatus.getInstance().setThreshold(StatusEvent.StatusLevel.NONE); + Cookie sessionCookie = servlet.createSessionCookie( + createSessionCookieToken(Instant.now().minusSeconds(2 * 60 * 60).getEpochSecond()) + ); + sessionCookie.setValue("000" + sessionCookie.getValue().substring(3)); + + boolean authenticated = servlet.authenticated(response, new Cookie[] { sessionCookie }); + assertFalse(sessionCookie + " should be expired", authenticated); + verify(response).addCookie(sessionCookie); + } + + @Test + public void shouldFormatLogEvent() throws IOException { + LogEventBuffer buffer = new LogEventBuffer(); + LogEventBuffer.clear(); + WebLogEventObserver observer = new WebLogEventObserver() { + @Override + public LogEventBuffer getLogEventSource() { + return buffer; + } + }; + LogEventsServlet servlet = new LogEventsServlet() { + @Override + public WebLogEventObserver getObserver() { + return observer; + } + }; + LogEvent logEvent = new LogEventSampler() + .withLevel(Level.ERROR) + .withMarker().withMdc("clientIp", "127.0.0.1") + .withThrowable(new IOException()).build(); + buffer.logEvent(logEvent); + + HashMap parameters = new HashMap<>(); + parameters.put("level", new String[] { "ERROR" }); + when(request.getParameterMap()).thenReturn(parameters); + when(request.getPathInfo()).thenReturn("/events"); + Map idToken = createSessionCookieToken(Instant.now().getEpochSecond()); + when(request.getCookies()).thenReturn(new Cookie[] { servlet.createSessionCookie(idToken)}); + + StringWriter result = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(result)); + servlet.doGet(request, response); + + verify(response).setContentType("application/json"); + Map object = JsonParser.parseObject(result.toString()); + + List events = JsonUtil.getList(object, "events"); + assertEquals(events.toString(), 1, events.size()); + Object classNameOfStacktrace = JsonUtil.getField( + JsonUtil.getObject( + JsonUtil.getList(JsonUtil.getObject(events, 0), "stackTrace"), + 0), "className"); + assertEquals(getClass().getName(), classNameOfStacktrace); + } + + @Test + public void shouldGenerateAuthenticationUrl() throws IOException { + String openIdIssuer = "https://login.microsoftonline.com/common"; + Map properties = new HashMap<>(); + properties.put("observer.servlet.clientId", "my-application"); + properties.put("observer.servlet.clientSecret", "abc123"); + properties.put("observer.servlet.openIdIssuer", openIdIssuer); + + try { + HttpURLConnection conn = (HttpURLConnection) new URL(openIdIssuer).openConnection(); + conn.getResponseCode(); + } catch (UnknownHostException e) { + Assume.assumeNoException("Network down", e); + } + + WebLogEventObserver observer = new WebLogEventObserver(new Configuration(properties, "observer.servlet")); + HashMap> observers = new HashMap<>(); + observers.put("servlet", () -> observer); + LogEventFactory.getInstance().setObservers(observers); + + when(request.getPathInfo()).thenReturn("/login"); + + servlet.doGet(request, response); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(response).sendRedirect(captor.capture()); + + String prefix = "https://login.microsoftonline.com/common/oauth2/authorize?response_type=code&client_id=my-application&redirect_uri=https://www.example.com"; + assertTrue(captor.getValue() + " should start with " + prefix, + captor.getValue().startsWith(prefix)); + } + + @Test + public void shouldCompleteLogin() throws IOException, GeneralSecurityException { + when(request.getPathInfo()).thenReturn("/oauth2callback"); + when(request.getParameter("code")).thenReturn(String.valueOf(random.nextInt())); + + Instant issueTime = ZonedDateTime.of(2019, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault()).plusMinutes(random.nextInt(60 * 24 * 365)).toInstant(); + long subject = random.nextLong(); + OpenIdConfiguration openIdConfiguration = new OpenIdConfiguration(null, null, null) { + @Override + protected Map postTokenRequest(Map formPayload) { + HashMap tokenResponse = new HashMap<>(); + + Map idToken = new HashMap<>(); + idToken.put("iat", issueTime.toEpochMilli()/1000); + idToken.put("sub", subject); + String payload = Base64.getEncoder().encodeToString(JsonUtil.toIndentedJson(idToken).getBytes()); + tokenResponse.put("id_token", "sdgslnl." + payload + ".dgs"); + return tokenResponse; + } + }; + WebLogEventObserver observer = new WebLogEventObserver(openIdConfiguration, new MessageFormatter(), new LogEventBuffer()); + HashMap> observers = new HashMap<>(); + observers.put("servlet", () -> observer); + LogEventFactory.getInstance().setObservers(observers); + + LogEventsServlet servlet = new LogEventsServlet(); + + servlet.doGet(request, response); + + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookieCaptor.capture()); + String cookieValue = servlet.decrypt(cookieCaptor.getValue().getValue()); + + assertEquals("subject=" + subject + "\nsessionTime=" + issueTime, + cookieValue); + } + + @Test + public void shouldRejectIdTokensWithoutRequiredClaims() throws IOException { + Map properties = new HashMap<>(); + properties.put("observer.servlet.clientId", "my-application"); + properties.put("observer.servlet.clientSecret", "abc123"); + properties.put("observer.servlet.openIdIssuer", "https://login.microsoftonline.com/common"); + properties.put("observer.servlet.requiredClaim.email_verified", "true"); + properties.put("observer.servlet.requiredClaim.email", "alice@example.com, bob@example.com"); + + OpenIdConfiguration openIdConfiguration = new OpenIdConfiguration(new Configuration(properties, "observer.servlet")) { + @Override + protected Map postTokenRequest(Map formPayload) { + HashMap tokenResponse = new HashMap<>(); + Map idToken = new HashMap<>(); + idToken.put("iat", System.currentTimeMillis() / 1000); + idToken.put("email", "stranger@example.org"); + idToken.put("email_verified", true); + String payload = Base64.getEncoder().encodeToString(JsonUtil.toIndentedJson(idToken).getBytes()); + tokenResponse.put("id_token", "sdgslnl." + payload + ".dgs"); + return tokenResponse; + } + }; + LogEventsServlet servlet = new LogEventsServlet() { + @Override + protected OpenIdConfiguration getOpenIdConfiguration() { + return openIdConfiguration; + } + }; + + when(request.getPathInfo()).thenReturn("/oauth2callback"); + when(request.getParameter("code")).thenReturn(String.valueOf(random.nextInt())); + servlet.doGet(request, response); + + verify(response, never()).addCookie(any()); + verify(response).sendError(eq(403), anyString()); + } + + @Test + public void shouldGenerateLoginUrl() throws IOException { + OpenIdConfiguration openIdConfiguration = new OpenIdConfiguration(null, null, null) { + @Override + protected String getAuthorizationEndpoint() { + return "https://accounts.example.com/authorize"; + } + }; + LogEventsServlet servlet = new LogEventsServlet() { + @Override + protected OpenIdConfiguration getOpenIdConfiguration() { + return openIdConfiguration; + } + }; + when(request.getPathInfo()).thenReturn("/login"); + + servlet.doGet(request, response); + verify(response).sendRedirect(startsWith("https://accounts.example.com")); + } + + @Test + public void shouldReturnOpenApiDefinition() throws IOException { + String output = requestUrl("/openapi.json"); + verify(response).setContentType("application/json"); + Map openApiDefinition = JsonParser.parseObject(output); + assertEquals("Log Events - a simple Java Logging library", + JsonUtil.getField(JsonUtil.getObject(openApiDefinition, "info"), "description")); + } + + @Test + public void shouldReturnHtmlPage() throws IOException { + String output = requestUrl("/"); + verify(response).setContentType("text/html"); + assertTrue("Should be an HTML file: " + output, output.startsWith("\n loggers = servlet.loggersAsJson(factory); + + List> logArray = JsonUtil.getObjectList(loggers, "loggers"); + assertEquals(Arrays.asList("ROOT", "org.example", "org.example.subexample"), + logArray.stream().map(o -> o.get("loggerName")).collect(Collectors.toList())); + Map exampleLogger = logArray.stream() + .filter(o -> o.get("loggerName").equals("org.example")) + .findFirst().orElseThrow(AssertionError::new); + assertEquals(Arrays.asList("TestObserver{global}"), + ((List)exampleLogger.get("info")).stream() + .map(o -> (Map)o) + .map(o -> o.get("observerDescription")) + .collect(Collectors.toList())); + assertEquals(Arrays.asList("TestObserver{global}", "TestObserver{info}", "TestObserver{warn}"), + ((List)exampleLogger.get("warn")).stream() + .map(o -> (Map)o) + .map(o -> o.get("observerDescription")) + .collect(Collectors.toList())); + } + + private String requestUrl(String s) throws IOException { + when(request.getPathInfo()).thenReturn(s); + StringWriter output = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(output)); + servlet.doGet(request, response); + return output.toString(); + } + + public Map createSessionCookieToken(long epochSecond) { + HashMap idToken = new HashMap<>(); + idToken.put("sub", "subjectId12345_abc"); + idToken.put("iat", epochSecond); + return idToken; + } +}