diff --git a/CHANGELOG.md b/CHANGELOG.md index 42dc0f0..8d58b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.7] - 2026-06-20 +### Added +- Implemented a new `SECURITY` severity classification for unhandled filter-level exceptions. +- Added strict type safety to the dispatch payload structure via `LogDispatchPayload`. +- Added support for excluding specific URI paths from being intercepted by the filter. +- Added support for tracking and masking additional HTTP headers in `LogDispatchFilter`. +- Added `LogDispatchAspectTest` for full AOP test coverage. + +### Changed +- Refactored `LogDispatchFilterTest` into focused, SRP-compliant test files with a shared base test class. +- Simplified GitHub Actions CI/CD workflows and implemented a PR gating mechanism. +- Bumped `spring.boot.version` dependency from `3.3.2` to `3.5.15`. + +### Documentation +- Added explicit testing guidelines and contribution instructions via `TESTING.md`. +- Added a comprehensive troubleshooting guide (`TROUBLESHOOTING.md`). +- Added `application.properties` configuration examples. +- Removed deprecated references to `logdispatch.enabled`. +- Added CI, Java, and License status badges to `README.md`. + ## [1.0.6] - 2026-06-12 ### Changed - Upgraded internal build infrastructure: Maven wrapper bumped to 3.9.8 and `maven-compiler-plugin` to 3.15.0. diff --git a/README.md b/README.md index cf7a2a9..3b694ee 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,29 @@ # LogDispatch Spring Boot Starter [![Maven Central](https://img.shields.io/maven-central/v/in.maheshlangote/logdispatch-spring-boot-starter)](https://central.sonatype.com/artifact/in.maheshlangote/logdispatch-spring-boot-starter) +[![CI](https://github.com/Mahesh-Langote/logdispatch/actions/workflows/logdispatch-pr-gate.yml/badge.svg?branch=main)](https://github.com/Mahesh-Langote/logdispatch/actions) +[![Java](https://img.shields.io/badge/Java-17%2B-blue?logo=openjdk)](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -A lightweight, zero-configuration **Application Performance Monitoring (APM) client** for Spring Boot. Uses Spring AOP to automatically intercept unhandled exceptions from `@RestController` classes and dispatches them asynchronously to your centralized log server. +A lightweight, zero-configuration **Application Performance Monitoring (APM) client** for Spring Boot. + +It uses Spring AOP to automatically intercept unhandled exceptions from `@RestController` classes and dispatch them asynchronously to your centralized log server. --- ## Features -- **Zero Code Changes** — Works out of the box with no changes to your controllers or exception handlers. -- **Asynchronous** — All log pushes run in a `CompletableFuture` fire-and-forget thread — zero impact on API response times. -- **Resilient** — Fails silently if the log server is unreachable. Your app never crashes due to a monitoring failure. -- **Multi-Tenant Ready** — Uses an `X-API-KEY` header to authenticate and route logs to the correct destination. -- **Customizable** — Use the `@LogDispatch` annotation to control how errors appear on your dashboard. +* **Zero Code Changes** — Works out of the box with no changes to your controllers or exception handlers. +* **Asynchronous** — All log pushes run in a `CompletableFuture` fire-and-forget thread with minimal impact on API response times. +* **Resilient** — Fails silently if the log server is unreachable. Your application never crashes because of monitoring failures. +* **Multi-Tenant Ready** — Uses an `X-API-KEY` header to authenticate and route logs correctly. +* **Customizable** — Use the `@LogDispatch` annotation to control how errors appear on your dashboard. --- -## Installation +# Installation -Add to your `pom.xml`: +Add the dependency to your `pom.xml`: ```xml @@ -28,66 +33,96 @@ Add to your `pom.xml`: ``` ---- - ## Configuration -Add to your `application.yml`: +### application.yml ```yaml logdispatch: + enabled: true server-url: "https://your-apm-server.com/api/v1/ingest/logs" api-key: "your-secret-api-key" masked-headers: "authorization,cookie,x-api-key" exclude-paths: "/health,/actuator/**,/metrics/**" ``` -| Property | Required | Description | -|------------------------------|----------|--------------------------------------------------------------------------| -| `logdispatch.server-url` | ✅ Yes | Full URL of the APM ingest endpoint | -| `logdispatch.api-key` | ✅ Yes | API key used to authenticate with the APM server | -| `logdispatch.masked-headers` | ❌ No | Comma-separated list of headers to mask. Defaults to empty (none masked) | -| `logdispatch.exclude-paths` | ❌ No | Comma-separated list of URI paths to exclude from logging. Supports wildcard patterns (e.g., `/health,/actuator/**`). Defaults to empty (all paths logged) | +### application.properties + +```properties +logdispatch.enabled=true +logdispatch.server-url=https://your-apm-server.com/api/v1/ingest/logs +logdispatch.api-key=your-secret-api-key +logdispatch.masked-headers=authorization,cookie,x-api-key +logdispatch.exclude-paths=/health,/actuator/**,/metrics/** +``` + +### Configuration Properties + +| Property | Required | Description | +| ---------------------------- | -------- | --------------------------------------------------------------------------------------- | +| `logdispatch.enabled` | ❌ No | Enables or disables the SDK. Defaults to `true` | +| `logdispatch.server-url` | ✅ Yes, when enabled | Full URL of the APM ingest endpoint | +| `logdispatch.api-key` | ✅ Yes, when enabled | API key used to authenticate with the APM server | +| `logdispatch.masked-headers` | ❌ No | Comma-separated list of headers to mask. Defaults to none | +| `logdispatch.exclude-paths` | ❌ No | Comma-separated list of URI paths to exclude. Supports wildcards such as `/actuator/**` | +| `logdispatch.timeout-ms` | ❌ No | Connection and read timeout in milliseconds. Defaults to `3000`. | + +Disable LogDispatch in local or test profiles when you want the dependency on the classpath but do not want any APM activity: + +```yaml +# application-dev.yml +logdispatch: + enabled: false + +# application-prod.yml +logdispatch: + enabled: true + server-url: "https://apm.mycompany.com/ingest" + api-key: "${APM_API_KEY}" +``` + +When `logdispatch.enabled=false`, the SDK passes requests through without inspecting or dispatching errors, and the health endpoint reports that LogDispatch is disabled. --- -## How It Works +# How It Works When a `@RestController` method throws an unhandled exception, or when a filter rejects a request (e.g., `403 Forbidden`, `404 Not Found`), the SDK: -1. Captures the **request URI**, **HTTP method**, **exception class**, **message**, and **full stack trace**. -2. Reads optional metadata from the `@LogDispatch` annotation (feature, api, function names). -3. Asynchronously `POST`s a JSON payload to `server-url` with `X-API-KEY` in the header. -4. Logs a `WARN` to your application log if the push fails — and continues silently. +1. Captures the request URI, HTTP method, exception class, message, and full stack trace. +2. Reads optional metadata from the `@LogDispatch` annotation. +3. Asynchronously sends a JSON payload to the configured `server-url`. +4. Includes the `X-API-KEY` header for authentication. +5. Logs a warning if the push fails and continues execution without affecting the application. --- -## What This SDK Sends +# What This SDK Sends Every exception is pushed as a `POST` request to the configured `server-url`. -### Request Headers +## Request Headers -| Header | Value | -|-----------------|---------------------------| -| `Content-Type` | `application/json` | -| `X-API-KEY` | Value of `logdispatch.api-key` | +| Header | Value | +| -------------- | ------------------------------ | +| `Content-Type` | `application/json` | +| `X-API-KEY` | Value of `logdispatch.api-key` | -### Request Body +## Request Body Example ```json { - "timestamp": "2026-05-28T17:58:43.805Z", - "errorType": "IllegalArgumentException", - "statusCode": 500, - "errorMessage": "Invalid entries", - "errorPath": "/api/v1/user/create", - "affectedFeature": "UserController", - "affectedAPI": "/api/v1/user/create", - "apiType": "POST", + "timestamp": "2026-05-28T17:58:43.805Z", + "errorType": "IllegalArgumentException", + "statusCode": 500, + "errorMessage": "Invalid entries", + "errorPath": "/api/v1/user/create", + "affectedFeature": "UserController", + "affectedAPI": "/api/v1/user/create", + "apiType": "POST", "affectedFunction": "createUser", - "stackTrace": "java.lang.IllegalArgumentException: Invalid entries\n\tat com.example...", - "severity": "CRITICAL", + "stackTrace": "java.lang.IllegalArgumentException: Invalid entries\n\tat com.example...", + "severity": "CRITICAL", "inputInformation": { "queryString": null, "parameters": {}, @@ -100,39 +135,49 @@ Every exception is pushed as a `POST` request to the configured `server-url`. } ``` -| Field | Type | Description | -|---------------------|----------|---------------------------------------------------------------------| -| `timestamp` | `String` | ISO-8601 UTC timestamp of when the exception occurred | -| `errorType` | `String` | Simple class name of the exception (e.g. `NullPointerException`) | -| `statusCode` | `Number` | HTTP status code — `500` for unhandled, `4xx` for known errors | -| `errorMessage` | `String` | The exception's `.getMessage()` value | -| `errorPath` | `String` | The request URI where the exception was thrown | -| `affectedFeature` | `String` | Controller class name, or value from `@LogDispatch(feature = "...")` | -| `affectedAPI` | `String` | Request path, or value from `@LogDispatch(api = "...")` | -| `apiType` | `String` | The HTTP request method (e.g., `GET`, `POST`, `PUT`, `DELETE`) | -| `affectedFunction` | `String` | Method name, or value from `@LogDispatch(function = "...")` | -| `stackTrace` | `String` | Full stack trace as a newline-separated string | -| `severity` | `String` | `CRITICAL` for 5xx, `WARNING` for 4xx | -| `inputInformation` | `Object` | Request details (query params, headers, and body up to 32 KB) | +## Payload Fields + +| Field | Type | Description | +| ------------------ | ------ | -------------------------------------------------------- | +| `timestamp` | String | ISO-8601 UTC timestamp | +| `errorType` | String | Exception class name | +| `statusCode` | Number | HTTP status code | +| `errorMessage` | String | Exception message | +| `errorPath` | String | Request URI | +| `affectedFeature` | String | Controller name or annotation override | +| `affectedAPI` | String | API path or annotation override | +| `apiType` | String | HTTP method | +| `affectedFunction` | String | Method name or annotation override | +| `stackTrace` | String | Full stack trace | +| `severity` | String | WARNING, CRITICAL, or SECURITY | +| `inputInformation` | Object | Request metadata including headers, parameters, and body | + +> **Note:** `inputInformation.body` is skipped for `multipart/form-data` uploads or payloads larger than 32 KB. -> **Note:** For safety, `inputInformation.body` is skipped for `multipart/form-data` uploads or if the payload exceeds 32 KB to prevent `OutOfMemory` issues. +--- -### Severity Mapping +## Severity Mapping -| HTTP Status | Severity | -|-------------|--------------| -| `4xx` | `WARNING` | -| `5xx` | `CRITICAL` | +| HTTP Status / Condition | Severity | +| ----------- | -------- | +| 4xx (Exception) | WARNING | +| 5xx (Exception) | CRITICAL | +| Filter/Routing Error | SECURITY | --- -## Server Health Check +# Server Health Check -The starter automatically exposes a public, lightweight HTTP endpoint that your APM server can poll to check if the application is alive and calculate its total uptime. +The starter automatically exposes a lightweight endpoint that allows your APM server to verify application health and uptime. -**Endpoint:** `GET /logdispatch/health` +### Endpoint + +```http +GET /logdispatch/health +``` + +### Response -**Response (`200 OK`):** ```json { "status": "UP", @@ -141,49 +186,79 @@ The starter automatically exposes a public, lightweight HTTP endpoint that your } ``` -> **Note:** To prevent abuse, this endpoint has a built-in strict rate limiter of **60 requests per minute per IP address**. Exceeding this limit will return a `429 Too Many Requests` response. +### Rate Limiting + +To prevent abuse, the endpoint is limited to: + +```text +60 requests per minute per IP +``` + +Requests exceeding the limit receive: + +```http +429 Too Many Requests +``` --- -## What Your Server Must Return +# Expected Server Responses -Your APM ingest endpoint must comply with the following contract for the SDK to behave correctly. +Your APM ingest endpoint should follow this contract. -### ✅ Success — `2xx` +## Success (2xx) -Any `2xx` response is treated as success. The SDK does not process the response body. +Any `2xx` response is treated as successful. + +The SDK ignores the response body. + +--- -### ❌ `401 Unauthorized` — Bad API Key +## Unauthorized (401) -Return a JSON body describing the issue. The SDK will log it as: +Example response: +```json +{ + "status": 401, + "error": "Unauthorized", + "message": "Invalid API key" +} ``` -WARN [LogDispatch] Failed to push error: 401 UNAUTHORIZED : {"status":401,"error":"Unauthorized","message":"..."} + +SDK log: + +```text +WARN [LogDispatch] Failed to push error: 401 UNAUTHORIZED : {"status":401,"error":"Unauthorized","message":"Invalid API key"} ``` -### ❌ `4xx` / `5xx` — Any other error +--- -The SDK catches these, extracts the full response body, and logs at `WARN` level: +## Other 4xx / 5xx Errors -``` +Example SDK log: + +```text WARN [LogDispatch] Failed to push error: 500 INTERNAL_SERVER_ERROR : {"status":500,...} ``` -### ❌ Network / Connection Error +--- + +## Network Failure -If the server is unreachable, the SDK logs at `WARN` level and continues: +Example SDK log: -``` +```text WARN [LogDispatch] Failed to push error: Connection refused: connect ``` -> **Important:** The SDK **never rethrows** any exception. It always fails silently so your application is never affected. +> **Important:** The SDK never rethrows exceptions. Monitoring failures never affect the application. --- -## Optional: `@LogDispatch` Annotation +# Optional: @LogDispatch Annotation -Override the default metadata (class name / method name / request path) with human-readable labels: +Override default metadata with human-readable labels. ```java import in.maheshlangote.logdispatch.annotation.LogDispatch; @@ -193,24 +268,55 @@ import in.maheshlangote.logdispatch.annotation.LogDispatch; public class PaymentController { @PostMapping("/pay") - @LogDispatch(api = "Process Payment", function = "handlePayment") + @LogDispatch( + api = "Process Payment", + function = "handlePayment" + ) public void handlePayment() { // ... } } ``` -If an exception is thrown here, the payload will contain: +Generated payload: ```json { - "affectedFeature": "Payment Gateway", - "affectedAPI": "Process Payment", + "affectedFeature": "Payment Gateway", + "affectedAPI": "Process Payment", "affectedFunction": "handlePayment" } ``` -Without the annotation, it defaults to the controller class name, method name, and raw request URI. +Without the annotation, the SDK defaults to: + +* Controller class name +* Method name +* Raw request URI + +--- + +# Testing & Contributing + +If you want to contribute to this project, please follow our established automation testing best practices. + +Please see the [TESTING.md](TESTING.md) file for detailed guidelines on how to run, structure, and write tests for this SDK. + +--- + +# Troubleshooting + +For common problems and solutions, see: + +```text +TROUBLESHOOTING.md +``` + +--- + +## Example App + +A runnable Spring Boot demo is available in [example-app](./example-app). It includes dummy REST endpoints that intentionally throw exceptions so you can see LogDispatch capture and dispatch APM log payloads. --- diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..e39ad54 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,37 @@ +# Testing Guidelines + +We welcome and encourage contributions to the LogDispatch project! To maintain the quality and reliability of the SDK, we strictly adhere to automation testing best practices. + +If you are adding new features, fixing bugs, or refactoring code, please ensure your tests comply with the following guidelines. + +## 1. Run Tests Locally +Before opening a Pull Request, always verify that your changes haven't broken existing functionality. You can execute the entire test suite locally using the Maven Wrapper: +```bash +./mvnw test +``` + +## 2. File Structure & Single Responsibility +Do not create or add to large, monolithic test classes. Each test file should focus on testing a specific responsibility (Single Responsibility Principle). +If you are adding a completely new behavior, create a new, appropriately named test file. + +## 3. Use the Base Test Class +When writing tests related to the `LogDispatchFilter`, extend the abstract `LogDispatchFilterBaseTest.java` class. +This base class contains: +* Shared constants (API keys, URLs). +* Pre-configured mocks (e.g., `RestTemplate`). +* Helper methods for creating dummy `HttpServletRequest` and `FilterChain` objects. +* Utility methods for capturing and verifying the dispatched JSON payload. + +Reusing these utilities keeps the test suite DRY (Don't Repeat Yourself). + +## 4. Naming Conventions +* **Classes:** Test classes must end in `*Test.java` (e.g., `LogDispatchFilterHeaderMaskingTest.java`). The `maven-surefire-plugin` is configured to automatically detect and execute these classes. +* **Methods:** Test methods should be descriptively named (e.g., `shouldMaskConfiguredHeaderInApmPayload`). +* **Display Names:** Always decorate your test classes and methods with the JUnit 5 `@DisplayName` annotation to provide clean, human-readable output in the test logs. + +## 5. Testing the AOP Aspect +Any tests verifying the AOP interception logic (which intercepts `@RestController` exceptions and reads `@LogDispatch` annotations) should be placed inside `LogDispatchAspectTest.java` or in similarly named isolated files. + +--- + +Thank you for helping us keep LogDispatch robust! diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..b47d5c7 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,83 @@ +# Troubleshooting + +Frequently asked questions and common issues when using LogDispatch. + +--- + +## Why are no errors appearing on my APM dashboard? + +**Possible causes and fixes:** + +1. **Missing or incorrect configuration:** Verify `logdispatch.server-url` and `logdispatch.api-key` are set and point to your actual APM server. +2. **No exceptions thrown:** LogDispatch only captures unhandled exceptions from `@RestController` classes and 4xx/5xx filter-level errors. Controlled responses (e.g., `ResponseEntity.status(400).body(...)` without throwing) are not intercepted. +3. **Network issues:** Check your application logs for `WARN [LogDispatch]` entries. The SDK logs connection failures silently — check for messages like `Connection refused` or `401 UNAUTHORIZED`. + +```yml +# Verify your configuration: +logdispatch: + server-url: "https://your-apm-server.com/api/v1/ingest/logs" + api-key: "your-secret-api-key" +``` + +--- + +## Why is the `/logdispatch/health` endpoint returning 429? + +The health endpoint has a built-in rate limit of **60 requests per minute per IP address**. If your APM server polls more frequently, it will receive `429 Too Many Requests` responses. + +The rate limit uses a 60-second sliding window per client IP. If your monitoring needs a higher frequency, you can: + +- Reduce the polling interval to once every second (60 req/min max). +- Have multiple clients spread across different IPs. +- Upgrade to a future release that supports a configurable rate limit (tracked in issue #14). + +--- + +## Why are sensitive headers still visible in the APM payload? + +The `logdispatch.masked-headers` property masks header values in the `inputInformation.headers` section of the payload. Check the following: + +1. **Header names must be lowercase:** The masking is case-insensitive, but the list uses lowercase internally. Use `authorization` not `Authorization`. +2. **Comma-separated format:** Each header name should be separated by a comma. Leading/trailing spaces are trimmed. + +```yml +# Correct format for masked headers: +logdispatch: + masked-headers: "authorization,cookie,x-api-key,set-cookie" +``` + +3. **Verify in application logs:** The masked headers will appear as `********` in the payload sent to the APM server. No additional code changes are needed. + +--- + +## Why is the SDK dispatching on paths I have excluded? + +The `logdispatch.exclude-paths` property uses [Ant-style path patterns](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/AntPathMatcher.html) and is matched against the request URI. + +Common mistakes: + +1. **Pattern syntax:** Patterns like `/health` match exactly that path. Wildcard patterns like `/actuator/**` match all sub-paths. +2. **Comma-separated format:** Paths must be comma-separated. Whitespace around commas is trimmed. +3. **Case sensitivity:** URIs are matched case-sensitively. + +```yml +# Correct format for exclude paths: +logdispatch: + exclude-paths: "/health,/actuator/**,/metrics/**,/swagger-ui/**" +``` + +> **Note:** The `/logdispatch/health` endpoint is automatically excluded and does not need to be added to `exclude-paths`. + +--- + +## Why are there duplicate log entries on my APM dashboard? + +Duplicate entries typically occur in one of these scenarios: + +1. **Multiple instances of LogDispatch:** If your application has multiple LogDispatch configurations (e.g., manual bean registration alongside auto-configuration), the filter may be registered more than once. + +2. **Exception re-throwing:** If a global `@ControllerAdvice` catches an exception and re-throws it, or returns a 4xx/5xx status, the filter may capture it a second time. + +3. **Filter and Aspect overlap:** The `LogDispatchAspect` captures exceptions from `@RestController` methods, stores them in request attributes, and the `LogDispatchFilter` picks them up in its `finally` block. This produces a single dispatch per error — but if your security filter or custom filter also triggers an error response, both may be recorded. + +**To diagnose:** Check your application logs for duplicate `WARN [LogDispatch]` entries and verify the `errorPath` and `timestamp` fields in the duplicate payloads to trace the origin. diff --git a/example-app/README.md b/example-app/README.md new file mode 100644 index 0000000..75ef3e2 --- /dev/null +++ b/example-app/README.md @@ -0,0 +1,61 @@ +# LogDispatch Example App + +This demo Spring Boot application shows how `logdispatch-spring-boot-starter` captures exceptions from `@RestController` endpoints and dispatches them to the configured APM ingest endpoint. + +## Prerequisites + +- Java 17 or later +- Maven, or the Maven wrapper from the repository root +- An APM ingest endpoint that accepts LogDispatch payloads + +## Run the App + +From the repository root, install the starter into your local Maven repository: + +```bash +./mvnw -DskipTests -Dgpg.skip install +``` + +On Windows: + +```powershell +.\mvnw.cmd -DskipTests -Dgpg.skip install +``` + +Then start the example app from the repository root: + +```bash +./mvnw -f example-app/pom.xml spring-boot:run +``` + +On Windows: + +```powershell +.\mvnw.cmd -f example-app\pom.xml spring-boot:run +``` + +If you do not have a real APM ingest endpoint available yet, the app still runs. LogDispatch will attempt to post to the demo URL in `src/main/resources/application.yml`, log a warning if the endpoint is unreachable, and leave the application response flow unaffected. + +## Try the Demo Endpoints + +```bash +curl http://localhost:8080/api/demo/null-pointer +curl http://localhost:8080/api/demo/illegal-argument +curl http://localhost:8080/api/demo/illegal-state +curl http://localhost:8080/api/demo/annotated +curl -X POST http://localhost:8080/api/demo/body -H "Content-Type: application/json" -d "{\"message\":\"hello\"}" +``` + +Each endpoint intentionally throws an exception so you can see LogDispatch capture the request details, exception type, stack trace, and optional `@LogDispatch` metadata. + +## Configuration + +Edit `src/main/resources/application.yml` to point LogDispatch at your own ingest endpoint: + +```yaml +logdispatch: + server-url: "http://localhost:8081/api/v1/ingest/logs" + api-key: "demo-api-key" +``` + +The example also masks `authorization`, `cookie`, and `x-api-key` request headers in captured input data. diff --git a/example-app/pom.xml b/example-app/pom.xml new file mode 100644 index 0000000..93caa68 --- /dev/null +++ b/example-app/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.15 + + + + in.maheshlangote + logdispatch-example-app + 0.0.1-SNAPSHOT + LogDispatch Example App + Demo Spring Boot application for the LogDispatch starter. + + + 17 + 1.0.6 + + + + + org.springframework.boot + spring-boot-starter-web + + + + in.maheshlangote + logdispatch-spring-boot-starter + ${logdispatch.version} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/example-app/src/main/java/in/maheshlangote/logdispatch/example/DemoExceptionController.java b/example-app/src/main/java/in/maheshlangote/logdispatch/example/DemoExceptionController.java new file mode 100644 index 0000000..4befe05 --- /dev/null +++ b/example-app/src/main/java/in/maheshlangote/logdispatch/example/DemoExceptionController.java @@ -0,0 +1,44 @@ +package in.maheshlangote.logdispatch.example; + +import in.maheshlangote.logdispatch.annotation.LogDispatch; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/demo") +@LogDispatch(feature = "Example Demo Controller") +public class DemoExceptionController { + + @GetMapping("/null-pointer") + public String throwNullPointerException() { + String value = null; + return value.toUpperCase(); + } + + @GetMapping("/illegal-argument") + public String throwIllegalArgumentException() { + throw new IllegalArgumentException("The supplied demo value is not valid."); + } + + @GetMapping("/illegal-state") + public String throwIllegalStateException() { + throw new IllegalStateException("The demo workflow is in an invalid state."); + } + + @GetMapping("/annotated") + @LogDispatch(api = "Annotated Demo Endpoint", function = "throwAnnotatedException") + public String throwAnnotatedException() { + throw new UnsupportedOperationException("This endpoint demonstrates custom LogDispatch metadata."); + } + + @PostMapping("/body") + @LogDispatch(api = "Request Body Demo", function = "throwBodyException") + public String throwRequestBodyException(@RequestBody Map body) { + throw new IllegalArgumentException("Received body keys: " + body.keySet()); + } +} diff --git a/example-app/src/main/java/in/maheshlangote/logdispatch/example/LogDispatchExampleApplication.java b/example-app/src/main/java/in/maheshlangote/logdispatch/example/LogDispatchExampleApplication.java new file mode 100644 index 0000000..6df1fc1 --- /dev/null +++ b/example-app/src/main/java/in/maheshlangote/logdispatch/example/LogDispatchExampleApplication.java @@ -0,0 +1,12 @@ +package in.maheshlangote.logdispatch.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LogDispatchExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(LogDispatchExampleApplication.class, args); + } +} diff --git a/example-app/src/main/resources/application.yml b/example-app/src/main/resources/application.yml new file mode 100644 index 0000000..cdae9b4 --- /dev/null +++ b/example-app/src/main/resources/application.yml @@ -0,0 +1,13 @@ +server: + port: 8080 + +spring: + application: + name: logdispatch-example-app + +logdispatch: + enabled: true + server-url: "http://localhost:8081/api/v1/ingest/logs" + api-key: "demo-api-key" + masked-headers: "authorization,cookie,x-api-key" + exclude-paths: "/logdispatch/health" diff --git a/pom.xml b/pom.xml index fb52639..e1eb432 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ in.maheshlangote logdispatch-spring-boot-starter - 1.0.6 + 1.0.7 LogDispatch Spring Boot Starter A standalone, zero-configuration APM client for intercepting and dispatching logs to a centralized server. https://maheshlangote.in diff --git a/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java b/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java index 28ccbc2..83b5e5b 100644 --- a/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java +++ b/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java @@ -9,9 +9,6 @@ import org.aspectj.lang.reflect.MethodSignature; import in.maheshlangote.logdispatch.annotation.LogDispatch; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.lang.reflect.Method; import java.lang.reflect.Method; /** @@ -28,12 +25,22 @@ @Aspect public class LogDispatchAspect { - private static final Logger log = LoggerFactory.getLogger(LogDispatchAspect.class); + private final boolean enabled; /** * Constructs a new LogDispatchAspect. */ public LogDispatchAspect() { + this(true); + } + + /** + * Constructs a new LogDispatchAspect. + * + * @param enabled whether LogDispatch should capture controller exceptions + */ + public LogDispatchAspect(boolean enabled) { + this.enabled = enabled; } /** @@ -45,14 +52,16 @@ public LogDispatchAspect() { */ @AfterThrowing(pointcut = "within(@org.springframework.web.bind.annotation.RestController *)", throwing = "ex") public void handleControllerException(JoinPoint joinPoint, Throwable ex) { + if (!enabled) { + return; + } + String path = "UNKNOWN"; - String httpMethod = "UNKNOWN"; try { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = attributes.getRequest(); path = request.getRequestURI(); - httpMethod = request.getMethod(); request.setAttribute("logdispatch.handled", true); } } catch (Exception ignored) {} diff --git a/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java b/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java index 885986f..1acfaa1 100644 --- a/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java +++ b/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java @@ -9,6 +9,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.util.AntPathMatcher; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; @@ -21,9 +22,12 @@ import java.time.Instant; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.stream.Collectors; /** @@ -37,8 +41,11 @@ public class LogDispatchFilter extends OncePerRequestFilter { private final String serverUrl; private final String apiKey; private final RestTemplate restTemplate; + private final boolean enabled; + private final int timeoutMs; private final Set maskedHeaders; private final List excludePaths; + private final Executor dispatchExecutor; private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher(); @@ -49,17 +56,60 @@ public class LogDispatchFilter extends OncePerRequestFilter { * @param apiKey the authentication key required by the APM server * @param maskedHeaders list of headers to mask * @param excludePaths list of URI paths to exclude from logging (supports wildcard patterns) + * @param timeoutMs the HTTP connection and read timeout in milliseconds */ - public LogDispatchFilter(String serverUrl, String apiKey, List maskedHeaders, List excludePaths) { + public LogDispatchFilter(String serverUrl, String apiKey, List maskedHeaders, List excludePaths, int timeoutMs) { + this(true, serverUrl, apiKey, maskedHeaders, excludePaths, timeoutMs); + } + + /** + * Constructs the LogDispatchFilter. + * + * @param enabled whether LogDispatch should inspect and dispatch request errors + * @param serverUrl the endpoint URL of the centralized APM server + * @param apiKey the authentication key required by the APM server + * @param maskedHeaders list of headers to mask + * @param excludePaths list of URI paths to exclude from logging (supports wildcard patterns) + * @param timeoutMs the HTTP connection and read timeout in milliseconds + */ + public LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List maskedHeaders, + List excludePaths, int timeoutMs) { + this(enabled, serverUrl, apiKey, maskedHeaders, excludePaths, new RestTemplate(), null, timeoutMs); + } + + LogDispatchFilter(String serverUrl, String apiKey, List maskedHeaders, List excludePaths, + RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs) { + this(true, serverUrl, apiKey, maskedHeaders, excludePaths, restTemplate, dispatchExecutor, timeoutMs); + } + + LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List maskedHeaders, + List excludePaths, RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs) { + this.enabled = enabled; this.serverUrl = serverUrl; this.apiKey = apiKey; - this.restTemplate = new RestTemplate(); + this.timeoutMs = (timeoutMs > 0) ? timeoutMs : 3000; + this.dispatchExecutor = dispatchExecutor; + this.restTemplate = Objects.requireNonNull(restTemplate, "restTemplate"); + + // Configure the timeout on the RestTemplate's underlying request factory + if (this.restTemplate.getRequestFactory() instanceof SimpleClientHttpRequestFactory) { + SimpleClientHttpRequestFactory factory = (SimpleClientHttpRequestFactory) this.restTemplate.getRequestFactory(); + factory.setConnectTimeout(this.timeoutMs); + factory.setReadTimeout(this.timeoutMs); + } else { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(this.timeoutMs); + factory.setReadTimeout(this.timeoutMs); + this.restTemplate.setRequestFactory(factory); + } + this.maskedHeaders = maskedHeaders == null ? Set.of() : maskedHeaders.stream() .filter(h -> h != null && !h.trim().isEmpty()) - .map(String::toLowerCase) + .map(h -> h.trim().toLowerCase(Locale.ROOT)) .collect(Collectors.toSet()); this.excludePaths = excludePaths == null ? List.of() : excludePaths.stream() .filter(p -> p != null && !p.trim().isEmpty()) + .map(String::trim) .collect(Collectors.toList()); } @@ -79,6 +129,9 @@ private boolean isPathExcluded(String requestPath) { } private boolean shouldWrapRequest(HttpServletRequest request) { + if ("GET".equalsIgnoreCase(request.getMethod()) && request.getContentLength() <= 0) { + return false; + } String contentType = request.getContentType(); if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) { return false; // Skip file uploads @@ -93,7 +146,11 @@ private boolean shouldWrapRequest(HttpServletRequest request) { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - + if (!enabled) { + filterChain.doFilter(request, response); + return; + } + // Check if the request path is excluded String requestPath = request.getRequestURI(); if (isPathExcluded(requestPath)) { @@ -151,29 +208,31 @@ private void pushFilterErrorAsync(HttpServletRequest request, int statusCode, Ma String path = request.getRequestURI(); String method = request.getMethod(); - CompletableFuture.runAsync(() -> { + dispatchAsync(() -> { try { - String severity = (statusCode >= 500) ? "CRITICAL" : "WARNING"; + String severity = "SECURITY"; + String feature = "FilterSecurity/Routing"; - Map payload = new HashMap<>(); - payload.put("timestamp", Instant.now().toString()); - payload.put("errorType", "FilterError"); - payload.put("statusCode", statusCode); - payload.put("errorMessage", "Request failed with status " + statusCode + " at filter level."); - payload.put("errorPath", path); - payload.put("affectedFeature", "FilterSecurity/Routing"); - payload.put("affectedAPI", path); - payload.put("apiType", method); - payload.put("affectedFunction", "doFilter"); - payload.put("stackTrace", "No stack trace available for filter-level status codes."); - payload.put("severity", severity); - payload.put("inputInformation", inputInfo); + LogDispatchPayload payload = new LogDispatchPayload( + Instant.now().toString(), + "FilterError", + statusCode, + "Request failed with status " + statusCode + " at filter level.", + path, + feature, + path, + method, + "doFilter", + "No stack trace available for filter-level status codes.", + severity, + inputInfo + ); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-API-KEY", apiKey); - HttpEntity> entity = new HttpEntity<>(payload, headers); + HttpEntity entity = new HttpEntity<>(payload, headers); restTemplate.postForEntity(serverUrl, entity, String.class); } catch (HttpClientErrorException | HttpServerErrorException e) { @@ -188,34 +247,35 @@ private void pushErrorAsync(HttpServletRequest request, int statusCode, Throwabl String path = request.getRequestURI(); String method = request.getMethod(); - CompletableFuture.runAsync(() -> { + dispatchAsync(() -> { try { String severity = (statusCode >= 500) ? "CRITICAL" : "WARNING"; - Map payload = new HashMap<>(); - payload.put("timestamp", Instant.now().toString()); - payload.put("errorType", ex.getClass().getSimpleName()); - payload.put("statusCode", statusCode); - payload.put("errorMessage", ex.getMessage()); - payload.put("errorPath", path); - payload.put("affectedFeature", feature); - payload.put("affectedAPI", api); - payload.put("apiType", method); - payload.put("affectedFunction", function); - StringBuilder stackTrace = new StringBuilder(); for (StackTraceElement element : ex.getStackTrace()) { stackTrace.append(element.toString()).append("\n"); } - payload.put("stackTrace", stackTrace.toString()); - payload.put("severity", severity); - payload.put("inputInformation", inputInfo); + + LogDispatchPayload payload = new LogDispatchPayload( + Instant.now().toString(), + ex.getClass().getSimpleName(), + statusCode, + ex.getMessage(), + path, + feature, + api, + method, + function, + stackTrace.toString(), + severity, + inputInfo + ); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-API-KEY", apiKey); - HttpEntity> entity = new HttpEntity<>(payload, headers); + HttpEntity entity = new HttpEntity<>(payload, headers); restTemplate.postForEntity(serverUrl, entity, String.class); } catch (HttpClientErrorException | HttpServerErrorException e) { @@ -226,6 +286,14 @@ private void pushErrorAsync(HttpServletRequest request, int statusCode, Throwabl }); } + private void dispatchAsync(Runnable task) { + if (dispatchExecutor == null) { + CompletableFuture.runAsync(task); + } else { + CompletableFuture.runAsync(task, dispatchExecutor); + } + } + private Map extractInputInformation(HttpServletRequest request) { Map inputInfo = new HashMap<>(); @@ -241,7 +309,7 @@ private Map extractInputInformation(HttpServletRequest request) if (headerNames != null) { while (headerNames.hasMoreElements()) { String headerName = headerNames.nextElement(); - String value = maskedHeaders.contains(headerName.toLowerCase()) ? "********" : request.getHeader(headerName); + String value = maskedHeaders.contains(headerName.toLowerCase(Locale.ROOT)) ? "********" : request.getHeader(headerName); headers.put(headerName, value); } } diff --git a/src/main/java/in/maheshlangote/logdispatch/LogDispatchHealthController.java b/src/main/java/in/maheshlangote/logdispatch/LogDispatchHealthController.java index ee43fef..c33e730 100644 --- a/src/main/java/in/maheshlangote/logdispatch/LogDispatchHealthController.java +++ b/src/main/java/in/maheshlangote/logdispatch/LogDispatchHealthController.java @@ -21,6 +21,7 @@ @RequestMapping("/logdispatch/health") public class LogDispatchHealthController { + private final boolean enabled; private final Instant startupTime; // Rate Limiting (60 requests per minute per IP) @@ -33,6 +34,16 @@ public class LogDispatchHealthController { * Initializes the health controller and records the startup time. */ public LogDispatchHealthController() { + this(true); + } + + /** + * Initializes the health controller and records the startup time. + * + * @param enabled whether LogDispatch health reporting should be active + */ + public LogDispatchHealthController(boolean enabled) { + this.enabled = enabled; this.startupTime = Instant.now(); } @@ -45,6 +56,13 @@ public LogDispatchHealthController() { */ @GetMapping public ResponseEntity> healthCheck(HttpServletRequest request) { + if (!enabled) { + Map response = new HashMap<>(); + response.put("status", "DISABLED"); + response.put("message", "LogDispatch is disabled."); + return ResponseEntity.ok(response); + } + String clientIp = getClientIp(request); if (!isAllowed(clientIp)) { diff --git a/src/main/java/in/maheshlangote/logdispatch/LogDispatchPayload.java b/src/main/java/in/maheshlangote/logdispatch/LogDispatchPayload.java new file mode 100644 index 0000000..43130cc --- /dev/null +++ b/src/main/java/in/maheshlangote/logdispatch/LogDispatchPayload.java @@ -0,0 +1,21 @@ +package in.maheshlangote.logdispatch; + +import java.util.Map; + +/** + * Represents the JSON payload dispatched to the centralized APM server. + */ +public record LogDispatchPayload( + String timestamp, + String errorType, + int statusCode, + String errorMessage, + String errorPath, + String affectedFeature, + String affectedAPI, + String apiType, + String affectedFunction, + String stackTrace, + String severity, + Map inputInformation +) {} diff --git a/src/main/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfiguration.java b/src/main/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfiguration.java index 6d15773..c543bd8 100644 --- a/src/main/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfiguration.java +++ b/src/main/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfiguration.java @@ -1,22 +1,25 @@ package in.maheshlangote.logdispatch.config; import in.maheshlangote.logdispatch.LogDispatchAspect; -import org.springframework.beans.factory.annotation.Value; +import in.maheshlangote.logdispatch.LogDispatchFilter; +import in.maheshlangote.logdispatch.LogDispatchHealthController; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; /** * Auto-configuration class for LogDispatch. *

