Compare commits

...
Author SHA1 Message Date
Anthony Stirling 6baf02e630 Add JUnit tests for proprietary enterprise module coverage 2026-06-17 00:23:01 +01:00
24 changed files with 17074 additions and 0 deletions
@@ -0,0 +1,932 @@
package stirling.software.proprietary.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import org.aspectj.lang.ProceedingJoinPoint;
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.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
/**
* Unit tests for {@link ControllerAuditAspect}. The aspect is exercised by driving its
* around-advice methods with a mocked {@link ProceedingJoinPoint} whose signature returns real
* reflected methods (so the {@code @Audited} annotation lookup behaves exactly as in production).
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ControllerAuditAspect")
class ControllerAuditAspectTest {
@Mock private AuditService auditService;
@Mock private AuditConfigurationProperties auditConfig;
@Mock private ProceedingJoinPoint joinPoint;
@Mock private MethodSignature methodSignature;
private ControllerAuditAspect aspect;
private static final Object PROCEED_RESULT = "proceed-result";
@BeforeEach
void setUp() {
aspect = new ControllerAuditAspect(auditService, auditConfig);
RequestContextHolder.resetRequestAttributes();
MDC.clear();
}
@AfterEach
void tearDown() {
RequestContextHolder.resetRequestAttributes();
MDC.clear();
}
// ====================================================================
// Test fixture controllers with real annotated methods
// ====================================================================
@RequestMapping("/base")
static class SampleController {
@GetMapping("/get")
public Object getMethod() {
return null;
}
@PostMapping("/post")
public Object postMethod() {
return null;
}
@PutMapping("/put")
public Object putMethod() {
return null;
}
@DeleteMapping("/delete")
public Object deleteMethod() {
return null;
}
@PatchMapping("/patch")
public Object patchMethod() {
return null;
}
@PostMapping("/audited")
@Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC)
public Object auditedMethod() {
return null;
}
@PostMapping("/audited-string")
@Audited(typeString = "CUSTOM_EVENT", level = AuditLevel.STANDARD)
public Object auditedStringMethod() {
return null;
}
@GetMapping
public Object getNoPath() {
return null;
}
}
/** Controller without any class-level @RequestMapping, to exercise the null-base branch. */
static class NoRequestMappingController {
@GetMapping("/plain")
public Object plainGet() {
return null;
}
}
// ====================================================================
// Helpers
// ====================================================================
private Method method(String name) {
for (Method m : SampleController.class.getDeclaredMethods()) {
if (m.getName().equals(name)) {
return m;
}
}
throw new IllegalStateException("No such method: " + name);
}
/** Wire the join point to return the given reflected method and target instance. */
private void wireJoinPoint(Method m, Object target) {
when(joinPoint.getSignature()).thenReturn(methodSignature);
when(methodSignature.getMethod()).thenReturn(m);
when(joinPoint.getTarget()).thenReturn(target);
}
private void wireJoinPoint(Method m) {
wireJoinPoint(m, new SampleController());
}
private void bindRequest(MockHttpServletRequest request) {
bindRequest(request, null);
}
private void bindRequest(MockHttpServletRequest request, MockHttpServletResponse response) {
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response));
}
/** Default happy-path config: enterprise audit enabled at the given level. */
private void enableAuditing(AuditLevel level) {
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditConfig.getAuditLevel()).thenReturn(level);
when(auditService.createBaseAuditData(any(), any())).thenReturn(new HashMap<>());
when(auditService.resolveEventType(any(), any(), any(), any(), any()))
.thenReturn(AuditEventType.HTTP_REQUEST);
}
// ====================================================================
// shouldAudit == false: fast path just proceeds without any data work
// ====================================================================
@Nested
@DisplayName("fast path when auditing disabled")
class FastPath {
@Test
@DisplayName("auditGetMethod proceeds without auditing when shouldAudit is false")
void getFastPath() throws Throwable {
wireJoinPoint(method("getMethod"));
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
Object result = aspect.auditGetMethod(joinPoint);
assertSame(PROCEED_RESULT, result);
verify(joinPoint).proceed();
verify(auditService, never()).createBaseAuditData(any(), any());
verify(auditService, never())
.audit(
anyString(),
anyString(),
anyString(),
any(AuditEventType.class),
any(),
any());
}
@Test
@DisplayName("does not read audit level or collect data on the fast path")
void fastPathDoesNoWork() throws Throwable {
wireJoinPoint(method("postMethod"));
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
aspect.auditPostMethod(joinPoint);
verify(auditConfig, never()).getAuditLevel();
verify(auditService, never()).addHttpData(any(), any(), any(), any());
}
}
// ====================================================================
// HTTP method routing: each entry point passes the correct verb through
// ====================================================================
@Nested
@DisplayName("HTTP verb routing")
class VerbRouting {
@Test
@DisplayName("GET advice records httpMethod GET")
void getVerb() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/base/get");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditGetMethod(joinPoint);
verify(auditService)
.addHttpData(any(), eq("GET"), anyString(), eq(AuditLevel.STANDARD));
}
@Test
@DisplayName("POST advice records httpMethod POST")
void postVerb() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("POST"), anyString(), any());
}
@Test
@DisplayName("PUT advice records httpMethod PUT")
void putVerb() throws Throwable {
wireJoinPoint(method("putMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("PUT", "/base/put"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPutMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("PUT"), anyString(), any());
}
@Test
@DisplayName("DELETE advice records httpMethod DELETE")
void deleteVerb() throws Throwable {
wireJoinPoint(method("deleteMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("DELETE", "/base/delete"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditDeleteMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("DELETE"), anyString(), any());
}
@Test
@DisplayName("PATCH advice records httpMethod PATCH")
void patchVerb() throws Throwable {
wireJoinPoint(method("patchMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("PATCH", "/base/patch"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPatchMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("PATCH"), anyString(), any());
}
@Test
@DisplayName("AutoJob advice records httpMethod POST")
void autoJobVerb() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditAutoJobMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("POST"), anyString(), any());
}
@Test
@DisplayName("static-resource advice records httpMethod GET")
void staticResourceVerb() throws Throwable {
// Reuse a GET-annotated method; the static-resource advice always passes "GET".
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/static/x");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditStaticResource(joinPoint);
verify(auditService).addHttpData(any(), eq("GET"), anyString(), any());
}
}
// ====================================================================
// GET-specific skip branches
// ====================================================================
@Nested
@DisplayName("GET skip branches")
class GetSkips {
@Test
@DisplayName("skips auditing for static resource GET requests")
void skipsStaticResource() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/css/app.css");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(auditService.isStaticResourceRequest(req)).thenReturn(true);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
Object result = aspect.auditGetMethod(joinPoint);
assertSame(PROCEED_RESULT, result);
verify(joinPoint).proceed();
verify(auditService, never()).createBaseAuditData(any(), any());
verify(auditService, never())
.audit(
anyString(),
anyString(),
anyString(),
any(AuditEventType.class),
any(),
any());
}
@Test
@DisplayName("skips polling GET requests at STANDARD level")
void skipsPollingAtStandard() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/auth/me");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(auditService.isStaticResourceRequest(req)).thenReturn(false);
when(auditService.isPollingCall(req)).thenReturn(true);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
Object result = aspect.auditGetMethod(joinPoint);
assertSame(PROCEED_RESULT, result);
verify(auditService, never()).createBaseAuditData(any(), any());
}
@Test
@DisplayName("does NOT skip polling GET requests at VERBOSE level")
void doesNotSkipPollingAtVerbose() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.VERBOSE);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/auth/me");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(auditService.isStaticResourceRequest(req)).thenReturn(false);
when(auditService.isPollingCall(req)).thenReturn(true);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditGetMethod(joinPoint);
// Polling is only skipped at STANDARD; at VERBOSE the full audit flow runs.
verify(auditService).createBaseAuditData(any(), any());
}
@Test
@DisplayName("proceeds with full audit when GET request is neither static nor polling")
void normalGetIsAudited() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/base/get");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(auditService.isStaticResourceRequest(req)).thenReturn(false);
when(auditService.isPollingCall(req)).thenReturn(false);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditGetMethod(joinPoint);
verify(auditService).createBaseAuditData(any(), any());
}
@Test
@DisplayName("getCurrentRequest null skips both static and polling checks")
void nullCurrentRequest() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("GET", "/base/get"));
when(auditService.getCurrentRequest()).thenReturn(null);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditGetMethod(joinPoint);
verify(auditService, never()).isStaticResourceRequest(any());
verify(auditService, never()).isPollingCall(any());
verify(auditService).createBaseAuditData(any(), any());
}
}
// ====================================================================
// @Audited annotated methods: aspect must defer to AuditAspect (just proceed)
// ====================================================================
@Nested
@DisplayName("@Audited annotated methods")
class AuditedMethods {
@Test
@DisplayName("proceeds and does NOT emit a duplicate audit event for @Audited methods")
void auditedMethodProceedsWithoutDuplicate() throws Throwable {
wireJoinPoint(method("auditedMethod"));
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditConfig.getAuditLevel()).thenReturn(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/audited"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
Object result = aspect.auditPostMethod(joinPoint);
assertSame(PROCEED_RESULT, result);
verify(joinPoint).proceed();
// The aspect leaves @Audited methods to AuditAspect: no data collection, no audit call.
verify(auditService, never()).createBaseAuditData(any(), any());
verify(auditService, never())
.audit(
anyString(),
anyString(),
anyString(),
any(AuditEventType.class),
any(),
any());
}
@Test
@DisplayName("captures principal/origin into MDC even for @Audited methods")
void auditedMethodStillCapturesContext() throws Throwable {
wireJoinPoint(method("auditedMethod"));
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditConfig.getAuditLevel()).thenReturn(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/audited"));
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService).captureCurrentPrincipal();
verify(auditService).captureCurrentOrigin();
}
}
// ====================================================================
// Full audit flow: success and failure outcomes
// ====================================================================
@Nested
@DisplayName("full audit flow")
class FullFlow {
@Test
@DisplayName("success outcome: collects data, proceeds, and records an audit event")
void successOutcome() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletResponse resp = new MockHttpServletResponse();
resp.setStatus(200);
bindRequest(new MockHttpServletRequest("POST", "/base/post"), resp);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
Object result = aspect.auditPostMethod(joinPoint);
assertSame(PROCEED_RESULT, result);
ArgumentCaptor<Map<String, Object>> dataCaptor = ArgumentCaptor.forClass(Map.class);
verify(auditService)
.audit(
any(),
any(),
any(),
eq(AuditEventType.HTTP_REQUEST),
dataCaptor.capture(),
eq(AuditLevel.STANDARD));
assertEquals("success", dataCaptor.getValue().get("outcome"));
assertEquals(200, dataCaptor.getValue().get("statusCode"));
}
@Test
@DisplayName("failure outcome: records failure data and rethrows the original exception")
void failureOutcome() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
IllegalStateException boom = new IllegalStateException("kaboom");
when(joinPoint.proceed()).thenThrow(boom);
IllegalStateException thrown =
assertThrows(
IllegalStateException.class, () -> aspect.auditPostMethod(joinPoint));
assertSame(boom, thrown);
ArgumentCaptor<Map<String, Object>> dataCaptor = ArgumentCaptor.forClass(Map.class);
verify(auditService)
.audit(
any(),
any(),
any(),
any(AuditEventType.class),
dataCaptor.capture(),
any());
Map<String, Object> data = dataCaptor.getValue();
assertEquals("failure", data.get("outcome"));
assertEquals("IllegalStateException", data.get("errorType"));
assertEquals("kaboom", data.get("errorMessage"));
}
@Test
@DisplayName("passes early-captured principal/origin/ip to the audit call")
void passesCapturedContext() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/base/post");
bindRequest(req);
when(auditService.captureCurrentPrincipal()).thenReturn("bob");
when(auditService.captureCurrentOrigin()).thenReturn("API");
when(auditService.extractClientIp(any())).thenReturn("10.0.0.5");
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService)
.audit(
eq("bob"),
eq("API"),
eq("10.0.0.5"),
any(AuditEventType.class),
any(),
any());
}
@Test
@DisplayName("adds method arguments only at VERBOSE level")
void methodArgsAtVerbose() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.VERBOSE);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService).addMethodArguments(any(), eq(joinPoint), eq(AuditLevel.VERBOSE));
}
@Test
@DisplayName("does NOT add method arguments below VERBOSE level")
void noMethodArgsBelowVerbose() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService, never()).addMethodArguments(any(), any(), any());
}
}
// ====================================================================
// Operation-result capture
// ====================================================================
@Nested
@DisplayName("operation result capture")
class ResultCapture {
@Test
@DisplayName("captures result string when capture enabled and event is not UI_DATA")
void capturesResult() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
when(auditService.shouldCaptureOperationResults()).thenReturn(true);
when(auditService.safeToString(eq(PROCEED_RESULT), anyInt())).thenReturn("converted");
aspect.auditPostMethod(joinPoint);
verify(auditService).safeToString(eq(PROCEED_RESULT), eq(1000));
ArgumentCaptor<Map<String, Object>> dataCaptor = ArgumentCaptor.forClass(Map.class);
verify(auditService)
.audit(
any(),
any(),
any(),
any(AuditEventType.class),
dataCaptor.capture(),
any());
assertEquals("converted", dataCaptor.getValue().get("result"));
}
@Test
@DisplayName("does NOT capture result for UI_DATA events even when capture enabled")
void skipsResultForUiData() throws Throwable {
wireJoinPoint(method("getMethod"));
enableAuditing(AuditLevel.STANDARD);
when(auditService.resolveEventType(any(), any(), any(), any(), any()))
.thenReturn(AuditEventType.UI_DATA);
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/ui-data/x");
bindRequest(req);
when(auditService.getCurrentRequest()).thenReturn(req);
when(auditService.isStaticResourceRequest(req)).thenReturn(false);
when(auditService.isPollingCall(req)).thenReturn(false);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
when(auditService.shouldCaptureOperationResults()).thenReturn(true);
aspect.auditGetMethod(joinPoint);
verify(auditService, never()).safeToString(any(), anyInt());
}
@Test
@DisplayName("does NOT capture result when capture disabled")
void skipsResultWhenDisabled() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
when(auditService.shouldCaptureOperationResults()).thenReturn(false);
aspect.auditPostMethod(joinPoint);
verify(auditService, never()).safeToString(any(), anyInt());
}
@Test
@DisplayName("does NOT capture result when proceed returns null")
void skipsResultWhenNull() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(null);
when(auditService.shouldCaptureOperationResults()).thenReturn(true);
Object result = aspect.auditPostMethod(joinPoint);
assertNull(result);
verify(auditService, never()).safeToString(any(), anyInt());
}
}
// ====================================================================
// MDC propagation and restoration
// ====================================================================
@Nested
@DisplayName("MDC capture and restoration")
class MdcHandling {
@Test
@DisplayName(
"populates MDC keys from AuditService when not already set, then restores (removes) them")
void populatesAndRemovesMdc() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/base/post");
bindRequest(req);
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(auditService.extractClientIp(any())).thenReturn("1.2.3.4");
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
// No previous values were present, so MDC must be cleared afterwards.
assertNull(MDC.get("auditPrincipal"));
assertNull(MDC.get("auditOrigin"));
assertNull(MDC.get("auditIp"));
}
@Test
@DisplayName("restores pre-existing MDC values instead of removing them")
void restoresPreExistingMdc() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
MDC.put("auditPrincipal", "prePrincipal");
MDC.put("auditOrigin", "preOrigin");
MDC.put("auditIp", "preIp");
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
assertEquals("prePrincipal", MDC.get("auditPrincipal"));
assertEquals("preOrigin", MDC.get("auditOrigin"));
assertEquals("preIp", MDC.get("auditIp"));
// Pre-existing values short-circuit re-capture from the service.
verify(auditService, never()).captureCurrentPrincipal();
verify(auditService, never()).captureCurrentOrigin();
verify(auditService, never()).extractClientIp(any());
}
@Test
@DisplayName("MDC is restored even when the controller throws")
void restoresMdcOnException() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(joinPoint.proceed()).thenThrow(new RuntimeException("fail"));
assertThrows(RuntimeException.class, () -> aspect.auditPostMethod(joinPoint));
assertNull(MDC.get("auditPrincipal"));
assertNull(MDC.get("auditOrigin"));
assertNull(MDC.get("auditIp"));
}
@Test
@DisplayName("does not put auditIp into MDC when extractClientIp returns null")
void noIpMdcWhenNull() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(auditService.extractClientIp(any())).thenReturn(null);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
assertNull(MDC.get("auditIp"));
}
}
// ====================================================================
// @Audited level + typeString resolution
// ====================================================================
@Nested
@DisplayName("@Audited level override")
class AuditedLevelOverride {
@Test
@DisplayName("BASIC-annotated method that fails shouldAudit only proceeds (fast path)")
void basicAnnotatedFastPath() throws Throwable {
wireJoinPoint(method("auditedMethod"));
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
Object result = aspect.auditPostMethod(joinPoint);
assertSame(PROCEED_RESULT, result);
verify(auditConfig, never()).getAuditLevel();
}
}
// ====================================================================
// Request path resolution via getRequestPath
// ====================================================================
@Nested
@DisplayName("request path resolution")
class PathResolution {
@Test
@DisplayName("prefers the live request URI for the audited path")
void usesRequestUri() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/actual/uri/path"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("POST"), eq("/actual/uri/path"), any());
}
@Test
@DisplayName("falls back to annotation path when no request context is bound")
void fallsBackToAnnotationPath() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
// No request bound -> getRequestPath rebuilds from @RequestMapping + @PostMapping.
when(auditService.getCurrentRequest()).thenReturn(null);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("POST"), eq("/base/post"), any());
}
@Test
@DisplayName("annotation fallback handles controllers without @RequestMapping")
void fallbackNoClassMapping() throws Throwable {
Method plainGet = null;
for (Method m : NoRequestMappingController.class.getDeclaredMethods()) {
if (m.getName().equals("plainGet")) {
plainGet = m;
}
}
wireJoinPoint(plainGet, new NoRequestMappingController());
enableAuditing(AuditLevel.STANDARD);
// No request context: path built solely from the @GetMapping value.
when(auditService.getCurrentRequest()).thenReturn(null);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditGetMethod(joinPoint);
verify(auditService).addHttpData(any(), eq("GET"), eq("/plain"), any());
}
}
// ====================================================================
// Timing / status code in finally block
// ====================================================================
@Nested
@DisplayName("timing and status capture")
class TimingAndStatus {
@Test
@DisplayName("adds latency and status code at STANDARD level with a response present")
void latencyAndStatus() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
MockHttpServletResponse resp = new MockHttpServletResponse();
resp.setStatus(201);
bindRequest(new MockHttpServletRequest("POST", "/base/post"), resp);
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
ArgumentCaptor<Map<String, Object>> dataCaptor = ArgumentCaptor.forClass(Map.class);
verify(auditService)
.audit(
any(),
any(),
any(),
any(AuditEventType.class),
dataCaptor.capture(),
any());
Map<String, Object> data = dataCaptor.getValue();
assertEquals(201, data.get("statusCode"));
// latencyMs must be present and non-negative
Object latency = data.get("latencyMs");
assertTrue(latency instanceof Long);
verify(auditService)
.addTimingData(any(), anyLong(), eq(resp), eq(AuditLevel.STANDARD), eq(true));
}
@Test
@DisplayName("invokes addTimingData with isHttpRequest=true")
void delegatesTimingToService() throws Throwable {
wireJoinPoint(method("postMethod"));
enableAuditing(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/post"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
verify(auditService)
.addTimingData(any(), anyLong(), any(), eq(AuditLevel.STANDARD), eq(true));
}
}
// ====================================================================
// @Audited typeString path (string-based event type)
// ====================================================================
@Nested
@DisplayName("@Audited methods are never double-audited regardless of typeString")
class AuditedTypeString {
@Test
@DisplayName(
"typeString @Audited method only proceeds, no string-type audit is emitted here")
void typeStringMethodProceedsOnly() throws Throwable {
wireJoinPoint(method("auditedStringMethod"));
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditConfig.getAuditLevel()).thenReturn(AuditLevel.STANDARD);
bindRequest(new MockHttpServletRequest("POST", "/base/audited-string"));
when(joinPoint.proceed()).thenReturn(PROCEED_RESULT);
aspect.auditPostMethod(joinPoint);
// @Audited methods short-circuit before any audit emission in this aspect.
verify(auditService, never())
.audit(anyString(), anyString(), anyString(), anyString(), any(), any());
verify(auditService, never())
.audit(
anyString(),
anyString(),
anyString(),
any(AuditEventType.class),
any(),
any());
}
}
}
@@ -0,0 +1,417 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiEngineEndpointResolver;
import stirling.software.proprietary.service.AiWorkflowService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiEngineControllerTest {
@Mock private AiEngineClient aiEngineClient;
@Mock private AiWorkflowService aiWorkflowService;
@Mock private TaskManager taskManager;
@Mock private JobOwnershipService jobOwnershipService;
@Mock private AiEngineEndpointResolver endpointResolver;
@Mock private UserServiceInterface userService;
// Real ObjectMapper so parseJson / withEnabledEndpoints exercise genuine JSON behaviour.
private final ObjectMapper objectMapper = JsonMapper.builder().build();
// Synchronous executor so orchestrateStream runs its work inline on the calling thread.
private final Executor inlineExecutor = Runnable::run;
private AiEngineController controller;
private AiEngineController newController(UserServiceInterface user) {
AiEngineController c =
new AiEngineController(
aiEngineClient,
aiWorkflowService,
objectMapper,
inlineExecutor,
taskManager,
jobOwnershipService,
endpointResolver,
user);
// @Value field; no setter, so inject the default timeout used in production.
ReflectionTestUtils.setField(c, "streamTimeoutMs", 1_800_000L);
return c;
}
@BeforeEach
void setUp() {
controller = newController(userService);
}
@Nested
@DisplayName("health()")
class Health {
@Test
@DisplayName("returns 200 JSON body from the engine client with current user id")
void healthReturnsEngineBody() throws IOException {
when(userService.getCurrentUsername()).thenReturn("alice");
when(aiEngineClient.get("/health", "alice")).thenReturn("{\"status\":\"ok\"}");
ResponseEntity<String> response = controller.health();
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("{\"status\":\"ok\"}", response.getBody());
assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType());
verify(aiEngineClient).get("/health", "alice");
}
@Test
@DisplayName("passes a null user id when no UserServiceInterface bean is wired")
void healthPassesNullUserWhenSecurityDisabled() throws IOException {
AiEngineController noSecurity = newController(null);
when(aiEngineClient.get("/health", null)).thenReturn("{}");
ResponseEntity<String> response = noSecurity.health();
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(aiEngineClient).get("/health", null);
verifyNoInteractions(userService);
}
@Test
@DisplayName("propagates IOException from the engine client")
void healthPropagatesIoException() throws IOException {
when(userService.getCurrentUsername()).thenReturn("alice");
when(aiEngineClient.get(eq("/health"), anyString()))
.thenThrow(new IOException("engine down"));
assertThrows(IOException.class, () -> controller.health());
}
}
@Nested
@DisplayName("orchestrate()")
class Orchestrate {
@Test
@DisplayName("returns the workflow result unchanged when it has no result files")
void orchestrateNoFilesSkipsJobRegistration() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
AiWorkflowResponse result = new AiWorkflowResponse();
result.setOutcome(AiWorkflowOutcome.ANSWER);
result.setResultFiles(new ArrayList<>());
when(aiWorkflowService.orchestrate(request)).thenReturn(result);
AiWorkflowResponse returned = controller.orchestrate(request);
assertSame(result, returned);
verify(aiWorkflowService).orchestrate(request);
verifyNoInteractions(taskManager, jobOwnershipService);
}
@Test
@DisplayName("registers result files with the task manager under a scoped job key")
void orchestrateRegistersResultFilesAsJob() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
AiWorkflowResponse result = new AiWorkflowResponse();
result.setOutcome(AiWorkflowOutcome.COMPLETED);
result.setResultFiles(
List.of(
new AiWorkflowResultFile("fid-1", "out.pdf", "application/pdf"),
new AiWorkflowResultFile("fid-2", "out.txt", "text/plain")));
when(aiWorkflowService.orchestrate(request)).thenReturn(result);
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("alice:job-123");
AiWorkflowResponse returned = controller.orchestrate(request);
assertSame(result, returned);
verify(jobOwnershipService).createScopedJobKey(anyString());
verify(taskManager).createTask("alice:job-123");
verify(taskManager).setComplete("alice:job-123");
ArgumentCaptor<List<ResultFile>> filesCaptor = captorForResultFiles();
verify(taskManager).setMultipleFileResults(eq("alice:job-123"), filesCaptor.capture());
List<ResultFile> jobFiles = filesCaptor.getValue();
assertEquals(2, jobFiles.size());
assertEquals("fid-1", jobFiles.get(0).getFileId());
assertEquals("out.pdf", jobFiles.get(0).getFileName());
assertEquals("application/pdf", jobFiles.get(0).getContentType());
assertEquals("fid-2", jobFiles.get(1).getFileId());
assertEquals("text/plain", jobFiles.get(1).getContentType());
}
@Test
@DisplayName("treats a null resultFiles list as no files and skips registration")
void orchestrateNullResultFilesSkipsRegistration() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
AiWorkflowResponse result = new AiWorkflowResponse();
result.setResultFiles(null);
when(aiWorkflowService.orchestrate(request)).thenReturn(result);
controller.orchestrate(request);
verifyNoInteractions(taskManager, jobOwnershipService);
}
@Test
@DisplayName("propagates IOException from the workflow service")
void orchestratePropagatesIoException() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
when(aiWorkflowService.orchestrate(request)).thenThrow(new IOException("boom"));
assertThrows(IOException.class, () -> controller.orchestrate(request));
verifyNoInteractions(taskManager);
}
}
@Nested
@DisplayName("orchestrateStream()")
class OrchestrateStream {
@Test
@DisplayName("runs the workflow on the executor and registers result files")
void streamRunsWorkflowAndRegistersFiles() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
AiWorkflowResponse result = new AiWorkflowResponse();
result.setOutcome(AiWorkflowOutcome.COMPLETED);
result.setResultFiles(
List.of(new AiWorkflowResultFile("fid-9", "out.pdf", "application/pdf")));
when(aiWorkflowService.orchestrate(
eq(request), any(AiWorkflowService.ProgressListener.class)))
.thenReturn(result);
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("scoped-key");
// Inline executor means the workflow has fully run by the time this returns.
var emitter = controller.orchestrateStream(request);
assertNotNull(emitter);
verify(aiWorkflowService)
.orchestrate(eq(request), any(AiWorkflowService.ProgressListener.class));
verify(taskManager).createTask("scoped-key");
verify(taskManager).setMultipleFileResults(eq("scoped-key"), any());
verify(taskManager).setComplete("scoped-key");
}
@Test
@DisplayName("does not register a job when the streamed workflow returns no files")
void streamWithoutFilesDoesNotRegisterJob() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
AiWorkflowResponse result = new AiWorkflowResponse();
result.setOutcome(AiWorkflowOutcome.ANSWER);
result.setResultFiles(new ArrayList<>());
when(aiWorkflowService.orchestrate(
eq(request), any(AiWorkflowService.ProgressListener.class)))
.thenReturn(result);
controller.orchestrateStream(request);
verify(aiWorkflowService)
.orchestrate(eq(request), any(AiWorkflowService.ProgressListener.class));
verifyNoInteractions(taskManager, jobOwnershipService);
}
@Test
@DisplayName(
"swallows a workflow failure inside the stream task and still returns an emitter")
void streamHandlesWorkflowExceptionGracefully() throws IOException {
AiWorkflowRequest request = new AiWorkflowRequest();
when(aiWorkflowService.orchestrate(
eq(request), any(AiWorkflowService.ProgressListener.class)))
.thenThrow(new IOException("engine exploded"));
// The failure is caught inside runOrchestrationStream; it must not escape this call.
var emitter = controller.orchestrateStream(request);
assertNotNull(emitter);
verify(aiWorkflowService)
.orchestrate(eq(request), any(AiWorkflowService.ProgressListener.class));
// No files were produced, so no job registration is attempted.
verifyNoInteractions(taskManager);
}
}
@Nested
@DisplayName("pdfEdit()")
class PdfEdit {
@Test
@DisplayName("forwards the body with server-owned enabled_endpoints injected")
void pdfEditInjectsEnabledEndpoints() throws IOException {
when(userService.getCurrentUsername()).thenReturn("bob");
when(endpointResolver.getEnabledEndpointUrls())
.thenReturn(List.of("/api/v1/misc/compress-pdf", "/api/v1/general/rotate"));
when(aiEngineClient.post(eq("/api/v1/pdf/edit"), anyString(), eq("bob")))
.thenReturn("{\"plan\":[]}");
ResponseEntity<String> response = controller.pdfEdit("{\"message\":\"rotate it\"}");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("{\"plan\":[]}", response.getBody());
assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType());
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(aiEngineClient).post(eq("/api/v1/pdf/edit"), bodyCaptor.capture(), eq("bob"));
String forwarded = bodyCaptor.getValue();
assertTrue(forwarded.contains("enabled_endpoints"), forwarded);
assertTrue(forwarded.contains("/api/v1/misc/compress-pdf"), forwarded);
assertTrue(forwarded.contains("/api/v1/general/rotate"), forwarded);
// Original field is preserved alongside the injected list.
assertTrue(forwarded.contains("rotate it"), forwarded);
}
@Test
@DisplayName("overwrites a client-supplied enabled_endpoints with the server view")
void pdfEditOverwritesClientSuppliedEndpoints() throws IOException {
when(userService.getCurrentUsername()).thenReturn("bob");
when(endpointResolver.getEnabledEndpointUrls())
.thenReturn(List.of("/api/v1/general/rotate"));
when(aiEngineClient.post(eq("/api/v1/pdf/edit"), anyString(), anyString()))
.thenReturn("ok");
controller.pdfEdit("{\"enabled_endpoints\":[\"/api/v1/evil/hack\"]}");
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(aiEngineClient).post(eq("/api/v1/pdf/edit"), bodyCaptor.capture(), anyString());
String forwarded = bodyCaptor.getValue();
assertTrue(forwarded.contains("/api/v1/general/rotate"), forwarded);
assertTrue(!forwarded.contains("/api/v1/evil/hack"), forwarded);
}
@Test
@DisplayName("emits an empty endpoint array when nothing is enabled")
void pdfEditWithNoEnabledEndpoints() throws IOException {
when(userService.getCurrentUsername()).thenReturn(null);
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
when(aiEngineClient.post(eq("/api/v1/pdf/edit"), anyString(), eq(null)))
.thenReturn("ok");
controller.pdfEdit("{\"message\":\"hi\"}");
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(aiEngineClient).post(eq("/api/v1/pdf/edit"), bodyCaptor.capture(), eq(null));
assertTrue(bodyCaptor.getValue().contains("\"enabled_endpoints\":[]"));
}
@Test
@DisplayName("rejects a JSON array body with 400 (must be a JSON object)")
void pdfEditRejectsNonObjectJson() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class, () -> controller.pdfEdit("[1,2,3]"));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
assertTrue(ex.getReason() != null && ex.getReason().contains("JSON object"));
verifyNoInteractions(aiEngineClient);
}
@Test
@DisplayName("rejects a JSON scalar (number) body with 400")
void pdfEditRejectsScalarJson() {
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> controller.pdfEdit("42"));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
verifyNoInteractions(aiEngineClient);
}
@Test
@DisplayName("rejects malformed JSON with a 400 'not valid JSON' error")
void pdfEditRejectsInvalidJson() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.pdfEdit("{not valid json"));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
assertTrue(ex.getReason() != null && ex.getReason().contains("valid JSON"));
verifyNoInteractions(aiEngineClient);
}
@Test
@DisplayName("propagates IOException from the engine client")
void pdfEditPropagatesIoException() throws IOException {
when(userService.getCurrentUsername()).thenReturn("bob");
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
when(aiEngineClient.post(eq("/api/v1/pdf/edit"), anyString(), anyString()))
.thenThrow(new IOException("unreachable"));
assertThrows(IOException.class, () -> controller.pdfEdit("{\"message\":\"x\"}"));
}
}
@Nested
@DisplayName("currentUserId() propagation")
class CurrentUser {
@Test
@DisplayName("null UserServiceInterface yields a null user id everywhere")
void nullUserServiceMeansNullUserId() throws IOException {
AiEngineController noSecurity = newController(null);
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
when(aiEngineClient.post(eq("/api/v1/pdf/edit"), anyString(), eq(null)))
.thenReturn("ok");
noSecurity.pdfEdit("{\"message\":\"x\"}");
verify(aiEngineClient).post(eq("/api/v1/pdf/edit"), anyString(), eq(null));
verifyNoInteractions(userService);
}
@Test
@DisplayName("a logged-in user id is forwarded to the engine client")
void loggedInUserIdForwarded() throws IOException {
when(userService.getCurrentUsername()).thenReturn("carol");
when(aiEngineClient.get("/health", "carol")).thenReturn("{}");
controller.health();
verify(aiEngineClient).get("/health", "carol");
}
}
@SuppressWarnings("unchecked")
private static ArgumentCaptor<List<ResultFile>> captorForResultFiles() {
return ArgumentCaptor.forClass(List.class);
}
}
@@ -0,0 +1,729 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.api.audit.AuditDataRequest;
import stirling.software.proprietary.model.api.audit.AuditDataResponse;
import stirling.software.proprietary.model.api.audit.AuditExportRequest;
import stirling.software.proprietary.model.api.audit.AuditStatsResponse;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AuditDashboardControllerTest {
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private ObjectMapper objectMapper;
private AuditDashboardController controller;
/** Concrete JacksonException subtype: needed because JacksonException ctors are protected. */
static class TestJacksonException extends JacksonException {
TestJacksonException(String message) {
super(message);
}
}
private static PersistentAuditEvent event(
long id, String principal, String type, String data, Instant timestamp) {
return PersistentAuditEvent.builder()
.id(id)
.principal(principal)
.type(type)
.data(data)
.timestamp(timestamp)
.build();
}
private AuditDataRequest dataRequest(
String type, String principal, LocalDate start, LocalDate end, int page, int size) {
AuditDataRequest request = new AuditDataRequest();
request.setType(type);
request.setPrincipal(principal);
request.setStartDate(start);
request.setEndDate(end);
request.setPage(page);
request.setSize(size);
return request;
}
private AuditExportRequest exportRequest(
String type, String principal, LocalDate start, LocalDate end) {
AuditExportRequest request = new AuditExportRequest();
request.setType(type);
request.setPrincipal(principal);
request.setStartDate(start);
request.setEndDate(end);
return request;
}
private Page<PersistentAuditEvent> pageOf(List<PersistentAuditEvent> content) {
return new PageImpl<>(content, PageRequest.of(0, 30), content.size());
}
private void init() {
controller = new AuditDashboardController(auditRepository, objectMapper);
}
@Nested
@DisplayName("getAuditData filter routing")
class GetAuditData {
@Test
@DisplayName("no filters -> findAll(pageable)")
void noFilters() {
init();
PersistentAuditEvent e = event(1L, "admin", "USER_LOGIN", "{}", Instant.now());
Page<PersistentAuditEvent> page =
new PageImpl<>(List.of(e), PageRequest.of(2, 15), 100);
when(auditRepository.findAll(any(Pageable.class))).thenReturn(page);
AuditDataResponse response =
controller.getAuditData(dataRequest(null, null, null, null, 2, 15));
assertEquals(List.of(e), response.getContent());
assertEquals(page.getTotalPages(), response.getTotalPages());
assertEquals(100L, response.getTotalElements());
assertEquals(2, response.getCurrentPage());
verify(auditRepository).findAll(any(Pageable.class));
}
@Test
@DisplayName("page/size/sort propagated to PageRequest")
void pageableConstruction() {
init();
when(auditRepository.findAll(any(Pageable.class))).thenReturn(pageOf(List.of()));
controller.getAuditData(dataRequest(null, null, null, null, 3, 25));
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
verify(auditRepository).findAll(captor.capture());
Pageable pageable = captor.getValue();
assertEquals(3, pageable.getPageNumber());
assertEquals(25, pageable.getPageSize());
assertNotNull(pageable.getSort().getOrderFor("timestamp"));
assertTrue(pageable.getSort().getOrderFor("timestamp").isDescending());
}
@Test
@DisplayName("type only -> findByType")
void typeOnly() {
init();
when(auditRepository.findByType(eq("USER_LOGIN"), any(Pageable.class)))
.thenReturn(pageOf(List.of()));
controller.getAuditData(dataRequest("USER_LOGIN", null, null, null, 0, 30));
verify(auditRepository).findByType(eq("USER_LOGIN"), any(Pageable.class));
verify(auditRepository, never()).findAll(any(Pageable.class));
}
@Test
@DisplayName("principal only -> findByPrincipal")
void principalOnly() {
init();
when(auditRepository.findByPrincipal(eq("admin"), any(Pageable.class)))
.thenReturn(pageOf(List.of()));
controller.getAuditData(dataRequest(null, "admin", null, null, 0, 30));
verify(auditRepository).findByPrincipal(eq("admin"), any(Pageable.class));
}
@Test
@DisplayName("type + principal -> findByPrincipalAndType")
void typeAndPrincipal() {
init();
when(auditRepository.findByPrincipalAndType(
eq("admin"), eq("USER_LOGIN"), any(Pageable.class)))
.thenReturn(pageOf(List.of()));
controller.getAuditData(dataRequest("USER_LOGIN", "admin", null, null, 0, 30));
verify(auditRepository)
.findByPrincipalAndType(eq("admin"), eq("USER_LOGIN"), any(Pageable.class));
}
@Test
@DisplayName("date range only -> findByTimestampBetween with [start, end+1day)")
void dateRangeOnly() {
init();
when(auditRepository.findByTimestampBetween(
any(Instant.class), any(Instant.class), any(Pageable.class)))
.thenReturn(pageOf(List.of()));
LocalDate start = LocalDate.of(2025, 1, 1);
LocalDate end = LocalDate.of(2025, 1, 31);
controller.getAuditData(dataRequest(null, null, start, end, 0, 30));
ArgumentCaptor<Instant> startCap = ArgumentCaptor.forClass(Instant.class);
ArgumentCaptor<Instant> endCap = ArgumentCaptor.forClass(Instant.class);
verify(auditRepository)
.findByTimestampBetween(
startCap.capture(), endCap.capture(), any(Pageable.class));
assertEquals(
start.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(),
startCap.getValue());
assertEquals(
end.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(),
endCap.getValue());
}
@Test
@DisplayName("type + date range -> findByTypeAndTimestampBetween")
void typeAndDateRange() {
init();
when(auditRepository.findByTypeAndTimestampBetween(
eq("PDF_PROCESS"),
any(Instant.class),
any(Instant.class),
any(Pageable.class)))
.thenReturn(pageOf(List.of()));
controller.getAuditData(
dataRequest(
"PDF_PROCESS",
null,
LocalDate.of(2025, 2, 1),
LocalDate.of(2025, 2, 2),
0,
30));
verify(auditRepository)
.findByTypeAndTimestampBetween(
eq("PDF_PROCESS"),
any(Instant.class),
any(Instant.class),
any(Pageable.class));
}
@Test
@DisplayName("principal + date range -> findByPrincipalAndTimestampBetween")
void principalAndDateRange() {
init();
when(auditRepository.findByPrincipalAndTimestampBetween(
eq("bob"), any(Instant.class), any(Instant.class), any(Pageable.class)))
.thenReturn(pageOf(List.of()));
controller.getAuditData(
dataRequest(
null,
"bob",
LocalDate.of(2025, 3, 1),
LocalDate.of(2025, 3, 5),
0,
30));
verify(auditRepository)
.findByPrincipalAndTimestampBetween(
eq("bob"), any(Instant.class), any(Instant.class), any(Pageable.class));
}
@Test
@DisplayName("all filters -> findByPrincipalAndTypeAndTimestampBetween")
void allFilters() {
init();
when(auditRepository.findByPrincipalAndTypeAndTimestampBetween(
eq("admin"),
eq("HTTP_REQUEST"),
any(Instant.class),
any(Instant.class),
any(Pageable.class)))
.thenReturn(pageOf(List.of()));
controller.getAuditData(
dataRequest(
"HTTP_REQUEST",
"admin",
LocalDate.of(2025, 4, 1),
LocalDate.of(2025, 4, 30),
0,
30));
verify(auditRepository)
.findByPrincipalAndTypeAndTimestampBetween(
eq("admin"),
eq("HTTP_REQUEST"),
any(Instant.class),
any(Instant.class),
any(Pageable.class));
}
@Test
@DisplayName("partial date range (only startDate) ignored -> falls through to type branch")
void onlyStartDateIgnored() {
init();
when(auditRepository.findByType(eq("USER_LOGIN"), any(Pageable.class)))
.thenReturn(pageOf(List.of()));
// startDate set but endDate null -> date-range branches don't fire; type wins
controller.getAuditData(
dataRequest("USER_LOGIN", null, LocalDate.of(2025, 1, 1), null, 0, 30));
verify(auditRepository).findByType(eq("USER_LOGIN"), any(Pageable.class));
verify(auditRepository, never())
.findByTypeAndTimestampBetween(
any(), any(Instant.class), any(Instant.class), any(Pageable.class));
}
@Test
@DisplayName("response maps Page metadata even with content")
void responseMetadataMapping() {
init();
PersistentAuditEvent a = event(1L, "u1", "USER_LOGIN", "{}", Instant.now());
PersistentAuditEvent b = event(2L, "u2", "USER_LOGOUT", "{}", Instant.now());
Page<PersistentAuditEvent> page =
new PageImpl<>(List.of(a, b), PageRequest.of(1, 2), 6);
when(auditRepository.findAll(any(Pageable.class))).thenReturn(page);
AuditDataResponse response =
controller.getAuditData(dataRequest(null, null, null, null, 1, 2));
assertEquals(2, response.getContent().size());
assertEquals(3, response.getTotalPages());
assertEquals(6L, response.getTotalElements());
assertEquals(1, response.getCurrentPage());
}
}
@Nested
@DisplayName("getAuditStats")
class GetAuditStats {
@Test
@DisplayName("groups events by type, principal, and day")
void groupsCorrectly() {
init();
Instant t1 =
LocalDate.of(2025, 5, 10)
.atStartOfDay(java.time.ZoneId.systemDefault())
.toInstant();
Instant t2 =
LocalDate.of(2025, 5, 11)
.atStartOfDay(java.time.ZoneId.systemDefault())
.toInstant();
List<PersistentAuditEvent> events =
List.of(
event(1L, "admin", "USER_LOGIN", "{}", t1),
event(2L, "admin", "USER_LOGIN", "{}", t1),
event(3L, "bob", "PDF_PROCESS", "{}", t2));
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(events);
AuditStatsResponse stats = controller.getAuditStats(7);
assertEquals(3, stats.getTotalEvents());
assertEquals(2L, stats.getEventsByType().get("USER_LOGIN"));
assertEquals(1L, stats.getEventsByType().get("PDF_PROCESS"));
assertEquals(2L, stats.getEventsByPrincipal().get("admin"));
assertEquals(1L, stats.getEventsByPrincipal().get("bob"));
assertEquals(2, stats.getEventsByDay().size());
}
@Test
@DisplayName("empty repository result yields zeroed stats")
void emptyEvents() {
init();
when(auditRepository.findByTimestampAfter(any(Instant.class)))
.thenReturn(Collections.emptyList());
AuditStatsResponse stats = controller.getAuditStats(30);
assertEquals(0, stats.getTotalEvents());
assertTrue(stats.getEventsByType().isEmpty());
assertTrue(stats.getEventsByPrincipal().isEmpty());
assertTrue(stats.getEventsByDay().isEmpty());
}
@Test
@DisplayName("days param controls the lookback cutoff instant")
void lookbackCutoff() {
init();
when(auditRepository.findByTimestampAfter(any(Instant.class)))
.thenReturn(Collections.emptyList());
Instant before = Instant.now().minus(java.time.Duration.ofDays(7));
controller.getAuditStats(7);
Instant after = Instant.now().minus(java.time.Duration.ofDays(7));
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(auditRepository).findByTimestampAfter(captor.capture());
Instant cutoff = captor.getValue();
// cutoff should be ~now-7d, between the two bounds we measured.
assertFalse(cutoff.isBefore(before.minusSeconds(5)));
assertFalse(cutoff.isAfter(after.plusSeconds(5)));
}
}
@Nested
@DisplayName("getAuditTypes")
class GetAuditTypes {
@Test
@DisplayName("merges DB types with enum types, dedupes and sorts")
void mergeDedupeSort() {
init();
// CUSTOM_TYPE only in DB; USER_LOGIN overlaps with enum.
when(auditRepository.findDistinctEventTypes())
.thenReturn(List.of("CUSTOM_TYPE", "USER_LOGIN"));
List<String> result = controller.getAuditTypes();
// every enum value present
for (AuditEventType t : AuditEventType.values()) {
assertTrue(result.contains(t.name()), "missing enum type " + t.name());
}
assertTrue(result.contains("CUSTOM_TYPE"));
// sorted ascending
List<String> sorted = result.stream().sorted().toList();
assertEquals(sorted, result);
// USER_LOGIN appears exactly once (dedupe)
assertEquals(1, result.stream().filter("USER_LOGIN"::equals).count());
}
@Test
@DisplayName("empty DB types still returns all enum types")
void emptyDbTypes() {
init();
when(auditRepository.findDistinctEventTypes()).thenReturn(Collections.emptyList());
List<String> result = controller.getAuditTypes();
assertEquals(AuditEventType.values().length, result.size());
List<String> expected =
Arrays.stream(AuditEventType.values()).map(Enum::name).sorted().toList();
assertEquals(expected, result);
}
}
@Nested
@DisplayName("exportAuditData (CSV)")
class ExportCsv {
@Test
@DisplayName("no filters -> findAll(); CSV header + rows; octet-stream attachment")
void csvNoFilters() {
init();
Instant ts = Instant.parse("2025-01-01T00:00:00Z");
PersistentAuditEvent e = event(7L, "admin", "USER_LOGIN", "{\"k\":\"v\"}", ts);
when(auditRepository.findAll()).thenReturn(List.of(e));
ResponseEntity<byte[]> response =
controller.exportAuditData(exportRequest(null, null, null, null));
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(
MediaType.APPLICATION_OCTET_STREAM, response.getHeaders().getContentType());
String disposition = response.getHeaders().getFirst("Content-Disposition");
assertNotNull(disposition);
assertTrue(disposition.contains("audit_export.csv"));
String csv = new String(response.getBody(), StandardCharsets.UTF_8);
assertTrue(csv.startsWith("ID,Principal,Type,Timestamp,Data\n"));
assertTrue(csv.contains("7,"));
assertTrue(csv.contains("\"admin\""));
assertTrue(csv.contains("\"USER_LOGIN\""));
assertTrue(csv.contains("2025-01-01T00:00:00Z"));
// data contains quotes which must be doubled and wrapped
assertTrue(csv.contains("\"{\"\"k\"\":\"\"v\"\"}\""));
}
@Test
@DisplayName("null principal/type/data escaped to empty quoted/empty fields")
void csvNullFieldsEscaped() {
init();
Instant ts = Instant.parse("2025-06-01T12:30:00Z");
PersistentAuditEvent e = event(9L, null, null, null, ts);
when(auditRepository.findAll()).thenReturn(List.of(e));
ResponseEntity<byte[]> response =
controller.exportAuditData(exportRequest(null, null, null, null));
String csv = new String(response.getBody(), StandardCharsets.UTF_8);
// null principal/type -> empty string; row: 9,,,<ts>,\n
assertTrue(csv.contains("9,,,2025-06-01T12:30:00Z,\n"));
}
@Test
@DisplayName("empty events -> only header")
void csvEmpty() {
init();
when(auditRepository.findAll()).thenReturn(Collections.emptyList());
ResponseEntity<byte[]> response =
controller.exportAuditData(exportRequest(null, null, null, null));
String csv = new String(response.getBody(), StandardCharsets.UTF_8);
assertEquals("ID,Principal,Type,Timestamp,Data\n", csv);
}
@Test
@DisplayName("type filter -> findByTypeForExport")
void csvTypeFilter() {
init();
when(auditRepository.findByTypeForExport("USER_LOGIN"))
.thenReturn(Collections.emptyList());
controller.exportAuditData(exportRequest("USER_LOGIN", null, null, null));
verify(auditRepository).findByTypeForExport("USER_LOGIN");
verify(auditRepository, never()).findAll();
}
@Test
@DisplayName("principal filter -> findAllByPrincipalForExport")
void csvPrincipalFilter() {
init();
when(auditRepository.findAllByPrincipalForExport("admin"))
.thenReturn(Collections.emptyList());
controller.exportAuditData(exportRequest(null, "admin", null, null));
verify(auditRepository).findAllByPrincipalForExport("admin");
}
@Test
@DisplayName("type + principal -> findAllByPrincipalAndTypeForExport")
void csvTypeAndPrincipal() {
init();
when(auditRepository.findAllByPrincipalAndTypeForExport("admin", "USER_LOGIN"))
.thenReturn(Collections.emptyList());
controller.exportAuditData(exportRequest("USER_LOGIN", "admin", null, null));
verify(auditRepository).findAllByPrincipalAndTypeForExport("admin", "USER_LOGIN");
}
@Test
@DisplayName("date range -> findAllByTimestampBetweenForExport")
void csvDateRange() {
init();
when(auditRepository.findAllByTimestampBetweenForExport(
any(Instant.class), any(Instant.class)))
.thenReturn(Collections.emptyList());
controller.exportAuditData(
exportRequest(null, null, LocalDate.of(2025, 1, 1), LocalDate.of(2025, 1, 2)));
verify(auditRepository)
.findAllByTimestampBetweenForExport(any(Instant.class), any(Instant.class));
}
@Test
@DisplayName("type + date range -> findAllByTypeAndTimestampBetweenForExport")
void csvTypeAndDateRange() {
init();
when(auditRepository.findAllByTypeAndTimestampBetweenForExport(
eq("PDF_PROCESS"), any(Instant.class), any(Instant.class)))
.thenReturn(Collections.emptyList());
controller.exportAuditData(
exportRequest(
"PDF_PROCESS",
null,
LocalDate.of(2025, 2, 1),
LocalDate.of(2025, 2, 2)));
verify(auditRepository)
.findAllByTypeAndTimestampBetweenForExport(
eq("PDF_PROCESS"), any(Instant.class), any(Instant.class));
}
@Test
@DisplayName("principal + date range -> findAllByPrincipalAndTimestampBetweenForExport")
void csvPrincipalAndDateRange() {
init();
when(auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
eq("bob"), any(Instant.class), any(Instant.class)))
.thenReturn(Collections.emptyList());
controller.exportAuditData(
exportRequest(null, "bob", LocalDate.of(2025, 3, 1), LocalDate.of(2025, 3, 2)));
verify(auditRepository)
.findAllByPrincipalAndTimestampBetweenForExport(
eq("bob"), any(Instant.class), any(Instant.class));
}
@Test
@DisplayName("all filters -> findAllByPrincipalAndTypeAndTimestampBetweenForExport")
void csvAllFilters() {
init();
when(auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
eq("admin"),
eq("HTTP_REQUEST"),
any(Instant.class),
any(Instant.class)))
.thenReturn(Collections.emptyList());
controller.exportAuditData(
exportRequest(
"HTTP_REQUEST",
"admin",
LocalDate.of(2025, 4, 1),
LocalDate.of(2025, 4, 2)));
verify(auditRepository)
.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
eq("admin"),
eq("HTTP_REQUEST"),
any(Instant.class),
any(Instant.class));
}
}
@Nested
@DisplayName("exportAuditDataJson (JSON)")
class ExportJson {
@Test
@DisplayName("serializes events; application/json attachment")
void jsonSuccess() {
init();
PersistentAuditEvent e = event(1L, "admin", "USER_LOGIN", "{}", Instant.now());
List<PersistentAuditEvent> events = List.of(e);
when(auditRepository.findAll()).thenReturn(events);
byte[] bytes = "[{}]".getBytes(StandardCharsets.UTF_8);
when(objectMapper.writeValueAsBytes(events)).thenReturn(bytes);
ResponseEntity<byte[]> response =
controller.exportAuditDataJson(exportRequest(null, null, null, null));
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType());
String disposition = response.getHeaders().getFirst("Content-Disposition");
assertNotNull(disposition);
assertTrue(disposition.contains("audit_export.json"));
assertArrayEquals(bytes, response.getBody());
}
@Test
@DisplayName("serialization failure -> 500 with no body")
void jsonSerializationError() {
init();
List<PersistentAuditEvent> events = Collections.emptyList();
when(auditRepository.findAll()).thenReturn(events);
when(objectMapper.writeValueAsBytes(events))
.thenThrow(new TestJacksonException("boom"));
ResponseEntity<byte[]> response =
controller.exportAuditDataJson(exportRequest(null, null, null, null));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals(null, response.getBody());
}
@Test
@DisplayName("json export reuses the same criteria routing (type filter)")
void jsonReusesCriteriaRouting() {
init();
when(auditRepository.findByTypeForExport("USER_LOGOUT"))
.thenReturn(Collections.emptyList());
when(objectMapper.writeValueAsBytes(any())).thenReturn(new byte[0]);
controller.exportAuditDataJson(exportRequest("USER_LOGOUT", null, null, null));
verify(auditRepository).findByTypeForExport("USER_LOGOUT");
}
}
@Nested
@DisplayName("cleanupBefore")
class CleanupBefore {
@Test
@DisplayName("past date -> deletes and reports deleted count + cutoff date")
void pastDateDeletes() {
init();
LocalDate date = LocalDate.now().minusDays(5);
when(auditRepository.deleteByTimestampBefore(any(Instant.class))).thenReturn(42);
Map<String, Object> result = controller.cleanupBefore(date);
assertEquals(42, result.get("deleted"));
assertEquals(date.toString(), result.get("cutoffDate"));
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(auditRepository).deleteByTimestampBefore(captor.capture());
assertEquals(
date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(),
captor.getValue());
}
@Test
@DisplayName("today's date is allowed (not after now)")
void todayAllowed() {
init();
LocalDate today = LocalDate.now();
when(auditRepository.deleteByTimestampBefore(any(Instant.class))).thenReturn(0);
Map<String, Object> result = controller.cleanupBefore(today);
assertEquals(0, result.get("deleted"));
assertEquals(today.toString(), result.get("cutoffDate"));
verify(auditRepository).deleteByTimestampBefore(any(Instant.class));
}
@Test
@DisplayName("future date -> rejected, no deletion, error message")
void futureDateRejected() {
init();
LocalDate future = LocalDate.now().plusDays(1);
Map<String, Object> result = controller.cleanupBefore(future);
assertTrue(result.containsKey("error"));
assertFalse(result.containsKey("deleted"));
verifyNoInteractions(auditRepository);
}
@Test
@DisplayName("null date -> rejected with error, no deletion")
void nullDateRejected() {
init();
Map<String, Object> result = controller.cleanupBefore(null);
assertTrue(result.containsKey("error"));
verifyNoInteractions(auditRepository);
}
}
}
@@ -0,0 +1,603 @@
package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.controller.api.UsageRestController.EndpointStatistic;
import stirling.software.proprietary.controller.api.UsageRestController.EndpointStatisticsResponse;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UsageRestControllerTest {
@Mock private PersistentAuditEventRepository auditRepository;
// Real ObjectMapper so the controller's JSON-blob parsing actually executes.
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private UsageRestController controller;
@BeforeEach
void setUp() {
controller = new UsageRestController(auditRepository, objectMapper);
}
// ---------- helpers ----------
private PersistentAuditEvent event(String data) {
return PersistentAuditEvent.builder()
.id(1L)
.principal("alice")
.type("PDF_PROCESS")
.timestamp(Instant.parse("2024-01-01T10:00:00Z"))
.data(data)
.build();
}
private PersistentAuditEvent endpointEvent(String endpoint) {
return event("{\"endpoint\":\"" + endpoint + "\"}");
}
private EndpointStatistic findEndpoint(EndpointStatisticsResponse body, String endpoint) {
return body.getEndpoints().stream()
.filter(s -> endpoint.equals(s.getEndpoint()))
.findFirst()
.orElse(null);
}
// ============================================================
// dataType routing
// ============================================================
@Nested
@DisplayName("dataType routing")
class DataTypeRouting {
@Test
@DisplayName("'all' queries findByTimestampAfter")
void all() {
when(auditRepository.findByTimestampAfter(any(Instant.class)))
.thenReturn(List.of(endpointEvent("/api/v1/convert")));
ResponseEntity<EndpointStatisticsResponse> resp =
controller.getEndpointStatistics(null, "all", 30);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(auditRepository).findByTimestampAfter(any(Instant.class));
verify(auditRepository, never())
.findByTypeAndTimestampAfterForExport(any(), any(Instant.class));
verify(auditRepository, never())
.findAllExceptTypeAndTimestampAfterForExport(any(), any(Instant.class));
assertThat(resp.getBody().getTotalVisits()).isEqualTo(1);
}
@Test
@DisplayName("default dataType behaves as 'all'")
void defaultDataTypeIsAll() {
when(auditRepository.findByTimestampAfter(any(Instant.class)))
.thenReturn(List.of(endpointEvent("/api/v1/merge")));
ResponseEntity<EndpointStatisticsResponse> resp =
controller.getEndpointStatistics(null, "all", 30);
verify(auditRepository).findByTimestampAfter(any(Instant.class));
assertThat(resp.getBody().getTotalEndpoints()).isEqualTo(1);
}
@Test
@DisplayName("'ui' queries findByTypeAndTimestampAfterForExport with UI_DATA type")
void ui() {
when(auditRepository.findByTypeAndTimestampAfterForExport(
eq(AuditEventType.UI_DATA.name()), any(Instant.class)))
.thenReturn(List.of(endpointEvent("/api/v1/ui/stats")));
ResponseEntity<EndpointStatisticsResponse> resp =
controller.getEndpointStatistics(null, "ui", 30);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(auditRepository)
.findByTypeAndTimestampAfterForExport(
eq(AuditEventType.UI_DATA.name()), any(Instant.class));
verify(auditRepository, never()).findByTimestampAfter(any(Instant.class));
assertThat(resp.getBody().getTotalVisits()).isEqualTo(1);
}
@Test
@DisplayName("'api' queries findAllExceptTypeAndTimestampAfterForExport excluding UI_DATA")
void api() {
when(auditRepository.findAllExceptTypeAndTimestampAfterForExport(
eq(AuditEventType.UI_DATA.name()), any(Instant.class)))
.thenReturn(List.of(endpointEvent("/api/v1/split")));
ResponseEntity<EndpointStatisticsResponse> resp =
controller.getEndpointStatistics(null, "api", 30);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(auditRepository)
.findAllExceptTypeAndTimestampAfterForExport(
eq(AuditEventType.UI_DATA.name()), any(Instant.class));
verify(auditRepository, never()).findByTimestampAfter(any(Instant.class));
assertThat(resp.getBody().getTotalVisits()).isEqualTo(1);
}
@Test
@DisplayName("dataType is case-insensitive ('ALL')")
void caseInsensitiveAll() {
when(auditRepository.findByTimestampAfter(any(Instant.class)))
.thenReturn(List.of(endpointEvent("/api/v1/rotate")));
controller.getEndpointStatistics(null, "ALL", 30);
verify(auditRepository).findByTimestampAfter(any(Instant.class));
}
@Test
@DisplayName("dataType is case-insensitive ('Ui')")
void caseInsensitiveUi() {
when(auditRepository.findByTypeAndTimestampAfterForExport(any(), any(Instant.class)))
.thenReturn(List.of(endpointEvent("/api/v1/ui")));
controller.getEndpointStatistics(null, "Ui", 30);
verify(auditRepository)
.findByTypeAndTimestampAfterForExport(
eq(AuditEventType.UI_DATA.name()), any(Instant.class));
}
@Test
@DisplayName("unknown dataType yields empty result and no repository query")
void unknownDataType() {
ResponseEntity<EndpointStatisticsResponse> resp =
controller.getEndpointStatistics(null, "garbage", 30);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
EndpointStatisticsResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getEndpoints()).isEmpty();
assertThat(body.getTotalEndpoints()).isZero();
assertThat(body.getTotalVisits()).isZero();
verifyNoInteractions(auditRepository);
}
}
// ============================================================
// days clamping
// ============================================================
@Nested
@DisplayName("days lookback clamping")
class DaysClamping {
private Instant capturedStart() {
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(auditRepository).findByTimestampAfter(captor.capture());
return captor.getValue();
}
private void assertLookbackDays(Instant start, int expectedDays) {
Instant expected = Instant.now().minus(Duration.ofDays(expectedDays));
long deltaSeconds = Math.abs(Duration.between(expected, start).getSeconds());
// Allow a generous window for clock drift between controller and assertion.
assertThat(deltaSeconds).isLessThan(60);
}
@Test
@DisplayName("typical value (30) used as-is")
void typical() {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of());
controller.getEndpointStatistics(null, "all", 30);
assertLookbackDays(capturedStart(), 30);
}
@Test
@DisplayName("days above 365 clamps to 365")
void clampUpper() {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of());
controller.getEndpointStatistics(null, "all", 5000);
assertLookbackDays(capturedStart(), 365);
}
@Test
@DisplayName("days below 1 clamps to 1")
void clampLower() {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of());
controller.getEndpointStatistics(null, "all", 0);
assertLookbackDays(capturedStart(), 1);
}
@Test
@DisplayName("negative days clamps to 1")
void clampNegative() {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of());
controller.getEndpointStatistics(null, "all", -10);
assertLookbackDays(capturedStart(), 1);
}
}
// ============================================================
// endpoint extraction from audit data JSON
// ============================================================
@Nested
@DisplayName("endpoint extraction")
class EndpointExtraction {
private EndpointStatisticsResponse runWith(List<PersistentAuditEvent> events) {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(events);
return controller.getEndpointStatistics(null, "all", 30).getBody();
}
@Test
@DisplayName("reads 'endpoint' key")
void endpointKey() {
EndpointStatisticsResponse body =
runWith(List.of(event("{\"endpoint\":\"/api/v1/convert\"}")));
assertThat(findEndpoint(body, "/api/v1/convert")).isNotNull();
assertThat(body.getTotalEndpoints()).isEqualTo(1);
}
@Test
@DisplayName("falls back to 'path' key when no 'endpoint'")
void pathKey() {
EndpointStatisticsResponse body =
runWith(List.of(event("{\"path\":\"/api/v1/merge\"}")));
assertThat(findEndpoint(body, "/api/v1/merge")).isNotNull();
}
@Test
@DisplayName("falls back to 'requestUri' key when no 'endpoint' or 'path'")
void requestUriKey() {
EndpointStatisticsResponse body =
runWith(List.of(event("{\"requestUri\":\"/api/v1/split\"}")));
assertThat(findEndpoint(body, "/api/v1/split")).isNotNull();
}
@Test
@DisplayName("'endpoint' takes priority over 'path' and 'requestUri'")
void endpointPriority() {
EndpointStatisticsResponse body =
runWith(
List.of(
event(
"{\"endpoint\":\"/win\",\"path\":\"/lose\","
+ "\"requestUri\":\"/lose2\"}")));
assertThat(findEndpoint(body, "/win")).isNotNull();
assertThat(findEndpoint(body, "/lose")).isNull();
assertThat(body.getTotalEndpoints()).isEqualTo(1);
}
@Test
@DisplayName("'path' takes priority over 'requestUri'")
void pathOverRequestUri() {
EndpointStatisticsResponse body =
runWith(List.of(event("{\"path\":\"/p\",\"requestUri\":\"/r\"}")));
assertThat(findEndpoint(body, "/p")).isNotNull();
assertThat(findEndpoint(body, "/r")).isNull();
}
@Test
@DisplayName("null data is skipped")
void nullData() {
EndpointStatisticsResponse body = runWith(List.of(event(null)));
assertThat(body.getEndpoints()).isEmpty();
assertThat(body.getTotalVisits()).isZero();
}
@Test
@DisplayName("empty data is skipped")
void emptyData() {
EndpointStatisticsResponse body = runWith(List.of(event("")));
assertThat(body.getEndpoints()).isEmpty();
}
@Test
@DisplayName("malformed JSON is skipped (JacksonException swallowed)")
void malformedJson() {
EndpointStatisticsResponse body = runWith(List.of(event("this-is-not-json")));
assertThat(body.getEndpoints()).isEmpty();
assertThat(body.getTotalVisits()).isZero();
}
@Test
@DisplayName("JSON without recognized keys yields no endpoint")
void noRecognizedKeys() {
EndpointStatisticsResponse body = runWith(List.of(event("{\"unrelated\":\"value\"}")));
assertThat(body.getEndpoints()).isEmpty();
}
@Test
@DisplayName("valid and invalid events mixed -> only valid counted")
void mixedValidInvalid() {
EndpointStatisticsResponse body =
runWith(
new ArrayList<>(
List.of(
event("{\"endpoint\":\"/good\"}"),
event("not-json"),
event(""),
event(null))));
assertThat(body.getEndpoints()).hasSize(1);
assertThat(findEndpoint(body, "/good").getVisits()).isEqualTo(1);
assertThat(body.getTotalVisits()).isEqualTo(1);
}
}
// ============================================================
// endpoint normalization
// ============================================================
@Nested
@DisplayName("endpoint normalization")
class Normalization {
private EndpointStatisticsResponse runWith(List<PersistentAuditEvent> events) {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(events);
return controller.getEndpointStatistics(null, "all", 30).getBody();
}
@Test
@DisplayName("query string is stripped")
void stripsQueryString() {
EndpointStatisticsResponse body =
runWith(List.of(event("{\"endpoint\":\"/api/v1/convert?foo=bar&baz=1\"}")));
assertThat(findEndpoint(body, "/api/v1/convert")).isNotNull();
}
@Test
@DisplayName("leading slash is added when missing")
void addsLeadingSlash() {
EndpointStatisticsResponse body =
runWith(List.of(event("{\"endpoint\":\"api/v1/convert\"}")));
assertThat(findEndpoint(body, "/api/v1/convert")).isNotNull();
}
@Test
@DisplayName("paths differing only by query string collapse to same endpoint")
void collapsesByQueryString() {
EndpointStatisticsResponse body =
runWith(
new ArrayList<>(
List.of(
event("{\"endpoint\":\"/api/v1/convert?a=1\"}"),
event("{\"endpoint\":\"/api/v1/convert?a=2\"}"))));
assertThat(body.getEndpoints()).hasSize(1);
assertThat(findEndpoint(body, "/api/v1/convert").getVisits()).isEqualTo(2);
}
}
// ============================================================
// counting, percentages, sorting, totals
// ============================================================
@Nested
@DisplayName("aggregation and sorting")
class Aggregation {
private EndpointStatisticsResponse runWith(List<PersistentAuditEvent> events) {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(events);
return controller.getEndpointStatistics(null, "all", 30).getBody();
}
@Test
@DisplayName("counts duplicate endpoints and computes totals")
void countsDuplicates() {
EndpointStatisticsResponse body =
runWith(
new ArrayList<>(
List.of(
endpointEvent("/a"),
endpointEvent("/a"),
endpointEvent("/a"),
endpointEvent("/b"))));
assertThat(body.getTotalEndpoints()).isEqualTo(2);
assertThat(body.getTotalVisits()).isEqualTo(4);
assertThat(findEndpoint(body, "/a").getVisits()).isEqualTo(3);
assertThat(findEndpoint(body, "/b").getVisits()).isEqualTo(1);
}
@Test
@DisplayName("results sorted by visit count descending")
void sortedDescending() {
EndpointStatisticsResponse body =
runWith(
new ArrayList<>(
List.of(
endpointEvent("/low"),
endpointEvent("/high"),
endpointEvent("/high"),
endpointEvent("/high"),
endpointEvent("/mid"),
endpointEvent("/mid"))));
List<EndpointStatistic> stats = body.getEndpoints();
assertThat(stats).hasSize(3);
assertThat(stats.get(0).getEndpoint()).isEqualTo("/high");
assertThat(stats.get(0).getVisits()).isEqualTo(3);
assertThat(stats.get(1).getEndpoint()).isEqualTo("/mid");
assertThat(stats.get(1).getVisits()).isEqualTo(2);
assertThat(stats.get(2).getEndpoint()).isEqualTo("/low");
assertThat(stats.get(2).getVisits()).isEqualTo(1);
}
@Test
@DisplayName("percentage computed and rounded to one decimal")
void percentageRounding() {
// 1 of 3 visits -> 33.33% -> rounded to 33.3
EndpointStatisticsResponse body =
runWith(
new ArrayList<>(
List.of(
endpointEvent("/a"),
endpointEvent("/b"),
endpointEvent("/c"))));
assertThat(findEndpoint(body, "/a").getPercentage()).isEqualTo(33.3);
assertThat(findEndpoint(body, "/b").getPercentage()).isEqualTo(33.3);
assertThat(findEndpoint(body, "/c").getPercentage()).isEqualTo(33.3);
}
@Test
@DisplayName("single endpoint is 100 percent")
void hundredPercent() {
EndpointStatisticsResponse body = runWith(List.of(endpointEvent("/only")));
assertThat(findEndpoint(body, "/only").getPercentage()).isEqualTo(100.0);
}
@Test
@DisplayName("empty event list yields zeroed response")
void emptyEvents() {
EndpointStatisticsResponse body = runWith(List.of());
assertThat(body.getEndpoints()).isEmpty();
assertThat(body.getTotalEndpoints()).isZero();
assertThat(body.getTotalVisits()).isZero();
}
}
// ============================================================
// limit handling
// ============================================================
@Nested
@DisplayName("limit handling")
class LimitHandling {
private EndpointStatisticsResponse runWith(
Integer limit, List<PersistentAuditEvent> events) {
when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(events);
return controller.getEndpointStatistics(limit, "all", 30).getBody();
}
private List<PersistentAuditEvent> fiveDistinctEndpoints() {
return new ArrayList<>(
List.of(
endpointEvent("/a"),
endpointEvent("/b"),
endpointEvent("/c"),
endpointEvent("/d"),
endpointEvent("/e")));
}
@Test
@DisplayName("limit smaller than result size truncates the endpoint list")
void truncates() {
EndpointStatisticsResponse body = runWith(2, fiveDistinctEndpoints());
assertThat(body.getEndpoints()).hasSize(2);
// totals reflect the full set, not the truncated list
assertThat(body.getTotalEndpoints()).isEqualTo(5);
assertThat(body.getTotalVisits()).isEqualTo(5);
}
@Test
@DisplayName("limit larger than result size returns all endpoints")
void limitLargerThanSize() {
EndpointStatisticsResponse body = runWith(100, fiveDistinctEndpoints());
assertThat(body.getEndpoints()).hasSize(5);
}
@Test
@DisplayName("limit equal to result size returns all endpoints")
void limitEqualsSize() {
EndpointStatisticsResponse body = runWith(5, fiveDistinctEndpoints());
assertThat(body.getEndpoints()).hasSize(5);
}
@Test
@DisplayName("null limit returns all endpoints")
void nullLimit() {
EndpointStatisticsResponse body = runWith(null, fiveDistinctEndpoints());
assertThat(body.getEndpoints()).hasSize(5);
}
@Test
@DisplayName("zero limit is ignored (returns all)")
void zeroLimit() {
EndpointStatisticsResponse body = runWith(0, fiveDistinctEndpoints());
assertThat(body.getEndpoints()).hasSize(5);
}
@Test
@DisplayName("negative limit is ignored (returns all)")
void negativeLimit() {
EndpointStatisticsResponse body = runWith(-1, fiveDistinctEndpoints());
assertThat(body.getEndpoints()).hasSize(5);
}
@Test
@DisplayName("truncation keeps the top endpoints by visit count")
void truncationKeepsTop() {
List<PersistentAuditEvent> events =
new ArrayList<>(
List.of(
endpointEvent("/top"),
endpointEvent("/top"),
endpointEvent("/top"),
endpointEvent("/mid"),
endpointEvent("/mid"),
endpointEvent("/low")));
EndpointStatisticsResponse body = runWith(1, events);
assertThat(body.getEndpoints()).hasSize(1);
assertThat(body.getEndpoints().get(0).getEndpoint()).isEqualTo("/top");
}
}
}
@@ -0,0 +1,566 @@
package stirling.software.proprietary.policy.controller;
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.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.PolicyValidator;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Unit tests for {@link PolicyController}: the premium policy admin entry point. Each handler is
* called directly with mocked collaborators; JSON parsing uses a real Jackson mapper so the parse
* branches are exercised end to end. No Spring context is booted.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PolicyControllerTest {
@Mock private PolicyRunner policyRunner;
@Mock private PolicyRunRegistry runRegistry;
@Mock private PolicyStore policyStore;
@Mock private PolicyValidator policyValidator;
@Mock private FolderAccessGuard folderAccessGuard;
@Mock private UserServiceInterface userService;
@Mock private stirling.software.common.util.TempFileManager tempFileManager;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private ApplicationProperties applicationProperties;
private PolicyController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
controller =
new PolicyController(
policyRunner,
runRegistry,
policyStore,
policyValidator,
folderAccessGuard,
userService,
applicationProperties,
objectMapper,
tempFileManager);
}
/** Multipart request whose file map is empty, so collectInputs returns empty inputs. */
private static MultipartHttpServletRequest emptyMultipart() {
MultipartHttpServletRequest request =
org.mockito.Mockito.mock(MultipartHttpServletRequest.class);
MultiValueMap<String, MultipartFile> map = new LinkedMultiValueMap<>();
when(request.getMultiFileMap()).thenReturn(map);
return request;
}
private static String validDefinitionJson() {
return "{\"name\":\"d\",\"steps\":[{\"operation\":\"/api/v1/misc/compress-pdf\","
+ "\"parameters\":{}}],\"output\":{\"type\":\"inline\",\"options\":{}}}";
}
private static Policy samplePolicy() {
return new Policy(
"p1",
"name",
"owner",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
// ------------------------------------------------------------------
// POST /run (ad-hoc pipeline)
// ------------------------------------------------------------------
@Nested
@DisplayName("run (ad-hoc pipeline)")
class Run {
@Test
@DisplayName("accepts a valid definition and returns 202 with the run id")
void runAcceptedReturnsRunId() throws IOException {
PolicyRunHandle handle = new PolicyRunHandle("run-123", new CompletableFuture<>());
when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP)))
.thenReturn(handle);
ResponseEntity<JobResponse<Void>> response =
controller.run(validDefinitionJson(), emptyMultipart());
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
JobResponse<Void> body = response.getBody();
assertNotNull(body);
assertTrue(body.isAsync());
assertEquals("run-123", body.getJobId());
assertNull(body.getResult());
}
@Test
@DisplayName("passes the parsed definition through to the runner")
void runPassesParsedDefinition() throws IOException {
when(policyRunner.runAdHoc(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
controller.run(validDefinitionJson(), emptyMultipart());
ArgumentCaptor<PipelineDefinition> captor =
ArgumentCaptor.forClass(PipelineDefinition.class);
verify(policyRunner).runAdHoc(captor.capture(), any(), eq(PolicyProgressListener.NOOP));
assertEquals("d", captor.getValue().name());
assertEquals(1, captor.getValue().steps().size());
}
@Test
@DisplayName("rejects malformed definition JSON with 400")
void runRejectsMalformedJson() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.run("{not json", emptyMultipart()));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
verifyNoInteractions(policyRunner);
}
@Test
@DisplayName("rejects a definition with no steps with 400")
void runRejectsEmptySteps() {
String json = "{\"name\":\"d\",\"steps\":[],\"output\":{\"type\":\"inline\"}}";
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.run(json, emptyMultipart()));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
verifyNoInteractions(policyRunner);
}
}
// ------------------------------------------------------------------
// POST /run/stream
// ------------------------------------------------------------------
@Nested
@DisplayName("runStream")
class RunStream {
@Test
@DisplayName("returns a non-null SseEmitter using the configured timeout")
void runStreamReturnsEmitter() throws IOException {
applicationProperties.getPolicies().setStreamTimeoutMs(12345L);
PolicyRun run = new PolicyRun("r", new PipelineDefinition("d", List.of(), null));
run.complete(List.of());
PolicyRunHandle handle =
new PolicyRunHandle("r", CompletableFuture.completedFuture(run));
when(policyRunner.runAdHoc(any(), any(), any())).thenReturn(handle);
SseEmitter emitter = controller.runStream(validDefinitionJson(), emptyMultipart());
assertNotNull(emitter);
assertEquals(Long.valueOf(12345L), emitter.getTimeout());
verify(policyRunner).runAdHoc(any(), any(), any());
}
@Test
@DisplayName("rejects malformed JSON before touching the runner")
void runStreamRejectsMalformedJson() {
assertThrows(
ResponseStatusException.class,
() -> controller.runStream("{bad", emptyMultipart()));
verifyNoInteractions(policyRunner);
}
@Test
@DisplayName("completes normally when the run completion future resolves successfully")
void runStreamHandlesSuccessfulCompletion() throws IOException {
PolicyRun run = new PolicyRun("r", new PipelineDefinition("d", List.of(), null));
run.complete(List.of());
when(policyRunner.runAdHoc(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", CompletableFuture.completedFuture(run)));
// Should not throw even though the completion callback runs inline.
assertNotNull(controller.runStream(validDefinitionJson(), emptyMultipart()));
}
@Test
@DisplayName("completes normally when the run completion future fails")
void runStreamHandlesFailedCompletion() throws IOException {
CompletableFuture<PolicyRun> failed = new CompletableFuture<>();
failed.completeExceptionally(new RuntimeException("boom"));
when(policyRunner.runAdHoc(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", failed));
assertNotNull(controller.runStream(validDefinitionJson(), emptyMultipart()));
}
}
// ------------------------------------------------------------------
// GET /run/{runId}
// ------------------------------------------------------------------
@Nested
@DisplayName("status")
class Status {
@Test
@DisplayName("returns 200 with the run view when the run exists")
void statusReturnsView() {
PolicyRun run = new PolicyRun("r9", new PipelineDefinition("d", List.of(), null));
when(runRegistry.get("r9")).thenReturn(run);
ResponseEntity<stirling.software.proprietary.policy.model.PolicyRunView> response =
controller.status("r9");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("r9", response.getBody().runId());
}
@Test
@DisplayName("returns 404 when the run is unknown")
void statusReturnsNotFound() {
when(runRegistry.get("missing")).thenReturn(null);
ResponseEntity<stirling.software.proprietary.policy.model.PolicyRunView> response =
controller.status("missing");
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNull(response.getBody());
}
}
// ------------------------------------------------------------------
// POST / (save policy) + folder-access authorization
// ------------------------------------------------------------------
@Nested
@DisplayName("savePolicy")
class SavePolicy {
private String policyJson() {
return "{\"id\":\"\",\"name\":\"My Policy\",\"owner\":\"o\",\"enabled\":true,"
+ "\"steps\":[{\"operation\":\"/api/v1/misc/compress-pdf\",\"parameters\":{}}],"
+ "\"output\":{\"type\":\"inline\",\"options\":{}}}";
}
@Test
@DisplayName(
"validates and stores a non-folder policy, returning 200 with the saved policy")
void savesValidPolicy() {
Policy saved = samplePolicy();
when(folderAccessGuard.usesFolderAccess(any())).thenReturn(false);
when(policyStore.save(any())).thenReturn(saved);
ResponseEntity<Policy> response = controller.savePolicy(policyJson());
assertEquals(HttpStatus.OK, response.getStatusCode());
assertSame(saved, response.getBody());
verify(policyValidator).validate(any());
// Non-folder policy never consults the admin check.
verifyNoInteractions(userService);
}
@Test
@DisplayName("rejects malformed policy JSON with 400 before any side effects")
void rejectsMalformedJson() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.savePolicy("{not valid"));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
assertEquals("Invalid policy JSON", ex.getReason());
verifyNoInteractions(policyStore);
verifyNoInteractions(policyValidator);
}
@Test
@DisplayName("maps a validation IllegalArgumentException to 400 with its message")
void mapsValidationFailureToBadRequest() {
when(folderAccessGuard.usesFolderAccess(any())).thenReturn(false);
org.mockito.Mockito.doThrow(new IllegalArgumentException("bad schedule"))
.when(policyValidator)
.validate(any());
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.savePolicy(policyJson()));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
assertEquals("bad schedule", ex.getReason());
verify(policyStore, never()).save(any());
}
@Test
@DisplayName("folder policy + login enabled + non-admin is forbidden (403)")
void folderPolicyNonAdminForbidden() {
applicationProperties.getSecurity().setEnableLogin(true);
when(folderAccessGuard.usesFolderAccess(any())).thenReturn(true);
when(userService.isCurrentUserAdmin()).thenReturn(false);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.savePolicy(policyJson()));
assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode());
// Denied before validation/storage.
verify(policyValidator, never()).validate(any());
verify(policyStore, never()).save(any());
}
@Test
@DisplayName("folder policy + login enabled + admin is allowed")
void folderPolicyAdminAllowed() {
applicationProperties.getSecurity().setEnableLogin(true);
Policy saved = samplePolicy();
when(folderAccessGuard.usesFolderAccess(any())).thenReturn(true);
when(userService.isCurrentUserAdmin()).thenReturn(true);
when(policyStore.save(any())).thenReturn(saved);
ResponseEntity<Policy> response = controller.savePolicy(policyJson());
assertEquals(HttpStatus.OK, response.getStatusCode());
assertSame(saved, response.getBody());
}
@Test
@DisplayName("folder policy with login disabled skips the admin check (single-user trust)")
void folderPolicyLoginDisabledSkipsAdminCheck() {
applicationProperties.getSecurity().setEnableLogin(false);
Policy saved = samplePolicy();
when(folderAccessGuard.usesFolderAccess(any())).thenReturn(true);
when(policyStore.save(any())).thenReturn(saved);
ResponseEntity<Policy> response = controller.savePolicy(policyJson());
assertEquals(HttpStatus.OK, response.getStatusCode());
// The admin check is never consulted when login is off.
verify(userService, never()).isCurrentUserAdmin();
}
}
// ------------------------------------------------------------------
// GET / , GET /{id} , DELETE /{id}
// ------------------------------------------------------------------
@Nested
@DisplayName("policy CRUD reads")
class PolicyReads {
@Test
@DisplayName("listPolicies returns the store's full list")
void listReturnsAll() {
List<Policy> all = List.of(samplePolicy());
when(policyStore.all()).thenReturn(all);
assertSame(all, controller.listPolicies());
}
@Test
@DisplayName("getPolicy returns 200 with the policy when present")
void getReturnsPolicy() {
Policy policy = samplePolicy();
when(policyStore.get("p1")).thenReturn(Optional.of(policy));
ResponseEntity<Policy> response = controller.getPolicy("p1");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertSame(policy, response.getBody());
}
@Test
@DisplayName("getPolicy returns 404 when absent")
void getReturnsNotFound() {
when(policyStore.get("nope")).thenReturn(Optional.empty());
ResponseEntity<Policy> response = controller.getPolicy("nope");
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNull(response.getBody());
}
@Test
@DisplayName("deletePolicy returns 204 when the policy existed")
void deleteReturnsNoContent() {
when(policyStore.delete("p1")).thenReturn(true);
ResponseEntity<Void> response = controller.deletePolicy("p1");
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
}
@Test
@DisplayName("deletePolicy returns 404 when the policy did not exist")
void deleteReturnsNotFound() {
when(policyStore.delete("ghost")).thenReturn(false);
ResponseEntity<Void> response = controller.deletePolicy("ghost");
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
}
// ------------------------------------------------------------------
// POST /{id}/run (run stored policy)
// ------------------------------------------------------------------
@Nested
@DisplayName("runStoredPolicy")
class RunStoredPolicy {
@Test
@DisplayName("runs the stored policy and returns 202 with the run id")
void runsStoredPolicy() throws IOException {
Policy policy = samplePolicy();
when(policyStore.get("p1")).thenReturn(Optional.of(policy));
when(policyRunner.runWith(eq(policy), any(), eq(PolicyProgressListener.NOOP)))
.thenReturn(new PolicyRunHandle("run-77", new CompletableFuture<>()));
ResponseEntity<JobResponse<Void>> response =
controller.runStoredPolicy("p1", emptyMultipart());
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().isAsync());
assertEquals("run-77", response.getBody().getJobId());
}
@Test
@DisplayName("returns 404 (ResponseStatusException) when the policy id is unknown")
void unknownPolicyNotFound() {
when(policyStore.get("missing")).thenReturn(Optional.empty());
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.runStoredPolicy("missing", emptyMultipart()));
assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode());
verifyNoInteractions(policyRunner);
}
}
// ------------------------------------------------------------------
// collectInputs (exercised via run): primary vs supporting-file split
// ------------------------------------------------------------------
@Nested
@DisplayName("collectInputs file splitting")
class CollectInputs {
@Test
@DisplayName("splits 'fileInput' into primary and other fields into supporting assets")
void splitsPrimaryAndSupporting() throws IOException {
MultipartHttpServletRequest request =
org.mockito.Mockito.mock(MultipartHttpServletRequest.class);
MultiValueMap<String, MultipartFile> map = new LinkedMultiValueMap<>();
MultipartFile primary = nonEmptyFile("doc.pdf");
MultipartFile logo = nonEmptyFile("logo.png");
map.add("fileInput", primary);
map.add("company-logo", logo);
when(request.getMultiFileMap()).thenReturn(map);
stirling.software.common.util.TempFile temp =
org.mockito.Mockito.mock(stirling.software.common.util.TempFile.class);
when(temp.getPath()).thenReturn(java.nio.file.Path.of("temp-path"));
when(temp.getFile()).thenReturn(new java.io.File("temp-file"));
when(tempFileManager.createManagedTempFile(any())).thenReturn(temp);
when(policyRunner.runAdHoc(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
controller.run(validDefinitionJson(), request);
ArgumentCaptor<PolicyInputs> captor = ArgumentCaptor.forClass(PolicyInputs.class);
verify(policyRunner).runAdHoc(any(), captor.capture(), any());
PolicyInputs inputs = captor.getValue();
assertEquals(1, inputs.primary().size());
assertTrue(inputs.supportingFiles().containsKey("company-logo"));
assertEquals(1, inputs.supportingFiles().get("company-logo").size());
assertFalse(inputs.supportingFiles().containsKey("fileInput"));
}
@Test
@DisplayName("skips empty/null files so no temp file is created for them")
void skipsEmptyFiles() throws IOException {
MultipartHttpServletRequest request =
org.mockito.Mockito.mock(MultipartHttpServletRequest.class);
MultiValueMap<String, MultipartFile> map = new LinkedMultiValueMap<>();
MultipartFile empty = org.mockito.Mockito.mock(MultipartFile.class);
when(empty.isEmpty()).thenReturn(true);
map.add("fileInput", empty);
when(request.getMultiFileMap()).thenReturn(map);
when(policyRunner.runAdHoc(any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
controller.run(validDefinitionJson(), request);
// An empty file never reaches the temp-file manager and produces no primary resource.
verify(tempFileManager, never()).createManagedTempFile(any());
ArgumentCaptor<PolicyInputs> captor = ArgumentCaptor.forClass(PolicyInputs.class);
verify(policyRunner).runAdHoc(any(), captor.capture(), any());
assertTrue(captor.getValue().primary().isEmpty());
assertTrue(captor.getValue().supportingFiles().isEmpty());
}
private MultipartFile nonEmptyFile(String name) throws IOException {
MultipartFile file = org.mockito.Mockito.mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn(name);
return file;
}
}
}
@@ -0,0 +1,291 @@
package stirling.software.proprietary.security.configuration.ee;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import tools.jackson.databind.ObjectMapper;
/**
* Unit tests for {@link KeygenLicenseVerifier}.
*
* <p>Strategy: drive the single public method {@code verifyLicense(String)} with real, in-memory
* collaborators (a real Jackson {@link ObjectMapper} and a real {@link ApplicationProperties}). No
* Spring context, no network, no clock waiting.
*
* <p>The certificate / JWT verification paths perform Ed25519 signature checks against a hard-coded
* public key whose matching private key is a secret we do not possess. We therefore cannot forge a
* signature that verifies, so those inputs resolve to {@link License#NORMAL}. We exercise all of
* the reachable branches: premium gating, format detection / routing, malformed payloads, the
* unsupported-algorithm branch, and signature rejection. The "standard" (HTTP API) path is
* deliberately NOT triggered because it performs a real keygen.sh call and a multi-second retry/
* sleep loop.
*/
class KeygenLicenseVerifierTest {
private static final String CERT_PREFIX = "-----BEGIN LICENSE FILE-----";
private static final String CERT_SUFFIX = "-----END LICENSE FILE-----";
private ObjectMapper objectMapper;
private ApplicationProperties applicationProperties;
private KeygenLicenseVerifier verifier;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
applicationProperties = new ApplicationProperties();
verifier = new KeygenLicenseVerifier(objectMapper, applicationProperties);
}
private void enablePremium() {
applicationProperties.getPremium().setEnabled(true);
}
/** Wraps a JSON cert-file body into the PEM-style envelope the verifier expects. */
private static String certFile(String innerJson) {
String b64 = Base64.getEncoder().encodeToString(innerJson.getBytes(StandardCharsets.UTF_8));
return CERT_PREFIX + "\n" + b64 + "\n" + CERT_SUFFIX;
}
@Nested
@DisplayName("Premium gating")
class PremiumGating {
@Test
@DisplayName("returns NORMAL immediately when premium is disabled (default)")
void premiumDisabled_returnsNormal() {
// premium defaults to disabled; any input must short-circuit to NORMAL
assertEquals(License.NORMAL, verifier.verifyLicense("anything-at-all"));
}
@Test
@DisplayName(
"does not parse / touch input when premium disabled, even for a cert-shaped key")
void premiumDisabled_certShapedInput_returnsNormal() {
String cert = certFile("{\"alg\":\"base64+ed25519\",\"enc\":\"x\",\"sig\":\"y\"}");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("does not touch input when premium disabled, even for a JWT-shaped key")
void premiumDisabled_jwtShapedInput_returnsNormal() {
assertEquals(License.NORMAL, verifier.verifyLicense("key/payload.signature"));
}
@Test
@DisplayName("premium disabled tolerates null without throwing")
void premiumDisabled_null_returnsNormal() {
assertEquals(License.NORMAL, verifier.verifyLicense(null));
}
}
@Nested
@DisplayName("Certificate-based license routing")
class CertificateLicense {
@Test
@DisplayName("unsupported algorithm is rejected -> NORMAL")
void unsupportedAlgorithm_returnsNormal() {
enablePremium();
// Valid JSON envelope, but alg is not base64+ed25519, so it is rejected before
// signature.
String cert =
certFile("{\"enc\":\"ZGF0YQ==\",\"sig\":\"c2ln\",\"alg\":\"base64+rsa\"}");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("missing algorithm field is rejected -> NORMAL")
void missingAlgorithm_returnsNormal() {
enablePremium();
String cert = certFile("{\"enc\":\"ZGF0YQ==\",\"sig\":\"c2ln\"}");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("correct algorithm but unforgeable signature is rejected -> NORMAL")
void correctAlgorithmInvalidSignature_returnsNormal() {
enablePremium();
// alg accepted, but sig cannot validate against the real public key.
String cert =
certFile("{\"enc\":\"ZGF0YQ==\",\"sig\":\"AAAA\",\"alg\":\"base64+ed25519\"}");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("non-base64 inner payload is handled -> NORMAL")
void nonBase64InnerPayload_returnsNormal() {
enablePremium();
// The raw cert body (between header/footer) is not valid base64.
String cert = CERT_PREFIX + "\n!!!not-base64!!!\n" + CERT_SUFFIX;
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("decoded payload that is not JSON is handled -> NORMAL")
void decodedPayloadNotJson_returnsNormal() {
enablePremium();
String cert = certFile("this is plainly not json {");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("empty JSON object (no alg) is rejected -> NORMAL")
void emptyJsonObject_returnsNormal() {
enablePremium();
String cert = certFile("{}");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName("leading/trailing whitespace still detected as certificate -> NORMAL")
void whitespaceWrappedCert_stillRoutedAsCert() {
enablePremium();
String cert =
" \n"
+ certFile(
"{\"enc\":\"ZGF0YQ==\",\"sig\":\"AAAA\",\"alg\":\"base64+ed25519\"}")
+ "\n ";
// Routed through the cert path (trim().startsWith(CERT_PREFIX)); sig fails -> NORMAL.
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
}
@Nested
@DisplayName("JWT-style (key/) license routing")
class JwtLicense {
@Test
@DisplayName("missing dot separator -> invalid format -> NORMAL")
void noSeparator_returnsNormal() {
enablePremium();
assertEquals(License.NORMAL, verifier.verifyLicense("key/onlypayloadnodot"));
}
@Test
@DisplayName("payload.signature with unforgeable signature -> NORMAL")
void wellFormedButInvalidSignature_returnsNormal() {
enablePremium();
String payload =
Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(
"{\"license\":{\"id\":\"x\"}}"
.getBytes(StandardCharsets.UTF_8));
String sig = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[64]);
assertEquals(License.NORMAL, verifier.verifyLicense("key/" + payload + "." + sig));
}
@Test
@DisplayName("empty payload and empty signature after prefix -> NORMAL")
void emptyPayloadAndSignature_returnsNormal() {
enablePremium();
// "key/." splits into ["", ""] -> length 2, signature verification then fails.
assertEquals(License.NORMAL, verifier.verifyLicense("key/."));
}
@Test
@DisplayName("just the prefix 'key/' (no payload, no dot) -> invalid format -> NORMAL")
void onlyPrefix_returnsNormal() {
enablePremium();
assertEquals(License.NORMAL, verifier.verifyLicense("key/"));
}
@Test
@DisplayName("garbage (non-base64) signature is caught -> NORMAL")
void garbageSignature_returnsNormal() {
enablePremium();
String payload =
Base64.getUrlEncoder()
.withoutPadding()
.encodeToString("{\"id\":\"abc\"}".getBytes(StandardCharsets.UTF_8));
assertEquals(
License.NORMAL, verifier.verifyLicense("key/" + payload + ".***not-base64***"));
}
}
@Nested
@DisplayName("Format detection precedence")
class FormatDetection {
@Test
@DisplayName("'key/' prefixed inside a cert envelope still routes as certificate")
void certPrefixWins() {
enablePremium();
// Starts with CERT_PREFIX, so cert detection runs first regardless of content.
String cert = certFile("{\"alg\":\"none\"}");
assertEquals(License.NORMAL, verifier.verifyLicense(cert));
}
@Test
@DisplayName(
"blank string is not cert/JWT; would hit standard path but premium gates it off")
void blankString_withPremiumDisabled_returnsNormal() {
// Premium left disabled so we never reach the network standard path.
assertEquals(License.NORMAL, verifier.verifyLicense(" "));
}
}
@Nested
@DisplayName("Side effects on ApplicationProperties")
class SideEffects {
@Test
@DisplayName("rejected certificate license does not mutate maxUsers")
void rejectedCert_doesNotSetMaxUsers() {
enablePremium();
applicationProperties.getPremium().setMaxUsers(42);
String cert =
certFile("{\"enc\":\"ZGF0YQ==\",\"sig\":\"AAAA\",\"alg\":\"base64+ed25519\"}");
verifier.verifyLicense(cert);
// Signature failed before any metadata processing, so maxUsers is untouched.
assertEquals(42, applicationProperties.getPremium().getMaxUsers());
}
@Test
@DisplayName("premium-disabled path leaves maxUsers untouched")
void premiumDisabled_doesNotSetMaxUsers() {
applicationProperties.getPremium().setMaxUsers(7);
verifier.verifyLicense("anything");
assertEquals(7, applicationProperties.getPremium().getMaxUsers());
}
}
@Nested
@DisplayName("License enum contract")
class LicenseEnum {
@Test
@DisplayName("declares NORMAL, SERVER and ENTERPRISE")
void enumValues() {
License[] values = License.values();
assertEquals(3, values.length);
assertSame(License.NORMAL, License.valueOf("NORMAL"));
assertSame(License.SERVER, License.valueOf("SERVER"));
assertSame(License.ENTERPRISE, License.valueOf("ENTERPRISE"));
}
@Test
@DisplayName("verifier is constructible with its two collaborators")
void constructible() {
KeygenLicenseVerifier v =
new KeygenLicenseVerifier(new ObjectMapper(), new ApplicationProperties());
assertNotNull(v);
}
}
}
@@ -0,0 +1,631 @@
package stirling.software.proprietary.security.controller.api;
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.doThrow;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AdminLicenseControllerTest {
private static final String VALID_CERT =
"-----BEGIN LICENSE FILE-----\nabc123\n-----END LICENSE FILE-----";
@Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private KeygenLicenseVerifier keygenLicenseVerifier;
private ApplicationProperties applicationProperties;
private AdminLicenseController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
controller = new AdminLicenseController();
ReflectionTestUtils.setField(controller, "licenseKeyChecker", licenseKeyChecker);
ReflectionTestUtils.setField(controller, "keygenLicenseVerifier", keygenLicenseVerifier);
ReflectionTestUtils.setField(controller, "applicationProperties", applicationProperties);
}
private void setChecker(LicenseKeyChecker checker) {
ReflectionTestUtils.setField(controller, "licenseKeyChecker", checker);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> bodyAsObjectMap(ResponseEntity<Map<String, Object>> resp) {
return resp.getBody();
}
// ----- getInstallationId -----
@Nested
@DisplayName("getInstallationId")
class GetInstallationId {
@Test
@DisplayName("returns 200 with the machine fingerprint")
void returnsFingerprint() {
try (MockedStatic<GeneralUtils> general = mockStatic(GeneralUtils.class)) {
general.when(GeneralUtils::generateMachineFingerprint)
.thenReturn("FINGERPRINT-123");
ResponseEntity<Map<String, String>> resp = controller.getInstallationId();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).containsEntry("installationId", "FINGERPRINT-123");
}
}
@Test
@DisplayName("returns 500 with an error body when fingerprint generation throws")
void fingerprintThrows_returns500() {
try (MockedStatic<GeneralUtils> general = mockStatic(GeneralUtils.class)) {
general.when(GeneralUtils::generateMachineFingerprint)
.thenThrow(new RuntimeException("boom"));
ResponseEntity<Map<String, String>> resp = controller.getInstallationId();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(resp.getBody())
.containsEntry("error", "Failed to generate installation ID");
}
}
}
// ----- saveLicenseKey -----
@Nested
@DisplayName("saveLicenseKey")
class SaveLicenseKey {
@Test
@DisplayName("returns 400 when licenseKey is null")
void nullKey_returns400() {
ResponseEntity<Map<String, Object>> resp =
controller.saveLicenseKey(
Map.of()); // no "licenseKey" entry -> get returns null
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", false);
assertThat(body).containsEntry("error", "License key is required");
}
@Test
@DisplayName("returns 500 when the license checker bean is not available")
void checkerNull_returns500() {
setChecker(null);
ResponseEntity<Map<String, Object>> resp =
controller.saveLicenseKey(Map.of("licenseKey", "abc"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", false);
assertThat(body).containsEntry("error", "License checker not available");
}
@Test
@DisplayName("trims the key, activates a premium license and saves settings")
void validPremiumLicense_savesSettings() throws IOException {
applicationProperties.getPremium().setMaxUsers(25);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
try (MockedStatic<GeneralUtils> general = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> resp =
controller.saveLicenseKey(Map.of("licenseKey", " mykey "));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", true);
assertThat(body).containsEntry("licenseType", "ENTERPRISE");
assertThat(body).containsEntry("enabled", true);
assertThat(body).containsEntry("maxUsers", 25);
assertThat(body).containsEntry("requiresRestart", false);
assertThat(body).containsEntry("message", "License key saved and activated");
// key is trimmed before being passed to the checker
verify(licenseKeyChecker).updateLicenseKey("mykey");
// premium assumed enabled when setting a key
assertThat(applicationProperties.getPremium().isEnabled()).isTrue();
general.verify(() -> GeneralUtils.saveKeyToSettings("premium.enabled", true));
general.verify(() -> GeneralUtils.saveKeyToSettings("premium.maxUsers", 25));
}
}
@Test
@DisplayName("NORMAL license result disables premium and clears the enabled flag")
void normalLicense_disablesPremium() throws IOException {
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
try (MockedStatic<GeneralUtils> general = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> resp =
controller.saveLicenseKey(Map.of("licenseKey", "freekey"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", true);
assertThat(body).containsEntry("licenseType", "NORMAL");
verify(licenseKeyChecker).updateLicenseKey("freekey");
general.verify(() -> GeneralUtils.saveKeyToSettings("premium.enabled", false));
// maxUsers settings should NOT be saved on the NORMAL branch
general.verify(
() -> GeneralUtils.saveKeyToSettings(eq("premium.maxUsers"), any()),
never());
}
}
@Test
@DisplayName("empty string is allowed (clears license) and forwarded as empty after trim")
void emptyKey_isForwarded() throws IOException {
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
try (MockedStatic<GeneralUtils> general = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> resp =
controller.saveLicenseKey(Map.of("licenseKey", " "));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(licenseKeyChecker).updateLicenseKey("");
}
}
@Test
@DisplayName("returns 400 when updateLicenseKey throws")
void updateThrows_returns400() throws IOException {
doThrow(new IOException("disk full"))
.when(licenseKeyChecker)
.updateLicenseKey(anyString());
try (MockedStatic<GeneralUtils> general = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> resp =
controller.saveLicenseKey(Map.of("licenseKey", "boom"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", false);
assertThat(String.valueOf(body.get("error")))
.startsWith("Failed to activate license:");
}
}
}
// ----- resyncLicense -----
@Nested
@DisplayName("resyncLicense")
class ResyncLicense {
@Test
@DisplayName("returns 500 when the license checker bean is not available")
void checkerNull_returns500() {
setChecker(null);
ResponseEntity<Map<String, Object>> resp = controller.resyncLicense();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", false);
assertThat(body).containsEntry("error", "License checker not available");
}
@Test
@DisplayName("returns 400 when no license key is configured (null)")
void nullKey_returns400() {
applicationProperties.getPremium().setKey(null);
ResponseEntity<Map<String, Object>> resp = controller.resyncLicense();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("error", "No license key configured");
verify(licenseKeyChecker, never()).resyncLicense();
}
@Test
@DisplayName("returns 400 when the configured key is blank")
void blankKey_returns400() {
applicationProperties.getPremium().setKey(" ");
ResponseEntity<Map<String, Object>> resp = controller.resyncLicense();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyAsObjectMap(resp)).containsEntry("error", "No license key configured");
}
@Test
@DisplayName("resyncs and returns the updated license info")
void success_returnsLicenseInfo() {
applicationProperties.getPremium().setKey("existing-key");
applicationProperties.getPremium().setEnabled(true);
applicationProperties.getPremium().setMaxUsers(50);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
ResponseEntity<Map<String, Object>> resp = controller.resyncLicense();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", true);
assertThat(body).containsEntry("licenseType", "SERVER");
assertThat(body).containsEntry("enabled", true);
assertThat(body).containsEntry("maxUsers", 50);
assertThat(body).containsEntry("message", "License resynced successfully");
verify(licenseKeyChecker).resyncLicense();
}
@Test
@DisplayName("returns 500 when resyncLicense throws")
void resyncThrows_returns500() {
applicationProperties.getPremium().setKey("existing-key");
doThrow(new RuntimeException("keygen down")).when(licenseKeyChecker).resyncLicense();
ResponseEntity<Map<String, Object>> resp = controller.resyncLicense();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", false);
assertThat(String.valueOf(body.get("error"))).startsWith("Failed to resync license:");
}
}
// ----- getLicenseInfo -----
@Nested
@DisplayName("getLicenseInfo")
class GetLicenseInfo {
@Test
@DisplayName("uses the checker result and includes the key when present")
void withCheckerAndKey() {
applicationProperties.getPremium().setKey("some-key");
applicationProperties.getPremium().setEnabled(true);
applicationProperties.getPremium().setMaxUsers(7);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
ResponseEntity<Map<String, Object>> resp = controller.getLicenseInfo();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("licenseType", "ENTERPRISE");
assertThat(body).containsEntry("enabled", true);
assertThat(body).containsEntry("maxUsers", 7);
assertThat(body).containsEntry("hasKey", true);
assertThat(body).containsEntry("licenseKey", "some-key");
}
@Test
@DisplayName("falls back to NORMAL and omits the key when checker is null and no key set")
void checkerNullNoKey() {
setChecker(null);
applicationProperties.getPremium().setKey(null);
ResponseEntity<Map<String, Object>> resp = controller.getLicenseInfo();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("licenseType", "NORMAL");
assertThat(body).containsEntry("hasKey", false);
assertThat(body).doesNotContainKey("licenseKey");
}
@Test
@DisplayName("blank key counts as no key and is not echoed back")
void blankKey_notEchoed() {
applicationProperties.getPremium().setKey(" ");
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
ResponseEntity<Map<String, Object>> resp = controller.getLicenseInfo();
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("hasKey", false);
assertThat(body).doesNotContainKey("licenseKey");
}
@Test
@DisplayName("returns 500 when reading license status throws")
void throws_returns500() {
when(licenseKeyChecker.getPremiumLicenseEnabledResult())
.thenThrow(new RuntimeException("bad state"));
ResponseEntity<Map<String, Object>> resp = controller.getLicenseInfo();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(bodyAsObjectMap(resp))
.containsEntry("error", "Failed to retrieve license information");
}
}
// ----- uploadLicenseFile -----
@Nested
@DisplayName("uploadLicenseFile")
class UploadLicenseFile {
@Test
@DisplayName("returns 400 when the file is null")
void nullFile_returns400() {
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(null);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyAsObjectMap(resp)).containsEntry("error", "File is empty");
}
@Test
@DisplayName("returns 400 when the file is empty")
void emptyFile_returns400() {
MockMultipartFile file =
new MockMultipartFile("file", "license.lic", "text/plain", new byte[0]);
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyAsObjectMap(resp)).containsEntry("error", "File is empty");
}
@Test
@DisplayName("returns 400 when the filename is null")
void nullFilename_returns400() {
MockMultipartFile file =
new MockMultipartFile("file", null, "text/plain", VALID_CERT.getBytes());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyAsObjectMap(resp)).containsEntry("error", "Invalid filename");
}
@Test
@DisplayName("returns 400 when the filename contains '..'")
void parentTraversal_returns400() {
MockMultipartFile file =
new MockMultipartFile(
"file", "..license.lic", "text/plain", VALID_CERT.getBytes());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(String.valueOf(bodyAsObjectMap(resp).get("error")))
.contains("path separators or '..'");
}
@Test
@DisplayName("returns 400 when the filename contains a forward slash")
void forwardSlash_returns400() {
MockMultipartFile file =
new MockMultipartFile(
"file", "sub/license.lic", "text/plain", VALID_CERT.getBytes());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(String.valueOf(bodyAsObjectMap(resp).get("error")))
.contains("path separators or '..'");
}
@Test
@DisplayName("returns 400 when the filename contains a backslash")
void backslash_returns400() {
MockMultipartFile file =
new MockMultipartFile(
"file", "sub\\license.lic", "text/plain", VALID_CERT.getBytes());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(String.valueOf(bodyAsObjectMap(resp).get("error")))
.contains("path separators or '..'");
}
@Test
@DisplayName("returns 400 for an unsupported file extension")
void badExtension_returns400() {
MockMultipartFile file =
new MockMultipartFile(
"file", "license.txt", "text/plain", VALID_CERT.getBytes());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(String.valueOf(bodyAsObjectMap(resp).get("error")))
.contains("Expected .lic or .cert");
}
@Test
@DisplayName("returns 400 when the file exceeds the 1MB limit")
void tooLarge_returns400() {
byte[] big = new byte[1_048_577];
// ensure a valid header so we only trip the size check
byte[] header = VALID_CERT.getBytes(StandardCharsets.UTF_8);
System.arraycopy(header, 0, big, 0, header.length);
MockMultipartFile file =
new MockMultipartFile("file", "license.lic", "text/plain", big);
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(String.valueOf(bodyAsObjectMap(resp).get("error")))
.contains("File too large");
}
@Test
@DisplayName("returns 400 when the certificate header is missing")
void invalidCertHeader_returns400(@TempDir Path tempDir) {
MockMultipartFile file =
new MockMultipartFile(
"file", "license.lic", "text/plain", "not a license".getBytes());
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyAsObjectMap(resp))
.containsEntry("error", "Invalid license certificate format");
}
}
@Test
@DisplayName("writes the file, activates the license and returns success (.lic)")
void validUpload_writesFileAndActivates(@TempDir Path tempDir) throws IOException {
applicationProperties.getPremium().setMaxUsers(99);
MockMultipartFile file =
new MockMultipartFile(
"file",
"license.lic",
"text/plain",
VALID_CERT.getBytes(StandardCharsets.UTF_8));
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", true);
assertThat(body).containsEntry("licenseType", "SERVER");
assertThat(body).containsEntry("filename", "license.lic");
assertThat(body).containsEntry("filePath", "configs/license.lic");
assertThat(body).containsEntry("enabled", true);
assertThat(body).containsEntry("maxUsers", 99);
assertThat(body).containsEntry("message", "License file uploaded and activated");
// file actually written into the config dir
Path written = tempDir.resolve("license.lic");
assertThat(Files.exists(written)).isTrue();
assertThat(Files.readString(written)).isEqualTo(VALID_CERT);
// license updated with a relative file reference
verify(licenseKeyChecker).updateLicenseKey("file:configs/license.lic");
assertThat(applicationProperties.getPremium().isEnabled()).isTrue();
}
}
@Test
@DisplayName("accepts the .cert extension (case-insensitive)")
void certExtension_isAccepted(@TempDir Path tempDir) throws IOException {
MockMultipartFile file =
new MockMultipartFile(
"file",
"License.CERT",
"text/plain",
VALID_CERT.getBytes(StandardCharsets.UTF_8));
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(bodyAsObjectMap(resp)).containsEntry("filename", "License.CERT");
assertThat(Files.exists(tempDir.resolve("License.CERT"))).isTrue();
}
}
@Test
@DisplayName("backs up an existing license file before overwriting it")
void existingFile_isBackedUp(@TempDir Path tempDir) throws IOException {
// pre-existing license file at the target path
Path existing = tempDir.resolve("license.lic");
Files.writeString(existing, "old-content");
MockMultipartFile file =
new MockMultipartFile(
"file",
"license.lic",
"text/plain",
VALID_CERT.getBytes(StandardCharsets.UTF_8));
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// new content written
assertThat(Files.readString(existing)).isEqualTo(VALID_CERT);
// a backup directory with one .bak file was created
Path backupDir = tempDir.resolve("backup");
assertThat(Files.isDirectory(backupDir)).isTrue();
try (var stream = Files.list(backupDir)) {
assertThat(
stream.anyMatch(
p ->
p.getFileName()
.toString()
.startsWith("license.lic.bak.")))
.isTrue();
}
}
}
@Test
@DisplayName("returns 400 when license activation throws after writing the file")
void activationThrows_returns400(@TempDir Path tempDir) throws IOException {
MockMultipartFile file =
new MockMultipartFile(
"file",
"license.lic",
"text/plain",
VALID_CERT.getBytes(StandardCharsets.UTF_8));
doThrow(new IllegalStateException("invalid key"))
.when(licenseKeyChecker)
.updateLicenseKey(anyString());
try (MockedStatic<InstallationPathConfig> paths =
mockStatic(InstallationPathConfig.class)) {
paths.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
ResponseEntity<Map<String, Object>> resp = controller.uploadLicenseFile(file);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Map<String, Object> body = bodyAsObjectMap(resp);
assertThat(body).containsEntry("success", false);
assertThat(String.valueOf(body.get("error")))
.startsWith("Failed to activate license:");
}
}
}
}
@@ -0,0 +1,901 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AdminSettingsControllerTest {
// Real Jackson mapper so convertValue against ApplicationProperties behaves like production.
private final ObjectMapper objectMapper = JsonMapper.builder().build();
@Mock private ApplicationContext applicationContext;
private ApplicationProperties applicationProperties;
private AdminSettingsController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
controller =
new AdminSettingsController(
applicationProperties, objectMapper, applicationContext);
clearPendingChanges();
}
@AfterEach
void tearDown() {
clearPendingChanges();
}
// pendingChanges is a private static ConcurrentHashMap shared across instances; reset it
// between tests so state never leaks. There is no public reset hook on the controller.
@SuppressWarnings("unchecked")
private static ConcurrentHashMap<String, Object> pendingChanges() {
try {
Field field = AdminSettingsController.class.getDeclaredField("pendingChanges");
field.setAccessible(true);
return (ConcurrentHashMap<String, Object>) field.get(null);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
private static void clearPendingChanges() {
pendingChanges().clear();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object body) {
return (Map<String, Object>) body;
}
// ------------------------------------------------------------------
// getSettings
// ------------------------------------------------------------------
@Nested
@DisplayName("getSettings")
class GetSettings {
@Test
@DisplayName("returns 200 and a settings map containing known sections")
void returnsAllSettings() {
ResponseEntity<?> response = controller.getSettings(false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = asMap(response.getBody());
assertThat(body).isNotNull();
// ApplicationProperties exposes these top-level sections.
assertThat(body).containsKeys("security", "system", "ui", "premium");
}
@Test
@DisplayName("masks sensitive nested fields even when no pending changes exist")
void masksSensitiveFields() {
applicationProperties.getSecurity().getOauth2().setClientSecret("super-secret");
ResponseEntity<?> response = controller.getSettings(false);
Map<String, Object> body = asMap(response.getBody());
Map<String, Object> security = asMap(body.get("security"));
Map<String, Object> oauth2 = asMap(security.get("oauth2"));
assertThat(oauth2.get("clientSecret")).isEqualTo("********");
}
@Test
@DisplayName("does NOT mask premium.key (explicitly excluded from masking)")
void doesNotMaskPremiumKey() {
applicationProperties.getPremium().setKey("LICENSE-1234");
ResponseEntity<?> response = controller.getSettings(false);
Map<String, Object> body = asMap(response.getBody());
Map<String, Object> premium = asMap(body.get("premium"));
assertThat(premium.get("key")).isEqualTo("LICENSE-1234");
}
@Test
@DisplayName("merges pending changes when includePending=true")
void mergesPendingChangesWhenRequested() {
pendingChanges().put("ui.appName", "Pending Name");
ResponseEntity<?> response = controller.getSettings(true);
Map<String, Object> body = asMap(response.getBody());
Map<String, Object> ui = asMap(body.get("ui"));
assertThat(ui.get("appName")).isEqualTo("Pending Name");
}
@Test
@DisplayName("ignores pending changes when includePending=false")
void ignoresPendingChangesWhenNotRequested() {
pendingChanges().put("ui.appName", "Pending Name");
ResponseEntity<?> response = controller.getSettings(false);
Map<String, Object> body = asMap(response.getBody());
Map<String, Object> ui = asMap(body.get("ui"));
assertThat(ui.get("appName")).isNotEqualTo("Pending Name");
}
}
// ------------------------------------------------------------------
// getSettingsDelta
// ------------------------------------------------------------------
@Nested
@DisplayName("getSettingsDelta")
class GetSettingsDelta {
@Test
@DisplayName("reports no pending changes when map is empty")
void emptyDelta() {
ResponseEntity<?> response = controller.getSettingsDelta();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = asMap(response.getBody());
assertThat(body.get("hasPendingChanges")).isEqualTo(false);
assertThat(body.get("count")).isEqualTo(0);
assertThat(asMap(body.get("pendingChanges"))).isEmpty();
}
@Test
@DisplayName("reports pending changes and masks sensitive keys")
void reportsPendingChanges() {
pendingChanges().put("ui.appName", "New App");
pendingChanges().put("security.oauth2.clientSecret", "shh");
ResponseEntity<?> response = controller.getSettingsDelta();
Map<String, Object> body = asMap(response.getBody());
assertThat(body.get("hasPendingChanges")).isEqualTo(true);
assertThat(body.get("count")).isEqualTo(2);
Map<String, Object> masked = asMap(body.get("pendingChanges"));
assertThat(masked.get("ui.appName")).isEqualTo("New App");
// The flat key "security.oauth2.clientSecret" ends in a sensitive field name.
assertThat(masked.get("security.oauth2.clientSecret")).isEqualTo("********");
}
}
// ------------------------------------------------------------------
// updateSettings (PUT)
// ------------------------------------------------------------------
@Nested
@DisplayName("updateSettings")
class UpdateSettings {
@Test
@DisplayName("returns 400 when no settings provided (null map)")
void rejectsNullSettings() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(null);
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error")).isEqualTo("No settings provided to update");
}
@Test
@DisplayName("returns 400 when settings map is empty")
void rejectsEmptySettings() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(new HashMap<>());
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error")).isEqualTo("No settings provided to update");
}
@Test
@DisplayName("returns 400 for an invalid key format and does not persist")
void rejectsInvalidKeyFormat() {
Map<String, Object> settings = new HashMap<>();
settings.put("security.foo bar", "x"); // space is illegal per SAFE_KEY_PATTERN
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody().get("error"))
.startsWith("Invalid setting key format");
gu.verify(() -> GeneralUtils.updateSettingsTransactional(any()), never());
}
assertThat(pendingChanges()).isEmpty();
}
@Test
@DisplayName("returns 400 when the first key part is not a valid section name")
void rejectsUnknownSection() {
Map<String, Object> settings = new HashMap<>();
settings.put("notARealSection.value", "x");
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
gu.verify(() -> GeneralUtils.updateSettingsTransactional(any()), never());
}
}
@Test
@DisplayName("persists valid settings, tracks them as pending and returns 200")
void appliesValidSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui.appName", "My PDF Tool");
settings.put("system.enableAnalytics", false);
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat((String) response.getBody().get("message"))
.contains("Successfully updated 2 setting(s)");
gu.verify(() -> GeneralUtils.updateSettingsTransactional(settings), times(1));
}
assertThat(pendingChanges()).containsEntry("ui.appName", "My PDF Tool");
assertThat(pendingChanges()).containsEntry("system.enableAnalytics", false);
}
@Test
@DisplayName("stores empty string for a null value (pending tracking never holds null)")
void nullValueTrackedAsEmptyString() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui.appName", null);
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
assertThat(pendingChanges()).containsEntry("ui.appName", "");
}
@Test
@DisplayName("returns 500 with generic file error when persistence throws IOException")
void ioExceptionYields500() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui.appName", "X");
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.updateSettingsTransactional(any()))
.thenThrow(new IOException("disk full"));
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody().get("error"))
.isEqualTo("Failed to save settings to configuration file.");
}
// Nothing should have been tracked as pending after a failed save.
assertThat(pendingChanges()).isEmpty();
}
@Test
@DisplayName("returns 400 generic message when persistence throws IllegalArgumentException")
void illegalArgumentYields400() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui.appName", "X");
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.updateSettingsTransactional(any()))
.thenThrow(new IllegalArgumentException("bad"));
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error"))
.isEqualTo("Invalid setting key or value.");
}
}
@Test
@DisplayName("returns 500 generic server error for an unexpected runtime exception")
void unexpectedExceptionYields500() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui.appName", "X");
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.updateSettingsTransactional(any()))
.thenThrow(new RuntimeException("boom"));
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody().get("error"))
.isEqualTo("Internal server error occurred.");
}
}
@Test
@DisplayName("accepts empty watched-folders path list (uses default, no error)")
void acceptsEmptyWatchedFolderList() {
Map<String, Object> settings = new HashMap<>();
settings.put("system.customPaths.pipeline.watchedFoldersDirs", new ArrayList<String>());
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@Test
@DisplayName("rejects duplicate watched-folders paths")
void rejectsDuplicateWatchedFolderPaths() {
List<String> paths = new ArrayList<>();
paths.add("folderA");
paths.add("folderA");
Map<String, Object> settings = new HashMap<>();
settings.put("system.customPaths.pipeline.watchedFoldersDirs", paths);
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody().get("error"))
.contains("Duplicate path detected");
gu.verify(() -> GeneralUtils.updateSettingsTransactional(any()), never());
}
}
@Test
@DisplayName("rejects overlapping watched-folders paths")
void rejectsOverlappingWatchedFolderPaths() {
List<String> paths = new ArrayList<>();
paths.add("parent");
paths.add("parent/child");
Map<String, Object> settings = new HashMap<>();
settings.put("system.customPaths.pipeline.watchedFoldersDirs", paths);
UpdateSettingsRequest request = new UpdateSettingsRequest();
request.setSettings(settings);
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody().get("error"))
.contains("Overlapping paths detected");
gu.verify(() -> GeneralUtils.updateSettingsTransactional(any()), never());
}
}
}
// ------------------------------------------------------------------
// getSettingsSection (GET /section/{name})
// ------------------------------------------------------------------
@Nested
@DisplayName("getSettingsSection")
class GetSettingsSection {
@Test
@DisplayName("returns 200 with the section map for a valid section")
void returnsValidSection() {
ResponseEntity<?> response = controller.getSettingsSection("ui", false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = asMap(response.getBody());
assertThat(body).isNotNull();
// The ui section should not contain the "_pending" marker when there are no pending.
assertThat(body).doesNotContainKey("_pending");
}
@Test
@DisplayName("section name lookup is case-insensitive")
void sectionNameCaseInsensitive() {
ResponseEntity<?> response = controller.getSettingsSection("SECURITY", false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("returns 400 for an unknown section name")
void rejectsUnknownSection() {
ResponseEntity<?> response = controller.getSettingsSection("nope", false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody()).startsWith("Invalid section name");
}
@Test
@DisplayName("returns 400 for a blank section name")
void rejectsBlankSection() {
ResponseEntity<?> response = controller.getSettingsSection(" ", false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("adds a _pending block when includePending=true and changes exist")
void includesPendingBlock() {
pendingChanges().put("ui.appName", "Pending UI Name");
ResponseEntity<?> response = controller.getSettingsSection("ui", true);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = asMap(response.getBody());
assertThat(body).containsKey("_pending");
Map<String, Object> pending = asMap(body.get("_pending"));
assertThat(pending.get("appName")).isEqualTo("Pending UI Name");
}
@Test
@DisplayName("does not add a _pending block when changes belong to other sections")
void noPendingBlockForUnrelatedSection() {
pendingChanges().put("security.enableLogin", true);
ResponseEntity<?> response = controller.getSettingsSection("ui", true);
Map<String, Object> body = asMap(response.getBody());
assertThat(body).doesNotContainKey("_pending");
}
@Test
@DisplayName("masks sensitive fields in the returned section map")
void masksSensitiveSectionFields() {
applicationProperties.getSecurity().getOauth2().setClientSecret("hidden");
ResponseEntity<?> response = controller.getSettingsSection("security", false);
Map<String, Object> body = asMap(response.getBody());
Map<String, Object> oauth2 = asMap(body.get("oauth2"));
assertThat(oauth2.get("clientSecret")).isEqualTo("********");
}
}
// ------------------------------------------------------------------
// updateSettingsSection (PUT /section/{name})
// ------------------------------------------------------------------
@Nested
@DisplayName("updateSettingsSection")
class UpdateSettingsSection {
@Test
@DisplayName("returns 400 when section data is null")
void rejectsNullData() {
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("ui", null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error"))
.isEqualTo("No section data provided to update");
}
@Test
@DisplayName("returns 400 when section data is empty")
void rejectsEmptyData() {
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("ui", new HashMap<>());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error"))
.isEqualTo("No section data provided to update");
}
@Test
@DisplayName("returns 400 for an invalid section name")
void rejectsInvalidSectionName() {
Map<String, Object> data = new HashMap<>();
data.put("appName", "X");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("madeup", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody().get("error"))
.startsWith("Invalid section name");
gu.verify(() -> GeneralUtils.saveKeyToSettings(anyString(), any()), never());
}
}
@Test
@DisplayName("persists each property, tracks pending and returns 200")
void appliesSectionUpdates() {
Map<String, Object> data = new HashMap<>();
data.put("appName", "Renamed");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("ui", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat((String) response.getBody().get("message"))
.contains("Successfully updated 1 setting(s) in section");
gu.verify(() -> GeneralUtils.saveKeyToSettings("ui.appName", "Renamed"), times(1));
}
assertThat(pendingChanges()).containsEntry("ui.appName", "Renamed");
}
@Test
@DisplayName("auto-enables premium when a non-empty license key is supplied")
void autoEnablesPremiumWithKey() {
Map<String, Object> data = new HashMap<>();
data.put("key", "LICENSE-XYZ");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("premium", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// Auto-enable adds an "enabled=true" entry that is also persisted.
gu.verify(() -> GeneralUtils.saveKeyToSettings("premium.key", "LICENSE-XYZ"));
gu.verify(() -> GeneralUtils.saveKeyToSettings("premium.enabled", true));
}
assertThat(pendingChanges()).containsEntry("premium.enabled", true);
}
@Test
@DisplayName("does NOT auto-enable premium when the key is blank")
void doesNotAutoEnablePremiumWithBlankKey() {
Map<String, Object> data = new HashMap<>();
data.put("key", " ");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("premium", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
gu.verify(() -> GeneralUtils.saveKeyToSettings("premium.enabled", true), never());
}
assertThat(pendingChanges()).doesNotContainKey("premium.enabled");
}
@Test
@DisplayName("returns 500 with generic file error when persistence throws IOException")
void ioExceptionYields500() {
Map<String, Object> data = new HashMap<>();
data.put("appName", "X");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.saveKeyToSettings(anyString(), any()))
.thenThrow(new IOException("io"));
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("ui", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody().get("error"))
.isEqualTo("Failed to save settings to configuration file.");
}
}
@Test
@DisplayName("returns 400 generic section error when persistence throws IllegalArgument")
void illegalArgumentYields400() {
Map<String, Object> data = new HashMap<>();
data.put("appName", "X");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.saveKeyToSettings(anyString(), any()))
.thenThrow(new IllegalArgumentException("bad"));
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("ui", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().get("error"))
.isEqualTo("Invalid section data provided.");
}
}
@Test
@DisplayName("returns 500 generic server error for an unexpected runtime exception")
void unexpectedExceptionYields500() {
Map<String, Object> data = new HashMap<>();
data.put("appName", "X");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.saveKeyToSettings(anyString(), any()))
.thenThrow(new RuntimeException("boom"));
ResponseEntity<Map<String, Object>> response =
controller.updateSettingsSection("ui", data);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody().get("error"))
.isEqualTo("Internal server error occurred.");
}
}
}
// ------------------------------------------------------------------
// getSettingValue (GET /key/{key})
// ------------------------------------------------------------------
@Nested
@DisplayName("getSettingValue")
class GetSettingValue {
@Test
@DisplayName("returns 400 for an invalid key format")
void rejectsInvalidKeyFormat() {
ResponseEntity<?> response = controller.getSettingValue("ui.app name");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody()).startsWith("Invalid setting key format");
}
@Test
@DisplayName("returns 400 when the section is unknown")
void rejectsUnknownSection() {
ResponseEntity<?> response = controller.getSettingValue("madeup.value");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("returns 400 when the key resolves to no value")
void rejectsMissingValue() {
// premium.key defaults to null -> getSettingByKey returns null -> 400 not found
ResponseEntity<?> response = controller.getSettingValue("premium.nonExistentField");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat((String) response.getBody()).startsWith("Setting key not found");
}
@Test
@DisplayName("returns the value wrapped in SettingValueResponse for a present key")
void returnsPresentValue() {
applicationProperties.getUi().setAppNameNavbar("Stirling");
ResponseEntity<?> response = controller.getSettingValue("ui.appNameNavbar");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isInstanceOf(SettingValueResponse.class);
SettingValueResponse body = (SettingValueResponse) response.getBody();
assertThat(body.getKey()).isEqualTo("ui.appNameNavbar");
assertThat(body.getValue()).isEqualTo("Stirling");
}
@Test
@DisplayName("masks a sensitive value before returning it")
void masksSensitiveValue() {
applicationProperties.getSecurity().getOauth2().setClientSecret("topsecret");
ResponseEntity<?> response = controller.getSettingValue("security.oauth2.clientSecret");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
SettingValueResponse body = (SettingValueResponse) response.getBody();
assertThat(body.getValue()).isEqualTo("********");
}
@Test
@DisplayName("does NOT mask premium.key when present")
void doesNotMaskPremiumKey() {
applicationProperties.getPremium().setKey("REALKEY");
ResponseEntity<?> response = controller.getSettingValue("premium.key");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
SettingValueResponse body = (SettingValueResponse) response.getBody();
assertThat(body.getValue()).isEqualTo("REALKEY");
}
}
// ------------------------------------------------------------------
// updateSettingValue (PUT /key/{key})
// ------------------------------------------------------------------
@Nested
@DisplayName("updateSettingValue")
class UpdateSettingValue {
private UpdateSettingValueRequest req(Object value) {
UpdateSettingValueRequest request = new UpdateSettingValueRequest();
request.setValue(value);
return request;
}
@Test
@DisplayName("returns 400 for an invalid key format and does not persist")
void rejectsInvalidKeyFormat() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<String> response =
controller.updateSettingValue("ui.app name", req("x"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).startsWith("Invalid setting key format");
gu.verify(() -> GeneralUtils.saveKeyToSettings(anyString(), any()), never());
}
}
@Test
@DisplayName("blocks saving a masked value for a sensitive field")
void blocksMaskedSensitiveValue() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<String> response =
controller.updateSettingValue(
"security.oauth2.clientSecret", req("********"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody())
.contains("Cannot save masked values for sensitive settings");
gu.verify(() -> GeneralUtils.saveKeyToSettings(anyString(), any()), never());
}
}
@Test
@DisplayName("allows saving the masked sentinel for a NON-sensitive field")
void allowsMaskedSentinelForNonSensitiveField() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<String> response =
controller.updateSettingValue("ui.appName", req("********"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
gu.verify(() -> GeneralUtils.saveKeyToSettings("ui.appName", "********"));
}
assertThat(pendingChanges()).containsEntry("ui.appName", "********");
}
@Test
@DisplayName("persists a valid value, tracks pending and returns 200")
void appliesValidValue() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ResponseEntity<String> response =
controller.updateSettingValue("ui.appName", req("Hello"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("Successfully updated setting");
gu.verify(() -> GeneralUtils.saveKeyToSettings("ui.appName", "Hello"));
}
assertThat(pendingChanges()).containsEntry("ui.appName", "Hello");
}
@Test
@DisplayName("returns 500 generic file error when persistence throws IOException")
void ioExceptionYields500() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.saveKeyToSettings(anyString(), any()))
.thenThrow(new IOException("io"));
ResponseEntity<String> response =
controller.updateSettingValue("ui.appName", req("Hello"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody())
.isEqualTo("Failed to save settings to configuration file.");
}
}
@Test
@DisplayName("returns 400 generic message when persistence throws IllegalArgument")
void illegalArgumentYields400() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.saveKeyToSettings(anyString(), any()))
.thenThrow(new IllegalArgumentException("bad"));
ResponseEntity<String> response =
controller.updateSettingValue("ui.appName", req("Hello"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isEqualTo("Invalid setting key or value.");
}
}
@Test
@DisplayName("returns 500 generic server error for an unexpected runtime exception")
void unexpectedExceptionYields500() {
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
gu.when(() -> GeneralUtils.saveKeyToSettings(anyString(), any()))
.thenThrow(new RuntimeException("boom"));
ResponseEntity<String> response =
controller.updateSettingValue("ui.appName", req("Hello"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isEqualTo("Internal server error occurred.");
}
}
}
// ------------------------------------------------------------------
// restartApplication (POST /restart) - only the safe dev-mode branch
// ------------------------------------------------------------------
@Nested
@DisplayName("restartApplication")
class RestartApplication {
@Test
@DisplayName("returns 503 in development mode when no JAR is detected")
void returns503InDevMode() {
try (MockedStatic<stirling.software.common.util.JarPathUtil> jar =
Mockito.mockStatic(stirling.software.common.util.JarPathUtil.class)) {
jar.when(stirling.software.common.util.JarPathUtil::currentJar).thenReturn(null);
ResponseEntity<Map<String, Object>> response = controller.restartApplication();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat((String) response.getBody().get("error"))
.contains("Restart not available in development mode");
}
}
@Test
@DisplayName("returns 503 when the restart helper jar is missing")
void returns503WhenHelperMissing() {
Path fakeAppJar = Path.of("nonexistent-app.jar");
Path missingHelper = Path.of("nonexistent-restart-helper.jar");
try (MockedStatic<stirling.software.common.util.JarPathUtil> jar =
Mockito.mockStatic(stirling.software.common.util.JarPathUtil.class)) {
jar.when(stirling.software.common.util.JarPathUtil::currentJar)
.thenReturn(fakeAppJar);
jar.when(stirling.software.common.util.JarPathUtil::restartHelperJar)
.thenReturn(missingHelper);
ResponseEntity<Map<String, Object>> response = controller.restartApplication();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat((String) response.getBody().get("error"))
.contains("Restart helper not found");
}
}
}
}
@@ -0,0 +1,427 @@
package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.service.DatabaseService;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DatabaseControllerTest {
@Mock private DatabaseService databaseService;
@InjectMocks private DatabaseController databaseController;
/** Tracks temp files created during a test so they can be cleaned up afterwards. */
private Path tempBackupFile;
@AfterEach
void cleanUp() throws IOException {
if (tempBackupFile != null) {
Files.deleteIfExists(tempBackupFile);
tempBackupFile = null;
}
}
@SuppressWarnings("unchecked")
private static Map<String, Object> bodyAsMap(ResponseEntity<?> response) {
assertInstanceOf(Map.class, response.getBody());
return (Map<String, Object>) response.getBody();
}
private static FileInfo backup(String fileName) {
return new FileInfo(
fileName, "/backups/" + fileName, LocalDateTime.now(), 123L, LocalDateTime.now());
}
@Nested
@DisplayName("importDatabase (multipart upload)")
class ImportDatabase {
@Test
@DisplayName("returns 400 when the uploaded file is null")
void nullFileReturnsBadRequest() throws IOException {
ResponseEntity<?> response = databaseController.importDatabase(null);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("fileNullOrEmpty", bodyAsMap(response).get("error"));
verify(databaseService, never()).importDatabaseFromUI(any(Path.class));
}
@Test
@DisplayName("returns 400 when the uploaded file is empty")
void emptyFileReturnsBadRequest() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(true);
ResponseEntity<?> response = databaseController.importDatabase(file);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("fileNullOrEmpty", bodyAsMap(response).get("error"));
assertEquals("File is null or empty", bodyAsMap(response).get("message"));
verify(databaseService, never()).importDatabaseFromUI(any(Path.class));
}
@Test
@DisplayName("returns 200 and success payload when the import succeeds")
void successfulImportReturnsOk() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("backup_data.sql");
when(file.getInputStream())
.thenReturn(
new ByteArrayInputStream(
"CREATE TABLE t;".getBytes(StandardCharsets.UTF_8)));
when(databaseService.importDatabaseFromUI(any(Path.class))).thenReturn(true);
ResponseEntity<?> response = databaseController.importDatabase(file);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("importIntoDatabaseSuccessed", bodyAsMap(response).get("message"));
assertEquals("Database imported successfully", bodyAsMap(response).get("description"));
verify(databaseService).importDatabaseFromUI(any(Path.class));
}
@Test
@DisplayName("returns 500 when the import service reports failure")
void failedImportReturnsServerError() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("backup_data.sql");
when(file.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[] {1, 2, 3}));
when(databaseService.importDatabaseFromUI(any(Path.class))).thenReturn(false);
ResponseEntity<?> response = databaseController.importDatabase(file);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("failedImportFile", bodyAsMap(response).get("error"));
assertEquals("Failed to import database file", bodyAsMap(response).get("message"));
}
@Test
@DisplayName("returns 500 when reading the upload stream throws")
void inputStreamFailureReturnsServerError() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("backup_data.sql");
when(file.getInputStream()).thenThrow(new IOException("stream boom"));
ResponseEntity<?> response = databaseController.importDatabase(file);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("failedImportFile", bodyAsMap(response).get("error"));
assertTrue(
((String) bodyAsMap(response).get("message")).contains("stream boom"),
"message should include the underlying exception text");
verify(databaseService, never()).importDatabaseFromUI(any(Path.class));
}
@Test
@DisplayName("returns 500 when the import service throws an exception")
void serviceExceptionReturnsServerError() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("backup_data.sql");
when(file.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[] {1, 2, 3}));
when(databaseService.importDatabaseFromUI(any(Path.class)))
.thenThrow(new RuntimeException("import boom"));
ResponseEntity<?> response = databaseController.importDatabase(file);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("failedImportFile", bodyAsMap(response).get("error"));
assertTrue(((String) bodyAsMap(response).get("message")).contains("import boom"));
}
}
@Nested
@DisplayName("importDatabaseFromBackupUI (by file name)")
class ImportDatabaseFromBackupUI {
@Test
@DisplayName("returns 400 when the file name is null")
void nullFileNameReturnsBadRequest() {
ResponseEntity<?> response = databaseController.importDatabaseFromBackupUI(null);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("fileNullOrEmpty", bodyAsMap(response).get("error"));
verify(databaseService, never()).importDatabaseFromUI(anyString());
}
@Test
@DisplayName("returns 400 when the file name is empty")
void emptyFileNameReturnsBadRequest() {
ResponseEntity<?> response = databaseController.importDatabaseFromBackupUI("");
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("fileNullOrEmpty", bodyAsMap(response).get("error"));
verify(databaseService, never()).importDatabaseFromUI(anyString());
}
@Test
@DisplayName("returns 404 when the file is not in the backup list")
void missingFileReturnsNotFound() {
when(databaseService.getBackupList()).thenReturn(List.of(backup("backup_2020.sql")));
ResponseEntity<?> response =
databaseController.importDatabaseFromBackupUI("backup_other.sql");
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertEquals("fileNotFound", bodyAsMap(response).get("error"));
verify(databaseService, never()).importDatabaseFromUI(anyString());
}
@Test
@DisplayName("returns 200 when the file exists and imports successfully")
void existingFileImportsSuccessfully() {
String fileName = "backup_2024.sql";
when(databaseService.getBackupList()).thenReturn(List.of(backup(fileName)));
when(databaseService.importDatabaseFromUI(fileName)).thenReturn(true);
ResponseEntity<?> response = databaseController.importDatabaseFromBackupUI(fileName);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("importIntoDatabaseSuccessed", bodyAsMap(response).get("message"));
verify(databaseService).importDatabaseFromUI(fileName);
}
@Test
@DisplayName("returns 500 when the file exists but the import fails")
void existingFileImportFailureReturnsServerError() {
String fileName = "backup_2024.sql";
when(databaseService.getBackupList()).thenReturn(List.of(backup(fileName)));
when(databaseService.importDatabaseFromUI(fileName)).thenReturn(false);
ResponseEntity<?> response = databaseController.importDatabaseFromBackupUI(fileName);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("failedImportFile", bodyAsMap(response).get("error"));
}
}
@Nested
@DisplayName("deleteFile")
class DeleteFile {
@Test
@DisplayName("returns 400 when the file name is null")
void nullFileNameReturnsBadRequest() throws IOException {
ResponseEntity<?> response = databaseController.deleteFile(null);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("invalidFileName", bodyAsMap(response).get("error"));
verify(databaseService, never()).deleteBackupFile(anyString());
}
@Test
@DisplayName("returns 400 when the file name is empty")
void emptyFileNameReturnsBadRequest() throws IOException {
ResponseEntity<?> response = databaseController.deleteFile("");
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("invalidFileName", bodyAsMap(response).get("error"));
verify(databaseService, never()).deleteBackupFile(anyString());
}
@Test
@DisplayName("returns 200 when the file is deleted")
void successfulDeleteReturnsOk() throws IOException {
when(databaseService.deleteBackupFile("backup_x.sql")).thenReturn(true);
ResponseEntity<?> response = databaseController.deleteFile("backup_x.sql");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("File deleted successfully", bodyAsMap(response).get("message"));
verify(databaseService).deleteBackupFile("backup_x.sql");
}
@Test
@DisplayName("returns 500 when the service reports the delete failed")
void failedDeleteReturnsServerError() throws IOException {
when(databaseService.deleteBackupFile("backup_x.sql")).thenReturn(false);
ResponseEntity<?> response = databaseController.deleteFile("backup_x.sql");
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("failedToDeleteFile", bodyAsMap(response).get("error"));
}
@Test
@DisplayName("returns 500 when the service throws IOException")
void ioExceptionReturnsServerError() throws IOException {
when(databaseService.deleteBackupFile("backup_x.sql"))
.thenThrow(new IOException("delete boom"));
ResponseEntity<?> response = databaseController.deleteFile("backup_x.sql");
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("deleteError", bodyAsMap(response).get("error"));
assertTrue(((String) bodyAsMap(response).get("message")).contains("delete boom"));
}
}
@Nested
@DisplayName("downloadFile")
class DownloadFile {
@Test
@DisplayName("throws IllegalArgumentException when the file name is null")
void nullFileNameThrows() {
assertThrows(
IllegalArgumentException.class, () -> databaseController.downloadFile(null));
verify(databaseService, never()).getBackupFilePath(anyString());
}
@Test
@DisplayName("throws IllegalArgumentException when the file name is empty")
void emptyFileNameThrows() {
assertThrows(IllegalArgumentException.class, () -> databaseController.downloadFile(""));
}
@Test
@DisplayName("returns 400 when the name does not match the backup pattern (prefix)")
void rejectsNonBackupPrefix() {
ResponseEntity<?> response = databaseController.downloadFile("notabackup.sql");
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("invalidFileName", bodyAsMap(response).get("error"));
verify(databaseService, never()).getBackupFilePath(anyString());
}
@Test
@DisplayName("returns 400 when the name lacks the .sql suffix")
void rejectsNonSqlSuffix() {
ResponseEntity<?> response = databaseController.downloadFile("backup_data.txt");
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("invalidFileName", bodyAsMap(response).get("error"));
verify(databaseService, never()).getBackupFilePath(anyString());
}
@Test
@DisplayName("returns 200 with headers and resource for a valid backup file")
void validBackupReturnsResource() throws IOException {
String fileName = "backup_data.sql";
tempBackupFile = Files.createTempFile("backup_dl_", ".sql");
byte[] content = "CREATE TABLE t;".getBytes(StandardCharsets.UTF_8);
Files.write(tempBackupFile, content);
when(databaseService.getBackupFilePath(fileName)).thenReturn(tempBackupFile);
ResponseEntity<?> response = databaseController.downloadFile(fileName);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(
"attachment;filename=" + fileName,
response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
assertEquals(
MediaType.APPLICATION_OCTET_STREAM, response.getHeaders().getContentType());
assertEquals(content.length, response.getHeaders().getContentLength());
assertInstanceOf(InputStreamResource.class, response.getBody());
// drain the stream so the file handle is released before @AfterEach deletes it
try (InputStream in = ((InputStreamResource) response.getBody()).getInputStream()) {
assertNotNull(in.readAllBytes());
}
}
@Test
@DisplayName("returns 500 when opening the backup file fails")
void missingBackupFileReturnsServerError() {
String fileName = "backup_missing.sql";
Path nonExistent = Path.of("definitely-not-here", "backup_missing.sql");
when(databaseService.getBackupFilePath(fileName)).thenReturn(nonExistent);
ResponseEntity<?> response = databaseController.downloadFile(fileName);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("downloadFailed", bodyAsMap(response).get("error"));
}
}
@Nested
@DisplayName("createDatabaseBackup")
class CreateDatabaseBackup {
@Test
@DisplayName("returns 200 and triggers the export")
void createBackupReturnsOk() {
ResponseEntity<?> response = databaseController.createDatabaseBackup();
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("backupCreated", bodyAsMap(response).get("message"));
assertEquals(
"Database backup created successfully", bodyAsMap(response).get("description"));
verify(databaseService).exportDatabase();
}
@Test
@DisplayName("propagates a runtime exception from the export service")
void exportExceptionPropagates() {
org.mockito.Mockito.doThrow(new RuntimeException("export boom"))
.when(databaseService)
.exportDatabase();
RuntimeException ex =
assertThrows(
RuntimeException.class,
() -> databaseController.createDatabaseBackup());
assertEquals("export boom", ex.getMessage());
}
}
@Test
@DisplayName("importDatabaseFromBackupUI matches the exact requested file name")
void importMatchesExactFileName() {
String fileName = "backup_exact.sql";
when(databaseService.getBackupList())
.thenReturn(List.of(backup("backup_exact.sql"), backup("backup_exact.sql.bak")));
when(databaseService.importDatabaseFromUI(eq(fileName))).thenReturn(true);
ResponseEntity<?> response = databaseController.importDatabaseFromBackupUI(fileName);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(databaseService).importDatabaseFromUI(fileName);
}
/** Local helper mirroring Mockito.mock to keep static-import usage explicit and unambiguous. */
private static <T> T mock(Class<T> clazz) {
return org.mockito.Mockito.mock(clazz);
}
}
@@ -0,0 +1,304 @@
package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
@ExtendWith(MockitoExtension.class)
class TeamControllerTest {
@Mock private TeamRepository teamRepository;
@Mock private UserRepository userRepository;
@InjectMocks private TeamController teamController;
private static Team team(Long id, String name) {
Team t = new Team();
t.setId(id);
t.setName(name);
return t;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> body(ResponseEntity<?> response) {
assertInstanceOf(Map.class, response.getBody());
return (Map<String, Object>) response.getBody();
}
@Nested
@DisplayName("createTeam")
class CreateTeam {
@Test
@DisplayName("returns 409 CONFLICT when a team with the same name already exists")
void rejectsDuplicateName() {
when(teamRepository.existsByNameIgnoreCase("Sales")).thenReturn(true);
ResponseEntity<?> response = teamController.createTeam("Sales");
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals("Team name already exists.", body(response).get("error"));
verify(teamRepository, never()).save(any());
}
@Test
@DisplayName("saves and returns 200 OK when the name is unique")
void createsTeamWhenNameUnique() {
when(teamRepository.existsByNameIgnoreCase("Marketing")).thenReturn(false);
ResponseEntity<?> response = teamController.createTeam("Marketing");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Team created successfully", body(response).get("message"));
verify(teamRepository)
.save(
org.mockito.ArgumentMatchers.argThat(
saved -> "Marketing".equals(saved.getName())));
}
}
@Nested
@DisplayName("renameTeam")
class RenameTeam {
@Test
@DisplayName("returns 404 NOT_FOUND when the team id does not exist")
void rejectsMissingTeam() {
when(teamRepository.findById(99L)).thenReturn(Optional.empty());
ResponseEntity<?> response = teamController.renameTeam(99L, "NewName");
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertEquals("Team not found.", body(response).get("error"));
verify(teamRepository, never()).save(any());
}
@Test
@DisplayName("returns 409 CONFLICT when the new name is already taken")
void rejectsDuplicateNewName() {
when(teamRepository.findById(1L)).thenReturn(Optional.of(team(1L, "OldName")));
when(teamRepository.existsByNameIgnoreCase("Taken")).thenReturn(true);
ResponseEntity<?> response = teamController.renameTeam(1L, "Taken");
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals("Team name already exists.", body(response).get("error"));
verify(teamRepository, never()).save(any());
}
@Test
@DisplayName("returns 400 BAD_REQUEST when attempting to rename the Internal team")
void rejectsRenamingInternalTeam() {
Team internal = team(1L, TeamService.INTERNAL_TEAM_NAME);
when(teamRepository.findById(1L)).thenReturn(Optional.of(internal));
when(teamRepository.existsByNameIgnoreCase("NewName")).thenReturn(false);
ResponseEntity<?> response = teamController.renameTeam(1L, "NewName");
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("Cannot rename Internal team.", body(response).get("error"));
verify(teamRepository, never()).save(any());
}
@Test
@DisplayName("renames and returns 200 OK on the happy path")
void renamesTeamSuccessfully() {
Team existing = team(5L, "OldName");
when(teamRepository.findById(5L)).thenReturn(Optional.of(existing));
when(teamRepository.existsByNameIgnoreCase("FreshName")).thenReturn(false);
ResponseEntity<?> response = teamController.renameTeam(5L, "FreshName");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Team renamed successfully", body(response).get("message"));
assertEquals("FreshName", existing.getName());
verify(teamRepository).save(existing);
}
}
@Nested
@DisplayName("deleteTeam")
class DeleteTeam {
@Test
@DisplayName("returns 404 NOT_FOUND when the team id does not exist")
void rejectsMissingTeam() {
when(teamRepository.findById(7L)).thenReturn(Optional.empty());
ResponseEntity<?> response = teamController.deleteTeam(7L);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertEquals("Team not found.", body(response).get("error"));
verify(teamRepository, never()).delete(any());
}
@Test
@DisplayName("returns 400 BAD_REQUEST when attempting to delete the Internal team")
void rejectsDeletingInternalTeam() {
Team internal = team(2L, TeamService.INTERNAL_TEAM_NAME);
when(teamRepository.findById(2L)).thenReturn(Optional.of(internal));
ResponseEntity<?> response = teamController.deleteTeam(2L);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("Cannot delete Internal team.", body(response).get("error"));
verify(teamRepository, never()).delete(any());
verify(userRepository, never()).countByTeam(any());
}
@Test
@DisplayName("returns 400 BAD_REQUEST when the team still has members")
void rejectsDeletingNonEmptyTeam() {
Team team = team(3L, "Engineering");
when(teamRepository.findById(3L)).thenReturn(Optional.of(team));
when(userRepository.countByTeam(team)).thenReturn(4L);
ResponseEntity<?> response = teamController.deleteTeam(3L);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals(
"Team must be empty before deletion. Please remove all members first.",
body(response).get("error"));
verify(teamRepository, never()).delete(any());
}
@Test
@DisplayName("deletes and returns 200 OK when the team is empty")
void deletesEmptyTeamSuccessfully() {
Team team = team(4L, "Support");
when(teamRepository.findById(4L)).thenReturn(Optional.of(team));
when(userRepository.countByTeam(team)).thenReturn(0L);
ResponseEntity<?> response = teamController.deleteTeam(4L);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Team deleted successfully", body(response).get("message"));
verify(teamRepository).delete(team);
}
}
@Nested
@DisplayName("addUserToTeam")
class AddUserToTeam {
@Test
@DisplayName("returns 404 NOT_FOUND when the team id does not exist")
void rejectsMissingTeam() {
when(teamRepository.findById(10L)).thenReturn(Optional.empty());
ResponseEntity<?> response = teamController.addUserToTeam(10L, 20L);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertEquals("Team not found.", body(response).get("error"));
verify(userRepository, never()).findById(any());
verify(userRepository, never()).save(any());
}
@Test
@DisplayName("returns 400 BAD_REQUEST when the target team is the Internal team")
void rejectsAddingToInternalTeam() {
Team internal = team(11L, TeamService.INTERNAL_TEAM_NAME);
when(teamRepository.findById(11L)).thenReturn(Optional.of(internal));
ResponseEntity<?> response = teamController.addUserToTeam(11L, 20L);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("Cannot add users to Internal team.", body(response).get("error"));
verify(userRepository, never()).findById(any());
verify(userRepository, never()).save(any());
}
@Test
@DisplayName("returns 404 NOT_FOUND when the user id does not exist")
void rejectsMissingUser() {
Team team = team(12L, "Engineering");
when(teamRepository.findById(12L)).thenReturn(Optional.of(team));
when(userRepository.findById(20L)).thenReturn(Optional.empty());
ResponseEntity<?> response = teamController.addUserToTeam(12L, 20L);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertEquals("User not found.", body(response).get("error"));
verify(userRepository, never()).save(any());
}
@Test
@DisplayName("returns 400 BAD_REQUEST when the user currently belongs to the Internal team")
void rejectsMovingUserFromInternalTeam() {
Team target = team(13L, "Engineering");
when(teamRepository.findById(13L)).thenReturn(Optional.of(target));
User user = new User();
user.setUsername("bob");
user.setTeam(team(99L, TeamService.INTERNAL_TEAM_NAME));
when(userRepository.findById(21L)).thenReturn(Optional.of(user));
ResponseEntity<?> response = teamController.addUserToTeam(13L, 21L);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("Cannot move users from Internal team.", body(response).get("error"));
verify(userRepository, never()).save(any());
}
@Test
@DisplayName("assigns the user and returns 200 OK when the user has no current team")
void addsUserWithNoTeamSuccessfully() {
Team target = team(14L, "Engineering");
when(teamRepository.findById(14L)).thenReturn(Optional.of(target));
User user = new User();
user.setUsername("alice");
// No current team -> getTeam() returns null, branch skipped.
when(userRepository.findById(22L)).thenReturn(Optional.of(user));
ResponseEntity<?> response = teamController.addUserToTeam(14L, 22L);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("User added to team successfully", body(response).get("message"));
assertEquals(target, user.getTeam());
verify(userRepository).save(user);
}
@Test
@DisplayName("moves a user from a non-Internal team and returns 200 OK")
void movesUserFromAnotherTeamSuccessfully() {
Team target = team(15L, "Engineering");
when(teamRepository.findById(15L)).thenReturn(Optional.of(target));
User user = new User();
user.setUsername("carol");
user.setTeam(team(16L, "Marketing"));
when(userRepository.findById(23L)).thenReturn(Optional.of(user));
ResponseEntity<?> response = teamController.addUserToTeam(15L, 23L);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("User added to team successfully", body(response).get("message"));
assertEquals(target, user.getTeam());
verify(userRepository).save(user);
}
}
}
@@ -0,0 +1,513 @@
package stirling.software.proprietary.security.filter;
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.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
/**
* Unit tests for {@link UserAuthenticationFilter}. Verifies the enterprise auth filter does NOT
* fail open: unauthenticated, non-public requests must be denied with 401, while disabled/unknown
* users are rejected and SSO registration blocking is enforced.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UserAuthenticationFilterTest {
@Mock private UserService userService;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private FilterChain filterChain;
private ApplicationProperties.Security securityProp;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
private UserAuthenticationFilter newFilter(boolean loginEnabled) {
return new UserAuthenticationFilter(
securityProp, userService, sessionPersistentRegistry, loginEnabled);
}
private static stirling.software.proprietary.security.model.User apiUser(String apiKey) {
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("apiuser");
user.setApiKey(apiKey);
return user;
}
private void authenticateAs(Object principal) {
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
principal, "creds", List.of(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(token);
}
@BeforeEach
void setUp() {
securityProp = new ApplicationProperties.Security();
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
request.setContextPath("");
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Nested
@DisplayName("login disabled")
class LoginDisabled {
@Test
@DisplayName("passes every request straight through without touching auth services")
void passesThroughWhenLoginDisabled() throws ServletException, IOException {
request.setRequestURI("/api/v1/some/protected/endpoint");
UserAuthenticationFilter filter = newFilter(false);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
verifyNoInteractions(userService);
verifyNoInteractions(sessionPersistentRegistry);
assertEquals(HttpStatus.OK.value(), response.getStatus());
}
}
@Nested
@DisplayName("no authentication present")
class NoAuthentication {
@Test
@DisplayName("denies protected request with 401 JSON (does NOT fail open)")
void deniesProtectedRequestWhenNoAuth() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
assertEquals("application/json", response.getContentType());
assertTrue(response.getContentAsString().contains("Unauthorized"));
assertTrue(response.getContentAsString().contains("X-API-KEY"));
}
@Test
@DisplayName("allows public auth endpoint (/login) through without authentication")
void allowsPublicLoginEndpoint() throws ServletException, IOException {
request.setRequestURI("/login");
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
}
@Test
@DisplayName("allows public auth endpoint (/api/v1/auth/login) through")
void allowsPublicApiLoginEndpoint() throws ServletException, IOException {
request.setRequestURI("/api/v1/auth/login");
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
}
@Test
@DisplayName("honours context path when matching public endpoints")
void honoursContextPathForPublicEndpoint() throws ServletException, IOException {
request.setContextPath("/stirling");
request.setRequestURI("/stirling/login");
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
}
}
@Nested
@DisplayName("API key authentication")
class ApiKeyAuth {
@Test
@DisplayName("authenticates a valid API key and continues the chain")
void validApiKeyAuthenticates() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
request.addHeader("X-API-KEY", "good-key");
stirling.software.proprietary.security.model.User user = apiUser("good-key");
when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user));
when(userService.usernameExistsIgnoreCase("apiuser")).thenReturn(true);
when(userService.isUserDisabled("apiuser")).thenReturn(false);
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
assertTrue(SecurityContextHolder.getContext().getAuthentication().isAuthenticated());
}
@Test
@DisplayName("rejects an unknown API key with 401 and stops the chain")
void invalidApiKeyRejected() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
request.addHeader("X-API-KEY", "bad-key");
when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
assertEquals("Invalid API Key.", response.getContentAsString());
}
@Test
@DisplayName("blank API key is ignored and the request is denied as unauthenticated")
void blankApiKeyIgnored() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
request.addHeader("X-API-KEY", " ");
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(userService, never()).getUserByApiKey(anyString());
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
}
@Test
@DisplayName("missing API key on protected route is denied (does NOT fail open)")
void missingApiKeyDenied() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(userService, never()).getUserByApiKey(anyString());
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
}
}
@Nested
@DisplayName("already-authenticated user (UserDetails / String principal)")
class ExistingAuthentication {
@Test
@DisplayName("valid enabled UserDetails user passes through")
void enabledUserDetailsPassesThrough() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
UserDetails principal =
new User("alice", "pw", List.of(new SimpleGrantedAuthority("ROLE_USER")));
authenticateAs(principal);
when(userService.usernameExistsIgnoreCase("alice")).thenReturn(true);
when(userService.isUserDisabled("alice")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(Collections.emptyList());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
@DisplayName("string principal that exists and is enabled passes through")
void enabledStringPrincipalPassesThrough() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
authenticateAs("bob");
when(userService.usernameExistsIgnoreCase("bob")).thenReturn(true);
when(userService.isUserDisabled("bob")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(Collections.emptyList());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
}
@Test
@DisplayName("non-existent non-SSO user gets 401 and context is cleared")
void nonExistentUserRejected() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
authenticateAs("ghost");
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
when(userService.isUserDisabled("ghost")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(Collections.emptyList());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
assertEquals("application/json", response.getContentType());
assertTrue(response.getContentAsString().contains("Invalid credentials"));
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
@DisplayName("disabled user gets 403, context cleared, sessions expired")
void disabledUserRejected() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
authenticateAs("dave");
when(userService.usernameExistsIgnoreCase("dave")).thenReturn(true);
when(userService.isUserDisabled("dave")).thenReturn(true);
SessionInformation session =
new SessionInformation("dave", "sess-1", new java.util.Date());
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(List.of(session));
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.FORBIDDEN.value(), response.getStatus());
assertTrue(response.getContentAsString().contains("disabled"));
assertTrue(session.isExpired());
verify(sessionPersistentRegistry).expireSession("sess-1");
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
@DisplayName("non-existent user expires any active sessions before denying")
void nonExistentUserExpiresSessions() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
authenticateAs("ghost");
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
when(userService.isUserDisabled("ghost")).thenReturn(false);
SessionInformation session =
new SessionInformation("ghost", "sess-9", new java.util.Date());
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(List.of(session));
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
assertTrue(session.isExpired());
verify(sessionPersistentRegistry).expireSession("sess-9");
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
}
}
@Nested
@DisplayName("SSO principals (OAuth2 / SAML2)")
class SsoAuthentication {
@Test
@DisplayName("non-existent SAML2 user is allowed through when registration not blocked")
void samlUserAllowedWhenNotBlocked() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
CustomSaml2AuthenticatedPrincipal saml =
new CustomSaml2AuthenticatedPrincipal(
"sam@example.com", Map.of(), "sam@example.com", List.of("idx-1"));
authenticateAs(saml);
securityProp.getSaml2().setBlockRegistration(false);
// user does not yet exist but SSO auto-provisioning is allowed
when(userService.usernameExistsIgnoreCase("sam@example.com")).thenReturn(false);
when(userService.isUserDisabled("sam@example.com")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(Collections.emptyList());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
// notSsoLogin is false, so the 401 "Invalid credentials" branch is skipped
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
}
@Test
@DisplayName("SAML2 registration blocked for a new user yields 403 and clears context")
void samlBlockedRegistrationForbidden() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
CustomSaml2AuthenticatedPrincipal saml =
new CustomSaml2AuthenticatedPrincipal(
"newbie@example.com", Map.of(), "newbie@example.com", List.of("idx-2"));
authenticateAs(saml);
securityProp.getSaml2().setBlockRegistration(true);
when(userService.usernameExistsIgnoreCase("newbie@example.com")).thenReturn(false);
when(userService.isUserDisabled("newbie@example.com")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(Collections.emptyList());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.FORBIDDEN.value(), response.getStatus());
assertTrue(response.getContentAsString().contains("blocked"));
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
@DisplayName("existing SAML2 user passes through even when registration is blocked")
void samlExistingUserAllowedWhenBlocked() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
CustomSaml2AuthenticatedPrincipal saml =
new CustomSaml2AuthenticatedPrincipal(
"known@example.com", Map.of(), "known@example.com", List.of("idx-3"));
authenticateAs(saml);
securityProp.getSaml2().setBlockRegistration(true);
when(userService.usernameExistsIgnoreCase("known@example.com")).thenReturn(true);
when(userService.isUserDisabled("known@example.com")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
.thenReturn(Collections.emptyList());
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
}
}
@Nested
@DisplayName("shouldNotFilter routing")
class ShouldNotFilter {
private UserAuthenticationFilter filter() {
return newFilter(true);
}
@Test
@DisplayName("skips filtering for a GET static resource")
void skipsStaticResourceGet() {
request.setMethod("GET");
request.setRequestURI("/css/app.css");
assertTrue(filter().shouldNotFilter(request));
}
@Test
@DisplayName("skips filtering for a GET frontend (SPA) route")
void skipsFrontendRouteGet() {
request.setMethod("GET");
request.setRequestURI("/dashboard");
assertTrue(filter().shouldNotFilter(request));
}
@Test
@DisplayName("does NOT skip a POST to a static-looking path")
void doesNotSkipPostStatic() {
request.setMethod("POST");
request.setRequestURI("/css/app.css");
assertFalse(filter().shouldNotFilter(request));
}
@Test
@DisplayName("skips filtering for the public status API endpoint")
void skipsPublicStatusApi() {
request.setMethod("GET");
request.setRequestURI("/api/v1/info/status");
assertTrue(filter().shouldNotFilter(request));
}
@Test
@DisplayName("skips filtering for the public login API endpoint regardless of method")
void skipsPublicLoginApi() {
request.setMethod("POST");
request.setRequestURI("/api/v1/auth/login");
assertTrue(filter().shouldNotFilter(request));
}
@Test
@DisplayName("does NOT skip filtering for a protected API endpoint")
void doesNotSkipProtectedApi() {
request.setMethod("POST");
request.setRequestURI("/api/v1/general/merge-pdfs");
assertFalse(filter().shouldNotFilter(request));
}
@Test
@DisplayName("respects context path for public API patterns")
void respectsContextPathForPublicApi() {
request.setContextPath("/stirling");
request.setMethod("GET");
request.setRequestURI("/stirling/api/v1/info/status");
assertTrue(filter().shouldNotFilter(request));
}
}
@Nested
@DisplayName("authentication present but flagged not-authenticated")
class UnauthenticatedToken {
@Test
@DisplayName("an unauthenticated token still results in a 401 deny on protected route")
void unauthenticatedTokenDenied() throws ServletException, IOException {
request.setRequestURI("/api/v1/general/merge-pdfs");
// token explicitly not authenticated, no API key header present
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken("eve", "creds");
token.setAuthenticated(false);
SecurityContextHolder.getContext().setAuthentication(token);
UserAuthenticationFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
verify(userService, never()).usernameExistsIgnoreCase(anyString());
}
}
}
@@ -0,0 +1,392 @@
package stirling.software.proprietary.security.filter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import java.io.IOException;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import stirling.software.common.model.enumeration.Role;
/**
* Unit tests for {@link UserBasedRateLimitingFilter}. Drives the {@code doFilterInternal} servlet
* filter directly with {@link MockHttpServletRequest}/{@link MockHttpServletResponse} and a mock
* {@link FilterChain}, asserting pass-through branches, identifier resolution (API key / user /
* IP), per-role daily limits, the {@code X-Rate-Limit-Remaining} header, and the 429 exhaustion
* branch. The real bucket4j buckets are in-memory and require no clock mocking.
*/
class UserBasedRateLimitingFilterTest {
private MockHttpServletRequest request;
private MockHttpServletResponse response;
private FilterChain filterChain;
@BeforeEach
void setUp() {
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
filterChain = mock(FilterChain.class);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
private UserBasedRateLimitingFilter newFilter(boolean rateLimit) {
return new UserBasedRateLimitingFilter(rateLimit);
}
/** Authenticate as a {@link UserDetails} principal carrying the given Role's authority. */
private void authenticateAs(String username, Role role) {
UserDetails principal =
User.withUsername(username)
.password("pw")
.authorities(new SimpleGrantedAuthority(role.getRoleId()))
.build();
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
principal, "creds", List.of(new SimpleGrantedAuthority(role.getRoleId())));
SecurityContextHolder.getContext().setAuthentication(token);
}
@Nested
@DisplayName("rate limiting disabled")
class RateLimitingDisabled {
@Test
@DisplayName("passes POST straight through without resolving any identifier or role")
void passThroughWhenDisabled() throws ServletException, IOException {
request.setMethod("POST");
UserBasedRateLimitingFilter filter = newFilter(false);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
// No rate-limit headers added on the pass-through path.
assertNull(response.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@DisplayName("passes through even with no authentication present (never throws)")
void passThroughWhenDisabledAndAnonymous() throws ServletException, IOException {
request.setMethod("POST");
request.setRemoteAddr("10.0.0.1");
UserBasedRateLimitingFilter filter = newFilter(false);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
}
}
@Nested
@DisplayName("non-POST requests")
class NonPostRequests {
@Test
@DisplayName("GET is passed through without rate limiting even when enabled")
void getIsPassedThrough() throws ServletException, IOException {
request.setMethod("GET");
UserBasedRateLimitingFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertNull(response.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@DisplayName("PUT/DELETE are passed through without rate limiting")
void otherMethodsPassedThrough() throws ServletException, IOException {
for (String method : List.of("PUT", "DELETE", "PATCH", "HEAD", "OPTIONS")) {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse resp = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
req.setMethod(method);
newFilter(true).doFilterInternal(req, resp, chain);
verify(chain).doFilter(req, resp);
assertNull(resp.getHeader("X-Rate-Limit-Remaining"));
}
}
@Test
@DisplayName("method matching is case-insensitive: lowercase 'post' is rate limited")
void lowercasePostIsRateLimited() throws ServletException, IOException {
request.setMethod("post");
authenticateAs("admin", Role.ADMIN);
UserBasedRateLimitingFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
// Lowercase post is treated as POST -> rate limited -> remaining header set.
verify(filterChain).doFilter(request, response);
assertNotNull(response.getHeader("X-Rate-Limit-Remaining"));
}
}
@Nested
@DisplayName("web UI POST (no API key)")
class WebUiPost {
@Test
@DisplayName("authenticated user is allowed and gets a decremented remaining-token header")
void authenticatedWebCallConsumesWebBucket() throws ServletException, IOException {
request.setMethod("POST");
authenticateAs("alice", Role.DEMO_USER); // 100 web calls per day
UserBasedRateLimitingFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals(HttpStatus.OK.value(), response.getStatus());
// DEMO_USER web limit is 100; first consume leaves 99 remaining.
assertEquals("99", response.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@DisplayName("remaining tokens decrement across repeated calls for the same identifier")
void remainingTokensDecrementPerCall() throws ServletException, IOException {
UserBasedRateLimitingFilter filter = newFilter(true);
authenticateAs("bob", Role.DEMO_USER); // 100 web calls per day
for (int expectedRemaining = 99; expectedRemaining >= 96; expectedRemaining--) {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse resp = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
req.setMethod("POST");
filter.doFilterInternal(req, resp, chain);
verify(chain).doFilter(req, resp);
assertEquals(
String.valueOf(expectedRemaining),
resp.getHeader("X-Rate-Limit-Remaining"));
}
}
@Test
@DisplayName("distinct usernames get independent web buckets")
void distinctUsersHaveIndependentBuckets() throws ServletException, IOException {
UserBasedRateLimitingFilter filter = newFilter(true);
// First user consumes once.
authenticateAs("user-one", Role.DEMO_USER);
MockHttpServletResponse resp1 = new MockHttpServletResponse();
filter.doFilterInternal(new PostRequest(), resp1, mock(FilterChain.class));
assertEquals("99", resp1.getHeader("X-Rate-Limit-Remaining"));
// Second, different user starts fresh at 99 remaining (own bucket).
authenticateAs("user-two", Role.DEMO_USER);
MockHttpServletResponse resp2 = new MockHttpServletResponse();
filter.doFilterInternal(new PostRequest(), resp2, mock(FilterChain.class));
assertEquals("99", resp2.getHeader("X-Rate-Limit-Remaining"));
}
}
@Nested
@DisplayName("web UI POST rate-limit exhaustion (429)")
class WebExhaustion {
@Test
@DisplayName("WEB_ONLY_USER over its 20-call web budget is rejected with 429 and body")
void webBudgetExhaustedReturns429() throws ServletException, IOException {
UserBasedRateLimitingFilter filter = newFilter(true);
authenticateAs("weblimited", Role.WEB_ONLY_USER); // 20 web calls per day
// Drain the 20-token web bucket.
for (int i = 0; i < 20; i++) {
filter.doFilterInternal(
new PostRequest(), new MockHttpServletResponse(), mock(FilterChain.class));
}
// 21st call is over budget.
FilterChain blockedChain = mock(FilterChain.class);
filter.doFilterInternal(new PostRequest(), response, blockedChain);
verifyNoInteractions(blockedChain);
assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), response.getStatus());
assertTrue(
response.getContentAsString()
.contains("Rate limit exceeded for POST requests."));
assertNotNull(response.getHeader("X-Rate-Limit-Retry-After-Seconds"));
// No remaining-token header is written on the rejected branch.
assertNull(response.getHeader("X-Rate-Limit-Remaining"));
}
}
@Nested
@DisplayName("API POST (X-API-KEY header present)")
class ApiPost {
@Test
@DisplayName("API key request with authenticated role consumes the API bucket")
void apiKeyConsumesApiBucket() throws ServletException, IOException {
request.setMethod("POST");
request.addHeader("X-API-KEY", "secret-key-123");
// Auth context still required so getRoleFromAuthentication finds a valid role.
authenticateAs("apiuser", Role.DEMO_USER); // 100 API calls per day
UserBasedRateLimitingFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertEquals("99", response.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@DisplayName("API key path uses the API budget independently from the web budget")
void apiAndWebBucketsAreIndependent() throws ServletException, IOException {
UserBasedRateLimitingFilter filter = newFilter(true);
authenticateAs("dualuser", Role.DEMO_USER);
// One web call (no API key) -> web bucket -> 99 remaining.
MockHttpServletResponse webResp = new MockHttpServletResponse();
filter.doFilterInternal(new PostRequest(), webResp, mock(FilterChain.class));
assertEquals("99", webResp.getHeader("X-Rate-Limit-Remaining"));
// One API call (with API key) -> separate API bucket also at 99 remaining.
MockHttpServletRequest apiReq = new PostRequest();
apiReq.addHeader("X-API-KEY", "k");
MockHttpServletResponse apiResp = new MockHttpServletResponse();
filter.doFilterInternal(apiReq, apiResp, mock(FilterChain.class));
assertEquals("99", apiResp.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@Disabled(
"documents real bug: a role with 0 API calls/day (WEB_ONLY_USER) makes"
+ " createUserBucket(0) call Bandwidth.builder().capacity(0), which bucket4j"
+ " rejects with IllegalArgumentException (nonPositiveCapacity). The filter"
+ " should instead return a clean 429 for a zero budget. Asserting the correct"
+ " behaviour here; today the call throws instead.")
@DisplayName("WEB_ONLY_USER has 0 API calls/day: first API POST should be a clean 429")
void webOnlyUserApiBudgetIsZero() throws ServletException, IOException {
request.setMethod("POST");
request.addHeader("X-API-KEY", "any-key");
authenticateAs("webonly", Role.WEB_ONLY_USER); // 0 API calls per day
UserBasedRateLimitingFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain, never()).doFilter(request, response);
assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), response.getStatus());
assertTrue(
response.getContentAsString()
.contains("Rate limit exceeded for POST requests."));
}
@Test
@DisplayName(
"zero-budget API POST currently throws IllegalArgumentException from bucket4j"
+ " (captures present-day behaviour of the bug above)")
void webOnlyUserApiBudgetCurrentlyThrows() {
request.setMethod("POST");
request.addHeader("X-API-KEY", "any-key");
authenticateAs("webonly2", Role.WEB_ONLY_USER); // 0 API calls per day
UserBasedRateLimitingFilter filter = newFilter(true);
// bucket4j Bandwidth.capacity(0) rejects a non-positive capacity.
assertThrows(
IllegalArgumentException.class,
() -> filter.doFilterInternal(request, response, filterChain));
}
@Test
@DisplayName("blank/whitespace API key falls back to username identifier (web bucket)")
void blankApiKeyTreatedAsWebPath() throws ServletException, IOException {
// Header present but blank: identifier falls back to username, but because the header
// key exists, the API (not web) branch limit is used. Verify request is still allowed.
request.setMethod("POST");
request.addHeader("X-API-KEY", " ");
authenticateAs("blankkey", Role.ADMIN);
UserBasedRateLimitingFilter filter = newFilter(true);
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertNotNull(response.getHeader("X-Rate-Limit-Remaining"));
}
}
@Nested
@DisplayName("missing / invalid role on POST")
class InvalidRole {
@Test
@DisplayName("anonymous POST (no auth, no API key) throws IllegalStateException")
void anonymousPostThrowsNoValidRole() {
request.setMethod("POST");
request.setRemoteAddr("203.0.113.5");
UserBasedRateLimitingFilter filter = newFilter(true);
// No authentication is present, so getRoleFromAuthentication has no valid role.
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> filter.doFilterInternal(request, response, filterChain));
assertEquals("User does not have a valid role.", ex.getMessage());
verifyNoInteractions(filterChain);
}
@Test
@DisplayName("authenticated principal with no recognised role authority throws")
void authenticatedButNoValidRoleThrows() {
request.setMethod("POST");
UserDetails principal =
User.withUsername("noroleuser")
.password("pw")
.authorities(new SimpleGrantedAuthority("ROLE_SOMETHING_UNKNOWN"))
.build();
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
principal,
"creds",
List.of(new SimpleGrantedAuthority("ROLE_SOMETHING_UNKNOWN")));
SecurityContextHolder.getContext().setAuthentication(token);
UserBasedRateLimitingFilter filter = newFilter(true);
assertThrows(
IllegalStateException.class,
() -> filter.doFilterInternal(request, response, filterChain));
verifyNoInteractions(filterChain);
}
}
/** Convenience: a fresh POST {@link MockHttpServletRequest}. */
private static class PostRequest extends MockHttpServletRequest {
PostRequest() {
super();
setMethod("POST");
}
}
}
@@ -0,0 +1,568 @@
package stirling.software.proprietary.security.saml2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.http.Cookie;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("CustomSaml2AuthenticationSuccessHandler")
class CustomSaml2AuthenticationSuccessHandlerTest {
private static final String SAVED_REQUEST_ATTR = "SPRING_SECURITY_SAVED_REQUEST";
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String FRONTEND = "https://frontend.example.com";
@Mock private LoginAttemptService loginAttemptService;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties.Security.SAML2 saml2Properties;
private ApplicationProperties applicationProperties;
private CustomSaml2AuthenticationSuccessHandler handler;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
@BeforeEach
void setUp() {
saml2Properties = new ApplicationProperties.Security.SAML2();
saml2Properties.setAutoCreateUser(true);
saml2Properties.setBlockRegistration(false);
// Real ApplicationProperties so getSystem()/getSecurity()/getJwt() chains resolve.
applicationProperties = new ApplicationProperties();
// Configure a frontend URL so redirect origins are deterministic (no header guessing).
applicationProperties.getSystem().setFrontendUrl(FRONTEND);
handler =
new CustomSaml2AuthenticationSuccessHandler(
loginAttemptService,
saml2Properties,
userService,
jwtService,
licenseSettingsService,
applicationProperties);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
}
// ---- Helpers -------------------------------------------------------------
private Authentication saml2Auth(String username) {
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
username, Map.of(), "nameid-" + username, List.of("idx"));
Authentication authentication = org.mockito.Mockito.mock(Authentication.class);
when(authentication.getPrincipal()).thenReturn(principal);
return authentication;
}
private User userNamed(String username) {
User user = new User();
user.setUsername(username);
return user;
}
// -------------------------------------------------------------------------
@Nested
@DisplayName("Non-SAML2 principal")
class NonSaml2Principal {
@Test
@DisplayName("delegates to parent handler and never touches services")
void delegatesToParent() throws Exception {
Authentication authentication = org.mockito.Mockito.mock(Authentication.class);
when(authentication.getPrincipal()).thenReturn("just-a-string-principal");
handler.onAuthenticationSuccess(request, response, authentication);
// Parent SimpleUrl handler redirects to the default target URL "/".
assertEquals("/", response.getRedirectedUrl());
verifyNoInteractions(
userService, loginAttemptService, jwtService, licenseSettingsService);
}
}
@Nested
@DisplayName("SAML eligibility gating")
class EligibilityGating {
@Test
@DisplayName("existing ineligible user is redirected to logout?saml2RequiresLicense=true")
void existingUserNotEligible() throws Exception {
String username = "alice";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(true);
when(userService.findByUsernameIgnoreCase(username))
.thenReturn(Optional.of(userNamed(username)));
when(licenseSettingsService.isSamlEligible(any(User.class))).thenReturn(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(
FRONTEND + "/logout?saml2RequiresLicense=true", response.getRedirectedUrl());
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("new (non-existing) user with no enterprise license is blocked")
void newUserNotEligible() throws Exception {
String username = "bob";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(
FRONTEND + "/logout?saml2RequiresLicense=true", response.getRedirectedUrl());
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("existing user present but eligible proceeds past the license gate")
void existingUserEligibleProceeds() throws Exception {
String username = "carol";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(true);
when(userService.findByUsernameIgnoreCase(username))
.thenReturn(Optional.of(userNamed(username)));
when(licenseSettingsService.isSamlEligible(any(User.class))).thenReturn(true);
// existing SSO user, v1 mode (jwt disabled) -> redirect to contextPath + "/"
when(userService.isSsoAuthenticationTypeByUsername(username)).thenReturn(true);
when(jwtService.isJwtEnabled()).thenReturn(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// Not blocked by the license gate (would otherwise be logout?saml2RequiresLicense).
assertEquals("/", response.getRedirectedUrl());
}
@Test
@DisplayName(
"existing username with a null user record skips the per-user eligibility block")
void existingUserNullRecordSkipsBlock() throws Exception {
String username = "ghost";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(true);
// userExists true but lookup returns empty -> user == null branch, no block
when(userService.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
when(jwtService.isJwtEnabled()).thenReturn(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// Falls through; existing user with no password / not blocked -> v1 home redirect.
assertEquals("/", response.getRedirectedUrl());
// isSamlEligible(user) must not be consulted when the record is null.
verify(licenseSettingsService, never()).isSamlEligible(any(User.class));
}
}
@Nested
@DisplayName("Saved request redirect")
class SavedRequestRedirect {
@Test
@DisplayName("valid non-static saved request delegates to parent and redirects there")
void savedRequestRedirectsToOriginalDestination() throws Exception {
String username = "dave";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
SavedRequest savedRequest = org.mockito.Mockito.mock(SavedRequest.class);
when(savedRequest.getRedirectUrl()).thenReturn("https://app.example.com/dashboard");
request.getSession().setAttribute(SAVED_REQUEST_ATTR, savedRequest);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals("https://app.example.com/dashboard", response.getRedirectedUrl());
// We took the saved-request branch, so SSO post-login is never invoked.
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("static-resource saved request is ignored and normal SSO flow continues")
void staticSavedRequestIsIgnored() throws Exception {
String username = "erin";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(false);
SavedRequest savedRequest = org.mockito.Mockito.mock(SavedRequest.class);
// A static asset URL -> isStaticResource true -> branch ignored.
when(savedRequest.getRedirectUrl()).thenReturn("https://app.example.com/css/app.css");
request.getSession().setAttribute(SAVED_REQUEST_ATTR, savedRequest);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// Normal new-user SSO flow ran (v1) -> redirect to home, post-login invoked.
assertEquals("/", response.getRedirectedUrl());
verify(userService)
.processSSOPostLogin(
eq(username),
any(),
eq("saml2"),
eq(true),
eq(AuthenticationType.SAML2));
}
}
@Nested
@DisplayName("Account locking")
class AccountLocking {
@Test
@DisplayName("blocked user throws LockedException and clears saved request")
void blockedUserThrowsLockedException() throws Exception {
String username = "frank";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(loginAttemptService.isBlocked(username)).thenReturn(true);
// Static saved request -> not redirected, so the else branch (lock check) runs.
SavedRequest savedRequest = org.mockito.Mockito.mock(SavedRequest.class);
when(savedRequest.getRedirectUrl()).thenReturn("https://app.example.com/css/app.css");
request.getSession().setAttribute(SAVED_REQUEST_ATTR, savedRequest);
LockedException ex =
assertThrows(
LockedException.class,
() ->
handler.onAuthenticationSuccess(
request, response, saml2Auth(username)));
assertTrue(ex.getMessage().contains("locked"));
// Saved request attribute is removed when locked.
assertNull(request.getSession(false).getAttribute(SAVED_REQUEST_ATTR));
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("blocked user with no session still throws and does not NPE")
void blockedUserNoSession() {
String username = "grace";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(loginAttemptService.isBlocked(username)).thenReturn(true);
// No session created -> request.getSession(false) is null.
assertThrows(
LockedException.class,
() -> handler.onAuthenticationSuccess(request, response, saml2Auth(username)));
}
}
@Nested
@DisplayName("Existing local user collision")
class ExistingLocalUserCollision {
@Test
@DisplayName(
"existing user with password and not SSO is redirected to logout (oAuth2 error)")
void existingPasswordUserRedirectedToLogout() throws Exception {
String username = "heidi";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(true);
when(userService.findByUsernameIgnoreCase(username))
.thenReturn(Optional.of(userNamed(username)));
when(licenseSettingsService.isSamlEligible(any(User.class))).thenReturn(true);
when(userService.hasPassword(username)).thenReturn(true);
when(userService.isSsoAuthenticationTypeByUsername(username)).thenReturn(false);
saml2Properties.setAutoCreateUser(true);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(
FRONTEND + "/logout?oAuth2AuthenticationErrorWeb=true",
response.getRedirectedUrl());
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("existing password+nonSSO user NOT redirected when autoCreateUser disabled")
void existingPasswordUserNotRedirectedWhenAutoCreateDisabled() throws Exception {
String username = "ivan";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(true);
when(userService.findByUsernameIgnoreCase(username))
.thenReturn(Optional.of(userNamed(username)));
when(licenseSettingsService.isSamlEligible(any(User.class))).thenReturn(true);
when(userService.hasPassword(username)).thenReturn(true);
when(userService.isSsoAuthenticationTypeByUsername(username)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(false);
saml2Properties.setAutoCreateUser(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// The collision redirect is gated on autoCreateUser==true, so we fall through.
assertEquals("/", response.getRedirectedUrl());
}
}
@Nested
@DisplayName("Registration blocking and limits (new users)")
class RegistrationAndLimits {
@Test
@DisplayName("new user blocked when blockRegistration is true")
void newUserBlockedByBlockRegistration() throws Exception {
String username = "judy";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
saml2Properties.setAutoCreateUser(true);
saml2Properties.setBlockRegistration(true);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(
FRONTEND + "/login?errorOAuth=oAuth2AdminBlockedUser",
response.getRedirectedUrl());
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("new user blocked when autoCreateUser is false")
void newUserBlockedByAutoCreateDisabled() throws Exception {
String username = "ken";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
saml2Properties.setAutoCreateUser(false);
saml2Properties.setBlockRegistration(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(
FRONTEND + "/login?errorOAuth=oAuth2AdminBlockedUser",
response.getRedirectedUrl());
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
@Test
@DisplayName("new user blocked when user limit would be exceeded")
void newUserBlockedByUserLimit() throws Exception {
String username = "leo";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(true);
saml2Properties.setAutoCreateUser(true);
saml2Properties.setBlockRegistration(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(FRONTEND + "/logout?maxUsersReached=true", response.getRedirectedUrl());
verify(userService, never())
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
}
}
@Nested
@DisplayName("Successful SSO post-login")
class SuccessfulPostLogin {
@Test
@DisplayName("v1 (JWT disabled): processes login then redirects to contextPath home")
void v1RedirectsToHome() throws Exception {
String username = "mallory";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(false);
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
verify(userService)
.processSSOPostLogin(
eq(username),
eq("nameid-" + username),
eq("saml2"),
eq(true),
eq(AuthenticationType.SAML2));
assertEquals("/", response.getRedirectedUrl());
verify(jwtService, never()).generateToken(any(Authentication.class), any());
}
@Test
@DisplayName(
"v2 web (JWT enabled): issues web token and redirects with access_token fragment")
void v2WebIssuesTokenAndRedirects() throws Exception {
String username = "niaj";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(any(Authentication.class), any())).thenReturn("WEB.JWT");
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
String redirect = response.getRedirectedUrl();
assertNotNull(redirect);
// No SPA cookie + not Tauri -> default callback path on configured frontend origin.
assertEquals(FRONTEND + "/auth/callback#access_token=WEB.JWT", redirect);
// Redirect cookie is cleared.
assertTrue(
response.getHeaders("Set-Cookie").stream()
.anyMatch(h -> h.startsWith(SPA_REDIRECT_COOKIE + "=")));
verify(jwtService, never()).generateToken(any(String.class), any(), anyInt());
}
@Test
@DisplayName("v2 web: honours stirling_redirect_path cookie for the callback path")
void v2WebHonoursRedirectCookie() throws Exception {
String username = "olivia";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(any(Authentication.class), any())).thenReturn("TOK");
request.setCookies(new Cookie(SPA_REDIRECT_COOKIE, "/tools/merge"));
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals(FRONTEND + "/tools/merge#access_token=TOK", response.getRedirectedUrl());
}
@Test
@DisplayName("v2 web: ignores a redirect cookie that does not start with '/'")
void v2WebIgnoresNonAbsoluteCookie() throws Exception {
String username = "peggy";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(any(Authentication.class), any())).thenReturn("TOK");
request.setCookies(new Cookie(SPA_REDIRECT_COOKIE, "evil.com/path"));
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// Non-absolute path is rejected -> default callback path used.
assertEquals(FRONTEND + "/auth/callback#access_token=TOK", response.getRedirectedUrl());
}
@Test
@DisplayName("v2 desktop (Tauri UA): issues long-lived desktop token with custom expiry")
void v2DesktopIssuesDesktopToken() throws Exception {
String username = "quinn";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(eq(username), any(), anyInt())).thenReturn("DESKTOP.JWT");
request.addHeader("User-Agent", "StirlingPDF-Desktop Tauri/2.0");
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// Desktop path uses the username+expiry overload, not the authentication overload.
verify(jwtService).generateToken(eq(username), any(), anyInt());
verify(jwtService, never()).generateToken(any(Authentication.class), any());
assertEquals(
FRONTEND + "/auth/callback#access_token=DESKTOP.JWT",
response.getRedirectedUrl());
}
@Test
@DisplayName("v2: Tauri RelayState routes to the Tauri callback path and appends nonce")
void v2TauriRelayStateUsesTauriCallbackAndNonce() throws Exception {
String username = "rupert";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(any(Authentication.class), any())).thenReturn("TOK");
request.setParameter("RelayState", "tauri:abc123");
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
String redirect = response.getRedirectedUrl();
assertNotNull(redirect);
assertTrue(redirect.contains("#access_token=TOK"), redirect);
// Nonce extracted from RelayState is appended (URL-encoded).
assertTrue(redirect.endsWith("&nonce=abc123"), redirect);
}
}
@Nested
@DisplayName("Failure handling during post-login")
class PostLoginFailures {
@Test
@DisplayName(
"IllegalArgumentException from processSSOPostLogin -> logout?invalidUsername=true")
void processSSOPostLoginThrowsRedirectsToInvalidUsername() throws Exception {
String username = "sybil";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
org.mockito.Mockito.doThrow(new IllegalArgumentException("bad name"))
.when(userService)
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
// contextPath is empty for MockHttpServletRequest, so just the relative logout URL.
assertEquals("/logout?invalidUsername=true", response.getRedirectedUrl());
// JWT generation is never reached because the exception is thrown first.
verify(jwtService, never()).isJwtEnabled();
}
@Test
@DisplayName("SQLException from processSSOPostLogin -> logout?invalidUsername=true")
void processSSOPostLoginThrowsSqlException() throws Exception {
String username = "trent";
when(userService.usernameExistsIgnoreCase(username)).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
org.mockito.Mockito.doThrow(new java.sql.SQLException("db down"))
.when(userService)
.processSSOPostLogin(any(), any(), any(), anyBoolean(), any());
handler.onAuthenticationSuccess(request, response, saml2Auth(username));
assertEquals("/logout?invalidUsername=true", response.getRedirectedUrl());
}
}
}
@@ -0,0 +1,413 @@
package stirling.software.proprietary.security.saml2;
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.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import java.io.OutputStreamWriter;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
@DisplayName("Saml2Configuration")
class Saml2ConfigurationTest {
private static final String REGISTRATION_ID = "stirling";
private static final String IDP_ISSUER = "https://idp.example.com/issuer";
private static final String IDP_LOGIN = "https://idp.example.com/sso";
private static final String IDP_LOGOUT = "https://idp.example.com/slo";
private static final String BACKEND_URL = "https://api.example.com";
// Generating an RSA keypair + self-signed cert is expensive; build once and reuse.
private static X509Certificate cert;
private static KeyPair keyPair;
@TempDir Path tempDir;
private Path certPem;
private Path keyPem;
private Path missingFile;
@BeforeAll
static void buildCryptoFixtures() throws Exception {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
keyPair = kpg.generateKeyPair();
cert = selfSignedCert(keyPair);
}
private static X509Certificate selfSignedCert(KeyPair kp) throws Exception {
X500Name subject = new X500Name("CN=Saml2ConfigTest, O=Stirling, C=US");
Date notBefore = new Date(System.currentTimeMillis() - 1000);
Date notAfter = new Date(System.currentTimeMillis() + 86_400_000L);
JcaX509v3CertificateBuilder builder =
new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
kp.getPublic());
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(kp.getPrivate());
X509CertificateHolder holder = builder.build(signer);
return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
}
// Writes a PEM block CertificateUtils can read (CERTIFICATE -> X509, PRIVATE KEY -> PKCS#8).
private static void writePem(Path target, String type, byte[] der) throws Exception {
try (PemWriter writer =
new PemWriter(
new OutputStreamWriter(
Files.newOutputStream(target), StandardCharsets.UTF_8))) {
writer.writeObject(new PemObject(type, der));
}
}
@BeforeEach
void setUp() throws Exception {
certPem = tempDir.resolve("cert.pem");
keyPem = tempDir.resolve("key.pem");
missingFile = tempDir.resolve("does-not-exist.pem");
writePem(certPem, "CERTIFICATE", cert.getEncoded());
writePem(keyPem, "PRIVATE KEY", keyPair.getPrivate().getEncoded());
}
// Builds an ApplicationProperties wired for a working SAML2 setup. Individual tests mutate it.
private ApplicationProperties propsWithValidCredentials() {
ApplicationProperties props = new ApplicationProperties();
SAML2 saml2 = props.getSecurity().getSaml2();
saml2.setRegistrationId(REGISTRATION_ID);
saml2.setIdpIssuer(IDP_ISSUER);
saml2.setIdpSingleLoginUrl(IDP_LOGIN);
saml2.setIdpSingleLogoutUrl(IDP_LOGOUT);
saml2.setIdpCert(certPem.toString());
saml2.setSpCert(certPem.toString());
saml2.setPrivateKey(keyPem.toString());
props.getSystem().setBackendUrl(BACKEND_URL);
return props;
}
private Saml2Configuration configFor(ApplicationProperties props) {
return new Saml2Configuration(props);
}
@Nested
@DisplayName("relyingPartyRegistrations - happy path")
class HappyPath {
@Test
@DisplayName("returns a repository containing the configured registration id")
void buildsRegistrationForConfiguredId() throws Exception {
Saml2Configuration config = configFor(propsWithValidCredentials());
RelyingPartyRegistrationRepository repo = config.relyingPartyRegistrations();
assertNotNull(repo);
RelyingPartyRegistration rp = repo.findByRegistrationId(REGISTRATION_ID);
assertNotNull(rp);
assertEquals(REGISTRATION_ID, rp.getRegistrationId());
}
@Test
@DisplayName("entity id and ACS location are derived from the configured backend URL")
void usesConfiguredBackendUrlForEndpoints() throws Exception {
RelyingPartyRegistration rp =
configFor(propsWithValidCredentials())
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertEquals(
BACKEND_URL + "/saml2/service-provider-metadata/" + REGISTRATION_ID,
rp.getEntityId());
assertEquals(
BACKEND_URL + "/login/saml2/sso/{registrationId}",
rp.getAssertionConsumerServiceLocation());
assertEquals(Saml2MessageBinding.POST, rp.getAssertionConsumerServiceBinding());
}
@Test
@DisplayName("single logout response location points at the backend /login endpoint")
void singleLogoutResponseLocationUsesBackendLogin() throws Exception {
RelyingPartyRegistration rp =
configFor(propsWithValidCredentials())
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertEquals(BACKEND_URL + "/login", rp.getSingleLogoutServiceResponseLocation());
assertEquals(IDP_LOGOUT, rp.getSingleLogoutServiceLocation());
assertEquals(Saml2MessageBinding.POST, rp.getSingleLogoutServiceBinding());
}
@Test
@DisplayName("authn requests are configured as signed")
void authnRequestsSigned() throws Exception {
RelyingPartyRegistration rp =
configFor(propsWithValidCredentials())
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertTrue(rp.isAuthnRequestsSigned());
}
@Test
@DisplayName("asserting party metadata carries the IdP issuer and SSO location")
void assertingPartyMetadataPopulated() throws Exception {
RelyingPartyRegistration rp =
configFor(propsWithValidCredentials())
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertEquals(IDP_ISSUER, rp.getAssertingPartyMetadata().getEntityId());
assertEquals(
IDP_LOGIN, rp.getAssertingPartyMetadata().getSingleSignOnServiceLocation());
assertEquals(
Saml2MessageBinding.POST,
rp.getAssertingPartyMetadata().getSingleSignOnServiceBinding());
assertTrue(rp.getAssertingPartyMetadata().getWantAuthnRequestsSigned());
}
@Test
@DisplayName("signing and verification credentials are present")
void credentialsPresent() throws Exception {
RelyingPartyRegistration rp =
configFor(propsWithValidCredentials())
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertFalse(rp.getSigningX509Credentials().isEmpty());
assertFalse(rp.getAssertingPartyMetadata().getVerificationX509Credentials().isEmpty());
}
@Test
@DisplayName("honors a custom registration id")
void honorsCustomRegistrationId() throws Exception {
ApplicationProperties props = propsWithValidCredentials();
props.getSecurity().getSaml2().setRegistrationId("acme-okta");
RelyingPartyRegistrationRepository repo = configFor(props).relyingPartyRegistrations();
assertNotNull(repo.findByRegistrationId("acme-okta"));
assertEquals(
BACKEND_URL + "/saml2/service-provider-metadata/acme-okta",
repo.findByRegistrationId("acme-okta").getEntityId());
}
}
@Nested
@DisplayName("relyingPartyRegistrations - backend URL fallback")
class BackendUrlFallback {
@Test
@DisplayName("falls back to {baseUrl} placeholder when backend URL is null")
void nullBackendUrlFallsBackToPlaceholder() throws Exception {
ApplicationProperties props = propsWithValidCredentials();
props.getSystem().setBackendUrl(null);
RelyingPartyRegistration rp =
configFor(props)
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertEquals(
"{baseUrl}/saml2/service-provider-metadata/" + REGISTRATION_ID,
rp.getEntityId());
}
@Test
@DisplayName("falls back to {baseUrl} placeholder when backend URL is blank")
void blankBackendUrlFallsBackToPlaceholder() throws Exception {
ApplicationProperties props = propsWithValidCredentials();
props.getSystem().setBackendUrl(" ");
RelyingPartyRegistration rp =
configFor(props)
.relyingPartyRegistrations()
.findByRegistrationId(REGISTRATION_ID);
assertTrue(rp.getEntityId().startsWith("{baseUrl}/saml2/service-provider-metadata/"));
assertEquals("{baseUrl}/login", rp.getSingleLogoutServiceResponseLocation());
}
}
@Nested
@DisplayName("relyingPartyRegistrations - error branches")
class ErrorBranches {
@Test
@DisplayName("missing IdP certificate file is wrapped in IllegalStateException")
void missingIdpCertFails() {
ApplicationProperties props = propsWithValidCredentials();
props.getSecurity().getSaml2().setIdpCert(missingFile.toString());
Saml2Configuration config = configFor(props);
IllegalStateException ex =
assertThrows(IllegalStateException.class, config::relyingPartyRegistrations);
// Inner "file does not exist" check is rethrown wrapped by the IdP cert catch block.
assertEquals("Failed to load SAML2 IdP certificate", ex.getMessage());
}
@Test
@DisplayName("unreadable IdP certificate content is wrapped in IllegalStateException")
void unreadableIdpCertFails() throws Exception {
ApplicationProperties props = propsWithValidCredentials();
Path garbage = tempDir.resolve("garbage-cert.pem");
Files.write(garbage, "not a real certificate".getBytes(StandardCharsets.UTF_8));
props.getSecurity().getSaml2().setIdpCert(garbage.toString());
Saml2Configuration config = configFor(props);
IllegalStateException ex =
assertThrows(IllegalStateException.class, config::relyingPartyRegistrations);
assertEquals("Failed to load SAML2 IdP certificate", ex.getMessage());
}
@Test
@DisplayName("missing SP private key file fails before building the registration")
void missingPrivateKeyFails() {
ApplicationProperties props = propsWithValidCredentials();
props.getSecurity().getSaml2().setPrivateKey(missingFile.toString());
Saml2Configuration config = configFor(props);
IllegalStateException ex =
assertThrows(IllegalStateException.class, config::relyingPartyRegistrations);
assertTrue(
ex.getMessage().startsWith("SAML2 SP private key file does not exist:"),
"Unexpected message: " + ex.getMessage());
}
@Test
@DisplayName("missing SP certificate file fails before building the registration")
void missingSpCertFails() {
ApplicationProperties props = propsWithValidCredentials();
props.getSecurity().getSaml2().setSpCert(missingFile.toString());
Saml2Configuration config = configFor(props);
IllegalStateException ex =
assertThrows(IllegalStateException.class, config::relyingPartyRegistrations);
assertTrue(
ex.getMessage().startsWith("SAML2 SP certificate file does not exist:"),
"Unexpected message: " + ex.getMessage());
}
@Test
@DisplayName("unreadable SP private key content is wrapped in IllegalStateException")
void unreadablePrivateKeyFails() throws Exception {
ApplicationProperties props = propsWithValidCredentials();
Path garbageKey = tempDir.resolve("garbage-key.pem");
Files.write(garbageKey, "not a real key".getBytes(StandardCharsets.UTF_8));
props.getSecurity().getSaml2().setPrivateKey(garbageKey.toString());
Saml2Configuration config = configFor(props);
IllegalStateException ex =
assertThrows(IllegalStateException.class, config::relyingPartyRegistrations);
assertEquals("Failed to load SAML2 SP credentials", ex.getMessage());
}
}
@Nested
@DisplayName("authenticationRequestResolver")
class AuthenticationRequestResolver {
@Test
@DisplayName("returns a non-null resolver for the given repository")
void returnsResolver() {
Saml2Configuration config = configFor(propsWithValidCredentials());
RelyingPartyRegistrationRepository repo =
org.mockito.Mockito.mock(RelyingPartyRegistrationRepository.class);
OpenSaml5AuthenticationRequestResolver resolver =
config.authenticationRequestResolver(repo);
assertNotNull(resolver);
}
@Test
@DisplayName("does not touch the repository at construction time (lazy resolution)")
void doesNotTouchRepositoryOnConstruction() {
Saml2Configuration config = configFor(propsWithValidCredentials());
RelyingPartyRegistrationRepository repo =
org.mockito.Mockito.mock(RelyingPartyRegistrationRepository.class);
config.authenticationRequestResolver(repo);
verifyNoInteractions(repo);
}
@Test
@DisplayName("produces a fresh resolver instance on each invocation")
void freshInstancePerCall() {
Saml2Configuration config = configFor(propsWithValidCredentials());
RelyingPartyRegistrationRepository repo =
org.mockito.Mockito.mock(RelyingPartyRegistrationRepository.class);
OpenSaml5AuthenticationRequestResolver first =
config.authenticationRequestResolver(repo);
OpenSaml5AuthenticationRequestResolver second =
config.authenticationRequestResolver(repo);
assertNotSame(first, second);
}
}
@Nested
@DisplayName("bean wiring")
class BeanWiring {
@Test
@DisplayName("repository bean is rebuilt on each call (not cached on the config object)")
void repositoryRebuiltEachCall() throws Exception {
Saml2Configuration config = configFor(propsWithValidCredentials());
RelyingPartyRegistrationRepository first = config.relyingPartyRegistrations();
RelyingPartyRegistrationRepository second = config.relyingPartyRegistrations();
assertNotSame(first, second);
assertEquals(
first.findByRegistrationId(REGISTRATION_ID).getRegistrationId(),
second.findByRegistrationId(REGISTRATION_ID).getRegistrationId());
}
}
}
@@ -0,0 +1,548 @@
package stirling.software.proprietary.security.session;
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.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SessionPersistentRegistryTest {
@Mock private SessionRepository sessionRepository;
@InjectMocks private SessionPersistentRegistry registry;
@Captor private ArgumentCaptor<SessionEntity> sessionCaptor;
@BeforeEach
void setUp() {
// @Value field is not constructor-injected; set a default like Spring would.
ReflectionTestUtils.setField(
registry, "defaultMaxInactiveInterval", Duration.ofMinutes(30));
}
private static SessionEntity session(
String sessionId, String principalName, Instant lastRequest, boolean expired) {
SessionEntity entity = new SessionEntity();
entity.setSessionId(sessionId);
entity.setPrincipalName(principalName);
entity.setLastRequest(lastRequest);
entity.setExpired(expired);
return entity;
}
@Nested
@DisplayName("getAllPrincipals")
class GetAllPrincipals {
@Test
@DisplayName("maps every session to its principal name")
void returnsPrincipalNamesForAllSessions() {
Instant now = Instant.now();
when(sessionRepository.findAll())
.thenReturn(
List.of(
session("s1", "alice", now, false),
session("s2", "bob", now, true)));
List<Object> principals = registry.getAllPrincipals();
assertEquals(List.of("alice", "bob"), principals);
}
@Test
@DisplayName("returns empty list when no sessions exist")
void returnsEmptyWhenNoSessions() {
when(sessionRepository.findAll()).thenReturn(Collections.emptyList());
assertTrue(registry.getAllPrincipals().isEmpty());
}
@Test
@DisplayName("includes null principal names as-is")
void includesNullPrincipalNames() {
when(sessionRepository.findAll())
.thenReturn(List.of(session("s1", null, Instant.now(), false)));
List<Object> principals = registry.getAllPrincipals();
assertEquals(1, principals.size());
assertNull(principals.get(0));
}
}
@Nested
@DisplayName("getAllSessions(principal, includeExpiredSessions)")
class GetAllSessionsForPrincipal {
@Test
@DisplayName("resolves principal name from UserDetails")
void resolvesFromUserDetails() {
UserDetails userDetails = org.mockito.Mockito.mock(UserDetails.class);
when(userDetails.getUsername()).thenReturn("alice");
when(sessionRepository.findByPrincipalName("alice"))
.thenReturn(List.of(session("s1", "alice", Instant.now(), false)));
List<SessionInformation> result = registry.getAllSessions(userDetails, false);
assertEquals(1, result.size());
assertEquals("alice", result.get(0).getPrincipal());
assertEquals("s1", result.get(0).getSessionId());
}
@Test
@DisplayName("resolves principal name from OAuth2User")
void resolvesFromOAuth2User() {
OAuth2User oAuth2User = org.mockito.Mockito.mock(OAuth2User.class);
when(oAuth2User.getName()).thenReturn("oauth-user");
when(sessionRepository.findByPrincipalName("oauth-user"))
.thenReturn(List.of(session("s2", "oauth-user", Instant.now(), false)));
List<SessionInformation> result = registry.getAllSessions(oAuth2User, false);
assertEquals(1, result.size());
assertEquals("oauth-user", result.get(0).getPrincipal());
}
@Test
@DisplayName("resolves principal name from CustomSaml2AuthenticatedPrincipal")
void resolvesFromSaml2Principal() {
CustomSaml2AuthenticatedPrincipal saml2User =
new CustomSaml2AuthenticatedPrincipal(
"saml-user", Collections.emptyMap(), "nameId", Collections.emptyList());
when(sessionRepository.findByPrincipalName("saml-user"))
.thenReturn(List.of(session("s3", "saml-user", Instant.now(), false)));
List<SessionInformation> result = registry.getAllSessions(saml2User, false);
assertEquals(1, result.size());
assertEquals("saml-user", result.get(0).getPrincipal());
}
@Test
@DisplayName("resolves principal name from String")
void resolvesFromString() {
when(sessionRepository.findByPrincipalName("plain"))
.thenReturn(List.of(session("s4", "plain", Instant.now(), false)));
List<SessionInformation> result = registry.getAllSessions("plain", false);
assertEquals(1, result.size());
assertEquals("plain", result.get(0).getPrincipal());
}
@Test
@DisplayName("unknown principal type yields empty list and no repository call")
void unknownPrincipalTypeReturnsEmpty() {
List<SessionInformation> result = registry.getAllSessions(new Object(), true);
assertTrue(result.isEmpty());
verify(sessionRepository, never()).findByPrincipalName(any());
}
@Test
@DisplayName("null principal yields empty list and no repository call")
void nullPrincipalReturnsEmpty() {
List<SessionInformation> result = registry.getAllSessions(null, true);
assertTrue(result.isEmpty());
verify(sessionRepository, never()).findByPrincipalName(any());
}
@Test
@DisplayName("excludes expired sessions when includeExpiredSessions is false")
void excludesExpiredWhenFlagFalse() {
Instant now = Instant.now();
when(sessionRepository.findByPrincipalName("alice"))
.thenReturn(
List.of(
session("active", "alice", now, false),
session("expired", "alice", now, true)));
List<SessionInformation> result = registry.getAllSessions("alice", false);
assertEquals(1, result.size());
assertEquals("active", result.get(0).getSessionId());
}
@Test
@DisplayName("includes expired sessions when includeExpiredSessions is true")
void includesExpiredWhenFlagTrue() {
Instant now = Instant.now();
when(sessionRepository.findByPrincipalName("alice"))
.thenReturn(
List.of(
session("active", "alice", now, false),
session("expired", "alice", now, true)));
List<SessionInformation> result = registry.getAllSessions("alice", true);
assertEquals(2, result.size());
}
@Test
@DisplayName("maps lastRequest Instant into SessionInformation Date")
void mapsLastRequestToDate() {
Instant lastRequest = Instant.ofEpochMilli(1_700_000_000_000L);
when(sessionRepository.findByPrincipalName("alice"))
.thenReturn(List.of(session("s1", "alice", lastRequest, false)));
List<SessionInformation> result = registry.getAllSessions("alice", false);
assertEquals(Date.from(lastRequest), result.get(0).getLastRequest());
}
@Test
@DisplayName("returns empty list when no sessions found for principal")
void emptyWhenNoSessionsForPrincipal() {
when(sessionRepository.findByPrincipalName("ghost"))
.thenReturn(Collections.emptyList());
assertTrue(registry.getAllSessions("ghost", true).isEmpty());
}
}
@Nested
@DisplayName("registerNewSession")
class RegisterNewSession {
@Test
@DisplayName("persists a new non-expired session for a String principal")
void persistsNewSessionForString() {
Instant before = Instant.now();
registry.registerNewSession("sid-1", "alice");
verify(sessionRepository).save(sessionCaptor.capture());
SessionEntity saved = sessionCaptor.getValue();
assertEquals("sid-1", saved.getSessionId());
assertEquals("alice", saved.getPrincipalName());
assertFalse(saved.isExpired());
assertNotNull(saved.getLastRequest());
assertFalse(saved.getLastRequest().isBefore(before));
}
@Test
@DisplayName("resolves principal name from UserDetails")
void persistsForUserDetails() {
UserDetails userDetails = org.mockito.Mockito.mock(UserDetails.class);
when(userDetails.getUsername()).thenReturn("alice");
registry.registerNewSession("sid-2", userDetails);
verify(sessionRepository).save(sessionCaptor.capture());
assertEquals("alice", sessionCaptor.getValue().getPrincipalName());
}
@Test
@DisplayName("resolves principal name from OAuth2User")
void persistsForOAuth2User() {
OAuth2User oAuth2User = org.mockito.Mockito.mock(OAuth2User.class);
when(oAuth2User.getName()).thenReturn("oauth-user");
registry.registerNewSession("sid-3", oAuth2User);
verify(sessionRepository).save(sessionCaptor.capture());
assertEquals("oauth-user", sessionCaptor.getValue().getPrincipalName());
}
@Test
@DisplayName("resolves principal name from CustomSaml2AuthenticatedPrincipal")
void persistsForSaml2Principal() {
CustomSaml2AuthenticatedPrincipal saml2User =
new CustomSaml2AuthenticatedPrincipal(
"saml-user", Collections.emptyMap(), "nameId", Collections.emptyList());
registry.registerNewSession("sid-4", saml2User);
verify(sessionRepository).save(sessionCaptor.capture());
assertEquals("saml-user", sessionCaptor.getValue().getPrincipalName());
}
@Test
@DisplayName("does not persist when principal type is unknown")
void doesNotPersistForUnknownPrincipal() {
registry.registerNewSession("sid-5", new Object());
verify(sessionRepository, never()).save(any());
}
@Test
@DisplayName("does not persist when principal is null")
void doesNotPersistForNullPrincipal() {
registry.registerNewSession("sid-6", null);
verify(sessionRepository, never()).save(any());
}
}
@Nested
@DisplayName("removeSessionInformation")
class RemoveSessionInformation {
@Test
@DisplayName("deletes the session by id")
void deletesById() {
registry.removeSessionInformation("sid-1");
verify(sessionRepository).deleteById("sid-1");
}
}
@Nested
@DisplayName("refreshLastRequest")
class RefreshLastRequest {
@Test
@DisplayName("updates lastRequest and saves when session exists")
void updatesAndSavesWhenPresent() {
Instant before = Instant.now();
SessionEntity existing = session("sid-1", "alice", Instant.ofEpochMilli(0L), false);
when(sessionRepository.findById("sid-1")).thenReturn(Optional.of(existing));
registry.refreshLastRequest("sid-1");
verify(sessionRepository).save(sessionCaptor.capture());
SessionEntity saved = sessionCaptor.getValue();
assertSame(existing, saved);
assertFalse(saved.getLastRequest().isBefore(before));
}
@Test
@DisplayName("does nothing when session is absent")
void noOpWhenAbsent() {
when(sessionRepository.findById("missing")).thenReturn(Optional.empty());
registry.refreshLastRequest("missing");
verify(sessionRepository, never()).save(any());
}
}
@Nested
@DisplayName("getSessionInformation")
class GetSessionInformation {
@Test
@DisplayName("returns mapped SessionInformation when session exists")
void returnsInfoWhenPresent() {
Instant lastRequest = Instant.ofEpochMilli(1_700_000_000_000L);
when(sessionRepository.findById("sid-1"))
.thenReturn(Optional.of(session("sid-1", "alice", lastRequest, false)));
SessionInformation info = registry.getSessionInformation("sid-1");
assertNotNull(info);
assertEquals("alice", info.getPrincipal());
assertEquals("sid-1", info.getSessionId());
assertEquals(Date.from(lastRequest), info.getLastRequest());
}
@Test
@DisplayName("returns null when session is absent")
void returnsNullWhenAbsent() {
when(sessionRepository.findById("missing")).thenReturn(Optional.empty());
assertNull(registry.getSessionInformation("missing"));
}
}
@Nested
@DisplayName("getAllSessionsNotExpired / getAllSessions")
class PlainGetters {
@Test
@DisplayName("getAllSessionsNotExpired queries findByExpired(false)")
void getAllSessionsNotExpiredDelegates() {
List<SessionEntity> expected = List.of(session("s1", "alice", Instant.now(), false));
when(sessionRepository.findByExpired(false)).thenReturn(expected);
assertSame(expected, registry.getAllSessionsNotExpired());
verify(sessionRepository).findByExpired(false);
}
@Test
@DisplayName("getAllSessions returns repository findAll result")
void getAllSessionsDelegates() {
List<SessionEntity> expected = List.of(session("s1", "alice", Instant.now(), false));
when(sessionRepository.findAll()).thenReturn(expected);
assertSame(expected, registry.getAllSessions());
}
}
@Nested
@DisplayName("expireSession")
class ExpireSession {
@Test
@DisplayName("marks an existing session expired and saves it")
void marksExpiredWhenPresent() {
SessionEntity existing = session("sid-1", "alice", Instant.now(), false);
when(sessionRepository.findById("sid-1")).thenReturn(Optional.of(existing));
registry.expireSession("sid-1");
verify(sessionRepository).save(sessionCaptor.capture());
assertTrue(sessionCaptor.getValue().isExpired());
}
@Test
@DisplayName("does nothing when session is absent")
void noOpWhenAbsent() {
when(sessionRepository.findById("missing")).thenReturn(Optional.empty());
registry.expireSession("missing");
verify(sessionRepository, never()).save(any());
}
}
@Nested
@DisplayName("getMaxInactiveInterval")
class GetMaxInactiveInterval {
@Test
@DisplayName("returns configured timeout converted to whole seconds")
void returnsSecondsFromDuration() {
ReflectionTestUtils.setField(
registry, "defaultMaxInactiveInterval", Duration.ofMinutes(30));
assertEquals(1800, registry.getMaxInactiveInterval());
}
@Test
@DisplayName("truncates sub-second precision to whole seconds")
void truncatesToWholeSeconds() {
ReflectionTestUtils.setField(
registry, "defaultMaxInactiveInterval", Duration.ofMillis(1500));
assertEquals(1, registry.getMaxInactiveInterval());
}
@Test
@DisplayName("returns zero for a zero duration")
void returnsZeroForZeroDuration() {
ReflectionTestUtils.setField(registry, "defaultMaxInactiveInterval", Duration.ZERO);
assertEquals(0, registry.getMaxInactiveInterval());
}
}
@Nested
@DisplayName("getSessionEntity")
class GetSessionEntity {
@Test
@DisplayName("delegates to findBySessionId")
void delegatesToFindBySessionId() {
SessionEntity entity = session("sid-1", "alice", Instant.now(), false);
when(sessionRepository.findBySessionId("sid-1")).thenReturn(entity);
assertSame(entity, registry.getSessionEntity("sid-1"));
}
@Test
@DisplayName("returns null when repository returns null")
void returnsNullWhenRepositoryReturnsNull() {
when(sessionRepository.findBySessionId("missing")).thenReturn(null);
assertNull(registry.getSessionEntity("missing"));
}
}
@Nested
@DisplayName("updateSessionByPrincipalName")
class UpdateSessionByPrincipalName {
@Test
@DisplayName("forwards expired flag, instant and principal to the repository")
void forwardsArguments() {
Date lastRequest = new Date(1_700_000_000_000L);
registry.updateSessionByPrincipalName("alice", true, lastRequest);
verify(sessionRepository)
.saveByPrincipalName(eq(true), eq(lastRequest.toInstant()), eq("alice"));
}
}
@Nested
@DisplayName("findLatestSession")
class FindLatestSession {
@Test
@DisplayName("returns empty when principal has no sessions")
void emptyWhenNoSessions() {
when(sessionRepository.findByPrincipalName("ghost")).thenReturn(new ArrayList<>());
assertTrue(registry.findLatestSession("ghost").isEmpty());
}
@Test
@DisplayName("returns the session with the most recent lastRequest")
void returnsMostRecentSession() {
SessionEntity oldest = session("old", "alice", Instant.ofEpochMilli(1_000L), false);
SessionEntity newest = session("new", "alice", Instant.ofEpochMilli(3_000L), false);
SessionEntity middle = session("mid", "alice", Instant.ofEpochMilli(2_000L), false);
// Unsorted input to exercise the descending sort.
when(sessionRepository.findByPrincipalName("alice"))
.thenReturn(new ArrayList<>(List.of(oldest, newest, middle)));
Optional<SessionEntity> latest = registry.findLatestSession("alice");
assertTrue(latest.isPresent());
assertSame(newest, latest.get());
}
@Test
@DisplayName("returns the single session when only one exists")
void returnsSingleSession() {
SessionEntity only = session("only", "alice", Instant.now(), false);
when(sessionRepository.findByPrincipalName("alice"))
.thenReturn(new ArrayList<>(List.of(only)));
Optional<SessionEntity> latest = registry.findLatestSession("alice");
assertTrue(latest.isPresent());
assertSame(only, latest.get());
}
}
}
@@ -0,0 +1,579 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.AuditDiscrepancy;
import stirling.software.proprietary.model.api.ai.AuditSeverity;
import stirling.software.proprietary.model.api.ai.DiscrepancyKind;
import stirling.software.proprietary.model.api.ai.Evidence;
import stirling.software.proprietary.model.api.ai.FolioManifest;
import stirling.software.proprietary.model.api.ai.FolioType;
import stirling.software.proprietary.model.api.ai.Requisition;
import stirling.software.proprietary.model.api.ai.Verdict;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Unit tests for {@link MathAuditorOrchestrator}.
*
* <p>Collaborators (engine client, content extractor, user service) are mocked; the PDF factory is
* stubbed to return a real in-memory {@link PDDocument} so page-count arithmetic and the
* try-with-resources lifecycle are exercised for real. A real {@link JsonMapper} performs the
* round-trip serialisation so the wire contract (manifest out / requisition in / evidence out /
* verdict in) is genuinely exercised.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class MathAuditorOrchestratorTest {
private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine";
private static final String DELIBERATE_PATH = "/api/v1/ai/math-auditor-agent/deliberate";
@Mock private AiEngineClient aiEngineClient;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private UserServiceInterface userService;
private ObjectMapper objectMapper;
private MathAuditorOrchestrator orchestrator;
@BeforeEach
void setUp() {
objectMapper = JsonMapper.builder().build();
orchestrator =
new MathAuditorOrchestrator(
aiEngineClient,
pdfDocumentFactory,
pdfContentExtractor,
objectMapper,
userService);
}
// ---------------------------------------------------------------------
// Happy path
// ---------------------------------------------------------------------
@Nested
@DisplayName("audit() happy path")
class HappyPath {
@Test
@DisplayName("runs examine then deliberate and returns the Verdict")
void runsFullProtocolAndReturnsVerdict() throws IOException {
stubDocument(3);
when(userService.getCurrentUsername()).thenReturn("alice");
stubClassifyAll(FolioType.TEXT);
// Examiner asks for text on page 0 and tables on page 1.
Requisition requisition =
new Requisition(
"requisition", List.of(0), List.of(1), List.of(), "need it all");
Verdict verdict = cleanVerdict("sess");
stubEngine(requisition, verdict);
when(pdfContentExtractor.extractPageTextRaw(any(PDDocument.class), eq(1)))
.thenReturn("Total: 100");
when(pdfContentExtractor.extractTablesAsCsv(any(PDDocument.class), eq(2)))
.thenReturn(List.of("a,b\n1,2"));
Verdict result = orchestrator.audit(pdf("doc.pdf"), new BigDecimal("0.01"));
assertNotNull(result);
assertTrue(result.clean());
// Two engine round-trips: examine + deliberate.
verify(aiEngineClient, times(2)).post(anyString(), anyString(), nullable(String.class));
verify(aiEngineClient).post(eq(EXAMINE_PATH), anyString(), eq("alice"));
}
@Test
@DisplayName("classifies every page (1-based) before sending the manifest")
void classifiesEveryPage() throws IOException {
stubDocument(2);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "none"),
cleanVerdict("s"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
verify(pdfContentExtractor).classifyPage(any(PDDocument.class), eq(1));
verify(pdfContentExtractor).classifyPage(any(PDDocument.class), eq(2));
verify(pdfContentExtractor, never()).classifyPage(any(PDDocument.class), eq(3));
}
@Test
@DisplayName("manifest carries the page count and one FolioType per page")
void manifestCarriesPageCountAndTypes() throws IOException {
stubDocument(2);
when(pdfContentExtractor.classifyPage(any(PDDocument.class), eq(1)))
.thenReturn(FolioType.TEXT);
when(pdfContentExtractor.classifyPage(any(PDDocument.class), eq(2)))
.thenReturn(FolioType.IMAGE);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "none"),
cleanVerdict("s"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
FolioManifest manifest = objectMapper.readValue(examineBody(), FolioManifest.class);
assertEquals(2, manifest.pageCount());
assertEquals(2, manifest.folioTypes().size());
assertEquals(FolioType.TEXT, manifest.folioTypes().get(0));
assertEquals(FolioType.IMAGE, manifest.folioTypes().get(1));
assertEquals(1, manifest.round());
}
}
// ---------------------------------------------------------------------
// Requisition fulfilment branches
// ---------------------------------------------------------------------
@Nested
@DisplayName("fulfilment of the Requisition")
class Fulfilment {
@Test
@DisplayName("extracts text on requested page (0-based -> 1-based) and builds a folio")
void extractsTextForRequestedPage() throws IOException {
stubDocument(2);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(0), List.of(), List.of(), "page 0 text"),
cleanVerdict("s"));
when(pdfContentExtractor.extractPageTextRaw(any(PDDocument.class), eq(1)))
.thenReturn("Subtotal 42");
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
// Page index 0 from Python maps to 1-based page 1 in the extractor.
verify(pdfContentExtractor).extractPageTextRaw(any(PDDocument.class), eq(1));
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
assertEquals(1, evidence.folios().size());
assertEquals(0, evidence.folios().get(0).page());
assertEquals("Subtotal 42", evidence.folios().get(0).text());
assertTrue(evidence.unauditablePages().isEmpty());
assertEquals(2, evidence.round());
assertTrue(evidence.finalRound());
}
@Test
@DisplayName("extracts tables when only tables are requested")
void extractsTablesForRequestedPage() throws IOException {
stubDocument(3);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition(
"requisition", List.of(), List.of(2), List.of(), "page 2 tables"),
cleanVerdict("s"));
when(pdfContentExtractor.extractTablesAsCsv(any(PDDocument.class), eq(3)))
.thenReturn(List.of("x,y\n9,9"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
verify(pdfContentExtractor).extractTablesAsCsv(any(PDDocument.class), eq(3));
verify(pdfContentExtractor, never())
.extractPageTextRaw(any(PDDocument.class), anyInt());
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
assertEquals(1, evidence.folios().size());
assertEquals(List.of("x,y\n9,9"), evidence.folios().get(0).tables());
}
@Test
@DisplayName("OCR-requested pages are marked unauditable and produce no folio")
void ocrPagesAreMarkedUnauditable() throws IOException {
stubDocument(2);
stubClassifyAll(FolioType.IMAGE);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(1), "needs OCR"),
cleanVerdict("s"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
// OCR is not wired: no folio, page recorded as unauditable.
assertTrue(evidence.folios().isEmpty());
assertEquals(List.of(1), evidence.unauditablePages());
verify(pdfContentExtractor, never())
.extractPageTextRaw(any(PDDocument.class), anyInt());
verify(pdfContentExtractor, never())
.extractTablesAsCsv(any(PDDocument.class), anyInt());
}
@Test
@DisplayName("a page needing both text and tables yields a single folio with both")
void textAndTablesOnSamePageProduceOneFolio() throws IOException {
stubDocument(1);
stubClassifyAll(FolioType.MIXED);
stubEngine(
new Requisition("requisition", List.of(0), List.of(0), List.of(), "both"),
cleanVerdict("s"));
when(pdfContentExtractor.extractPageTextRaw(any(PDDocument.class), eq(1)))
.thenReturn("the text");
when(pdfContentExtractor.extractTablesAsCsv(any(PDDocument.class), eq(1)))
.thenReturn(List.of("c1,c2"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
assertEquals(1, evidence.folios().size());
assertEquals("the text", evidence.folios().get(0).text());
assertEquals(List.of("c1,c2"), evidence.folios().get(0).tables());
}
@Test
@DisplayName("out-of-bounds page indices are dropped before extraction")
void outOfBoundsPagesAreDropped() throws IOException {
stubDocument(2); // valid 0-based indices: 0, 1
stubClassifyAll(FolioType.TEXT);
// -1 (negative) and 2 (>= totalPages) are out of bounds; only 0 survives.
stubEngine(
new Requisition(
"requisition", List.of(-1, 0, 2), List.of(), List.of(), "mixed"),
cleanVerdict("s"));
when(pdfContentExtractor.extractPageTextRaw(any(PDDocument.class), eq(1)))
.thenReturn("kept");
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
verify(pdfContentExtractor).extractPageTextRaw(any(PDDocument.class), eq(1));
// Out-of-bounds page 2 -> would be 1-based page 3; must never be touched.
verify(pdfContentExtractor, never()).extractPageTextRaw(any(PDDocument.class), eq(3));
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
assertEquals(1, evidence.folios().size());
assertEquals(0, evidence.folios().get(0).page());
}
@Test
@DisplayName("empty Requisition yields evidence with no folios but still deliberates")
void emptyRequisitionStillDeliberates() throws IOException {
stubDocument(2);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "nothing"),
cleanVerdict("s"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
assertTrue(evidence.folios().isEmpty());
assertTrue(evidence.unauditablePages().isEmpty());
// Deliberate is still invoked even with empty evidence.
verify(aiEngineClient)
.post(deliberatePathMatcher(), anyString(), nullable(String.class));
}
@Test
@DisplayName("null requisition lists (needText/needTables/needOcr) are tolerated")
void nullRequisitionListsAreTolerated() throws IOException {
stubDocument(2);
stubClassifyAll(FolioType.TEXT);
// All three "need" lists null - union/contains must null-guard.
stubEngine(
new Requisition("requisition", null, null, null, "null lists"),
cleanVerdict("s"));
Verdict result = orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
assertNotNull(result);
Evidence evidence = objectMapper.readValue(deliberateBody(), Evidence.class);
assertTrue(evidence.folios().isEmpty());
}
}
// ---------------------------------------------------------------------
// Engine wiring details: paths, tolerance, user header
// ---------------------------------------------------------------------
@Nested
@DisplayName("engine wiring")
class EngineWiring {
@Test
@DisplayName(
"examine uses the examine path, deliberate uses the deliberate path with tolerance")
void pathsAndToleranceAreCorrect() throws IOException {
stubDocument(1);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "x"),
cleanVerdict("s"));
orchestrator.audit(pdf("doc.pdf"), new BigDecimal("0.5"));
ArgumentCaptor<String> paths = ArgumentCaptor.forClass(String.class);
verify(aiEngineClient, times(2))
.post(paths.capture(), anyString(), nullable(String.class));
List<String> captured = paths.getAllValues();
assertEquals(EXAMINE_PATH, captured.get(0));
assertEquals(DELIBERATE_PATH + "?tolerance=0.5", captured.get(1));
}
@Test
@DisplayName("tolerance is rendered with toPlainString (no scientific notation)")
void tolerancePlainString() throws IOException {
stubDocument(1);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "x"),
cleanVerdict("s"));
// 1E-7 would render as scientific notation via toString(); toPlainString avoids it.
orchestrator.audit(pdf("doc.pdf"), new BigDecimal("0.0000001"));
verify(aiEngineClient)
.post(
eq(DELIBERATE_PATH + "?tolerance=0.0000001"),
anyString(),
nullable(String.class));
}
@Test
@DisplayName("current user id is forwarded to the engine when a user service is present")
void userIdForwarded() throws IOException {
stubDocument(1);
when(userService.getCurrentUsername()).thenReturn("bob");
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "x"),
cleanVerdict("s"));
orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
verify(aiEngineClient, times(2)).post(anyString(), anyString(), eq("bob"));
}
@Test
@DisplayName("a null user service yields a null user id (no NPE)")
void nullUserServiceYieldsNullUserId() throws IOException {
// Re-create orchestrator with no user service (the @Autowired(required=false) case).
orchestrator =
new MathAuditorOrchestrator(
aiEngineClient,
pdfDocumentFactory,
pdfContentExtractor,
objectMapper,
null);
stubDocument(1);
stubClassifyAll(FolioType.TEXT);
stubEngine(
new Requisition("requisition", List.of(), List.of(), List.of(), "x"),
cleanVerdict("s"));
Verdict result = orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
assertNotNull(result);
verify(aiEngineClient, times(2)).post(anyString(), anyString(), nullable(String.class));
}
}
// ---------------------------------------------------------------------
// Error / edge behaviour
// ---------------------------------------------------------------------
@Nested
@DisplayName("error handling")
class ErrorHandling {
@Test
@DisplayName("null Verdict from deliberate raises IllegalStateException")
void nullVerdictThrows() throws IOException {
stubDocument(1);
stubClassifyAll(FolioType.TEXT);
Requisition requisition =
new Requisition("requisition", List.of(), List.of(), List.of(), "x");
when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class)))
.thenReturn(objectMapper.writeValueAsString(requisition));
// Deliberate returns JSON null -> deserialises to null Verdict.
when(aiEngineClient.post(deliberatePathMatcher(), anyString(), nullable(String.class)))
.thenReturn("null");
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE));
assertTrue(ex.getMessage().contains("null Verdict"));
}
@Test
@DisplayName("an IOException from the engine on examine propagates and skips deliberate")
void engineIoExceptionOnExaminePropagates() throws IOException {
stubDocument(1);
stubClassifyAll(FolioType.TEXT);
when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class)))
.thenThrow(new IOException("engine down"));
IOException ex =
assertThrows(
IOException.class,
() -> orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE));
assertEquals("engine down", ex.getMessage());
verify(aiEngineClient, never())
.post(deliberatePathMatcher(), anyString(), nullable(String.class));
}
@Test
@DisplayName("an IOException from the PDF factory load propagates")
void factoryLoadIoExceptionPropagates() throws IOException {
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenThrow(new IOException("corrupt pdf"));
IOException ex =
assertThrows(
IOException.class,
() -> orchestrator.audit(pdf("bad.pdf"), BigDecimal.ONE));
assertEquals("corrupt pdf", ex.getMessage());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
}
@Test
@DisplayName(
"verdict error/warning counts derived from discrepancies survive the round-trip")
void verdictCountsSurviveRoundTrip() throws IOException {
stubDocument(1);
stubClassifyAll(FolioType.TEXT);
Verdict dirty =
new Verdict(
"verdict",
"sess",
List.of(
new AuditDiscrepancy(
0,
DiscrepancyKind.TALLY,
AuditSeverity.ERROR,
"bad sum",
"100",
"99",
"row 3"),
new AuditDiscrepancy(
0,
DiscrepancyKind.ARITHMETIC,
AuditSeverity.WARNING,
"maybe",
"10",
"10.001",
"row 4")),
List.of(0),
2,
"found issues",
false,
List.of());
stubEngine(new Requisition("requisition", List.of(), List.of(), List.of(), "x"), dirty);
Verdict result = orchestrator.audit(pdf("doc.pdf"), BigDecimal.ONE);
assertEquals(1L, result.errorCount());
assertEquals(1L, result.warningCount());
assertTrue(!result.clean());
}
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
/** Stub the factory to return a fresh real {@code n}-page PDF each time it's loaded. */
private void stubDocument(int pages) throws IOException {
byte[] bytes = pdfBytes(pages);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(bytes));
}
private void stubClassifyAll(FolioType type) throws IOException {
when(pdfContentExtractor.classifyPage(any(PDDocument.class), anyInt())).thenReturn(type);
}
/**
* Wire the two engine calls: examine returns the requisition, deliberate returns the verdict.
*/
private void stubEngine(Requisition requisition, Verdict verdict) throws IOException {
when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class)))
.thenReturn(objectMapper.writeValueAsString(requisition));
when(aiEngineClient.post(deliberatePathMatcher(), anyString(), nullable(String.class)))
.thenReturn(objectMapper.writeValueAsString(verdict));
}
/** Matcher for the deliberate path, which always carries a {@code ?tolerance=} query string. */
private static String deliberatePathMatcher() {
return org.mockito.ArgumentMatchers.startsWith(DELIBERATE_PATH);
}
/** Captured request body sent to the examine endpoint. */
private String examineBody() throws IOException {
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
verify(aiEngineClient).post(eq(EXAMINE_PATH), body.capture(), nullable(String.class));
return body.getValue();
}
/** Captured request body sent to the deliberate endpoint. */
private String deliberateBody() throws IOException {
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
verify(aiEngineClient)
.post(deliberatePathMatcher(), body.capture(), nullable(String.class));
return body.getValue();
}
private static Verdict cleanVerdict(String sessionId) {
return new Verdict(
"verdict", sessionId, List.of(), List.of(), 2, "all good", true, List.of());
}
private static MockMultipartFile pdf(String filename) {
return new MockMultipartFile(
"fileInput",
filename,
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
}
private static byte[] pdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
}
@@ -0,0 +1,679 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.SPDF.pdf.parser.PdfModels.Bounds;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.FolioType;
import stirling.software.proprietary.service.PdfContentExtractor.ArtifactKind;
import stirling.software.proprietary.service.PdfContentExtractor.ExtractedFileText;
import stirling.software.proprietary.service.PdfContentExtractor.ExtractedTextArtifact;
import stirling.software.proprietary.service.PdfContentExtractor.ImageBlock;
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
import stirling.software.proprietary.service.PdfContentExtractor.TextBlock;
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
/**
* Unit tests for {@link PdfContentExtractor}. Exercises page classification, raw text extraction +
* clipping, table-to-CSV conversion (collaborator mocked), image-position extraction, text-position
* finding (literal + regex), and the package-private workflow extraction / artifact building paths.
*
* <p>Lives in the production package so it can reach the package-private {@code LoadedFile} record,
* {@code extractContent}/{@code buildArtifacts} methods and the {@code ExtractedFileText} / {@code
* ArtifactKind} types. PDFs are built in-memory; no I/O, network, DB or Spring context.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PdfContentExtractorTest {
@Mock private TabulaTableParser tabulaTableParser;
@InjectMocks private PdfContentExtractor extractor;
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private static byte[] textPagePdf(String... pageTexts) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (String text : pageTexts) {
addTextPage(doc, text);
}
return save(doc);
}
}
private static void addTextPage(PDDocument doc, String text) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
}
}
/** A page carrying only a drawn raster image (no text layer). */
private static void addImageOnlyPage(PDDocument doc) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
BufferedImage bi = new BufferedImage(40, 30, BufferedImage.TYPE_INT_RGB);
PDImageXObject img = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(img, 100, 600, 40, 30);
}
}
/** A page carrying both a long text run and a drawn raster image. */
private static void addMixedPage(PDDocument doc, String text) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
BufferedImage bi = new BufferedImage(40, 30, BufferedImage.TYPE_INT_RGB);
PDImageXObject img = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
cs.drawImage(img, 100, 500, 40, 30);
}
}
private static byte[] emptyPagesPdf(int count) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < count; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
private static TableFragment fragment(List<List<String>> rawRows) {
return new TableFragment(
"tbl-test",
1,
new Bounds(0f, 0f, 100f, 100f),
List.of(),
List.of(),
rawRows,
rawRows.isEmpty() ? 0 : rawRows.get(0).size(),
1.0f,
List.of(),
null);
}
// ------------------------------------------------------------------
// classifyPage
// ------------------------------------------------------------------
@Nested
@DisplayName("classifyPage")
class ClassifyPage {
@Test
@DisplayName("text-only page (text > threshold, no image) -> TEXT")
void textOnlyPageIsText() throws IOException {
byte[] pdf = textPagePdf("This is a sufficiently long line of selectable text content");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertEquals(FolioType.TEXT, extractor.classifyPage(doc, 1));
}
}
@Test
@DisplayName("image-only page (no text layer) -> IMAGE")
void imageOnlyPageIsImage() throws IOException {
try (PDDocument doc = new PDDocument()) {
addImageOnlyPage(doc);
byte[] pdf = save(doc);
try (PDDocument loaded = Loader.loadPDF(pdf)) {
assertEquals(FolioType.IMAGE, extractor.classifyPage(loaded, 1));
}
}
}
@Test
@DisplayName("page with text AND an image -> MIXED")
void textAndImagePageIsMixed() throws IOException {
try (PDDocument doc = new PDDocument()) {
addMixedPage(doc, "A long enough text layer to clear the presence threshold here");
byte[] pdf = save(doc);
try (PDDocument loaded = Loader.loadPDF(pdf)) {
assertEquals(FolioType.MIXED, extractor.classifyPage(loaded, 1));
}
}
}
@Test
@DisplayName("page with only a tiny amount of text (<= threshold) -> IMAGE")
void shortTextBelowThresholdIsImage() throws IOException {
// "hi" trims to 2 chars, well under TEXT_PRESENCE_THRESHOLD (20), and there is no
// image.
byte[] pdf = textPagePdf("hi");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertEquals(FolioType.IMAGE, extractor.classifyPage(doc, 1));
}
}
@Test
@DisplayName("targets the requested 1-based page number")
void respectsPageNumber() throws IOException {
byte[] pdf =
textPagePdf(
"tiny",
"Second page has a long selectable text run well over threshold");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertEquals(FolioType.IMAGE, extractor.classifyPage(doc, 1));
assertEquals(FolioType.TEXT, extractor.classifyPage(doc, 2));
}
}
}
// ------------------------------------------------------------------
// extractPageTextRaw
// ------------------------------------------------------------------
@Nested
@DisplayName("extractPageTextRaw")
class ExtractPageTextRaw {
@Test
@DisplayName("returns trimmed page text")
void returnsTrimmedText() throws IOException {
byte[] pdf = textPagePdf("Hello World");
try (PDDocument doc = Loader.loadPDF(pdf)) {
String text = extractor.extractPageTextRaw(doc, 1);
assertTrue(text.contains("Hello World"), "got: " + text);
assertEquals(text.trim(), text, "result should already be trimmed");
}
}
@Test
@DisplayName("empty page yields empty string")
void emptyPageYieldsEmptyString() throws IOException {
byte[] pdf = emptyPagesPdf(1);
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertEquals("", extractor.extractPageTextRaw(doc, 1));
}
}
@Test
@DisplayName("clips output to the 4000-character per-page cap")
void clipsToMaxCharacters() throws IOException {
// 600 repetitions of a 10-char token (~6000 chars + spaces) exceeds the 4000 cap.
StringBuilder big = new StringBuilder();
for (int i = 0; i < 600; i++) {
big.append("ABCDEFGHIJ ");
}
byte[] pdf = textPagePdf(big.toString());
try (PDDocument doc = Loader.loadPDF(pdf)) {
String text = extractor.extractPageTextRaw(doc, 1);
assertTrue(text.length() <= 4000, "expected <= 4000 chars, got " + text.length());
assertTrue(text.length() > 3000, "expected the page to actually be clipped");
}
}
@Test
@DisplayName("reads the requested page only")
void readsRequestedPageOnly() throws IOException {
byte[] pdf = textPagePdf("PageOneMarker", "PageTwoMarker");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(extractor.extractPageTextRaw(doc, 1).contains("PageOneMarker"));
assertFalse(extractor.extractPageTextRaw(doc, 1).contains("PageTwoMarker"));
assertTrue(extractor.extractPageTextRaw(doc, 2).contains("PageTwoMarker"));
}
}
}
// ------------------------------------------------------------------
// extractTablesAsCsv (TabulaTableParser mocked)
// ------------------------------------------------------------------
@Nested
@DisplayName("extractTablesAsCsv")
class ExtractTablesAsCsv {
@Test
@DisplayName("no fragments -> empty list, no CSV produced")
void noFragmentsReturnsEmpty() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1))).thenReturn(List.of());
List<String> csv = extractor.extractTablesAsCsv(doc, 1);
assertTrue(csv.isEmpty());
}
}
@Test
@DisplayName("one fragment -> one CSV string with all fields quoted")
void singleFragmentProducesQuotedCsv() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
TableFragment frag =
fragment(List.of(List.of("Name", "Age"), List.of("Alice", "30")));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(List.of(frag));
List<String> csv = extractor.extractTablesAsCsv(doc, 1);
assertEquals(1, csv.size());
String out = csv.get(0);
// QuoteMode.ALL quotes every field.
assertTrue(out.contains("\"Name\""), "header field quoted, got: " + out);
assertTrue(out.contains("\"Alice\""), "data field quoted, got: " + out);
assertTrue(out.contains("\"30\""), "data field quoted, got: " + out);
}
}
@Test
@DisplayName("multiple fragments -> one CSV string per fragment, in order")
void multipleFragmentsProduceOneCsvEach() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
TableFragment f1 = fragment(List.of(List.of("one")));
TableFragment f2 = fragment(List.of(List.of("two")));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(List.of(f1, f2));
List<String> csv = extractor.extractTablesAsCsv(doc, 1);
assertEquals(2, csv.size());
assertTrue(csv.get(0).contains("one"));
assertTrue(csv.get(1).contains("two"));
}
}
@Test
@DisplayName("delegates to the parser with the supplied page number")
void delegatesWithPageNumber() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
when(tabulaTableParser.parse(any(PDDocument.class), eq(3))).thenReturn(List.of());
extractor.extractTablesAsCsv(doc, 3);
verify(tabulaTableParser).parse(doc, 3);
}
}
}
// ------------------------------------------------------------------
// extractImagePositions
// ------------------------------------------------------------------
@Nested
@DisplayName("extractImagePositions")
class ExtractImagePositions {
@Test
@DisplayName("page with no images -> empty list")
void noImages() throws IOException {
byte[] pdf = textPagePdf("just some text, no pictures here at all");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(extractor.extractImagePositions(doc, 0).isEmpty());
}
}
@Test
@DisplayName("page with a drawn image -> bounding box reflecting the draw rectangle")
void singleImageBox() throws IOException {
try (PDDocument doc = new PDDocument()) {
addImageOnlyPage(doc); // drawn at x=100,y=600,w=40,h=30
byte[] pdf = save(doc);
try (PDDocument loaded = Loader.loadPDF(pdf)) {
List<ImageBlock> blocks = extractor.extractImagePositions(loaded, 0);
assertEquals(1, blocks.size());
ImageBlock b = blocks.get(0);
assertEquals(0, b.pageIndex());
// CTM maps the unit square to the draw rectangle; tolerate float rounding.
assertEquals(100f, b.x1(), 0.5f);
assertEquals(600f, b.y1(), 0.5f);
assertEquals(140f, b.x2(), 0.5f);
assertEquals(630f, b.y2(), 0.5f);
assertTrue(b.x2() > b.x1(), "x2 should be to the right of x1");
assertTrue(b.y2() > b.y1(), "y2 should be above y1");
}
}
}
}
// ------------------------------------------------------------------
// findTextPositions
// ------------------------------------------------------------------
@Nested
@DisplayName("findTextPositions")
class FindTextPositions {
@Test
@DisplayName("literal match -> one block with a positive-area bounding box")
void literalMatch() throws IOException {
byte[] pdf = textPagePdf("FindMeHere on the page");
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextBlock> blocks = extractor.findTextPositions(doc, "FindMeHere", false);
assertFalse(blocks.isEmpty(), "expected at least one match");
TextBlock b = blocks.get(0);
assertEquals(0, b.pageIndex());
assertTrue(b.x2() > b.x1(), "x2 > x1");
assertTrue(b.y2() > b.y1(), "y2 > y1");
}
}
@Test
@DisplayName("non-existent literal -> empty list")
void noMatch() throws IOException {
byte[] pdf = textPagePdf("nothing relevant here");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(extractor.findTextPositions(doc, "Absent", false).isEmpty());
}
}
@Test
@DisplayName("regex match finds the pattern")
void regexMatch() throws IOException {
byte[] pdf = textPagePdf("Order 12345 confirmed");
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextBlock> blocks = extractor.findTextPositions(doc, "\\d{5}", true);
assertFalse(blocks.isEmpty(), "regex \\d{5} should match 12345");
}
}
@Test
@DisplayName("blank search term -> no matches (guarded in endPage)")
void blankSearchTermYieldsNothing() throws IOException {
byte[] pdf = textPagePdf("Some text on the page");
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(extractor.findTextPositions(doc, " ", false).isEmpty());
}
}
@Test
@DisplayName("matches across multiple pages, in page order")
void matchesAcrossPages() throws IOException {
byte[] pdf = textPagePdf("TOKEN appears here", "and TOKEN appears again here");
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextBlock> blocks = extractor.findTextPositions(doc, "TOKEN", false);
assertEquals(2, blocks.size());
assertEquals(0, blocks.get(0).pageIndex());
assertEquals(1, blocks.get(1).pageIndex());
}
}
}
// ------------------------------------------------------------------
// extractContent (package-private workflow path)
// ------------------------------------------------------------------
@Nested
@DisplayName("extractContent")
class ExtractContentTests {
private LoadedFile load(String id, String name, byte[] pdf) throws IOException {
return new LoadedFile(id, name, Loader.loadPDF(pdf));
}
@Test
@DisplayName("default content type extracts page text into an ExtractedFileText result")
void defaultsToPageText() throws IOException {
LoadedFile lf = load("id1", "doc.pdf", textPagePdf("Body text on page one"));
try (PDDocument ignored = lf.document()) {
List<PdfContentResult> results =
extractor.extractContent(List.of(lf), Map.of(), 10, 10_000);
assertEquals(1, results.size());
PdfContentResult r = results.get(0);
ExtractedFileText eft = assertInstanceOf(ExtractedFileText.class, r);
assertEquals("doc.pdf", eft.getFileName());
assertFalse(eft.getPages().isEmpty());
assertTrue(
eft.getPages().get(0).getText().contains("Body text"),
"page text should include the body content");
assertEquals(ArtifactKind.EXTRACTED_TEXT, r.getArtifactKind());
}
}
@Test
@DisplayName("prepends a page-dimensions header to extracted page text")
void prependsDimensionHeader() throws IOException {
LoadedFile lf = load("id1", "doc.pdf", textPagePdf("Some body content here"));
try (PDDocument ignored = lf.document()) {
List<PdfContentResult> results =
extractor.extractContent(List.of(lf), Map.of(), 10, 10_000);
String text = ((ExtractedFileText) results.get(0)).getPages().get(0).getText();
assertTrue(
text.contains("--- Page dimensions:"),
"expected dimension header, got: " + text);
}
}
@Test
@DisplayName("respects explicitly requested page numbers")
void honoursRequestedPageNumbers() throws IOException {
LoadedFile lf = load("id1", "doc.pdf", textPagePdf("PageOne", "PageTwo", "PageThree"));
AiWorkflowFileRequest req = new AiWorkflowFileRequest();
req.setPageNumbers(List.of(2));
req.setContentTypes(List.of(AiPdfContentType.PAGE_TEXT));
Map<String, AiWorkflowFileRequest> byId = new HashMap<>();
byId.put("id1", req);
try (PDDocument ignored = lf.document()) {
List<PdfContentResult> results =
extractor.extractContent(List.of(lf), byId, 10, 10_000);
ExtractedFileText eft = (ExtractedFileText) results.get(0);
assertEquals(1, eft.getPages().size());
assertEquals(Integer.valueOf(2), eft.getPages().get(0).getPageNumber());
assertTrue(eft.getPages().get(0).getText().contains("PageTwo"));
}
}
@Test
@DisplayName("out-of-range requested page number throws IllegalArgumentException")
void outOfRangePageThrows() throws IOException {
LoadedFile lf = load("id1", "doc.pdf", textPagePdf("only one page"));
AiWorkflowFileRequest req = new AiWorkflowFileRequest();
req.setPageNumbers(List.of(99));
req.setContentTypes(List.of(AiPdfContentType.PAGE_TEXT));
Map<String, AiWorkflowFileRequest> byId = new HashMap<>();
byId.put("id1", req);
try (PDDocument ignored = lf.document()) {
assertThrows(
IllegalArgumentException.class,
() -> extractor.extractContent(List.of(lf), byId, 10, 10_000));
}
}
@Test
@DisplayName("unimplemented content type is skipped (no result, no throw)")
void unimplementedContentTypeSkipped() throws IOException {
LoadedFile lf = load("id1", "doc.pdf", textPagePdf("body"));
AiWorkflowFileRequest req = new AiWorkflowFileRequest();
req.setContentTypes(List.of(AiPdfContentType.IMAGES));
Map<String, AiWorkflowFileRequest> byId = new HashMap<>();
byId.put("id1", req);
try (PDDocument ignored = lf.document()) {
List<PdfContentResult> results =
extractor.extractContent(List.of(lf), byId, 10, 10_000);
assertTrue(results.isEmpty(), "IMAGES is not implemented; should yield no result");
}
}
@Test
@DisplayName("maxPages=0 short-circuits before extracting anything")
void zeroMaxPagesShortCircuits() throws IOException {
LoadedFile lf = load("id1", "doc.pdf", textPagePdf("body"));
try (PDDocument ignored = lf.document()) {
List<PdfContentResult> results =
extractor.extractContent(List.of(lf), Map.of(), 0, 10_000);
assertTrue(results.isEmpty());
}
}
@Test
@DisplayName("character budget is consumed across files and stops further extraction")
void characterBudgetStopsAfterFirstFile() throws IOException {
LoadedFile a = load("a", "a.pdf", textPagePdf("First file content body text"));
LoadedFile b = load("b", "b.pdf", textPagePdf("Second file content body text"));
try (PDDocument iA = a.document();
PDDocument iB = b.document()) {
// Tiny character budget: first file consumes it, loop breaks before the second.
List<PdfContentResult> results =
extractor.extractContent(List.of(a, b), Map.of(), 10, 5);
assertEquals(1, results.size(), "only the first file should fit the 5-char budget");
assertEquals("a.pdf", ((ExtractedFileText) results.get(0)).getFileName());
}
}
@Test
@DisplayName("two files within budget both produce results")
void twoFilesBothExtracted() throws IOException {
LoadedFile a = load("a", "a.pdf", textPagePdf("Alpha body"));
LoadedFile b = load("b", "b.pdf", textPagePdf("Beta body"));
try (PDDocument iA = a.document();
PDDocument iB = b.document()) {
List<PdfContentResult> results =
extractor.extractContent(List.of(a, b), Map.of(), 10, 100_000);
assertEquals(2, results.size());
}
}
}
// ------------------------------------------------------------------
// buildArtifacts
// ------------------------------------------------------------------
@Nested
@DisplayName("buildArtifacts")
class BuildArtifacts {
@Test
@DisplayName("empty results -> no artifacts")
void emptyResults() {
assertTrue(extractor.buildArtifacts(List.of()).isEmpty());
}
@Test
@DisplayName("EXTRACTED_TEXT results -> a single ExtractedTextArtifact wrapping all files")
void groupsExtractedTextResults() {
ExtractedFileText a = new ExtractedFileText();
a.setFileName("a.pdf");
ExtractedFileText b = new ExtractedFileText();
b.setFileName("b.pdf");
List<WorkflowArtifact> artifacts =
extractor.buildArtifacts(List.<PdfContentResult>of(a, b));
assertEquals(1, artifacts.size());
ExtractedTextArtifact art =
assertInstanceOf(ExtractedTextArtifact.class, artifacts.get(0));
assertEquals(ArtifactKind.EXTRACTED_TEXT, art.getKind());
assertEquals(2, art.getFiles().size());
}
}
// ------------------------------------------------------------------
// ExtractedFileText accounting + ArtifactKind values
// ------------------------------------------------------------------
@Nested
@DisplayName("supporting types")
class SupportingTypes {
@Test
@DisplayName("ExtractedFileText reports pages and characters consumed")
void extractedFileTextAccounting() {
ExtractedFileText eft = new ExtractedFileText();
var p1 = new stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection();
p1.setPageNumber(1);
p1.setText("hello");
var p2 = new stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection();
p2.setPageNumber(2);
p2.setText("world!");
eft.setPages(List.of(p1, p2));
assertEquals(ArtifactKind.EXTRACTED_TEXT, eft.getArtifactKind());
assertEquals(2, eft.pagesConsumed());
assertEquals("hello".length() + "world!".length(), eft.charactersConsumed());
}
@Test
@DisplayName("default ExtractedFileText consumes nothing")
void defaultExtractedFileTextIsEmpty() {
ExtractedFileText eft = new ExtractedFileText();
assertEquals(0, eft.pagesConsumed());
assertEquals(0, eft.charactersConsumed());
assertNotNull(eft.getPages());
}
@Test
@DisplayName("ArtifactKind json values match the engine contract")
void artifactKindJsonValues() {
assertEquals("extracted_text", ArtifactKind.EXTRACTED_TEXT.getValue());
assertEquals("tool_report", ArtifactKind.TOOL_REPORT.getValue());
}
@Test
@DisplayName("LoadedFile record exposes its id and fileName")
void loadedFileAccessors() throws IOException {
byte[] pdf = emptyPagesPdf(1);
try (PDDocument doc = Loader.loadPDF(pdf)) {
LoadedFile lf = new LoadedFile("the-id", "the-name.pdf", doc);
assertEquals("the-id", lf.id());
assertEquals("the-name.pdf", lf.fileName());
assertEquals(doc, lf.document());
}
}
}
}
@@ -0,0 +1,554 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.service.ServerCertificateServiceInterface.ServerCertificateInfo;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ServerCertificateService")
class ServerCertificateServiceTest {
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
// Built once: generating an RSA keypair and self-signed cert is expensive, so reuse across
// tests.
private static byte[] validP12WithDefaultPassword;
private static byte[] validP12WithUploadPassword;
private static byte[] certOnlyP12; // no private key entry
private static X509Certificate sampleCert;
private static final String UPLOAD_PASSWORD = "upload-secret";
@Mock private LicenseKeyChecker licenseKeyChecker;
@TempDir Path tempDir;
private ServerCertificateService service;
@BeforeAll
static void buildFixtures() throws Exception {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair keyPair = kpg.generateKeyPair();
sampleCert = selfSignedCert(keyPair);
// p12 stored under the service's standard alias + default password
KeyStore defaultStore = KeyStore.getInstance("PKCS12");
defaultStore.load(null, null);
defaultStore.setKeyEntry(
KEYSTORE_ALIAS,
keyPair.getPrivate(),
DEFAULT_PASSWORD.toCharArray(),
new Certificate[] {sampleCert});
validP12WithDefaultPassword = serialize(defaultStore, DEFAULT_PASSWORD);
// p12 with an arbitrary alias + a different password (simulates a user upload)
KeyStore uploadStore = KeyStore.getInstance("PKCS12");
uploadStore.load(null, null);
uploadStore.setKeyEntry(
"user-alias",
keyPair.getPrivate(),
UPLOAD_PASSWORD.toCharArray(),
new Certificate[] {sampleCert});
validP12WithUploadPassword = serialize(uploadStore, UPLOAD_PASSWORD);
// p12 holding only a trusted certificate (no private key entry)
KeyStore certOnly = KeyStore.getInstance("PKCS12");
certOnly.load(null, null);
certOnly.setCertificateEntry("trusted", sampleCert);
certOnlyP12 = serialize(certOnly, UPLOAD_PASSWORD);
}
private static X509Certificate selfSignedCert(KeyPair keyPair) throws Exception {
X500Name subject = new X500Name("CN=Test, O=Test, C=US");
Date notBefore = new Date(System.currentTimeMillis() - 1000);
Date notAfter = new Date(System.currentTimeMillis() + 86_400_000L);
JcaX509v3CertificateBuilder builder =
new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
keyPair.getPublic());
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(keyPair.getPrivate());
X509CertificateHolder holder = builder.build(signer);
return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
}
private static byte[] serialize(KeyStore keyStore, String password) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
keyStore.store(out, password.toCharArray());
return out.toByteArray();
}
@BeforeEach
void setUp() {
service = new ServerCertificateService(licenseKeyChecker);
// Default @Value field state; individual tests override as needed.
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "organizationName", "Stirling-PDF");
ReflectionTestUtils.setField(service, "validityDays", 365);
ReflectionTestUtils.setField(service, "regenerateOnStartup", false);
}
private MockedStatic<InstallationPathConfig> mockConfigPath() {
MockedStatic<InstallationPathConfig> mocked = mockStatic(InstallationPathConfig.class);
mocked.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString());
return mocked;
}
private Path keystorePath() {
return Path.of(tempDir.toString(), KEYSTORE_FILENAME);
}
private void licenseAs(License license) {
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(license);
}
@Nested
@DisplayName("isEnabled")
class IsEnabled {
@Test
@DisplayName("returns true when enabled and license is SERVER")
void enabledWithServerLicense() {
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.SERVER);
assertTrue(service.isEnabled());
}
@Test
@DisplayName("returns true when enabled and license is ENTERPRISE")
void enabledWithEnterpriseLicense() {
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
assertTrue(service.isEnabled());
}
@Test
@DisplayName("returns false when enabled but license is NORMAL")
void enabledWithoutPremiumLicense() {
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.NORMAL);
assertFalse(service.isEnabled());
}
@Test
@DisplayName("returns false when disabled even with ENTERPRISE license")
void disabledWithEnterpriseLicense() {
ReflectionTestUtils.setField(service, "enabled", false);
licenseAs(License.ENTERPRISE);
assertFalse(service.isEnabled());
}
}
@Nested
@DisplayName("hasServerCertificate")
class HasServerCertificate {
@Test
@DisplayName("returns false when keystore file is absent")
void absent() {
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
assertFalse(service.hasServerCertificate());
}
}
@Test
@DisplayName("returns true when keystore file exists")
void present() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
assertTrue(service.hasServerCertificate());
}
}
}
@Nested
@DisplayName("getServerCertificatePassword")
class GetServerCertificatePassword {
@Test
@DisplayName("returns the fixed default password")
void returnsDefaultPassword() {
assertEquals(DEFAULT_PASSWORD, service.getServerCertificatePassword());
}
}
@Nested
@DisplayName("initializeServerCertificate")
class InitializeServerCertificate {
@Test
@DisplayName("does nothing when feature disabled")
void noOpWhenDisabled() {
ReflectionTestUtils.setField(service, "enabled", false);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
service.initializeServerCertificate();
assertFalse(Files.exists(keystorePath()));
}
}
@Test
@DisplayName("does nothing when license is NORMAL")
void noOpWhenNoLicense() {
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.NORMAL);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
service.initializeServerCertificate();
assertFalse(Files.exists(keystorePath()));
}
}
@Test
@DisplayName("generates a new keystore when enabled, licensed and none exists")
void generatesWhenMissing() {
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
service.initializeServerCertificate();
assertTrue(Files.exists(keystorePath()));
}
}
@Test
@DisplayName("does not regenerate when keystore already exists and regenerate flag is off")
void keepsExistingWhenNotRegenerating() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "regenerateOnStartup", false);
licenseAs(License.SERVER);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
service.initializeServerCertificate();
// file content untouched (still the fixture bytes we wrote)
assertArrayEquals(validP12WithDefaultPassword, Files.readAllBytes(keystorePath()));
}
}
@Test
@DisplayName("regenerates when regenerateOnStartup is true even if keystore exists")
void regeneratesWhenFlagSet() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "regenerateOnStartup", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
service.initializeServerCertificate();
assertTrue(Files.exists(keystorePath()));
// content replaced with a freshly generated keystore
byte[] after = Files.readAllBytes(keystorePath());
assertNotNull(after);
}
}
@Test
@DisplayName("swallows generation failures instead of propagating them")
void swallowsGenerationErrors() throws Exception {
// Point the config path at a regular file so creating the keystore's parent directory
// fails; the method must catch the exception rather than letting it escape.
Path asFile = tempDir.resolve("not-a-directory");
Files.write(asFile, new byte[] {1});
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getConfigPath).thenReturn(asFile.toString());
// Should not throw even though directory creation under a file fails.
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
() -> service.initializeServerCertificate());
}
}
}
@Nested
@DisplayName("getServerKeyStore")
class GetServerKeyStore {
@Test
@DisplayName("throws when license is NORMAL")
void deniesWithoutLicense() {
licenseAs(License.NORMAL);
IllegalStateException ex =
assertThrows(IllegalStateException.class, () -> service.getServerKeyStore());
assertTrue(ex.getMessage().contains("Pro or Enterprise"));
}
@Test
@DisplayName("throws when feature is disabled")
void deniesWhenDisabled() {
ReflectionTestUtils.setField(service, "enabled", false);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
IllegalStateException ex =
assertThrows(
IllegalStateException.class, () -> service.getServerKeyStore());
assertTrue(ex.getMessage().contains("not available"));
}
}
@Test
@DisplayName("throws when enabled and licensed but no certificate file exists")
void deniesWhenNoCertificate() {
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
IllegalStateException ex =
assertThrows(
IllegalStateException.class, () -> service.getServerKeyStore());
assertTrue(ex.getMessage().contains("not available"));
}
}
@Test
@DisplayName("loads the PKCS12 keystore when present and licensed")
void loadsKeystore() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.SERVER);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
KeyStore loaded = service.getServerKeyStore();
assertNotNull(loaded);
assertEquals("PKCS12", loaded.getType());
assertTrue(loaded.containsAlias(KEYSTORE_ALIAS));
}
}
}
@Nested
@DisplayName("getServerCertificate / getServerCertificatePublicKey")
class GetServerCertificate {
@Test
@DisplayName("returns the X509 certificate stored under the standard alias")
void returnsCertificate() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
X509Certificate cert = service.getServerCertificate();
assertNotNull(cert);
assertInstanceOf(X509Certificate.class, cert);
}
}
@Test
@DisplayName("public key bytes equal the certificate's encoded form")
void returnsEncodedPublicKey() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
X509Certificate cert = service.getServerCertificate();
byte[] publicKey = service.getServerCertificatePublicKey();
assertArrayEquals(cert.getEncoded(), publicKey);
}
}
@Test
@DisplayName("propagates the license check failure from the underlying keystore lookup")
void deniesWithoutLicense() {
licenseAs(License.NORMAL);
assertThrows(IllegalStateException.class, () -> service.getServerCertificate());
}
}
@Nested
@DisplayName("uploadServerCertificate")
class UploadServerCertificate {
@Test
@DisplayName("throws when license is NORMAL")
void deniesWithoutLicense() {
licenseAs(License.NORMAL);
InputStream in = new ByteArrayInputStream(validP12WithUploadPassword);
IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> service.uploadServerCertificate(in, UPLOAD_PASSWORD));
assertTrue(ex.getMessage().contains("Pro or Enterprise"));
}
@Test
@DisplayName("rejects an uploaded keystore that has no private key entry")
void rejectsCertOnlyKeystore() {
licenseAs(License.ENTERPRISE);
InputStream in = new ByteArrayInputStream(certOnlyP12);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> service.uploadServerCertificate(in, UPLOAD_PASSWORD));
assertTrue(ex.getMessage().contains("No private key"));
}
}
@Test
@DisplayName("throws when the supplied password is wrong")
void wrongPasswordFails() {
licenseAs(License.ENTERPRISE);
InputStream in = new ByteArrayInputStream(validP12WithUploadPassword);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
assertThrows(
Exception.class,
() -> service.uploadServerCertificate(in, "wrong-password"));
}
}
@Test
@DisplayName("re-stores the uploaded key under the standard alias and default password")
void storesUnderStandardAlias() throws Exception {
licenseAs(License.ENTERPRISE);
InputStream in = new ByteArrayInputStream(validP12WithUploadPassword);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
service.uploadServerCertificate(in, UPLOAD_PASSWORD);
assertTrue(Files.exists(keystorePath()));
// The persisted keystore must be readable with the DEFAULT password and use our
// standard alias, regardless of what the upload used.
KeyStore persisted = KeyStore.getInstance("PKCS12");
try (InputStream fis = Files.newInputStream(keystorePath())) {
persisted.load(fis, DEFAULT_PASSWORD.toCharArray());
}
assertTrue(persisted.containsAlias(KEYSTORE_ALIAS));
assertTrue(persisted.isKeyEntry(KEYSTORE_ALIAS));
PrivateKey key =
(PrivateKey)
persisted.getKey(KEYSTORE_ALIAS, DEFAULT_PASSWORD.toCharArray());
assertNotNull(key);
}
}
}
@Nested
@DisplayName("deleteServerCertificate")
class DeleteServerCertificate {
@Test
@DisplayName("deletes the keystore file when it exists")
void deletesExisting() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
assertTrue(Files.exists(keystorePath()));
service.deleteServerCertificate();
assertFalse(Files.exists(keystorePath()));
}
}
@Test
@DisplayName("is a no-op when the keystore file is absent")
void noOpWhenAbsent() {
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
assertFalse(Files.exists(keystorePath()));
// must not throw
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
() -> service.deleteServerCertificate());
}
}
}
@Nested
@DisplayName("getServerCertificateInfo")
class GetServerCertificateInfo {
@Test
@DisplayName("reports exists=false and null fields when no certificate present")
void infoWhenAbsent() throws Exception {
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
ServerCertificateInfo info = service.getServerCertificateInfo();
assertFalse(info.isExists());
assertNull(info.getSubject());
assertNull(info.getIssuer());
assertNull(info.getValidFrom());
assertNull(info.getValidTo());
}
}
@Test
@DisplayName("populates subject/issuer/validity from the stored certificate")
void infoWhenPresent() throws Exception {
Files.write(keystorePath(), validP12WithDefaultPassword);
ReflectionTestUtils.setField(service, "enabled", true);
licenseAs(License.ENTERPRISE);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
ServerCertificateInfo info = service.getServerCertificateInfo();
assertTrue(info.isExists());
assertNotNull(info.getSubject());
assertNotNull(info.getIssuer());
assertEquals(sampleCert.getNotBefore(), info.getValidFrom());
assertEquals(sampleCert.getNotAfter(), info.getValidTo());
}
}
@Test
@DisplayName("throws when a certificate exists but the license has been revoked")
void deniesWhenCertPresentButNoLicense() throws Exception {
// hasServerCertificate() passes (file present) but getServerCertificate() requires a
// license, so the underlying lookup must fail.
Files.write(keystorePath(), validP12WithDefaultPassword);
licenseAs(License.NORMAL);
try (MockedStatic<InstallationPathConfig> ignored = mockConfigPath()) {
assertThrows(IllegalStateException.class, () -> service.getServerCertificateInfo());
}
}
}
}
@@ -0,0 +1,991 @@
package stirling.software.proprietary.workflow.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.security.Principal;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.SignDocumentRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowType;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SigningSessionController")
class SigningSessionControllerTest {
@Mock private WorkflowSessionService workflowSessionService;
@Mock private UserService userService;
@Mock private SigningFinalizationService signingFinalizationService;
@Mock private CertificateSubmissionValidator certificateSubmissionValidator;
private SigningSessionController controller;
private static final String SESSION_ID = "session-123";
private static final String USERNAME = "owner@example.com";
private User owner;
private Principal principal;
@BeforeEach
void setUp() {
controller =
new SigningSessionController(
workflowSessionService,
userService,
signingFinalizationService,
certificateSubmissionValidator);
owner = new User();
owner.setId(1L);
owner.setUsername(USERNAME);
principal = () -> USERNAME;
}
/** Builds a minimal but fully-mappable WorkflowSession (owner + empty participant list). */
private WorkflowSession newSession() {
WorkflowSession session = new WorkflowSession();
session.setSessionId(SESSION_ID);
session.setOwner(owner);
session.setWorkflowType(WorkflowType.SIGNING);
session.setDocumentName("contract.pdf");
return session;
}
private void stubCurrentUser() {
when(userService.findByUsernameIgnoreCase(USERNAME)).thenReturn(Optional.of(owner));
}
// ===================================================================
// listSessions
// ===================================================================
@Nested
@DisplayName("listSessions")
class ListSessions {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.listSessions(null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
assertEquals("Authentication required", response.getBody());
verify(workflowSessionService).ensureSigningEnabled();
verify(workflowSessionService, never()).listUserSessions(any());
}
@Test
@DisplayName("returns 200 with mapped sessions on success")
void okWithSessions() {
stubCurrentUser();
when(workflowSessionService.listUserSessions(owner)).thenReturn(List.of(newSession()));
ResponseEntity<?> response = controller.listSessions(principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertInstanceOf(List.class, response.getBody());
assertEquals(1, ((List<?>) response.getBody()).size());
}
@Test
@DisplayName("returns 500 when service throws unexpectedly")
void internalServerErrorOnFailure() {
stubCurrentUser();
when(workflowSessionService.listUserSessions(owner))
.thenThrow(new RuntimeException("db down"));
ResponseEntity<?> response = controller.listSessions(principal);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("Error listing sessions", response.getBody());
}
@Test
@DisplayName("propagates ResponseStatusException when signing disabled")
void signingDisabledPropagates() {
doThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "disabled"))
.when(workflowSessionService)
.ensureSigningEnabled();
assertThrows(ResponseStatusException.class, () -> controller.listSessions(principal));
}
}
// ===================================================================
// createSession
// ===================================================================
@Nested
@DisplayName("createSession")
class CreateSession {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() throws Exception {
MultipartFile file = mockFile();
WorkflowCreationRequest request = new WorkflowCreationRequest();
ResponseEntity<?> response = controller.createSession(file, request, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
assertEquals("Authentication required", response.getBody());
verify(workflowSessionService, never()).createSession(any(), any(), any());
}
@Test
@DisplayName("returns 200 with mapped session on success")
void okOnSuccess() throws Exception {
MultipartFile file = mockFile();
WorkflowCreationRequest request = new WorkflowCreationRequest();
stubCurrentUser();
when(workflowSessionService.createSession(owner, file, request))
.thenReturn(newSession());
ResponseEntity<?> response = controller.createSession(file, request, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
}
@Test
@DisplayName("returns 400 with message when creation fails")
void badRequestOnFailure() throws Exception {
MultipartFile file = mockFile();
WorkflowCreationRequest request = new WorkflowCreationRequest();
stubCurrentUser();
when(workflowSessionService.createSession(owner, file, request))
.thenThrow(new IllegalStateException("File is required"));
ResponseEntity<?> response = controller.createSession(file, request, principal);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("File is required", response.getBody());
}
private MultipartFile mockFile() {
MultipartFile file = org.mockito.Mockito.mock(MultipartFile.class);
return file;
}
}
// ===================================================================
// getSession
// ===================================================================
@Nested
@DisplayName("getSession")
class GetSession {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.getSession(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
assertEquals("Authentication required", response.getBody());
}
@Test
@DisplayName("returns 200 with mapped session for owner")
void okForOwner() {
stubCurrentUser();
when(workflowSessionService.getSessionForOwner(SESSION_ID, owner))
.thenReturn(newSession());
ResponseEntity<?> response = controller.getSession(SESSION_ID, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
}
@Test
@DisplayName("returns 403 when access is denied")
void forbiddenWhenDenied() {
stubCurrentUser();
when(workflowSessionService.getSessionForOwner(SESSION_ID, owner))
.thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "nope"));
ResponseEntity<?> response = controller.getSession(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
assertEquals("Access denied or session not found", response.getBody());
}
}
// ===================================================================
// deleteSession
// ===================================================================
@Nested
@DisplayName("deleteSession")
class DeleteSession {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.deleteSession(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
verify(workflowSessionService, never()).deleteSession(any(), any());
}
@Test
@DisplayName("returns 204 on successful delete")
void noContentOnSuccess() {
stubCurrentUser();
doNothing().when(workflowSessionService).deleteSession(SESSION_ID, owner);
ResponseEntity<?> response = controller.deleteSession(SESSION_ID, principal);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
verify(workflowSessionService).deleteSession(SESSION_ID, owner);
}
@Test
@DisplayName("returns 403 with reason when delete fails")
void forbiddenOnFailure() {
stubCurrentUser();
doThrow(new IllegalStateException("finalized"))
.when(workflowSessionService)
.deleteSession(SESSION_ID, owner);
ResponseEntity<?> response = controller.deleteSession(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
assertEquals("Cannot delete session: finalized", response.getBody());
}
}
// ===================================================================
// addParticipants
// ===================================================================
@Nested
@DisplayName("addParticipants")
class AddParticipants {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.addParticipants(SESSION_ID, List.of(), null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
verify(workflowSessionService, never()).addParticipants(any(), any(), any());
}
@Test
@DisplayName("returns 200 with updated session on success")
void okOnSuccess() {
List<ParticipantRequest> participants = List.of(new ParticipantRequest());
stubCurrentUser();
doNothing()
.when(workflowSessionService)
.addParticipants(SESSION_ID, participants, owner);
when(workflowSessionService.getSessionWithParticipantsForOwner(SESSION_ID, owner))
.thenReturn(newSession());
ResponseEntity<?> response =
controller.addParticipants(SESSION_ID, participants, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
verify(workflowSessionService).addParticipants(SESSION_ID, participants, owner);
}
@Test
@DisplayName("returns 403 with reason when add fails")
void forbiddenOnFailure() {
List<ParticipantRequest> participants = List.of(new ParticipantRequest());
stubCurrentUser();
doThrow(new IllegalStateException("inactive"))
.when(workflowSessionService)
.addParticipants(SESSION_ID, participants, owner);
ResponseEntity<?> response =
controller.addParticipants(SESSION_ID, participants, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
assertEquals("Cannot add participants: inactive", response.getBody());
}
}
// ===================================================================
// removeParticipant
// ===================================================================
@Nested
@DisplayName("removeParticipant")
class RemoveParticipant {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.removeParticipant(SESSION_ID, 5L, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
verify(workflowSessionService, never()).removeParticipant(any(), any(), any());
}
@Test
@DisplayName("returns 204 on successful removal")
void noContentOnSuccess() {
stubCurrentUser();
doNothing().when(workflowSessionService).removeParticipant(SESSION_ID, 5L, owner);
ResponseEntity<?> response = controller.removeParticipant(SESSION_ID, 5L, principal);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
verify(workflowSessionService).removeParticipant(SESSION_ID, 5L, owner);
}
@Test
@DisplayName("returns 403 with reason when removal fails")
void forbiddenOnFailure() {
stubCurrentUser();
doThrow(new IllegalStateException("not found"))
.when(workflowSessionService)
.removeParticipant(SESSION_ID, 5L, owner);
ResponseEntity<?> response = controller.removeParticipant(SESSION_ID, 5L, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
assertEquals("Cannot remove participant: not found", response.getBody());
}
}
// ===================================================================
// getSessionPdf
// ===================================================================
@Nested
@DisplayName("getSessionPdf")
class GetSessionPdf {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<byte[]> response = controller.getSessionPdf(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
@DisplayName("returns 200 with PDF bytes on success")
void okWithPdf() throws Exception {
byte[] pdf = new byte[] {1, 2, 3};
stubCurrentUser();
when(workflowSessionService.getSessionForOwner(SESSION_ID, owner))
.thenReturn(newSession());
when(workflowSessionService.getOriginalFile(SESSION_ID)).thenReturn(pdf);
ResponseEntity<byte[]> response = controller.getSessionPdf(SESSION_ID, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(pdf, response.getBody());
}
@Test
@DisplayName("returns 403 when retrieval fails")
void forbiddenOnFailure() throws Exception {
stubCurrentUser();
when(workflowSessionService.getSessionForOwner(SESSION_ID, owner))
.thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "denied"));
ResponseEntity<byte[]> response = controller.getSessionPdf(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
}
// ===================================================================
// finalizeSession
// ===================================================================
@Nested
@DisplayName("finalizeSession")
class FinalizeSession {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() throws Exception {
ResponseEntity<byte[]> response = controller.finalizeSession(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
verify(signingFinalizationService, never()).finalizeDocument(any(), any());
}
@Test
@DisplayName("returns 200 with signed PDF and runs the full finalize pipeline")
void okOnFullPipeline() throws Exception {
WorkflowSession session = newSession();
byte[] original = new byte[] {9};
byte[] signed = new byte[] {7, 7};
stubCurrentUser();
when(workflowSessionService.getSessionWithParticipantsForOwner(SESSION_ID, owner))
.thenReturn(session);
when(workflowSessionService.getOriginalFile(SESSION_ID)).thenReturn(original);
when(signingFinalizationService.finalizeDocument(session, original)).thenReturn(signed);
ResponseEntity<byte[]> response = controller.finalizeSession(SESSION_ID, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(signed, response.getBody());
verify(workflowSessionService)
.storeProcessedFile(eq(session), eq(signed), any(String.class));
verify(workflowSessionService).finalizeSession(SESSION_ID, owner);
verify(workflowSessionService).deleteOriginalFile(session);
verify(signingFinalizationService).clearSensitiveMetadata(session);
}
@Test
@DisplayName("derives the signed filename from the document name")
void filenameDerivedFromDocumentName() throws Exception {
WorkflowSession session = newSession();
session.setDocumentName("My Report.pdf");
stubCurrentUser();
when(workflowSessionService.getSessionWithParticipantsForOwner(SESSION_ID, owner))
.thenReturn(session);
when(workflowSessionService.getOriginalFile(SESSION_ID)).thenReturn(new byte[] {1});
when(signingFinalizationService.finalizeDocument(any(), any()))
.thenReturn(new byte[] {2});
controller.finalizeSession(SESSION_ID, principal);
verify(workflowSessionService)
.storeProcessedFile(eq(session), any(), eq("My Report_shared_signed.pdf"));
}
@Test
@DisplayName("returns 500 when finalization fails")
void internalServerErrorOnFailure() throws Exception {
stubCurrentUser();
when(workflowSessionService.getSessionWithParticipantsForOwner(SESSION_ID, owner))
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "missing"));
ResponseEntity<byte[]> response = controller.finalizeSession(SESSION_ID, principal);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
@Test
@DisplayName("swallows cleanup ResponseStatusException into 500 (caught by outer handler)")
void cleanupFailureBecomes500() throws Exception {
WorkflowSession session = newSession();
stubCurrentUser();
when(workflowSessionService.getSessionWithParticipantsForOwner(SESSION_ID, owner))
.thenReturn(session);
when(workflowSessionService.getOriginalFile(SESSION_ID)).thenReturn(new byte[] {1});
when(signingFinalizationService.finalizeDocument(any(), any()))
.thenReturn(new byte[] {2});
doThrow(new RuntimeException("cleanup boom"))
.when(signingFinalizationService)
.clearSensitiveMetadata(session);
ResponseEntity<byte[]> response = controller.finalizeSession(SESSION_ID, principal);
// The inner cleanup failure throws ResponseStatusException, which the outer
// try/catch converts into a plain 500.
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
// ===================================================================
// getSignedPdf
// ===================================================================
@Nested
@DisplayName("getSignedPdf")
class GetSignedPdf {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<byte[]> response = controller.getSignedPdf(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
@DisplayName("returns 404 when no processed file exists")
void notFoundWhenNull() throws Exception {
stubCurrentUser();
when(workflowSessionService.getProcessedFile(SESSION_ID, owner)).thenReturn(null);
ResponseEntity<byte[]> response = controller.getSignedPdf(SESSION_ID, principal);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNotNull(response.getBody());
}
@Test
@DisplayName("returns 200 with signed PDF when available")
void okWithSignedPdf() throws Exception {
byte[] signed = new byte[] {4, 5, 6};
stubCurrentUser();
when(workflowSessionService.getProcessedFile(SESSION_ID, owner)).thenReturn(signed);
when(workflowSessionService.getSessionForOwner(SESSION_ID, owner))
.thenReturn(newSession());
ResponseEntity<byte[]> response = controller.getSignedPdf(SESSION_ID, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(signed, response.getBody());
}
@Test
@DisplayName("returns 403 when retrieval throws")
void forbiddenOnFailure() throws Exception {
stubCurrentUser();
when(workflowSessionService.getProcessedFile(SESSION_ID, owner))
.thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "denied"));
ResponseEntity<byte[]> response = controller.getSignedPdf(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
}
// ===================================================================
// listSignRequests
// ===================================================================
@Nested
@DisplayName("listSignRequests")
class ListSignRequests {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.listSignRequests(null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
@DisplayName("returns 200 with sign request summaries")
void okWithSummaries() {
stubCurrentUser();
when(workflowSessionService.listSignRequests(owner)).thenReturn(List.of());
ResponseEntity<?> response = controller.listSignRequests(principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
}
@Test
@DisplayName("returns 500 with reason when listing fails")
void internalServerErrorOnFailure() {
stubCurrentUser();
when(workflowSessionService.listSignRequests(owner))
.thenThrow(new RuntimeException("boom"));
ResponseEntity<?> response = controller.listSignRequests(principal);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("Cannot list sign requests: boom", response.getBody());
}
}
// ===================================================================
// getSignRequestDetail
// ===================================================================
@Nested
@DisplayName("getSignRequestDetail")
class GetSignRequestDetail {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.getSignRequestDetail(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
@DisplayName("returns 200 with detail on success")
void okWithDetail() {
stubCurrentUser();
stirling.software.proprietary.workflow.dto.SignRequestDetailDTO detail =
new stirling.software.proprietary.workflow.dto.SignRequestDetailDTO();
when(workflowSessionService.getSignRequestDetail(SESSION_ID, owner)).thenReturn(detail);
ResponseEntity<?> response = controller.getSignRequestDetail(SESSION_ID, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(detail, response.getBody());
}
@Test
@DisplayName("returns 403 with reason when access denied")
void forbiddenOnFailure() {
stubCurrentUser();
when(workflowSessionService.getSignRequestDetail(SESSION_ID, owner))
.thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "denied"));
ResponseEntity<?> response = controller.getSignRequestDetail(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
}
// ===================================================================
// getSignRequestDocument
// ===================================================================
@Nested
@DisplayName("getSignRequestDocument")
class GetSignRequestDocument {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<byte[]> response = controller.getSignRequestDocument(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
@DisplayName("returns 200 with document bytes on success")
void okWithDocument() {
byte[] doc = new byte[] {1, 1, 1};
stubCurrentUser();
when(workflowSessionService.getSignRequestDocument(SESSION_ID, owner)).thenReturn(doc);
ResponseEntity<byte[]> response =
controller.getSignRequestDocument(SESSION_ID, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(doc, response.getBody());
}
@Test
@DisplayName("returns 403 when retrieval throws")
void forbiddenOnFailure() {
stubCurrentUser();
when(workflowSessionService.getSignRequestDocument(SESSION_ID, owner))
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "missing"));
ResponseEntity<byte[]> response =
controller.getSignRequestDocument(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
}
// ===================================================================
// signDocument
// ===================================================================
@Nested
@DisplayName("signDocument")
class SignDocument {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
SignDocumentRequest request = new SignDocumentRequest();
ResponseEntity<?> response = controller.signDocument(SESSION_ID, request, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
verify(workflowSessionService, never()).signDocument(any(), any(), any());
}
@Test
@DisplayName("returns 204 on successful sign")
void noContentOnSuccess() {
SignDocumentRequest request = new SignDocumentRequest();
stubCurrentUser();
doNothing().when(workflowSessionService).signDocument(SESSION_ID, owner, request);
ResponseEntity<?> response = controller.signDocument(SESSION_ID, request, principal);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
verify(workflowSessionService).signDocument(SESSION_ID, owner, request);
}
@Test
@DisplayName("returns 400 with message on IllegalArgumentException")
void badRequestOnIllegalArgument() {
SignDocumentRequest request = new SignDocumentRequest();
stubCurrentUser();
doThrow(new IllegalArgumentException("bad cert"))
.when(workflowSessionService)
.signDocument(SESSION_ID, owner, request);
ResponseEntity<?> response = controller.signDocument(SESSION_ID, request, principal);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("bad cert", response.getBody());
}
@Test
@DisplayName("returns 500 with reason on generic failure")
void internalServerErrorOnGenericFailure() {
SignDocumentRequest request = new SignDocumentRequest();
stubCurrentUser();
doThrow(new RuntimeException("storage failure"))
.when(workflowSessionService)
.signDocument(SESSION_ID, owner, request);
ResponseEntity<?> response = controller.signDocument(SESSION_ID, request, principal);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("Cannot sign document: storage failure", response.getBody());
}
}
// ===================================================================
// declineSignRequest
// ===================================================================
@Nested
@DisplayName("declineSignRequest")
class DeclineSignRequest {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<?> response = controller.declineSignRequest(SESSION_ID, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
verify(workflowSessionService, never()).declineSignRequest(any(), any());
}
@Test
@DisplayName("returns 204 on successful decline")
void noContentOnSuccess() {
stubCurrentUser();
doNothing().when(workflowSessionService).declineSignRequest(SESSION_ID, owner);
ResponseEntity<?> response = controller.declineSignRequest(SESSION_ID, principal);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
verify(workflowSessionService).declineSignRequest(SESSION_ID, owner);
}
@Test
@DisplayName("returns 403 with reason when decline fails")
void forbiddenOnFailure() {
stubCurrentUser();
doThrow(new IllegalStateException("already signed"))
.when(workflowSessionService)
.declineSignRequest(SESSION_ID, owner);
ResponseEntity<?> response = controller.declineSignRequest(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
assertEquals("Cannot decline sign request: already signed", response.getBody());
}
}
// ===================================================================
// validateCertificate
// ===================================================================
@Nested
@DisplayName("validateCertificate")
class ValidateCertificate {
@Test
@DisplayName("returns 401 when principal is null")
void unauthorizedWhenNoPrincipal() {
ResponseEntity<CertificateValidationResponse> response =
controller.validateCertificate("PKCS12", "pw", null, null, null);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
@DisplayName("throws 400 when no file provided for an upload cert type")
void badRequestWhenNoFile() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.validateCertificate(
"PKCS12", "pw", null, null, principal));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
@DisplayName(
"returns valid response for SERVER cert type (no file, validator returns null)")
void serverCertReturnsValidWithoutInfo() {
when(certificateSubmissionValidator.validateAndExtractInfo(any(), eq("SERVER"), any()))
.thenReturn(null);
ResponseEntity<CertificateValidationResponse> response =
controller.validateCertificate("SERVER", null, null, null, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
CertificateValidationResponse body = response.getBody();
assertNotNull(body);
assertTrue(body.valid());
assertNull(body.subjectName());
assertNull(body.error());
}
@Test
@DisplayName("returns populated valid response with cert info from p12 upload")
void validWithCertInfo() throws Exception {
MultipartFile p12 = org.mockito.Mockito.mock(MultipartFile.class);
when(p12.isEmpty()).thenReturn(false);
when(p12.getBytes()).thenReturn(new byte[] {1, 2, 3});
Date notBefore = new Date(1_000_000_000_000L);
Date notAfter = new Date(2_000_000_000_000L);
CertificateInfo info =
new CertificateInfo("CN=Alice", "CN=CA", notBefore, notAfter, true);
when(certificateSubmissionValidator.validateAndExtractInfo(
any(byte[].class), eq("PKCS12"), eq("pw")))
.thenReturn(info);
ResponseEntity<CertificateValidationResponse> response =
controller.validateCertificate("PKCS12", "pw", p12, null, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
CertificateValidationResponse body = response.getBody();
assertNotNull(body);
assertTrue(body.valid());
assertEquals("CN=Alice", body.subjectName());
assertEquals("CN=CA", body.issuerName());
assertTrue(body.selfSigned());
assertEquals(notAfter.toInstant().toString(), body.notAfter());
assertEquals(notBefore.toInstant().toString(), body.notBefore());
assertNull(body.error());
}
@Test
@DisplayName("reads jks file when provided and p12 is absent")
void usesJksFileWhenP12Absent() throws Exception {
MultipartFile jks = org.mockito.Mockito.mock(MultipartFile.class);
when(jks.isEmpty()).thenReturn(false);
when(jks.getBytes()).thenReturn(new byte[] {9});
when(certificateSubmissionValidator.validateAndExtractInfo(
any(byte[].class), eq("JKS"), any()))
.thenReturn(null);
ResponseEntity<CertificateValidationResponse> response =
controller.validateCertificate("JKS", null, null, jks, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody().valid());
verify(jks).getBytes();
}
@Test
@DisplayName("returns invalid response with reason when validator throws 400")
void invalidWhenValidatorThrows() throws Exception {
MultipartFile p12 = org.mockito.Mockito.mock(MultipartFile.class);
when(p12.isEmpty()).thenReturn(false);
when(p12.getBytes()).thenReturn(new byte[] {1});
when(certificateSubmissionValidator.validateAndExtractInfo(
any(byte[].class), eq("PKCS12"), any()))
.thenThrow(
new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Certificate has expired"));
ResponseEntity<CertificateValidationResponse> response =
controller.validateCertificate("PKCS12", "pw", p12, null, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
CertificateValidationResponse body = response.getBody();
assertNotNull(body);
assertEquals(false, body.valid());
assertEquals("Certificate has expired", body.error());
}
@Test
@DisplayName("returns invalid response when file read throws IOException")
void invalidWhenFileReadFails() throws Exception {
MultipartFile p12 = org.mockito.Mockito.mock(MultipartFile.class);
when(p12.isEmpty()).thenReturn(false);
when(p12.getBytes()).thenThrow(new IOException("read error"));
ResponseEntity<CertificateValidationResponse> response =
controller.validateCertificate("PKCS12", "pw", p12, null, principal);
assertEquals(HttpStatus.OK, response.getStatusCode());
CertificateValidationResponse body = response.getBody();
assertNotNull(body);
assertEquals(false, body.valid());
assertEquals("Failed to read certificate file", body.error());
}
}
// ===================================================================
// getCurrentUser (exercised via a handler) unknown user -> 401 path
// ===================================================================
@Nested
@DisplayName("current user resolution")
class CurrentUserResolution {
@Test
@DisplayName("listSessions surfaces unauthorized when username is unknown (500 wrapper)")
void unknownUserIsWrappedAsServerError() {
// getCurrentUser throws ResponseStatusException(401); listSessions' generic
// catch converts it into a 500 with a fixed body.
when(userService.findByUsernameIgnoreCase(USERNAME)).thenReturn(Optional.empty());
ResponseEntity<?> response = controller.listSessions(principal);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("Error listing sessions", response.getBody());
}
@Test
@DisplayName("getSessionPdf returns 403 when username is unknown")
void unknownUserOnPdfReturns403() {
when(userService.findByUsernameIgnoreCase(USERNAME)).thenReturn(Optional.empty());
ResponseEntity<byte[]> response = controller.getSessionPdf(SESSION_ID, principal);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
}
}
@@ -0,0 +1,697 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.security.KeyStore;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfSigningService;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Companion gap-coverage tests for {@link SigningFinalizationService}. The existing
* SigningFinalizationServiceTest only exercises {@code clearSensitiveMetadata}; this file drives
* the {@code finalizeDocument} pipeline to cover the wet-signature, summary-page, and
* digital-signature branches (keystore building, certificate-type dispatch, participant skipping).
*
* <p>A real (non-mocked) Jackson ObjectMapper is used so the private {@code
* extractCertificateSubmission}/{@code extractParticipantSignatureMetadata} JSON parsing runs for
* real. All slow boundaries (PDF load, signing, crypto keygen) are mocked: keystores are empty
* in-memory PKCS12 stores built without key generation, and the signing service is a mock.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SigningFinalizationServiceGapTest {
@Mock private WorkflowParticipantRepository participantRepository;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfSigningService pdfSigningService;
@Mock private MetadataEncryptionService metadataEncryptionService;
@Mock private ServerCertificateServiceInterface serverCertificateService;
@Mock private UserServerCertificateService userServerCertificateService;
// Real mapper so JSON tree parsing in extractCertificateSubmission works for real.
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private SigningFinalizationService service;
private static final byte[] ORIGINAL_PDF = "%PDF-1.4 original".getBytes();
private static final byte[] SIGNED_PDF = "%PDF-1.4 signed".getBytes();
@BeforeEach
void setUp() {
service =
new SigningFinalizationService(
participantRepository,
pdfDocumentFactory,
objectMapper,
pdfSigningService,
metadataEncryptionService,
serverCertificateService,
userServerCertificateService);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private WorkflowSession sessionWith(WorkflowParticipant... participants) {
WorkflowSession session = new WorkflowSession();
session.setSessionId("gap-session");
session.setDocumentName("contract.pdf");
User owner = new User();
owner.setUsername("owner@example.com");
session.setOwner(owner);
List<WorkflowParticipant> list = new ArrayList<>();
for (WorkflowParticipant p : participants) {
list.add(p);
}
session.setParticipants(list);
return session;
}
private WorkflowParticipant participant(Long id, ParticipantStatus status) {
WorkflowParticipant p = new WorkflowParticipant();
p.setId(id);
p.setStatus(status);
p.setEmail("p" + id + "@example.com");
p.setName("Participant " + id);
p.setParticipantMetadata(new HashMap<>());
return p;
}
/** Empty PKCS12 keystore — no key generation, instant to build. */
private KeyStore emptyP12() throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
return ks;
}
/** Fresh real one-page PDF document bytes (no rendering). */
private byte[] onePagePdf() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private Map<String, Object> certSubmissionMetadata(String certType) {
Map<String, Object> cert = new HashMap<>();
cert.put("certType", certType);
Map<String, Object> metadata = new HashMap<>();
metadata.put("certificateSubmission", cert);
return metadata;
}
// -------------------------------------------------------------------------
// finalizeDocument early-exit / participant skipping branches
// -------------------------------------------------------------------------
@Nested
@DisplayName("finalizeDocument participant dispatch")
class FinalizeDispatch {
@Test
@DisplayName("no wet signatures + no signed participants returns original bytes untouched")
void noWetSignatures_noSignedParticipants_returnsOriginal() throws Exception {
WorkflowParticipant pending = participant(1L, ParticipantStatus.PENDING);
WorkflowSession session = sessionWith(pending);
when(participantRepository.findById(1L)).thenReturn(Optional.of(pending));
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
assertThat(result).isEqualTo(ORIGINAL_PDF);
// Never loaded a PDDocument because there were no wet signatures.
verify(pdfDocumentFactory, never()).load(any(InputStream.class));
// No signing because the participant is not SIGNED.
verify(pdfSigningService, never())
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean());
}
@Test
@DisplayName("SIGNED participant missing from DB throws 500")
void signedParticipant_notFoundOnReload_throws500() {
WorkflowParticipant signed = participant(7L, ParticipantStatus.SIGNED);
WorkflowSession session = sessionWith(signed);
// First lookup (wet-signature pass) succeeds; the digital pass re-reads and must fail.
when(participantRepository.findById(7L))
.thenReturn(Optional.of(signed))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Participant not found: 7")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
@Test
@DisplayName(
"SIGNED participant with no certificateSubmission is skipped, returns original")
void signedParticipant_noCertSubmission_skipped() throws Exception {
WorkflowParticipant signed = participant(3L, ParticipantStatus.SIGNED);
WorkflowSession session = sessionWith(signed);
when(participantRepository.findById(3L)).thenReturn(Optional.of(signed));
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
assertThat(result).isEqualTo(ORIGINAL_PDF);
verify(pdfSigningService, never())
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean());
}
}
// -------------------------------------------------------------------------
// finalizeDocument SERVER certificate digital signing happy path
// -------------------------------------------------------------------------
@Nested
@DisplayName("digital signing via SERVER certificate")
class ServerCertSigning {
@Test
@DisplayName(
"SIGNED participant with SERVER cert invokes signing service and returns its output")
void serverCert_signsAndReturnsSignedBytes() throws Exception {
WorkflowParticipant signed = participant(5L, ParticipantStatus.SIGNED);
signed.setParticipantMetadata(certSubmissionMetadata("SERVER"));
WorkflowSession session = sessionWith(signed);
when(participantRepository.findById(5L)).thenReturn(Optional.of(signed));
when(serverCertificateService.isEnabled()).thenReturn(true);
when(serverCertificateService.hasServerCertificate()).thenReturn(true);
when(serverCertificateService.getServerKeyStore()).thenReturn(emptyP12());
when(serverCertificateService.getServerCertificatePassword()).thenReturn("serverpw");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean()))
.thenReturn(SIGNED_PDF);
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
assertThat(result).isEqualTo(SIGNED_PDF);
verify(pdfSigningService, times(1))
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean());
}
@Test
@DisplayName("default reason and password supplied to signing service when none provided")
void serverCert_defaultsReasonAndPassword() throws Exception {
WorkflowParticipant signed = participant(9L, ParticipantStatus.SIGNED);
signed.setName("Jane Doe");
signed.setParticipantMetadata(certSubmissionMetadata("SERVER"));
WorkflowSession session = sessionWith(signed);
when(participantRepository.findById(9L)).thenReturn(Optional.of(signed));
when(serverCertificateService.isEnabled()).thenReturn(true);
when(serverCertificateService.hasServerCertificate()).thenReturn(true);
when(serverCertificateService.getServerKeyStore()).thenReturn(emptyP12());
when(serverCertificateService.getServerCertificatePassword()).thenReturn("serverpw");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean()))
.thenReturn(SIGNED_PDF);
service.finalizeDocument(session, ORIGINAL_PDF);
ArgumentCaptor<char[]> pwCaptor = ArgumentCaptor.forClass(char[].class);
ArgumentCaptor<String> nameCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> reasonCaptor = ArgumentCaptor.forClass(String.class);
verify(pdfSigningService)
.signWithKeystore(
eq(ORIGINAL_PDF),
any(KeyStore.class),
pwCaptor.capture(),
anyBoolean(),
isNull(), // pageNumber null -> stays null (no -1 applied)
nameCaptor.capture(),
any(),
reasonCaptor.capture(),
anyBoolean());
assertThat(new String(pwCaptor.getValue())).isEqualTo("serverpw");
assertThat(nameCaptor.getValue()).isEqualTo("Jane Doe");
assertThat(reasonCaptor.getValue()).isEqualTo("Document Signing");
}
@Test
@DisplayName(
"includeSummaryPage suppresses the visible signature block (showSignature=false)")
void summaryPageEnabled_suppressesVisibleSignature() throws Exception {
WorkflowParticipant signed = participant(11L, ParticipantStatus.SIGNED);
signed.setParticipantMetadata(certSubmissionMetadata("SERVER"));
WorkflowSession session = sessionWith(signed);
Map<String, Object> wf = new HashMap<>();
wf.put("includeSummaryPage", true);
wf.put("showSignature", true);
session.setWorkflowMetadata(wf);
when(participantRepository.findById(11L)).thenReturn(Optional.of(signed));
when(serverCertificateService.isEnabled()).thenReturn(true);
when(serverCertificateService.hasServerCertificate()).thenReturn(true);
when(serverCertificateService.getServerKeyStore()).thenReturn(emptyP12());
when(serverCertificateService.getServerCertificatePassword()).thenReturn("pw");
// Summary page generation loads the original PDF.
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(inv -> Loader.loadPDF(onePagePdf()));
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean()))
.thenReturn(SIGNED_PDF);
service.finalizeDocument(session, ORIGINAL_PDF);
ArgumentCaptor<Boolean> showCaptor = ArgumentCaptor.forClass(Boolean.class);
verify(pdfSigningService)
.signWithKeystore(
any(),
any(),
any(),
showCaptor.capture(),
any(),
any(),
any(),
any(),
anyBoolean());
assertThat(showCaptor.getValue()).isFalse();
}
@Test
@DisplayName("pageNumber from session settings is converted to 0-indexed for signing")
void pageNumber_convertedToZeroIndexed() throws Exception {
WorkflowParticipant signed = participant(13L, ParticipantStatus.SIGNED);
signed.setParticipantMetadata(certSubmissionMetadata("SERVER"));
WorkflowSession session = sessionWith(signed);
Map<String, Object> wf = new HashMap<>();
wf.put("pageNumber", 3);
wf.put("showSignature", true);
session.setWorkflowMetadata(wf);
when(participantRepository.findById(13L)).thenReturn(Optional.of(signed));
when(serverCertificateService.isEnabled()).thenReturn(true);
when(serverCertificateService.hasServerCertificate()).thenReturn(true);
when(serverCertificateService.getServerKeyStore()).thenReturn(emptyP12());
when(serverCertificateService.getServerCertificatePassword()).thenReturn("pw");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean()))
.thenReturn(SIGNED_PDF);
service.finalizeDocument(session, ORIGINAL_PDF);
ArgumentCaptor<Integer> pageCaptor = ArgumentCaptor.forClass(Integer.class);
verify(pdfSigningService)
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
pageCaptor.capture(),
any(),
any(),
any(),
anyBoolean());
assertThat(pageCaptor.getValue()).isEqualTo(2); // 3 - 1
}
}
// -------------------------------------------------------------------------
// buildKeystore certificate-type dispatch (error branches)
// -------------------------------------------------------------------------
@Nested
@DisplayName("buildKeystore error branches via finalizeDocument")
class BuildKeystoreErrors {
private WorkflowSession singleSignedSessionWithCert(
String certType, Map<String, Object> extraCert) {
WorkflowParticipant signed = participant(20L, ParticipantStatus.SIGNED);
Map<String, Object> cert = new HashMap<>();
cert.put("certType", certType);
if (extraCert != null) {
cert.putAll(extraCert);
}
Map<String, Object> metadata = new HashMap<>();
metadata.put("certificateSubmission", cert);
signed.setParticipantMetadata(metadata);
when(participantRepository.findById(20L)).thenReturn(Optional.of(signed));
return sessionWith(signed);
}
@Test
@DisplayName("P12 cert type with no keystore bytes -> BAD_REQUEST")
void p12_missingKeystore_badRequest() {
WorkflowSession session = singleSignedSessionWithCert("P12", null);
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("P12 keystore data is required")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("JKS cert type with no keystore bytes -> BAD_REQUEST")
void jks_missingKeystore_badRequest() {
WorkflowSession session = singleSignedSessionWithCert("JKS", null);
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("JKS keystore data is required")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("P12 cert type with invalid keystore bytes -> BAD_REQUEST (open failure)")
void p12_invalidKeystore_badRequest() {
// base64 of garbage bytes so extractCertificateSubmission decodes them into p12Keystore
String junkB64 =
java.util.Base64.getEncoder().encodeToString("not-a-keystore".getBytes());
Map<String, Object> extra = new HashMap<>();
extra.put("p12Keystore", junkB64);
WorkflowSession session = singleSignedSessionWithCert("P12", extra);
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Failed to open P12 keystore")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("unknown cert type -> BAD_REQUEST invalid certificate type")
void unknownCertType_badRequest() {
WorkflowSession session = singleSignedSessionWithCert("SOMETHING_ELSE", null);
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Invalid certificate type: SOMETHING_ELSE")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("SERVER cert type but service reports disabled -> BAD_REQUEST")
void serverCert_disabled_badRequest() {
WorkflowSession session = singleSignedSessionWithCert("SERVER", null);
when(serverCertificateService.isEnabled()).thenReturn(false);
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Server certificate is not available")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("SERVER cert enabled but no server certificate present -> BAD_REQUEST")
void serverCert_enabledButNoCert_badRequest() {
WorkflowSession session = singleSignedSessionWithCert("SERVER", null);
when(serverCertificateService.isEnabled()).thenReturn(true);
when(serverCertificateService.hasServerCertificate()).thenReturn(false);
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Server certificate is not available")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("USER_CERT but participant has no authenticated user -> BAD_REQUEST")
void userCert_noUser_badRequest() {
WorkflowSession session = singleSignedSessionWithCert("USER_CERT", null);
// participant.getUser() is null by default
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("User certificate requires authenticated user")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
}
// -------------------------------------------------------------------------
// USER_CERT happy path + failure-to-generate branch
// -------------------------------------------------------------------------
@Nested
@DisplayName("digital signing via USER_CERT")
class UserCertSigning {
@Test
@DisplayName("USER_CERT with user invokes getOrCreate + getUserKeyStore and signs")
void userCert_signsWithUserKeystore() throws Exception {
WorkflowParticipant signed = participant(30L, ParticipantStatus.SIGNED);
signed.setParticipantMetadata(certSubmissionMetadata("USER_CERT"));
User user = new User();
user.setId(99L);
user.setUsername("signer");
signed.setUser(user);
WorkflowSession session = sessionWith(signed);
when(participantRepository.findById(30L)).thenReturn(Optional.of(signed));
when(userServerCertificateService.getUserKeyStore(99L)).thenReturn(emptyP12());
when(userServerCertificateService.getUserKeystorePassword(99L)).thenReturn("userpw");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean()))
.thenReturn(SIGNED_PDF);
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
assertThat(result).isEqualTo(SIGNED_PDF);
verify(userServerCertificateService).getOrCreateUserCertificate(99L);
verify(userServerCertificateService).getUserKeyStore(99L);
}
@Test
@DisplayName("USER_CERT keystore retrieval failure -> 500 wrapped ResponseStatusException")
void userCert_keystoreFailure_throws500() throws Exception {
WorkflowParticipant signed = participant(31L, ParticipantStatus.SIGNED);
signed.setParticipantMetadata(certSubmissionMetadata("USER_CERT"));
User user = new User();
user.setId(42L);
user.setUsername("signer");
signed.setUser(user);
WorkflowSession session = sessionWith(signed);
when(participantRepository.findById(31L)).thenReturn(Optional.of(signed));
when(userServerCertificateService.getUserKeyStore(42L))
.thenThrow(new IllegalStateException("boom"));
assertThatThrownBy(() -> service.finalizeDocument(session, ORIGINAL_PDF))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Failed to generate or retrieve user certificate")
.extracting(ex -> ((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// -------------------------------------------------------------------------
// Wet-signature pass out-of-range page is skipped, PDF still saved
// -------------------------------------------------------------------------
@Nested
@DisplayName("wet signature application")
class WetSignatures {
private Map<String, Object> wetSig(int page, double x, double y, double w, double h) {
Map<String, Object> sig = new HashMap<>();
sig.put("type", "image");
sig.put("data", "data:image/png;base64,iVBORw0KGgo=");
sig.put("page", page);
sig.put("x", x);
sig.put("y", y);
sig.put("width", w);
sig.put("height", h);
return sig;
}
@Test
@DisplayName("wet signature on an out-of-range page is skipped; document is reloaded/saved")
void wetSignature_pageOutOfRange_skippedButDocumentSaved() throws Exception {
WorkflowParticipant p = participant(40L, ParticipantStatus.VIEWED);
Map<String, Object> metadata = new HashMap<>();
// single page doc, but signature targets page index 5 -> skipped
metadata.put("wetSignatures", List.of(wetSig(5, 0.1, 0.1, 0.2, 0.1)));
p.setParticipantMetadata(metadata);
WorkflowSession session = sessionWith(p);
when(participantRepository.findById(40L)).thenReturn(Optional.of(p));
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(inv -> Loader.loadPDF(onePagePdf()));
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
// Document was loaded and re-saved (so result differs from the raw original bytes).
assertThat(result).isNotNull();
assertThat(result).isNotEqualTo(ORIGINAL_PDF);
verify(pdfDocumentFactory, times(1)).load(any(InputStream.class));
// Participant is VIEWED, not SIGNED -> no digital signing.
verify(pdfSigningService, never())
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean());
}
@Test
@DisplayName("wetSignatures stored as a non-List value is ignored, no PDF load")
void wetSignatures_notAList_ignored() throws Exception {
WorkflowParticipant p = participant(41L, ParticipantStatus.VIEWED);
Map<String, Object> metadata = new HashMap<>();
metadata.put("wetSignatures", "this-is-not-a-list");
p.setParticipantMetadata(metadata);
WorkflowSession session = sessionWith(p);
when(participantRepository.findById(41L)).thenReturn(Optional.of(p));
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
assertThat(result).isEqualTo(ORIGINAL_PDF);
// No valid wet signatures extracted -> never loaded a document.
verify(pdfDocumentFactory, never()).load(any(InputStream.class));
}
@Test
@DisplayName(
"reload failure during wet-signature extraction is swallowed, participant skipped")
void wetSignature_reloadFails_skipped() throws Exception {
WorkflowParticipant p = participant(42L, ParticipantStatus.VIEWED);
WorkflowSession session = sessionWith(p);
// Wet-signature pass reload returns empty -> RuntimeException caught internally.
when(participantRepository.findById(42L)).thenReturn(Optional.empty());
byte[] result = service.finalizeDocument(session, ORIGINAL_PDF);
assertThat(result).isEqualTo(ORIGINAL_PDF);
verify(pdfDocumentFactory, never()).load(any(InputStream.class));
}
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata empty participant list edge
// -------------------------------------------------------------------------
@Test
@DisplayName("clearSensitiveMetadata with no participants is a no-op")
void clearSensitiveMetadata_emptySession_noSave() {
WorkflowSession session = sessionWith();
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
}