Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6baf02e630 |
+932
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+417
@@ -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);
|
||||
}
|
||||
}
|
||||
+729
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1019
File diff suppressed because it is too large
Load Diff
+603
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+566
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+291
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+631
@@ -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:");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+901
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -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);
|
||||
}
|
||||
}
|
||||
+304
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1595
File diff suppressed because it is too large
Load Diff
+513
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+392
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+568
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+413
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+548
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1432
File diff suppressed because it is too large
Load Diff
+579
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+679
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+554
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1293
File diff suppressed because it is too large
Load Diff
+991
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+697
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user