- * This configuration automatically registers the {@link LogDispatchAspect} bean - * if the {@code logdispatch.enabled} property is set to true (or is missing). - * It dynamically injects the server URL and API key from the application properties. + * This configuration automatically registers LogDispatch beans and passes the + * {@code logdispatch.enabled} flag to each component so disabled mode can no-op. * * @author Mahesh Langote * @version 1.0.0 */ @AutoConfiguration +@EnableConfigurationProperties(LogDispatchProperties.class) public class LogDispatchAutoConfiguration { /** @@ -25,43 +28,38 @@ public class LogDispatchAutoConfiguration { public LogDispatchAutoConfiguration() { } - @Value("${logdispatch.server-url:http://localhost:8081/api/v1/ingest/logs}") - private String serverUrl; - - @Value("${logdispatch.api-key:default-key}") - private String apiKey; - - @Value("${logdispatch.masked-headers:}") - private java.util.List maskedHeaders; - - @Value("${logdispatch.exclude-paths:}") - private java.util.List excludePaths; - /** * Creates and exposes the {@link LogDispatchAspect} bean. * + * @param properties LogDispatch configuration properties * @return a fully configured {@link LogDispatchAspect} ready to intercept exceptions. */ @Bean - @ConditionalOnProperty(name = "logdispatch.enabled", havingValue = "true", matchIfMissing = true) - public LogDispatchAspect logDispatchAspect() { - return new LogDispatchAspect(); + public LogDispatchAspect logDispatchAspect(LogDispatchProperties properties) { + return new LogDispatchAspect(properties.isEnabled()); } /** * Creates and exposes the {@link in.maheshlangote.logdispatch.LogDispatchFilter} bean. * This filter catches filter-level exceptions (e.g. 403 Forbidden). * + * @param properties LogDispatch configuration properties * @return a fully configured {@link in.maheshlangote.logdispatch.LogDispatchFilter}. */ @Bean - @ConditionalOnProperty(name = "logdispatch.enabled", havingValue = "true", matchIfMissing = true) - public org.springframework.boot.web.servlet.FilterRegistrationBean logDispatchFilterRegistration() { - org.springframework.boot.web.servlet.FilterRegistrationBean registrationBean = new org.springframework.boot.web.servlet.FilterRegistrationBean<>(); - registrationBean.setFilter(new in.maheshlangote.logdispatch.LogDispatchFilter(serverUrl, apiKey, maskedHeaders, excludePaths)); + public FilterRegistrationBean logDispatchFilterRegistration(LogDispatchProperties properties) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new LogDispatchFilter( + properties.isEnabled(), + properties.getServerUrl(), + properties.getApiKey(), + properties.getMaskedHeaders(), + properties.getExcludePaths(), + properties.getTimeoutMs() + )); registrationBean.addUrlPatterns("/*"); // Use Highest Precedence to ensure it wraps everything including security filters - registrationBean.setOrder(org.springframework.core.Ordered.HIGHEST_PRECEDENCE); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); return registrationBean; } @@ -69,11 +67,11 @@ public org.springframework.boot.web.servlet.FilterRegistrationBean maskedHeaders = List.of(); + private List excludePaths = List.of(); + private int timeoutMs = 3000; + + /** + * Returns whether LogDispatch is enabled. + * + * @return {@code true} when LogDispatch should inspect and dispatch errors + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether LogDispatch is enabled. + * + * @param enabled {@code true} to enable LogDispatch, {@code false} to no-op + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Returns the APM ingest endpoint URL. + * + * @return the configured server URL + */ + public String getServerUrl() { + return serverUrl; + } + + /** + * Sets the APM ingest endpoint URL. + * + * @param serverUrl the server URL + */ + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + /** + * Returns the API key used for APM requests. + * + * @return the configured API key + */ + public String getApiKey() { + return apiKey; + } + + /** + * Sets the API key used for APM requests. + * + * @param apiKey the API key + */ + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + /** + * Returns headers that should be masked before dispatch. + * + * @return header names to mask + */ + public List getMaskedHeaders() { + return maskedHeaders; + } + + /** + * Sets headers that should be masked before dispatch. + * + * @param maskedHeaders header names to mask + */ + public void setMaskedHeaders(List maskedHeaders) { + this.maskedHeaders = maskedHeaders; + } + + /** + * Returns URI patterns that should be excluded from dispatch. + * + * @return URI patterns to exclude + */ + public List getExcludePaths() { + return excludePaths; + } + + /** + * Sets URI patterns that should be excluded from dispatch. + * + * @param excludePaths URI patterns to exclude + */ + public void setExcludePaths(List excludePaths) { + this.excludePaths = excludePaths; + } + + /** + * Returns the HTTP connection and read timeout in milliseconds. + * + * @return timeout in milliseconds + */ + public int getTimeoutMs() { + return timeoutMs; + } + + /** + * Sets the HTTP connection and read timeout in milliseconds. + * + * @param timeoutMs timeout in milliseconds + */ + public void setTimeoutMs(int timeoutMs) { + this.timeoutMs = timeoutMs; + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchAspectTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchAspectTest.java new file mode 100644 index 0000000..7bbb1da --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchAspectTest.java @@ -0,0 +1,134 @@ +package in.maheshlangote.logdispatch; + +import in.maheshlangote.logdispatch.annotation.LogDispatch; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@DisplayName("LogDispatch Aspect Tests") +class LogDispatchAspectTest { + + private LogDispatchAspect aspect; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + aspect = new LogDispatchAspect(); + request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI("/api/test"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("Should populate request attributes with defaults when no annotation is present") + void shouldPopulateDefaults() throws Exception { + JoinPoint joinPoint = mock(JoinPoint.class); + MethodSignature signature = mock(MethodSignature.class); + + when(joinPoint.getSignature()).thenReturn(signature); + when(signature.getDeclaringType()).thenReturn(DummyController.class); + when(signature.getName()).thenReturn("doSomething"); + + Method method = DummyController.class.getMethod("doSomething"); + when(signature.getMethod()).thenReturn(method); + when(joinPoint.getTarget()).thenReturn(new DummyController()); + + RuntimeException ex = new RuntimeException("Test exception"); + + aspect.handleControllerException(joinPoint, ex); + + assertThat(request.getAttribute("logdispatch.handled")).isEqualTo(true); + assertThat(request.getAttribute("logdispatch.exception")).isEqualTo(ex); + assertThat(request.getAttribute("logdispatch.feature")).isEqualTo("DummyController"); + assertThat(request.getAttribute("logdispatch.function")).isEqualTo("doSomething"); + assertThat(request.getAttribute("logdispatch.api")).isEqualTo("/api/test"); + } + + @Test + @DisplayName("Should populate request attributes from method annotation") + void shouldPopulateFromMethodAnnotation() throws Exception { + JoinPoint joinPoint = mock(JoinPoint.class); + MethodSignature signature = mock(MethodSignature.class); + + when(joinPoint.getSignature()).thenReturn(signature); + when(signature.getDeclaringType()).thenReturn(DummyController.class); + when(signature.getName()).thenReturn("doSomethingAnnotated"); + + Method method = DummyController.class.getMethod("doSomethingAnnotated"); + when(signature.getMethod()).thenReturn(method); + when(joinPoint.getTarget()).thenReturn(new DummyController()); + + RuntimeException ex = new RuntimeException("Test exception"); + + aspect.handleControllerException(joinPoint, ex); + + assertThat(request.getAttribute("logdispatch.feature")).isEqualTo("CustomFeature"); + assertThat(request.getAttribute("logdispatch.function")).isEqualTo("customFunction"); + assertThat(request.getAttribute("logdispatch.api")).isEqualTo("/custom/api"); + } + + @Test + @DisplayName("Should gracefully handle null request context") + void shouldHandleNullRequestContextGracefully() throws Exception { + RequestContextHolder.resetRequestAttributes(); // Remove context + + JoinPoint joinPoint = mock(JoinPoint.class); + MethodSignature signature = mock(MethodSignature.class); + + when(joinPoint.getSignature()).thenReturn(signature); + when(signature.getDeclaringType()).thenReturn(DummyController.class); + when(signature.getName()).thenReturn("doSomething"); + + Method method = DummyController.class.getMethod("doSomething"); + when(signature.getMethod()).thenReturn(method); + when(joinPoint.getTarget()).thenReturn(new DummyController()); + + RuntimeException ex = new RuntimeException("Test exception"); + + // Should not throw NPE + aspect.handleControllerException(joinPoint, ex); + } + + @Test + @DisplayName("Should no-op when LogDispatch is disabled") + void shouldNoOpWhenDisabled() { + LogDispatchAspect disabledAspect = new LogDispatchAspect(false); + JoinPoint joinPoint = mock(JoinPoint.class); + + disabledAspect.handleControllerException(joinPoint, new RuntimeException("Test exception")); + + assertThat(request.getAttribute("logdispatch.handled")).isNull(); + assertThat(request.getAttribute("logdispatch.exception")).isNull(); + assertThat(request.getAttribute("logdispatch.feature")).isNull(); + assertThat(request.getAttribute("logdispatch.api")).isNull(); + assertThat(request.getAttribute("logdispatch.function")).isNull(); + verifyNoInteractions(joinPoint); + } + + // Dummy controller for reflection + static class DummyController { + public void doSomething() {} + + @LogDispatch(feature = "CustomFeature", api = "/custom/api", function = "customFunction") + public void doSomethingAnnotated() {} + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterApmDispatchingTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterApmDispatchingTest.java new file mode 100644 index 0000000..0c5cc05 --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterApmDispatchingTest.java @@ -0,0 +1,100 @@ +package in.maheshlangote.logdispatch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.client.ResourceAccessException; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@DisplayName("APM Dispatching Rules Tests") +class LogDispatchFilterApmDispatchingTest extends LogDispatchFilterBaseTest { + + @Test + @DisplayName("Should dispatch 4xx responses as SECURITY") + void shouldDispatchToApmFor4xxResponse() throws Exception { + MockHttpServletRequest request = request("GET", "/api/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(404)); + + Map payload = dispatchedPayload(); + assertThat(payload).containsEntry("statusCode", 404); + assertThat(payload).containsEntry("severity", "SECURITY"); + assertThat(payload).containsEntry("errorPath", "/api/users"); + assertThat(payload).containsEntry("affectedFeature", "FilterSecurity/Routing"); + } + + @Test + @DisplayName("Should dispatch 401 responses as SECURITY") + void shouldDispatchToApmForSecurityError() throws Exception { + MockHttpServletRequest request = request("GET", "/secure"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(401)); + + Map payload = dispatchedPayload(); + assertThat(payload).containsEntry("statusCode", 401); + assertThat(payload).containsEntry("severity", "SECURITY"); + assertThat(payload).containsEntry("affectedFeature", "FilterSecurity/Routing"); + } + + @Test + @DisplayName("Should dispatch 5xx responses as SECURITY") + void shouldDispatchToApmFor5xxResponse() throws Exception { + MockHttpServletRequest request = request("GET", "/api/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(500)); + + Map payload = dispatchedPayload(); + assertThat(payload).containsEntry("statusCode", 500); + assertThat(payload).containsEntry("severity", "SECURITY"); + assertThat(payload).containsEntry("affectedFeature", "FilterSecurity/Routing"); + } + + @Test + @DisplayName("Should not dispatch 2xx successful responses") + void shouldNotDispatchToApmFor2xxResponse() throws Exception { + MockHttpServletRequest request = request("GET", "/api/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(204)); + + verifyNoApmDispatch(); + } + + @Test + @DisplayName("Should not dispatch 3xx redirect responses") + void shouldNotDispatchToApmFor3xxResponse() throws Exception { + MockHttpServletRequest request = request("GET", "/api/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(302)); + + verifyNoApmDispatch(); + } + + @Test + @DisplayName("Should gracefully continue if APM server is unreachable") + void shouldCompleteOriginalResponseWhenApmServerIsUnreachable() { + doThrow(new ResourceAccessException("Connection refused")) + .when(restTemplate) + .postForEntity(eq(SERVER_URL), any(), eq(String.class)); + MockHttpServletRequest request = request("GET", "/api/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertDoesNotThrow(() -> filter.doFilter(request, response, chainWithStatus(503))); + + assertThat(response.getStatus()).isEqualTo(503); + verify(restTemplate).postForEntity(eq(SERVER_URL), any(), eq(String.class)); + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterBaseTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterBaseTest.java new file mode 100644 index 0000000..067b52b --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterBaseTest.java @@ -0,0 +1,88 @@ +package in.maheshlangote.logdispatch; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public abstract class LogDispatchFilterBaseTest { + + protected static final String SERVER_URL = "http://localhost:8080"; + protected static final String API_KEY = "test-api-key"; + + protected RestTemplate restTemplate; + protected LogDispatchFilter filter; + + @BeforeEach + void setUp() { + restTemplate = mock(RestTemplate.class); + filter = filterWith(List.of("authorization"), List.of()); + } + + protected LogDispatchFilter filterWith(List maskedHeaders, List excludePaths) { + // Appended 3000 as the final argument to match your new constructor signature + return new LogDispatchFilter(SERVER_URL, API_KEY, maskedHeaders, excludePaths, restTemplate, Runnable::run, 3000); + } + + protected static MockHttpServletRequest request(String method, String path) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(method); + request.setRequestURI(path); + return request; + } + + protected static FilterChain chainWithStatus(int status) { + return (request, response) -> ((HttpServletResponse) response).setStatus(status); + } + + protected HttpServletRequest requestSeenByFilterChain(MockHttpServletRequest request) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + verify(filterChain).doFilter(requestCaptor.capture(), eq(response)); + return requestCaptor.getValue(); + } + + protected void verifyNoApmDispatch() { + verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class)); + } + + @SuppressWarnings("unchecked") + protected Map dispatchedPayload() { + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Object.class); + verify(restTemplate).postForEntity(eq(SERVER_URL), requestCaptor.capture(), eq(String.class)); + + HttpEntity entity = (HttpEntity) requestCaptor.getValue(); + assertThat(entity.getHeaders().getFirst("X-API-KEY")).isEqualTo(API_KEY); + + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + mapper.findAndRegisterModules(); + return mapper.convertValue(entity.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + } + + @SuppressWarnings("unchecked") + protected Map dispatchedHeaders() { + Map payload = dispatchedPayload(); + Map inputInformation = (Map) payload.get("inputInformation"); + return (Map) inputInformation.get("headers"); + } +} \ No newline at end of file diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterDisabledTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterDisabledTest.java new file mode 100644 index 0000000..03f08fe --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterDisabledTest.java @@ -0,0 +1,51 @@ +package in.maheshlangote.logdispatch; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@DisplayName("Disabled LogDispatch Filter Tests") +class LogDispatchFilterDisabledTest extends LogDispatchFilterBaseTest { + + @Test + @DisplayName("Should pass through without APM logic when disabled") + void shouldPassThroughWithoutApmLogicWhenDisabled() throws Exception { + LogDispatchFilter disabledFilter = new LogDispatchFilter( + false, + SERVER_URL, + API_KEY, + List.of("authorization"), + List.of(), + restTemplate, + Runnable::run, + 3000 + ); + + MockHttpServletRequest request = request("POST", "/test"); + request.setContentType("application/json"); + request.setContent("hello".getBytes()); + + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setStatus(500); + + FilterChain filterChain = mock(FilterChain.class); + + disabledFilter.doFilter(request, response, filterChain); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + verify(filterChain).doFilter(requestCaptor.capture(), eq(response)); + assertThat(requestCaptor.getValue()).isSameAs(request); + verifyNoApmDispatch(); + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterHeaderMaskingTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterHeaderMaskingTest.java new file mode 100644 index 0000000..5fba5af --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterHeaderMaskingTest.java @@ -0,0 +1,85 @@ +package in.maheshlangote.logdispatch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@DisplayName("Header Masking Logic Tests") +class LogDispatchFilterHeaderMaskingTest extends LogDispatchFilterBaseTest { + + @Test + @DisplayName("Should mask configured sensitive headers") + void shouldMaskConfiguredHeaderInApmPayload() throws Exception { + MockHttpServletRequest request = request("GET", "/secure"); + request.addHeader("Authorization", "Bearer secret-token"); + request.addHeader("X-User", "naman"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(401)); + + Map headers = dispatchedHeaders(); + assertThat(headers).containsEntry("Authorization", "********"); + } + + @Test + @DisplayName("Should pass non-configured headers as is") + void shouldPassNonMaskedHeaderAsIs() throws Exception { + MockHttpServletRequest request = request("GET", "/secure"); + request.addHeader("X-User", "naman"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(401)); + + Map headers = dispatchedHeaders(); + assertThat(headers).containsEntry("X-User", "naman"); + } + + @Test + @DisplayName("Should mask headers case-insensitively") + void shouldMaskHeadersCaseInsensitively() throws Exception { + filter = filterWith(List.of("AUTHORIZATION"), List.of()); + MockHttpServletRequest request = request("GET", "/secure"); + request.addHeader("authorization", "Bearer secret-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(401)); + + Map headers = dispatchedHeaders(); + assertThat(headers).containsEntry("authorization", "********"); + } + + @Test + @DisplayName("Should handle null configured headers gracefully") + void shouldHandleNullMaskedHeadersWithoutNpe() { + filter = filterWith(null, List.of()); + MockHttpServletRequest request = request("GET", "/secure"); + request.addHeader("Authorization", "Bearer secret-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertDoesNotThrow(() -> filter.doFilter(request, response, chainWithStatus(401))); + + Map headers = dispatchedHeaders(); + assertThat(headers).containsEntry("Authorization", "Bearer secret-token"); + } + + @Test + @DisplayName("Should handle empty configured headers gracefully") + void shouldHandleEmptyMaskedHeadersWithoutMasking() throws Exception { + filter = filterWith(List.of(), List.of()); + MockHttpServletRequest request = request("GET", "/secure"); + request.addHeader("Authorization", "Bearer secret-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(401)); + + Map headers = dispatchedHeaders(); + assertThat(headers).containsEntry("Authorization", "Bearer secret-token"); + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterPathExclusionTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterPathExclusionTest.java new file mode 100644 index 0000000..7a2db30 --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterPathExclusionTest.java @@ -0,0 +1,67 @@ +package in.maheshlangote.logdispatch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Path Exclusion Configuration Tests") +class LogDispatchFilterPathExclusionTest extends LogDispatchFilterBaseTest { + + @Test + @DisplayName("Should completely ignore exact excluded paths") + void shouldSkipApmForExactExcludedPath() throws Exception { + filter = filterWith(List.of("authorization"), List.of("/health")); + MockHttpServletRequest request = request("GET", "/health"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(500)); + + assertThat(response.getStatus()).isEqualTo(500); + verifyNoApmDispatch(); + } + + @Test + @DisplayName("Should completely ignore wildcard excluded paths") + void shouldSkipApmForWildcardExcludedPath() throws Exception { + filter = filterWith(List.of("authorization"), List.of("/actuator/**")); + MockHttpServletRequest request = request("GET", "/actuator/metrics"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(503)); + + assertThat(response.getStatus()).isEqualTo(503); + verifyNoApmDispatch(); + } + + @Test + @DisplayName("Should process paths not matching the exclusion list") + void shouldProcessNonExcludedPathNormally() throws Exception { + filter = filterWith(List.of("authorization"), List.of("/health", "/actuator/**")); + MockHttpServletRequest request = request("GET", "/api/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(500)); + + Map payload = dispatchedPayload(); + assertThat(payload).containsEntry("errorPath", "/api/users"); + } + + @Test + @DisplayName("Should process all paths if exclusion list is empty") + void shouldProcessAllPathsWhenExcludeListIsEmpty() throws Exception { + filter = filterWith(List.of("authorization"), List.of()); + MockHttpServletRequest request = request("GET", "/health"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chainWithStatus(500)); + + Map payload = dispatchedPayload(); + assertThat(payload).containsEntry("errorPath", "/health"); + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterRequestWrappingTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterRequestWrappingTest.java new file mode 100644 index 0000000..b1f84af --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterRequestWrappingTest.java @@ -0,0 +1,72 @@ +package in.maheshlangote.logdispatch; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Request Body Wrapping Tests") +class LogDispatchFilterRequestWrappingTest extends LogDispatchFilterBaseTest { + + @Test + @DisplayName("Should wrap normal payloads to capture body") + void shouldWrapNormalRequests() throws Exception { + MockHttpServletRequest request = request("POST", "/api/users"); + request.setContentType("application/json"); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + HttpServletRequest actualRequest = requestSeenByFilterChain(request); + + assertThat(actualRequest).isInstanceOf(ContentCachingRequestWrapper.class); + } + + @Test + @DisplayName("Should skip wrapping for multipart uploads") + void shouldNotWrapMultipartRequests() throws Exception { + MockHttpServletRequest request = request("POST", "/api/users"); + request.setContentType("multipart/form-data"); + + HttpServletRequest actualRequest = requestSeenByFilterChain(request); + + assertThat(actualRequest).isNotInstanceOf(ContentCachingRequestWrapper.class); + } + + @Test + @DisplayName("Should skip wrapping for massive payloads (>32KB)") + void shouldNotWrapLargePayloadRequests() throws Exception { + MockHttpServletRequest request = request("POST", "/api/users"); + request.setContentType("application/json"); + request.setContent(new byte[33 * 1024]); + + HttpServletRequest actualRequest = requestSeenByFilterChain(request); + + assertThat(actualRequest).isNotInstanceOf(ContentCachingRequestWrapper.class); + } + + @Test + @DisplayName("Should wrap exactly at the 32KB boundary") + void shouldWrapPayloadAtExactly32KbBoundary() throws Exception { + MockHttpServletRequest request = request("POST", "/api/users"); + request.setContentType("application/json"); + request.setContent(new byte[32 * 1024]); + + HttpServletRequest actualRequest = requestSeenByFilterChain(request); + + assertThat(actualRequest).isInstanceOf(ContentCachingRequestWrapper.class); + } + + @Test + @DisplayName("Should skip wrapping GET requests with no body") + void shouldNotWrapGetRequestsWithoutBody() throws Exception { + MockHttpServletRequest request = request("GET", "/api/users"); + + HttpServletRequest actualRequest = requestSeenByFilterChain(request); + + assertThat(actualRequest).isNotInstanceOf(ContentCachingRequestWrapper.class); + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterTest.java deleted file mode 100644 index 57a6e63..0000000 --- a/src/test/java/in/maheshlangote/logdispatch/LogDispatchFilterTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package in.maheshlangote.logdispatch; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.web.util.ContentCachingRequestWrapper; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class LogDispatchFilterTest { - - private LogDispatchFilter filter; - - @BeforeEach - void setUp() { - filter = new LogDispatchFilter( - "http://localhost:8080", - "test-api-key", - List.of("authorization"), - List.of() - ); - } - - @Test - void shouldWrapNormalRequests() throws Exception { - - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setMethod("POST"); - request.setContentType("application/json"); - request.setContent("hello".getBytes()); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - FilterChain filterChain = mock(FilterChain.class); - - filter.doFilter(request, response, filterChain); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(HttpServletRequest.class); - - verify(filterChain).doFilter(captor.capture(), eq(response)); - - HttpServletRequest wrappedRequest = captor.getValue(); - - assertTrue(wrappedRequest instanceof ContentCachingRequestWrapper); - } - - @Test - void shouldNotWrapMultipartRequests() throws Exception { - - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setMethod("POST"); - request.setContentType("multipart/form-data"); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - FilterChain filterChain = mock(FilterChain.class); - - filter.doFilter(request, response, filterChain); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(HttpServletRequest.class); - - verify(filterChain).doFilter(captor.capture(), eq(response)); - - HttpServletRequest actualRequest = captor.getValue(); - - assertFalse(actualRequest instanceof ContentCachingRequestWrapper); - } - - @Test - void shouldNotWrapLargePayloadRequests() throws Exception { - - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setMethod("POST"); - request.setContentType("application/json"); - - byte[] largeContent = new byte[33 * 1024]; - request.setContent(largeContent); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - FilterChain filterChain = mock(FilterChain.class); - - filter.doFilter(request, response, filterChain); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(HttpServletRequest.class); - - verify(filterChain).doFilter(captor.capture(), eq(response)); - - HttpServletRequest actualRequest = captor.getValue(); - - assertFalse(actualRequest instanceof ContentCachingRequestWrapper); - } - - @Test - void shouldProcessRequestsWithSensitiveHeaders() throws Exception { - - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setMethod("GET"); - request.setRequestURI("/test"); - - request.addHeader("Authorization", "Bearer secret-token"); - request.addHeader("X-User", "naman"); - - MockHttpServletResponse response = new MockHttpServletResponse(); - response.setStatus(401); - - FilterChain filterChain = mock(FilterChain.class); - - filter.doFilter(request, response, filterChain); - - verify(filterChain).doFilter(any(), eq(response)); - } -} \ No newline at end of file diff --git a/src/test/java/in/maheshlangote/logdispatch/LogDispatchHealthControllerTest.java b/src/test/java/in/maheshlangote/logdispatch/LogDispatchHealthControllerTest.java new file mode 100644 index 0000000..15ad742 --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/LogDispatchHealthControllerTest.java @@ -0,0 +1,33 @@ +package in.maheshlangote.logdispatch; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +class LogDispatchHealthControllerTest { + + @Test + void shouldReturnDisabledStatusWhenLogDispatchIsDisabled() { + + LogDispatchHealthController controller = new LogDispatchHealthController(false); + HttpServletRequest request = mock(HttpServletRequest.class); + + ResponseEntity> response = controller.healthCheck(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("DISABLED", response.getBody().get("status")); + assertEquals("LogDispatch is disabled.", response.getBody().get("message")); + assertFalse(response.getBody().containsKey("uptimeSeconds")); + verifyNoInteractions(request); + } +} diff --git a/src/test/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfigurationTest.java b/src/test/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfigurationTest.java new file mode 100644 index 0000000..20c65d7 --- /dev/null +++ b/src/test/java/in/maheshlangote/logdispatch/config/LogDispatchAutoConfigurationTest.java @@ -0,0 +1,35 @@ +package in.maheshlangote.logdispatch.config; + +import in.maheshlangote.logdispatch.LogDispatchAspect; +import in.maheshlangote.logdispatch.LogDispatchHealthController; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogDispatchAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LogDispatchAutoConfiguration.class)); + + @Test + void shouldEnableLogDispatchByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(LogDispatchProperties.class); + assertThat(context.getBean(LogDispatchProperties.class).isEnabled()).isTrue(); + }); + } + + @Test + void shouldRegisterBeansWithDisabledProperties() { + contextRunner + .withPropertyValues("logdispatch.enabled=false") + .run(context -> { + assertThat(context).hasSingleBean(LogDispatchAspect.class); + assertThat(context).hasSingleBean(LogDispatchHealthController.class); + assertThat(context).hasBean("logDispatchFilterRegistration"); + assertThat(context.getBean(LogDispatchProperties.class).isEnabled()).isFalse(); + }); + } +}