mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40d180d665 | ||
|
|
8a9c5baceb | ||
|
|
1ab7cc3374 | ||
|
|
e461cfbd9b | ||
|
|
d8964c7322 | ||
|
|
803dadf38e | ||
|
|
5c86ab2dae | ||
|
|
f06d34fbe5 | ||
|
|
349280cd4b | ||
|
|
a61af74590 | ||
|
|
3553aced94 | ||
|
|
d7f5ad4a02 | ||
|
|
1a15f2256c | ||
|
|
53a9271fd7 | ||
|
|
0749f5a7bf | ||
|
|
c3c3e6e7eb | ||
|
|
7fa53d7c31 | ||
|
|
dd738efbe6 | ||
|
|
c37340dd33 | ||
|
|
b8ad494d66 | ||
|
|
ff194d741b | ||
|
|
749d4c6829 | ||
|
|
4c95d854e4 | ||
|
|
36d58484a9 | ||
|
|
eadf6700e2 | ||
|
|
e817a5cdf6 | ||
|
|
af99dcb38a | ||
|
|
a1f20206d0 | ||
|
|
0533a8e7fb | ||
|
|
6e4c11fef2 |
+135
-19
@@ -25,6 +25,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private static final int MAX_ACTIVE_SESSIONS = 100;
|
||||
private static final int MAX_FILES_PER_SESSION = 20;
|
||||
private static final int MAX_UPLOADS_PER_SESSION = 30;
|
||||
private static final long MAX_FILE_SIZE_BYTES = 25L * 1024 * 1024;
|
||||
private static final long MAX_SESSION_STORAGE_BYTES = 100L * 1024 * 1024;
|
||||
private static final long MAX_TOTAL_STORAGE_BYTES = 500L * 1024 * 1024;
|
||||
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
@@ -45,18 +51,22 @@ public class MobileScannerService {
|
||||
* @param sessionId Unique session identifier
|
||||
* @return SessionInfo with creation time and expiry
|
||||
*/
|
||||
public SessionInfo createSession(String sessionId) {
|
||||
public synchronized SessionInfo createSession(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData existingSession = activeSessions.get(sessionId);
|
||||
if (existingSession != null) {
|
||||
return toSessionInfo(existingSession);
|
||||
}
|
||||
if (activeSessions.size() >= MAX_ACTIVE_SESSIONS) {
|
||||
throw new SessionLimitExceededException("Too many active mobile scanner sessions");
|
||||
}
|
||||
|
||||
SessionData session = new SessionData(sessionId);
|
||||
activeSessions.put(sessionId, session);
|
||||
|
||||
log.info("Created mobile scanner session: {}", sessionId);
|
||||
return new SessionInfo(
|
||||
sessionId,
|
||||
session.createdAt,
|
||||
session.createdAt + SESSION_TIMEOUT_MS,
|
||||
SESSION_TIMEOUT_MS);
|
||||
return toSessionInfo(session);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +75,7 @@ public class MobileScannerService {
|
||||
* @param sessionId Session identifier to validate
|
||||
* @return SessionInfo if valid, null if invalid/expired
|
||||
*/
|
||||
public SessionInfo validateSession(String sessionId) {
|
||||
public synchronized SessionInfo validateSession(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return null;
|
||||
@@ -91,21 +101,29 @@ public class MobileScannerService {
|
||||
* @param files Files to upload
|
||||
* @throws IOException If file storage fails
|
||||
*/
|
||||
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
|
||||
public synchronized void uploadFiles(String sessionId, List<MultipartFile> files)
|
||||
throws IOException {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session =
|
||||
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
throw new SessionNotFoundException("Session not found or expired: " + sessionId);
|
||||
}
|
||||
if (System.currentTimeMillis() - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
|
||||
deleteSession(sessionId);
|
||||
throw new SessionNotFoundException("Session not found or expired: " + sessionId);
|
||||
}
|
||||
|
||||
List<MultipartFile> nonEmptyFiles = files.stream().filter(file -> !file.isEmpty()).toList();
|
||||
session.recordUploadAttempt();
|
||||
validateUploadLimits(session, nonEmptyFiles);
|
||||
|
||||
// Create session directory
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Files.createDirectories(sessionDir);
|
||||
|
||||
// Save each file
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (MultipartFile file : nonEmptyFiles) {
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
@@ -156,11 +174,15 @@ public class MobileScannerService {
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata, or empty list if session doesn't exist
|
||||
*/
|
||||
public List<FileMetadata> getSessionFiles(String sessionId) {
|
||||
public synchronized List<FileMetadata> getSessionFiles(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (isExpired(session)) {
|
||||
deleteSession(sessionId);
|
||||
return List.of();
|
||||
}
|
||||
session.updateLastAccess();
|
||||
return new ArrayList<>(session.getFiles());
|
||||
}
|
||||
@@ -173,11 +195,15 @@ public class MobileScannerService {
|
||||
* @return File path
|
||||
* @throws IOException If file not found or session doesn't exist
|
||||
*/
|
||||
public Path getFile(String sessionId, String filename) throws IOException {
|
||||
public synchronized Path getFile(String sessionId, String filename) throws IOException {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
throw new IOException("Session not found: " + sessionId);
|
||||
}
|
||||
if (isExpired(session)) {
|
||||
deleteSession(sessionId);
|
||||
throw new IOException("Session expired: " + sessionId);
|
||||
}
|
||||
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
if (!Files.exists(filePath)) {
|
||||
@@ -196,7 +222,7 @@ public class MobileScannerService {
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
public void deleteFileAfterDownload(String sessionId, String filename) {
|
||||
public synchronized void deleteFileAfterDownload(String sessionId, String filename) {
|
||||
try {
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
Files.deleteIfExists(filePath);
|
||||
@@ -218,7 +244,7 @@ public class MobileScannerService {
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
*/
|
||||
public void deleteSession(String sessionId) {
|
||||
public synchronized void deleteSession(String sessionId) {
|
||||
SessionData session = activeSessions.remove(sessionId);
|
||||
if (session != null) {
|
||||
try {
|
||||
@@ -255,7 +281,7 @@ public class MobileScannerService {
|
||||
|
||||
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
public void cleanupExpiredSessions() {
|
||||
public synchronized void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expiredSessions = new ArrayList<>();
|
||||
|
||||
@@ -282,6 +308,51 @@ public class MobileScannerService {
|
||||
}
|
||||
}
|
||||
|
||||
private SessionInfo toSessionInfo(SessionData session) {
|
||||
return new SessionInfo(
|
||||
session.sessionId,
|
||||
session.createdAt,
|
||||
session.getLastAccessTime() + SESSION_TIMEOUT_MS,
|
||||
SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private void validateUploadLimits(SessionData session, List<MultipartFile> files)
|
||||
throws UploadSizeLimitExceededException {
|
||||
if (session.getFiles().size() + files.size() > MAX_FILES_PER_SESSION) {
|
||||
throw new UploadSizeLimitExceededException("Too many files in scanner session");
|
||||
}
|
||||
|
||||
long uploadSize = 0;
|
||||
for (MultipartFile file : files) {
|
||||
if (file.getSize() > MAX_FILE_SIZE_BYTES) {
|
||||
throw new UploadSizeLimitExceededException(
|
||||
"Mobile scanner file exceeds the maximum size");
|
||||
}
|
||||
try {
|
||||
uploadSize = Math.addExact(uploadSize, file.getSize());
|
||||
} catch (ArithmeticException e) {
|
||||
throw new UploadSizeLimitExceededException("Mobile scanner upload is too large");
|
||||
}
|
||||
}
|
||||
|
||||
if (session.getStoredBytes() + uploadSize > MAX_SESSION_STORAGE_BYTES) {
|
||||
throw new UploadSizeLimitExceededException(
|
||||
"Mobile scanner session storage quota exceeded");
|
||||
}
|
||||
if (getTotalStoredBytes() + uploadSize > MAX_TOTAL_STORAGE_BYTES) {
|
||||
throw new StorageCapacityExceededException(
|
||||
"Mobile scanner temporary storage quota exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
private long getTotalStoredBytes() {
|
||||
return activeSessions.values().stream().mapToLong(SessionData::getStoredBytes).sum();
|
||||
}
|
||||
|
||||
private boolean isExpired(SessionData session) {
|
||||
return System.currentTimeMillis() - session.getLastAccessTime() > SESSION_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
|
||||
@@ -401,6 +472,36 @@ public class MobileScannerService {
|
||||
}
|
||||
}
|
||||
|
||||
public static class SessionNotFoundException extends IOException {
|
||||
public SessionNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class UploadSizeLimitExceededException extends IOException {
|
||||
public UploadSizeLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class StorageCapacityExceededException extends UploadSizeLimitExceededException {
|
||||
public StorageCapacityExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class UploadRateLimitExceededException extends IOException {
|
||||
public UploadRateLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class SessionLimitExceededException extends IllegalStateException {
|
||||
public SessionLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Session data tracking */
|
||||
private static class SessionData {
|
||||
private final String sessionId;
|
||||
@@ -408,6 +509,8 @@ public class MobileScannerService {
|
||||
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
|
||||
private final long createdAt;
|
||||
private long lastAccessTime;
|
||||
private int uploadAttempts;
|
||||
private long storedBytes;
|
||||
|
||||
public SessionData(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
@@ -418,6 +521,7 @@ public class MobileScannerService {
|
||||
public void addFile(FileMetadata file) {
|
||||
files.add(file);
|
||||
downloadedFiles.put(file.getFilename(), false);
|
||||
storedBytes += file.getSize();
|
||||
}
|
||||
|
||||
public List<FileMetadata> getFiles() {
|
||||
@@ -440,5 +544,17 @@ public class MobileScannerService {
|
||||
public long getLastAccessTime() {
|
||||
return lastAccessTime;
|
||||
}
|
||||
|
||||
public long getStoredBytes() {
|
||||
return storedBytes;
|
||||
}
|
||||
|
||||
public void recordUploadAttempt() throws UploadRateLimitExceededException {
|
||||
if (uploadAttempts >= MAX_UPLOADS_PER_SESSION) {
|
||||
throw new UploadRateLimitExceededException(
|
||||
"Mobile scanner upload rate limit exceeded");
|
||||
}
|
||||
uploadAttempts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** Size validation and bounded reads for certificate credentials supplied as multipart files. */
|
||||
public final class CertificateFileUtils {
|
||||
|
||||
public static final long MAX_CERTIFICATE_FILE_SIZE_BYTES = 5L * 1024 * 1024;
|
||||
|
||||
private CertificateFileUtils() {}
|
||||
|
||||
public static void validateSize(MultipartFile file) {
|
||||
if (file != null && !file.isEmpty() && file.getSize() > MAX_CERTIFICATE_FILE_SIZE_BYTES) {
|
||||
throw certificateFileTooLarge();
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] read(MultipartFile file) throws IOException {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
validateSize(file);
|
||||
|
||||
byte[] bytes = file.getBytes();
|
||||
if (bytes.length > MAX_CERTIFICATE_FILE_SIZE_BYTES) {
|
||||
throw certificateFileTooLarge();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static ResponseStatusException certificateFileTooLarge() {
|
||||
return new ResponseStatusException(
|
||||
HttpStatus.CONTENT_TOO_LARGE,
|
||||
"Certificate credential file exceeds the 5 MiB limit");
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public class RequestUriUtils {
|
||||
|
||||
// API routes are never static except for the public status endpoint
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return normalizedUri.startsWith("/api/v1/info/status");
|
||||
return matchesPathOrChild(normalizedUri, "/api/v1/info/status");
|
||||
}
|
||||
|
||||
// Well-known static asset directories (backend + React build artifacts)
|
||||
@@ -69,7 +69,7 @@ public class RequestUriUtils {
|
||||
// cookie, so the server can't authenticate the navigation itself). The
|
||||
// portal gates access via its own auth gate + RequirePortalAccess, and its
|
||||
// data APIs stay protected, so serving the shell pre-auth is safe.
|
||||
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
|
||||
if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -180,32 +180,33 @@ public class RequestUriUtils {
|
||||
: requestURI;
|
||||
|
||||
// Public auth endpoints that don't require authentication
|
||||
return trimmedUri.startsWith("/login")
|
||||
return matchesPathOrChild(trimmedUri, "/login")
|
||||
|| trimmedUri.startsWith("/auth/")
|
||||
|| trimmedUri.startsWith("/oauth2")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| matchesPathOrChild(trimmedUri, "/oauth2")
|
||||
|| matchesPathOrChild(trimmedUri, "/saml2")
|
||||
|| trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback
|
||||
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
|| "/api/v1/auth/login".equals(trimmedUri)
|
||||
|| "/api/v1/auth/refresh".equals(trimmedUri)
|
||||
|| "/api/v1/auth/logout".equals(trimmedUri)
|
||||
|| "/api/v1/proprietary/ui-data/login"
|
||||
.equals(trimmedUri) // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| matchesPathOrChild(trimmedUri, "/api/v1/invite/validate")
|
||||
|| matchesPathOrChild(trimmedUri, "/api/v1/invite/accept")
|
||||
// Health Endpoints
|
||||
|| trimmedUri.startsWith("/actuator/health")
|
||||
|| trimmedUri.startsWith("/health")
|
||||
|| trimmedUri.startsWith("/healthz")
|
||||
|| trimmedUri.startsWith("/liveness")
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| matchesPathOrChild(trimmedUri, "/actuator/health")
|
||||
|| matchesPathOrChild(trimmedUri, "/health")
|
||||
|| matchesPathOrChild(trimmedUri, "/healthz")
|
||||
|| matchesPathOrChild(trimmedUri, "/liveness")
|
||||
|| matchesPathOrChild(trimmedUri, "/readiness")
|
||||
// Mobile scanner sessions are intentionally public so desktop and phone clients
|
||||
// can exchange files without a login round-trip.
|
||||
|| trimmedUri.startsWith("/api/v1/mobile-scanner/")
|
||||
|| trimmedUri.startsWith("/api/v1/webhooks/")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| matchesPathOrChild(trimmedUri, "/v1/api-docs")
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
|
||||
// Share-link SPA bootstrap; data APIs remain protected
|
||||
@@ -218,4 +219,9 @@ public class RequestUriUtils {
|
||||
}
|
||||
return requestURI;
|
||||
}
|
||||
|
||||
/** Matches one endpoint exactly, or a legitimate path-variable/sub-resource below it. */
|
||||
private static boolean matchesPathOrChild(String requestUri, String endpoint) {
|
||||
return requestUri.equals(endpoint) || requestUri.startsWith(endpoint + "/");
|
||||
}
|
||||
}
|
||||
|
||||
+78
-5
@@ -47,6 +47,15 @@ class MobileScannerServiceTest {
|
||||
return new MockMultipartFile("file", name, "text/plain", new byte[0]);
|
||||
}
|
||||
|
||||
private MultipartFile fileWithReportedSize(String name, long reportedSize) {
|
||||
return new MockMultipartFile("file", name, "text/plain", new byte[] {1}) {
|
||||
@Override
|
||||
public long getSize() {
|
||||
return reportedSize;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSession")
|
||||
class CreateSession {
|
||||
@@ -95,6 +104,18 @@ class MobileScannerServiceTest {
|
||||
void acceptsValidChars() {
|
||||
assertNotNull(service.createSession("ABC-def-123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("limits the number of active sessions")
|
||||
void limitsActiveSessions() {
|
||||
for (int index = 0; index < 100; index++) {
|
||||
service.createSession("session-" + index);
|
||||
}
|
||||
|
||||
assertThrows(
|
||||
MobileScannerService.SessionLimitExceededException.class,
|
||||
() -> service.createSession("one-too-many"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -156,12 +177,14 @@ class MobileScannerServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
|
||||
void autoCreatesSession() throws IOException {
|
||||
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
|
||||
@DisplayName("rejects uploads to an unregistered session ID")
|
||||
void rejectsUnknownSession() {
|
||||
assertThrows(
|
||||
MobileScannerService.SessionNotFoundException.class,
|
||||
() -> service.uploadFiles("new-session", List.of(file("a.txt", "data"))));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("new-session");
|
||||
assertEquals(1, metas.size());
|
||||
assertTrue(service.getSessionFiles("new-session").isEmpty());
|
||||
assertFalse(Files.exists(tempDir.resolve("new-session")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -236,6 +259,56 @@ class MobileScannerServiceTest {
|
||||
|
||||
assertTrue(service.getSessionFiles("up6").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("limits upload attempts per session")
|
||||
void limitsUploadAttempts() throws IOException {
|
||||
service.createSession("limited");
|
||||
for (int attempt = 0; attempt < 30; attempt++) {
|
||||
service.uploadFiles("limited", List.of());
|
||||
}
|
||||
|
||||
assertThrows(
|
||||
MobileScannerService.UploadRateLimitExceededException.class,
|
||||
() -> service.uploadFiles("limited", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("limits the number of files per session")
|
||||
void limitsFilesPerSession() throws IOException {
|
||||
service.createSession("many-files");
|
||||
for (int index = 0; index < 20; index++) {
|
||||
service.uploadFiles("many-files", List.of(file("file-" + index, "x")));
|
||||
}
|
||||
|
||||
assertThrows(
|
||||
MobileScannerService.UploadSizeLimitExceededException.class,
|
||||
() -> service.uploadFiles("many-files", List.of(file("one-too-many", "x"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enforces the global temporary storage quota")
|
||||
void enforcesGlobalStorageQuota() throws IOException {
|
||||
long twentyFiveMiB = 25L * 1024 * 1024;
|
||||
for (int sessionIndex = 0; sessionIndex < 5; sessionIndex++) {
|
||||
String sessionId = "quota-" + sessionIndex;
|
||||
service.createSession(sessionId);
|
||||
for (int fileIndex = 0; fileIndex < 4; fileIndex++) {
|
||||
service.uploadFiles(
|
||||
sessionId,
|
||||
List.of(
|
||||
fileWithReportedSize(
|
||||
"file-" + fileIndex + ".jpg", twentyFiveMiB)));
|
||||
}
|
||||
}
|
||||
|
||||
service.createSession("over-quota");
|
||||
assertThrows(
|
||||
MobileScannerService.StorageCapacityExceededException.class,
|
||||
() ->
|
||||
service.uploadFiles(
|
||||
"over-quota", List.of(fileWithReportedSize("extra.jpg", 1))));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class CertificateFileUtilsTest {
|
||||
|
||||
@Test
|
||||
void rejectsActualContentLargerThanDeclaredSize() throws Exception {
|
||||
MultipartFile deceptiveFile = mock(MultipartFile.class);
|
||||
when(deceptiveFile.isEmpty()).thenReturn(false);
|
||||
when(deceptiveFile.getSize()).thenReturn(1L);
|
||||
when(deceptiveFile.getBytes())
|
||||
.thenReturn(
|
||||
new byte
|
||||
[Math.toIntExact(
|
||||
CertificateFileUtils
|
||||
.MAX_CERTIFICATE_FILE_SIZE_BYTES)
|
||||
+ 1]);
|
||||
|
||||
assertThatThrownBy(() -> CertificateFileUtils.read(deceptiveFile))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
error ->
|
||||
org.assertj.core.api.Assertions.assertThat(
|
||||
((ResponseStatusException) error)
|
||||
.getStatusCode()
|
||||
.value())
|
||||
.isEqualTo(413));
|
||||
verify(deceptiveFile).getBytes();
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,38 @@ class RequestUriUtilsTest {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_rejectsLookalikeAuthPaths() {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/auth/login-admin", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/auth/refresh-token", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/invite/acceptance", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/actuator/healthz-extra", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/oauth2-admin", ""));
|
||||
assertFalse(RequestUriUtils.isStaticResource("/api/v1/info/status-details"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_allMobileScannerRoutesArePublic() {
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/mobile-scanner/validate-session/session-id", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/mobile-scanner/upload/session-id", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/mobile-scanner/create-session/session-id", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/mobile-scanner/files/session-id", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/mobile-scanner/download/session-id/file.jpg", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/mobile-scanner/session/session-id", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_webhookReceiver() {
|
||||
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
|
||||
|
||||
+5
-7
@@ -5,6 +5,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -46,13 +47,10 @@ public class ConfigController {
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
UserServiceInterface userService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ShowAdminInterface showAdmin,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ShowAdminInterface showAdmin,
|
||||
@Autowired(required = false)
|
||||
stirling.software.common.service.LicenseServiceInterface licenseService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
|
||||
+25
-3
@@ -34,6 +34,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.MobileScannerService;
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
import stirling.software.common.service.MobileScannerService.SessionLimitExceededException;
|
||||
import stirling.software.common.service.MobileScannerService.SessionNotFoundException;
|
||||
import stirling.software.common.service.MobileScannerService.StorageCapacityExceededException;
|
||||
import stirling.software.common.service.MobileScannerService.UploadRateLimitExceededException;
|
||||
import stirling.software.common.service.MobileScannerService.UploadSizeLimitExceededException;
|
||||
|
||||
/**
|
||||
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
|
||||
@@ -45,8 +50,8 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
@Tag(
|
||||
name = "Mobile Scanner",
|
||||
description =
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
|
||||
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. Files are"
|
||||
+ " temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class MobileScannerController {
|
||||
@@ -125,6 +130,10 @@ public class MobileScannerController {
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid session creation request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (SessionLimitExceededException e) {
|
||||
log.warn("Mobile scanner session limit reached");
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +228,18 @@ public class MobileScannerController {
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (SessionNotFoundException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (UploadRateLimitExceededException e) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (StorageCapacityExceededException e) {
|
||||
return ResponseEntity.status(HttpStatus.INSUFFICIENT_STORAGE)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (UploadSizeLimitExceededException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to upload files for session: {}", sessionId, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
@@ -271,7 +292,8 @@ public class MobileScannerController {
|
||||
@Operation(
|
||||
summary = "Download a specific file",
|
||||
description =
|
||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||
"Download a file that was uploaded to a session. File is automatically deleted"
|
||||
+ " after download.")
|
||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||
|
||||
+16
-4
@@ -83,6 +83,7 @@ import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.util.CertificateFileUtils;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
@@ -204,6 +205,11 @@ public class CertSignController {
|
||||
"certificate type");
|
||||
}
|
||||
|
||||
CertificateFileUtils.validateSize(privateKeyFile);
|
||||
CertificateFileUtils.validateSize(certFile);
|
||||
CertificateFileUtils.validateSize(p12File);
|
||||
CertificateFileUtils.validateSize(jksfile);
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
Provider signingProvider = null;
|
||||
@@ -219,8 +225,10 @@ public class CertSignController {
|
||||
certFile, "PEM certificate", "certificate file is required");
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(null);
|
||||
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
|
||||
Certificate cert = (Certificate) getCertificateFromPEM(certFile.getBytes());
|
||||
byte[] privateKeyBytes = CertificateFileUtils.read(privateKeyFile);
|
||||
byte[] certificateBytes = CertificateFileUtils.read(certFile);
|
||||
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyBytes, password);
|
||||
Certificate cert = (Certificate) getCertificateFromPEM(certificateBytes);
|
||||
ks.setKeyEntry(
|
||||
"alias", privateKey, password.toCharArray(), new Certificate[] {cert});
|
||||
break;
|
||||
@@ -230,14 +238,18 @@ public class CertSignController {
|
||||
validateFilePresent(
|
||||
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
|
||||
ks = KeyStore.getInstance("PKCS12");
|
||||
ks.load(p12File.getInputStream(), password.toCharArray());
|
||||
ks.load(
|
||||
new ByteArrayInputStream(CertificateFileUtils.read(p12File)),
|
||||
password.toCharArray());
|
||||
break;
|
||||
case "JKS":
|
||||
jksfile =
|
||||
validateFilePresent(
|
||||
jksfile, "JKS keystore", "JKS keystore file is required");
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
ks.load(
|
||||
new ByteArrayInputStream(CertificateFileUtils.read(jksfile)),
|
||||
password.toCharArray());
|
||||
break;
|
||||
case "SERVER":
|
||||
if (serverCertificateService == null) {
|
||||
|
||||
+3
-2
@@ -49,6 +49,7 @@ import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CertificateFileUtils;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
@@ -93,8 +94,8 @@ public class ValidateSignatureController {
|
||||
// Load custom certificate if provided
|
||||
X509Certificate customCert = null;
|
||||
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
|
||||
try (ByteArrayInputStream certStream =
|
||||
new ByteArrayInputStream(request.getCertFile().getBytes())) {
|
||||
byte[] certificateBytes = CertificateFileUtils.read(request.getCertFile());
|
||||
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certificateBytes)) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
customCert = (X509Certificate) cf.generateCertificate(certStream);
|
||||
} catch (CertificateException e) {
|
||||
|
||||
@@ -30,6 +30,7 @@ public class SharedSignatureService {
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private static final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SharedSignatureService(ObjectMapper objectMapper) {
|
||||
@@ -157,10 +158,17 @@ public class SharedSignatureService {
|
||||
// Extract and save image data
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) {
|
||||
throw new IllegalArgumentException("Signature data too large");
|
||||
}
|
||||
// Extract base64 data
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||
|
||||
if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("Signature image too large");
|
||||
}
|
||||
|
||||
// Determine and validate file extension from data URL
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf('/') + 1);
|
||||
|
||||
+47
@@ -24,6 +24,9 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.MobileScannerService;
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
import stirling.software.common.service.MobileScannerService.SessionInfo;
|
||||
import stirling.software.common.service.MobileScannerService.SessionNotFoundException;
|
||||
import stirling.software.common.service.MobileScannerService.UploadRateLimitExceededException;
|
||||
import stirling.software.common.service.MobileScannerService.UploadSizeLimitExceededException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MobileScannerControllerTest {
|
||||
@@ -199,6 +202,50 @@ class MobileScannerControllerTest {
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFiles_whenSessionDoesNotExist_returns404() throws Exception {
|
||||
enableMobileScanner();
|
||||
List<MultipartFile> files =
|
||||
List.of(new MockMultipartFile("files", "scan.jpg", "image/jpeg", new byte[] {1}));
|
||||
doThrow(new SessionNotFoundException("Session not found"))
|
||||
.when(mobileScannerService)
|
||||
.uploadFiles(eq("unknown"), any());
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.uploadFiles("unknown", files);
|
||||
|
||||
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFiles_whenRateLimited_returns429() throws Exception {
|
||||
enableMobileScanner();
|
||||
List<MultipartFile> files =
|
||||
List.of(new MockMultipartFile("files", "scan.jpg", "image/jpeg", new byte[] {1}));
|
||||
doThrow(new UploadRateLimitExceededException("Rate limit exceeded"))
|
||||
.when(mobileScannerService)
|
||||
.uploadFiles(eq("test-session"), any());
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.uploadFiles("test-session", files);
|
||||
|
||||
assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFiles_whenQuotaExceeded_returns413() throws Exception {
|
||||
enableMobileScanner();
|
||||
List<MultipartFile> files =
|
||||
List.of(new MockMultipartFile("files", "scan.jpg", "image/jpeg", new byte[] {1}));
|
||||
doThrow(new UploadSizeLimitExceededException("Quota exceeded"))
|
||||
.when(mobileScannerService)
|
||||
.uploadFiles(eq("test-session"), any());
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.uploadFiles("test-session", files);
|
||||
|
||||
assertEquals(HttpStatus.CONTENT_TOO_LARGE, response.getStatusCode());
|
||||
}
|
||||
|
||||
// --- getSessionFiles tests ---
|
||||
|
||||
@Test
|
||||
|
||||
+25
@@ -7,6 +7,9 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
@@ -29,12 +32,14 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CertificateFileUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@@ -233,6 +238,26 @@ class CertSignControllerTest {
|
||||
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedCredentialIsRejectedBeforeReadingIt() throws Exception {
|
||||
MultipartFile oversizedKey = mock(MultipartFile.class);
|
||||
when(oversizedKey.isEmpty()).thenReturn(false);
|
||||
when(oversizedKey.getSize())
|
||||
.thenReturn(CertificateFileUtils.MAX_CERTIFICATE_FILE_SIZE_BYTES + 1);
|
||||
|
||||
SignPDFWithCertRequest request = new SignPDFWithCertRequest();
|
||||
request.setCertType("PEM");
|
||||
request.setPrivateKeyFile(oversizedKey);
|
||||
|
||||
ResponseStatusException exception =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> certSignController.signPDFWithCert(request, httpRequest));
|
||||
|
||||
assertTrue(exception.getStatusCode().value() == 413);
|
||||
verify(oversizedKey, never()).getBytes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSignPdfWithJks() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
|
||||
+26
@@ -2,6 +2,9 @@ package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -26,11 +29,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationResult;
|
||||
import stirling.software.SPDF.service.CertificateValidationService;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CertificateFileUtils;
|
||||
|
||||
@DisplayName("ValidateSignatureController Tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -159,6 +165,26 @@ class ValidateSignatureControllerTest {
|
||||
() -> validateSignatureController.validateSignature(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject an oversized custom trust certificate before reading it")
|
||||
void testValidateSignature_OversizedCertFile() throws Exception {
|
||||
MultipartFile oversizedCert = mock(MultipartFile.class);
|
||||
when(oversizedCert.isEmpty()).thenReturn(false);
|
||||
when(oversizedCert.getSize())
|
||||
.thenReturn(CertificateFileUtils.MAX_CERTIFICATE_FILE_SIZE_BYTES + 1);
|
||||
|
||||
SignatureValidationRequest request = new SignatureValidationRequest();
|
||||
request.setCertFile(oversizedCert);
|
||||
|
||||
ResponseStatusException exception =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> validateSignatureController.validateSignature(request));
|
||||
|
||||
assertEquals(413, exception.getStatusCode().value());
|
||||
verify(oversizedCert, never()).getBytes();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle IOException from PDF loading")
|
||||
void testValidateSignature_IOException() throws Exception {
|
||||
|
||||
+2
-2
@@ -105,7 +105,7 @@ public class SignatureController {
|
||||
* shared signatures.
|
||||
*/
|
||||
@PostMapping("/{signatureId}/label")
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
public ResponseEntity<Void> updateSignatureLabel(
|
||||
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
|
||||
try {
|
||||
@@ -140,7 +140,7 @@ public class SignatureController {
|
||||
* signatures. Admins can also delete shared signatures.
|
||||
*/
|
||||
@DeleteMapping("/{signatureId}")
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
|
||||
+3
@@ -187,6 +187,9 @@ public class PolicyController {
|
||||
summary = "Get pipeline run status",
|
||||
description = "Returns the current status, step cursor, and output files of a run.")
|
||||
public ResponseEntity<PolicyRunView> status(@PathVariable String runId) {
|
||||
if (!ownedByCurrentUser(runId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
PolicyRun run = runRegistry.get(runId);
|
||||
if (run != null) {
|
||||
return ResponseEntity.ok(PolicyRunView.of(run));
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(participantRateLimitInterceptor)
|
||||
.addPathPatterns("/api/v1/workflow/participant/**");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -91,7 +91,7 @@ public class DatabaseController {
|
||||
@Operation(
|
||||
summary = "Import database backup by filename",
|
||||
description = "Imports a database backup file from the server using its file name.")
|
||||
@GetMapping("/import-database-file/{fileName}")
|
||||
@PostMapping("/import-database-file/{fileName}")
|
||||
public ResponseEntity<?> importDatabaseFromBackupUI(
|
||||
@Parameter(description = "Name of the file to import", required = true) @PathVariable
|
||||
String fileName) {
|
||||
@@ -141,7 +141,7 @@ public class DatabaseController {
|
||||
@Operation(
|
||||
summary = "Delete a database backup file",
|
||||
description = "Deletes a specified database backup file from the server.")
|
||||
@GetMapping("/delete/{fileName}")
|
||||
@DeleteMapping("/delete/{fileName}")
|
||||
public ResponseEntity<?> deleteFile(
|
||||
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
|
||||
String fileName) {
|
||||
@@ -228,7 +228,7 @@ public class DatabaseController {
|
||||
@Operation(
|
||||
summary = "Create a database backup",
|
||||
description = "This endpoint triggers the creation of a database backup.")
|
||||
@GetMapping("/createDatabaseBackup")
|
||||
@PostMapping("/createDatabaseBackup")
|
||||
public ResponseEntity<?> createDatabaseBackup() {
|
||||
log.info("Starting database backup creation...");
|
||||
databaseService.exportDatabase();
|
||||
|
||||
+14
-56
@@ -22,7 +22,9 @@ import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.InviteAcceptanceException;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -38,6 +40,7 @@ public class InviteLinkController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Optional<EmailService> emailService;
|
||||
private final UserLicenseSettingsService userLicenseSettingsService;
|
||||
private final InviteAcceptanceService inviteAcceptanceService;
|
||||
|
||||
/**
|
||||
* Generate a new invite link (admin only)
|
||||
@@ -411,67 +414,22 @@ public class InviteLinkController {
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
// Determine the email to use
|
||||
String effectiveEmail = invite.getEmail();
|
||||
if (effectiveEmail == null) {
|
||||
// Email not pre-set, must be provided by user
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Email address is required"));
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!email.contains("@")) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid email address"));
|
||||
}
|
||||
|
||||
effectiveEmail = email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(effectiveEmail)
|
||||
.password(password)
|
||||
.teamId(invite.getTeamId())
|
||||
.role(invite.getRole());
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Mark invite as used
|
||||
invite.setUsed(true);
|
||||
invite.setUsedAt(LocalDateTime.now());
|
||||
inviteTokenRepository.save(invite);
|
||||
AcceptanceResult result = inviteAcceptanceService.accept(token, email, password);
|
||||
|
||||
log.info(
|
||||
"User account created via invite link: {} with role: {}",
|
||||
effectiveEmail,
|
||||
invite.getRole());
|
||||
result.username(),
|
||||
result.role());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("message", "Account created successfully", "username", effectiveEmail));
|
||||
Map.of(
|
||||
"message",
|
||||
"Account created successfully",
|
||||
"username",
|
||||
result.username()));
|
||||
|
||||
} catch (InviteAcceptanceException e) {
|
||||
return ResponseEntity.status(e.getStatus()).body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to accept invite: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
+5
-1
@@ -37,7 +37,7 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
|
||||
: uri;
|
||||
|
||||
boolean isHealthCheck =
|
||||
trimmedUri.startsWith("/actuator/health")
|
||||
matchesPathOrChild(trimmedUri, "/actuator/health")
|
||||
|| "/health".equals(trimmedUri)
|
||||
|| "/healthz".equals(trimmedUri)
|
||||
|| "/liveness".equals(trimmedUri)
|
||||
@@ -54,4 +54,8 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
|
||||
private boolean isPrometheusEndpointRequest(HttpServletRequest request) {
|
||||
return request.getRequestURI().contains("/actuator/");
|
||||
}
|
||||
|
||||
private boolean matchesPathOrChild(String requestUri, String endpoint) {
|
||||
return requestUri.equals(endpoint) || requestUri.startsWith(endpoint + "/");
|
||||
}
|
||||
}
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Per-IP rate limiter for the unauthenticated participant token endpoints. */
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ParticipantRateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final int MAX_REQUESTS_PER_MINUTE = 20;
|
||||
private static final long WINDOW_MS = 60_000L;
|
||||
|
||||
// value: [requestCount, windowStartMs]
|
||||
private final ConcurrentHashMap<String, long[]> requestCounts = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
|
||||
String ip = getClientIp(request);
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
long[] entry =
|
||||
requestCounts.compute(
|
||||
ip,
|
||||
(key, existing) -> {
|
||||
if (existing == null || now - existing[1] >= WINDOW_MS) {
|
||||
return new long[] {1, now};
|
||||
}
|
||||
existing[0]++;
|
||||
return existing;
|
||||
});
|
||||
|
||||
if (entry[0] > MAX_REQUESTS_PER_MINUTE) {
|
||||
log.warn(
|
||||
"Rate limit exceeded for IP {} on participant endpoint {}",
|
||||
ip,
|
||||
request.getRequestURI());
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setHeader("Retry-After", "60");
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
// Do not trust X-Forwarded-For: it is user-controlled and trivially spoofed,
|
||||
// which would allow an attacker to bypass this rate limiter by rotating fake IPs.
|
||||
// Operators who deploy behind a trusted reverse proxy should configure Spring's
|
||||
// RemoteIpFilter / ForwardedHeaderFilter at the framework level instead.
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300_000)
|
||||
public void cleanupExpiredWindows() {
|
||||
long cutoff = System.currentTimeMillis() - WINDOW_MS;
|
||||
requestCounts.entrySet().removeIf(e -> e.getValue()[1] < cutoff);
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Protects workflow participant endpoints before multipart requests are parsed. */
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ParticipantRequestSecurityFilter extends OncePerRequestFilter {
|
||||
|
||||
static final long MAX_MULTIPART_REQUEST_SIZE_BYTES = 16L * 1024 * 1024;
|
||||
static final long MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES = 101L * 1024 * 1024;
|
||||
|
||||
private static final String PARTICIPANT_PATH = "/api/v1/workflow/participant/";
|
||||
private static final String MOBILE_SCANNER_UPLOAD_PATH = "/api/v1/mobile-scanner/upload/";
|
||||
private static final String AUTHENTICATED_CERTIFICATE_VALIDATION_PATH =
|
||||
"/api/v1/security/cert-sign/validate-certificate";
|
||||
private static final Pattern AUTHENTICATED_SIGN_PATH =
|
||||
Pattern.compile("^/api/v1/security/cert-sign/sign-requests/[^/]+/sign$");
|
||||
private static final Set<String> MULTIPART_UPLOAD_PATHS =
|
||||
Set.of(
|
||||
PARTICIPANT_PATH + "submit-signature",
|
||||
PARTICIPANT_PATH + "validate-certificate",
|
||||
AUTHENTICATED_CERTIFICATE_VALIDATION_PATH);
|
||||
private static final int MAX_REQUESTS_PER_MINUTE = 20;
|
||||
private static final long WINDOW_MS = 60_000L;
|
||||
|
||||
private final ConcurrentHashMap<String, RequestWindow> requestCounts =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = normalizedRequestPath(request);
|
||||
return !path.startsWith(PARTICIPANT_PATH)
|
||||
&& !path.startsWith(MOBILE_SCANNER_UPLOAD_PATH)
|
||||
&& !isAuthenticatedWorkflowUploadPath(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
if (rateLimitExceeded(request)) {
|
||||
log.warn(
|
||||
"Rate limit exceeded for IP {} on participant endpoint {}",
|
||||
request.getRemoteAddr(),
|
||||
request.getRequestURI());
|
||||
writeError(
|
||||
response,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Rate limit exceeded. Try again in 60 seconds.");
|
||||
response.setHeader("Retry-After", "60");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMultipartUploadEndpoint(request)) {
|
||||
long contentLength = request.getContentLengthLong();
|
||||
if (contentLength < 0) {
|
||||
writeError(
|
||||
response,
|
||||
HttpStatus.LENGTH_REQUIRED,
|
||||
"Content-Length is required for workflow uploads.");
|
||||
return;
|
||||
}
|
||||
long maxRequestSize =
|
||||
normalizedRequestPath(request).startsWith(MOBILE_SCANNER_UPLOAD_PATH)
|
||||
? MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES
|
||||
: MAX_MULTIPART_REQUEST_SIZE_BYTES;
|
||||
if (contentLength > maxRequestSize) {
|
||||
writeError(
|
||||
response,
|
||||
HttpStatus.CONTENT_TOO_LARGE,
|
||||
"Upload exceeds the configured request limit.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean rateLimitExceeded(HttpServletRequest request) {
|
||||
long now = System.currentTimeMillis();
|
||||
RequestWindow entry =
|
||||
requestCounts.compute(
|
||||
request.getRemoteAddr(),
|
||||
(key, existing) -> {
|
||||
if (existing == null || now - existing.windowStartMs() >= WINDOW_MS) {
|
||||
return new RequestWindow(1, now);
|
||||
}
|
||||
return new RequestWindow(
|
||||
existing.requestCount() + 1, existing.windowStartMs());
|
||||
});
|
||||
return entry.requestCount() > MAX_REQUESTS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private boolean isMultipartUploadEndpoint(HttpServletRequest request) {
|
||||
String path = normalizedRequestPath(request);
|
||||
return "POST".equalsIgnoreCase(request.getMethod())
|
||||
&& (path.startsWith(MOBILE_SCANNER_UPLOAD_PATH)
|
||||
|| MULTIPART_UPLOAD_PATHS.contains(path)
|
||||
|| AUTHENTICATED_SIGN_PATH.matcher(path).matches());
|
||||
}
|
||||
|
||||
private boolean isAuthenticatedWorkflowUploadPath(String path) {
|
||||
return AUTHENTICATED_CERTIFICATE_VALIDATION_PATH.equals(path)
|
||||
|| AUTHENTICATED_SIGN_PATH.matcher(path).matches();
|
||||
}
|
||||
|
||||
private String normalizedRequestPath(HttpServletRequest request) {
|
||||
String requestUri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (!contextPath.isEmpty() && requestUri.startsWith(contextPath)) {
|
||||
requestUri = requestUri.substring(contextPath.length());
|
||||
}
|
||||
try {
|
||||
requestUri = URLDecoder.decode(requestUri, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Leave malformed paths unchanged; they will not match a protected upload route.
|
||||
log.debug("Malformed encoded request path: {}", requestUri);
|
||||
}
|
||||
return requestUri.length() > 1 && requestUri.endsWith("/")
|
||||
? requestUri.substring(0, requestUri.length() - 1)
|
||||
: requestUri;
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, HttpStatus status, String errorMessage)
|
||||
throws IOException {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"" + errorMessage + "\"}");
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300_000)
|
||||
public void cleanupExpiredWindows() {
|
||||
long cutoff = System.currentTimeMillis() - WINDOW_MS;
|
||||
requestCounts.entrySet().removeIf(entry -> entry.getValue().windowStartMs() < cutoff);
|
||||
}
|
||||
|
||||
private record RequestWindow(int requestCount, long windowStartMs) {}
|
||||
}
|
||||
+7
@@ -5,11 +5,14 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
|
||||
@Repository
|
||||
@@ -17,6 +20,10 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, Long>
|
||||
|
||||
Optional<InviteToken> findByToken(String token);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT it FROM InviteToken it WHERE it.token = :token")
|
||||
Optional<InviteToken> findByTokenForUpdate(@Param("token") String token);
|
||||
|
||||
Optional<InviteToken> findByEmail(String email);
|
||||
|
||||
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
|
||||
/** Atomically consumes an invite and creates the invited account. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InviteAcceptanceService {
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final UserService userService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AcceptanceResult accept(String token, String requestedEmail, String password)
|
||||
throws Exception {
|
||||
InviteToken invite =
|
||||
inviteTokenRepository
|
||||
.findByTokenForUpdate(token)
|
||||
.orElseThrow(InviteAcceptanceException::invalidInvite);
|
||||
|
||||
if (invite.isUsed() || invite.isExpired()) {
|
||||
throw InviteAcceptanceException.invalidInvite();
|
||||
}
|
||||
|
||||
String effectiveEmail = resolveEmail(invite, requestedEmail);
|
||||
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
||||
throw InviteAcceptanceException.invalidInvite();
|
||||
}
|
||||
|
||||
SaveUserRequest request =
|
||||
SaveUserRequest.builder()
|
||||
.username(effectiveEmail)
|
||||
.password(password)
|
||||
.teamId(invite.getTeamId())
|
||||
.role(invite.getRole())
|
||||
.build();
|
||||
userService.saveUserCore(request);
|
||||
|
||||
invite.setUsed(true);
|
||||
invite.setUsedAt(LocalDateTime.now());
|
||||
inviteTokenRepository.save(invite);
|
||||
|
||||
return new AcceptanceResult(effectiveEmail, invite.getRole());
|
||||
}
|
||||
|
||||
private String resolveEmail(InviteToken invite, String requestedEmail) {
|
||||
if (invite.getEmail() != null) {
|
||||
return invite.getEmail();
|
||||
}
|
||||
if (requestedEmail == null || requestedEmail.trim().isEmpty()) {
|
||||
throw new InviteAcceptanceException(
|
||||
HttpStatus.BAD_REQUEST, "Email address is required");
|
||||
}
|
||||
if (!requestedEmail.contains("@")) {
|
||||
throw new InviteAcceptanceException(HttpStatus.BAD_REQUEST, "Invalid email address");
|
||||
}
|
||||
return requestedEmail.trim().toLowerCase();
|
||||
}
|
||||
|
||||
public record AcceptanceResult(String username, String role) {}
|
||||
|
||||
@Getter
|
||||
public static class InviteAcceptanceException extends RuntimeException {
|
||||
private final HttpStatus status;
|
||||
|
||||
public InviteAcceptanceException(HttpStatus status, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public static InviteAcceptanceException invalidInvite() {
|
||||
return new InviteAcceptanceException(HttpStatus.NOT_FOUND, "Invalid invite link");
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-50
@@ -41,8 +41,10 @@ import stirling.software.proprietary.workflow.dto.ParticipantRequest;
|
||||
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
|
||||
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator.FinalizedWorkflow;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -55,7 +57,7 @@ public class SigningSessionController {
|
||||
|
||||
private final WorkflowSessionService workflowSessionService;
|
||||
private final UserService userService;
|
||||
private final SigningFinalizationService signingFinalizationService;
|
||||
private final WorkflowFinalizationCoordinator workflowFinalizationCoordinator;
|
||||
private final CertificateSubmissionValidator certificateSubmissionValidator;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@@ -239,36 +241,9 @@ public class SigningSessionController {
|
||||
|
||||
try {
|
||||
User owner = getCurrentUser(principal);
|
||||
WorkflowSession session =
|
||||
workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner);
|
||||
|
||||
byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId);
|
||||
byte[] pdf = signingFinalizationService.finalizeDocument(session, originalPdf);
|
||||
|
||||
String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf";
|
||||
workflowSessionService.storeProcessedFile(session, pdf, filename);
|
||||
workflowSessionService.finalizeSession(sessionId, owner);
|
||||
workflowSessionService.deleteOriginalFile(session);
|
||||
|
||||
try {
|
||||
signingFinalizationService.clearSensitiveMetadata(session);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"SECURITY: Failed to clear sensitive metadata for session {} "
|
||||
+ "(participants: {}). Keystore credentials may remain in the "
|
||||
+ "database until manual cleanup.",
|
||||
sessionId,
|
||||
session.getParticipants() != null
|
||||
? session.getParticipants().stream().map(p -> p.getEmail()).toList()
|
||||
: "unknown",
|
||||
e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Document signed successfully but post-signing cleanup failed. "
|
||||
+ "Contact your administrator to complete the cleanup.");
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(pdf, filename);
|
||||
FinalizedWorkflow finalized =
|
||||
workflowFinalizationCoordinator.finalizeSession(sessionId, owner);
|
||||
return WebResponseUtils.bytesToWebResponse(finalized.pdf(), finalized.filename());
|
||||
} catch (Exception e) {
|
||||
log.error("Error finalizing session {}", sessionId, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
@@ -436,14 +411,27 @@ public class SigningSessionController {
|
||||
HttpStatus.BAD_REQUEST, "No certificate file provided");
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] keystoreBytes = null;
|
||||
if (p12File != null && !p12File.isEmpty()) {
|
||||
keystoreBytes = p12File.getBytes();
|
||||
} else if (jksFile != null && !jksFile.isEmpty()) {
|
||||
keystoreBytes = jksFile.getBytes();
|
||||
}
|
||||
WorkflowUploadUtils.rejectMultipleKeystores(p12File, jksFile);
|
||||
|
||||
byte[] keystoreBytes;
|
||||
try {
|
||||
keystoreBytes =
|
||||
WorkflowUploadUtils.readCredentialFile(
|
||||
p12File != null && !p12File.isEmpty() ? p12File : jksFile);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading certificate file during pre-validation", e);
|
||||
return ResponseEntity.ok(
|
||||
new CertificateValidationResponse(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"Failed to read certificate file"));
|
||||
}
|
||||
|
||||
try {
|
||||
CertificateInfo info =
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
keystoreBytes, certType, password);
|
||||
@@ -470,17 +458,6 @@ public class SigningSessionController {
|
||||
return ResponseEntity.ok(
|
||||
new CertificateValidationResponse(
|
||||
false, null, null, null, null, false, e.getReason()));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading certificate file during pre-validation", e);
|
||||
return ResponseEntity.ok(
|
||||
new CertificateValidationResponse(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"Failed to read certificate file"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+113
-42
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -24,6 +25,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@@ -40,10 +42,12 @@ 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 stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
|
||||
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowMapper;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
@@ -58,15 +62,34 @@ import tools.jackson.databind.ObjectMapper;
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/workflow/participant")
|
||||
@Tag(name = "Workflow Participant", description = "Participant Action APIs")
|
||||
@RequiredArgsConstructor
|
||||
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||
public class WorkflowParticipantController {
|
||||
|
||||
private final WorkflowSessionService workflowSessionService;
|
||||
private final WorkflowParticipantRepository participantRepository;
|
||||
private final WorkflowSessionRepository sessionRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MetadataEncryptionService metadataEncryptionService;
|
||||
private final CertificateSubmissionValidator certificateSubmissionValidator;
|
||||
|
||||
/**
|
||||
* Compatibility constructor for focused controller tests that do not exercise session locks.
|
||||
*/
|
||||
public WorkflowParticipantController(
|
||||
WorkflowSessionService workflowSessionService,
|
||||
WorkflowParticipantRepository participantRepository,
|
||||
ObjectMapper objectMapper,
|
||||
MetadataEncryptionService metadataEncryptionService,
|
||||
CertificateSubmissionValidator certificateSubmissionValidator) {
|
||||
this(
|
||||
workflowSessionService,
|
||||
participantRepository,
|
||||
null,
|
||||
objectMapper,
|
||||
metadataEncryptionService,
|
||||
certificateSubmissionValidator);
|
||||
}
|
||||
|
||||
private static final DateTimeFormatter ISO_UTC =
|
||||
DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
|
||||
|
||||
@@ -93,14 +116,16 @@ public class WorkflowParticipantController {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
|
||||
}
|
||||
|
||||
// Mark as viewed if not already
|
||||
if (participant.getStatus() == ParticipantStatus.PENDING
|
||||
|| participant.getStatus() == ParticipantStatus.NOTIFIED) {
|
||||
WorkflowSession session = participant.getWorkflowSession();
|
||||
|
||||
// Completed and cancelled workflows remain readable for participants, but immutable.
|
||||
if (session.isActive()
|
||||
&& (participant.getStatus() == ParticipantStatus.PENDING
|
||||
|| participant.getStatus() == ParticipantStatus.NOTIFIED)) {
|
||||
workflowSessionService.updateParticipantStatus(
|
||||
participant.getId(), ParticipantStatus.VIEWED);
|
||||
}
|
||||
|
||||
WorkflowSession session = participant.getWorkflowSession();
|
||||
// Strip peer share tokens — a single participant token must not enumerate peer bearer
|
||||
// tokens (GHSA-qgg6-mxw4-xg62).
|
||||
return ResponseEntity.ok(WorkflowMapper.toResponse(session, null, false));
|
||||
@@ -124,17 +149,23 @@ public class WorkflowParticipantController {
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
|
||||
if (participant.isExpired()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Submit signature (wet signature and/or certificate)",
|
||||
description =
|
||||
"Participants submit their signature data and certificate information for signing")
|
||||
"Participants submit their signature data and certificate information for"
|
||||
+ " signing")
|
||||
@PostMapping(
|
||||
value = "/submit-signature",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Transactional
|
||||
public ResponseEntity<ParticipantResponse> submitSignature(
|
||||
@ModelAttribute SignatureSubmissionRequest request) {
|
||||
|
||||
@@ -154,6 +185,24 @@ public class WorkflowParticipantController {
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
|
||||
if (sessionRepository != null) {
|
||||
sessionRepository
|
||||
.findByParticipantShareTokenForUpdate(request.getParticipantToken())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
}
|
||||
participant =
|
||||
participantRepository
|
||||
.findByShareToken(request.getParticipantToken())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
|
||||
// Check if participant can still submit
|
||||
if (participant.isExpired()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
|
||||
@@ -198,6 +247,7 @@ public class WorkflowParticipantController {
|
||||
summary = "Decline participation",
|
||||
description = "Participant declines to sign or participate in the workflow")
|
||||
@PostMapping(value = "/decline", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Transactional
|
||||
public ResponseEntity<ParticipantResponse> declineParticipation(
|
||||
@RequestParam("token") @NotBlank String token,
|
||||
@RequestParam(value = "reason", required = false) @Size(max = 500) String reason) {
|
||||
@@ -213,11 +263,38 @@ public class WorkflowParticipantController {
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
|
||||
if (sessionRepository != null) {
|
||||
sessionRepository
|
||||
.findByParticipantShareTokenForUpdate(token)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
}
|
||||
participant =
|
||||
participantRepository
|
||||
.findByShareToken(token)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Invalid or expired participant token"));
|
||||
|
||||
if (participant.isExpired()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
|
||||
}
|
||||
|
||||
if (participant.hasCompleted()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
|
||||
}
|
||||
|
||||
if (!participant.getWorkflowSession().isActive()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
|
||||
}
|
||||
|
||||
// Update status to DECLINED
|
||||
participant.setStatus(ParticipantStatus.DECLINED);
|
||||
|
||||
@@ -320,14 +397,27 @@ public class WorkflowParticipantController {
|
||||
HttpStatus.BAD_REQUEST, "No certificate file provided");
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] keystoreBytes = null;
|
||||
if (p12File != null && !p12File.isEmpty()) {
|
||||
keystoreBytes = p12File.getBytes();
|
||||
} else if (jksFile != null && !jksFile.isEmpty()) {
|
||||
keystoreBytes = jksFile.getBytes();
|
||||
}
|
||||
WorkflowUploadUtils.rejectMultipleKeystores(p12File, jksFile);
|
||||
|
||||
byte[] keystoreBytes;
|
||||
try {
|
||||
keystoreBytes =
|
||||
WorkflowUploadUtils.readCredentialFile(
|
||||
p12File != null && !p12File.isEmpty() ? p12File : jksFile);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading certificate file during pre-validation", e);
|
||||
return ResponseEntity.ok(
|
||||
new CertificateValidationResponse(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"Failed to read certificate file"));
|
||||
}
|
||||
|
||||
try {
|
||||
CertificateInfo info =
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
keystoreBytes, certType, password);
|
||||
@@ -356,17 +446,6 @@ public class WorkflowParticipantController {
|
||||
return ResponseEntity.ok(
|
||||
new CertificateValidationResponse(
|
||||
false, null, null, null, null, false, e.getReason()));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading certificate file during pre-validation", e);
|
||||
return ResponseEntity.ok(
|
||||
new CertificateValidationResponse(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"Failed to read certificate file"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,14 +457,13 @@ public class WorkflowParticipantController {
|
||||
throws IOException {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
|
||||
WorkflowUploadUtils.rejectMultipleKeystores(request.getP12File(), request.getJksFile());
|
||||
byte[] p12Bytes = WorkflowUploadUtils.readCredentialFile(request.getP12File());
|
||||
byte[] jksBytes = WorkflowUploadUtils.readCredentialFile(request.getJksFile());
|
||||
|
||||
// Validate certificate before storing — throws 400 if invalid, expired, or wrong password
|
||||
if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) {
|
||||
byte[] keystoreBytes = null;
|
||||
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
|
||||
keystoreBytes = request.getP12File().getBytes();
|
||||
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
|
||||
keystoreBytes = request.getJksFile().getBytes();
|
||||
}
|
||||
byte[] keystoreBytes = p12Bytes != null ? p12Bytes : jksBytes;
|
||||
if (keystoreBytes != null) {
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
keystoreBytes, request.getCertType(), request.getPassword());
|
||||
@@ -405,15 +483,11 @@ public class WorkflowParticipantController {
|
||||
certSubmission.put("showLogo", request.getShowLogo());
|
||||
|
||||
// Store the certificate keystores encrypted at rest.
|
||||
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
|
||||
certSubmission.put(
|
||||
"p12Keystore",
|
||||
metadataEncryptionService.encryptBytes(request.getP12File().getBytes()));
|
||||
if (p12Bytes != null) {
|
||||
certSubmission.put("p12Keystore", metadataEncryptionService.encryptBytes(p12Bytes));
|
||||
}
|
||||
if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
|
||||
certSubmission.put(
|
||||
"jksKeystore",
|
||||
metadataEncryptionService.encryptBytes(request.getJksFile().getBytes()));
|
||||
if (jksBytes != null) {
|
||||
certSubmission.put("jksKeystore", metadataEncryptionService.encryptBytes(jksBytes));
|
||||
}
|
||||
|
||||
metadata.put("certificateSubmission", certSubmission);
|
||||
@@ -421,10 +495,7 @@ public class WorkflowParticipantController {
|
||||
|
||||
// Add wet signatures data if provided - parse once and store as List directly
|
||||
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
|
||||
if (request.getWetSignaturesData().length() > 5 * 1024 * 1024) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Wet signatures data exceeds maximum allowed size");
|
||||
}
|
||||
WorkflowUploadUtils.validateWetSignatureDataSize(request.getWetSignaturesData());
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> wetSigs =
|
||||
objectMapper.readValue(
|
||||
|
||||
+13
@@ -4,10 +4,13 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||
@@ -24,6 +27,16 @@ public interface WorkflowSessionRepository extends JpaRepository<WorkflowSession
|
||||
"SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId")
|
||||
Optional<WorkflowSession> findBySessionIdWithParticipants(@Param("sessionId") String sessionId);
|
||||
|
||||
/** Lock a session before finalization so only one request can process it at a time. */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT ws FROM WorkflowSession ws WHERE ws.sessionId = :sessionId")
|
||||
Optional<WorkflowSession> findBySessionIdForUpdate(@Param("sessionId") String sessionId);
|
||||
|
||||
/** Lock the owning session while a participant performs a state-changing action. */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT ws FROM WorkflowSession ws JOIN ws.participants p WHERE p.shareToken = :token")
|
||||
Optional<WorkflowSession> findByParticipantShareTokenForUpdate(@Param("token") String token);
|
||||
|
||||
/** Find all workflow sessions owned by a specific user */
|
||||
List<WorkflowSession> findByOwnerOrderByCreatedAtDesc(User owner);
|
||||
|
||||
|
||||
+8
-5
@@ -7,6 +7,7 @@ import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -43,7 +44,7 @@ import stirling.software.proprietary.workflow.repository.UserServerCertificateRe
|
||||
public class UserServerCertificateService {
|
||||
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-user-cert";
|
||||
private static final String DEFAULT_PASSWORD_PREFIX = "stirling-user-cert-";
|
||||
private static final int GENERATED_PASSWORD_BYTES = 32;
|
||||
private static final int VALIDITY_DAYS = 365;
|
||||
|
||||
private final UserServerCertificateRepository certificateRepository;
|
||||
@@ -135,7 +136,7 @@ public class UserServerCertificateService {
|
||||
// Create keystore
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
String password = generateUserPassword(user.getId());
|
||||
String password = generateUserPassword();
|
||||
keyStore.setKeyEntry(
|
||||
KEYSTORE_ALIAS,
|
||||
keyPair.getPrivate(),
|
||||
@@ -251,8 +252,10 @@ public class UserServerCertificateService {
|
||||
return certificateRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
/** Generate consistent password for user (based on user ID) */
|
||||
private String generateUserPassword(Long userId) {
|
||||
return DEFAULT_PASSWORD_PREFIX + userId;
|
||||
/** Generate a high-entropy password for a newly created private-key keystore. */
|
||||
private String generateUserPassword() {
|
||||
byte[] passwordBytes = new byte[GENERATED_PASSWORD_BYTES];
|
||||
new SecureRandom().nextBytes(passwordBytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(passwordBytes);
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package stirling.software.proprietary.workflow.service;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
|
||||
/** Serializes the complete signing finalization lifecycle for a workflow session. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WorkflowFinalizationCoordinator {
|
||||
|
||||
private final WorkflowSessionService workflowSessionService;
|
||||
private final SigningFinalizationService signingFinalizationService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public FinalizedWorkflow finalizeSession(String sessionId, User owner) throws Exception {
|
||||
WorkflowSession session =
|
||||
workflowSessionService.getSessionWithParticipantsForOwnerForUpdate(
|
||||
sessionId, owner);
|
||||
if (!session.isActive()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
|
||||
}
|
||||
|
||||
byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId);
|
||||
byte[] finalizedPdf = signingFinalizationService.finalizeDocument(session, originalPdf);
|
||||
String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf";
|
||||
|
||||
workflowSessionService.storeProcessedFile(session, finalizedPdf, filename);
|
||||
workflowSessionService.finalizeSession(sessionId, owner);
|
||||
signingFinalizationService.clearSensitiveMetadata(session);
|
||||
// Deleting the source is an external storage side effect. Defer it until the DB
|
||||
// transaction has committed so a commit failure cannot leave the workflow pointing at a
|
||||
// deleted original document.
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
workflowSessionService.deleteOriginalFile(session);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Direct unit-test/in-process calls without a transaction retain the original cleanup
|
||||
// behavior; Spring-managed requests always take the after-commit branch above.
|
||||
workflowSessionService.deleteOriginalFile(session);
|
||||
}
|
||||
|
||||
return new FinalizedWorkflow(finalizedPdf, filename);
|
||||
}
|
||||
|
||||
public record FinalizedWorkflow(byte[] pdf, String filename) {}
|
||||
}
|
||||
+89
-69
@@ -54,6 +54,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
@@ -317,6 +318,25 @@ public class WorkflowSessionService {
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Retrieves and locks a workflow session for an owner-controlled state transition. */
|
||||
public WorkflowSession getSessionWithParticipantsForOwnerForUpdate(
|
||||
String sessionId, User owner) {
|
||||
WorkflowSession session =
|
||||
workflowSessionRepository
|
||||
.findBySessionIdForUpdate(sessionId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"Workflow session not found: " + sessionId));
|
||||
if (!session.getOwner().equals(owner)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
|
||||
}
|
||||
session.getParticipants().size();
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Lists all workflow sessions owned by a user. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<WorkflowSession> listUserSessions(User owner) {
|
||||
@@ -349,6 +369,11 @@ public class WorkflowSessionService {
|
||||
public void removeParticipant(String sessionId, Long participantId, User owner) {
|
||||
WorkflowSession session = getSessionForOwner(sessionId, owner);
|
||||
|
||||
if (!session.isActive()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Cannot remove participants from inactive workflow");
|
||||
}
|
||||
|
||||
WorkflowParticipant participant =
|
||||
workflowParticipantRepository
|
||||
.findById(participantId)
|
||||
@@ -635,7 +660,7 @@ public class WorkflowSessionService {
|
||||
}
|
||||
|
||||
// Update status to VIEWED if it was NOTIFIED
|
||||
if (participant.getStatus() == ParticipantStatus.NOTIFIED) {
|
||||
if (session.isActive() && participant.getStatus() == ParticipantStatus.NOTIFIED) {
|
||||
participant.setStatus(ParticipantStatus.VIEWED);
|
||||
workflowParticipantRepository.save(participant);
|
||||
}
|
||||
@@ -692,6 +717,11 @@ public class WorkflowSessionService {
|
||||
WorkflowSession session = getSession(sessionId);
|
||||
WorkflowParticipant participant = getParticipantForUser(session, user);
|
||||
|
||||
if (!session.isActive()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
|
||||
}
|
||||
|
||||
if (participant.getStatus() == ParticipantStatus.SIGNED) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Document already signed by this user");
|
||||
@@ -702,6 +732,18 @@ public class WorkflowSessionService {
|
||||
HttpStatus.BAD_REQUEST, "Cannot sign after declining");
|
||||
}
|
||||
|
||||
WorkflowUploadUtils.validateWetSignatureDataSize(request.getWetSignaturesData());
|
||||
WorkflowUploadUtils.rejectMultipleKeystores(request.getP12File(), request.getJksFile());
|
||||
|
||||
byte[] p12Bytes = readCredentialFile(request.getP12File(), "P12 keystore");
|
||||
byte[] jksBytes = readCredentialFile(request.getJksFile(), "JKS keystore");
|
||||
byte[] privateKeyBytes = null;
|
||||
byte[] certificateBytes = null;
|
||||
if ("PEM".equalsIgnoreCase(request.getCertType())) {
|
||||
privateKeyBytes = readCredentialFile(request.getPrivateKeyFile(), "PEM private key");
|
||||
certificateBytes = readCredentialFile(request.getCertFile(), "PEM certificate");
|
||||
}
|
||||
|
||||
// Build metadata JSON containing certificate submission and wet signature data
|
||||
// Merge with existing metadata if present (preserves owner-configured appearance
|
||||
// settings)
|
||||
@@ -717,36 +759,15 @@ public class WorkflowSessionService {
|
||||
// password
|
||||
if (request.getCertType() != null
|
||||
&& !"SERVER".equalsIgnoreCase(request.getCertType())
|
||||
&& request.getP12File() != null
|
||||
&& !request.getP12File().isEmpty()) {
|
||||
try {
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
request.getP12File().getBytes(),
|
||||
request.getCertType(),
|
||||
request.getPassword());
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read P12 keystore file for validation", e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
|
||||
}
|
||||
&& p12Bytes != null) {
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
p12Bytes, request.getCertType(), request.getPassword());
|
||||
}
|
||||
|
||||
// Validate an uploaded JKS keystore too (same early rejection as P12/PFX).
|
||||
if ("JKS".equalsIgnoreCase(request.getCertType())
|
||||
&& request.getJksFile() != null
|
||||
&& !request.getJksFile().isEmpty()) {
|
||||
try {
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
request.getJksFile().getBytes(), "JKS", request.getPassword());
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read JKS keystore file for validation", e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
|
||||
}
|
||||
if ("JKS".equalsIgnoreCase(request.getCertType()) && jksBytes != null) {
|
||||
certificateSubmissionValidator.validateAndExtractInfo(
|
||||
jksBytes, "JKS", request.getPassword());
|
||||
}
|
||||
|
||||
// 2. Store certificate submission data
|
||||
@@ -757,10 +778,7 @@ public class WorkflowSessionService {
|
||||
// PEM uploads are a separate private key + certificate, not a keystore. Convert them to
|
||||
// a PKCS12 keystore here so finalization signs via the standard PKCS12 path.
|
||||
byte[] p12 =
|
||||
buildPkcs12FromPem(
|
||||
request.getPrivateKeyFile(),
|
||||
request.getCertFile(),
|
||||
request.getPassword());
|
||||
buildPkcs12FromPem(privateKeyBytes, certificateBytes, request.getPassword());
|
||||
// Give PEM the same early validation (expiry, key recovery, test-sign) as uploaded
|
||||
// PKCS12/JKS keystores, so an expired or unusable cert is rejected now rather than at
|
||||
// finalization.
|
||||
@@ -772,28 +790,10 @@ public class WorkflowSessionService {
|
||||
} else {
|
||||
certSubmission.put("certType", request.getCertType());
|
||||
// Encrypt the uploaded keystore at rest: PKCS12/PFX → p12Keystore, JKS → jksKeystore.
|
||||
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
|
||||
try {
|
||||
certSubmission.put(
|
||||
"p12Keystore",
|
||||
metadataEncryptionService.encryptBytes(
|
||||
request.getP12File().getBytes()));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read P12 keystore file", e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
|
||||
}
|
||||
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
|
||||
try {
|
||||
certSubmission.put(
|
||||
"jksKeystore",
|
||||
metadataEncryptionService.encryptBytes(
|
||||
request.getJksFile().getBytes()));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read JKS keystore file", e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
|
||||
}
|
||||
if (p12Bytes != null) {
|
||||
certSubmission.put("p12Keystore", metadataEncryptionService.encryptBytes(p12Bytes));
|
||||
} else if (jksBytes != null) {
|
||||
certSubmission.put("jksKeystore", metadataEncryptionService.encryptBytes(jksBytes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +881,11 @@ public class WorkflowSessionService {
|
||||
WorkflowSession session = getSession(sessionId);
|
||||
WorkflowParticipant participant = getParticipantForUser(session, user);
|
||||
|
||||
if (!session.isActive()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
|
||||
}
|
||||
|
||||
if (participant.getStatus() == ParticipantStatus.SIGNED) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Cannot decline after signing");
|
||||
@@ -901,14 +906,19 @@ public class WorkflowSessionService {
|
||||
* @throws ResponseStatusException if user is not a participant
|
||||
*/
|
||||
private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) {
|
||||
return session.getParticipants().stream()
|
||||
.filter(p -> p.getUser() != null && p.getUser().equals(user))
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"User is not a participant in this session"));
|
||||
WorkflowParticipant participant =
|
||||
session.getParticipants().stream()
|
||||
.filter(p -> p.getUser() != null && p.getUser().equals(user))
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"User is not a participant in this session"));
|
||||
if (participant.isExpired()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
|
||||
}
|
||||
return participant;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -916,21 +926,21 @@ public class WorkflowSessionService {
|
||||
* supplied password) so finalization can sign via the standard PKCS12 path.
|
||||
*/
|
||||
private byte[] buildPkcs12FromPem(
|
||||
MultipartFile privateKeyFile, MultipartFile certFile, String password) {
|
||||
if (privateKeyFile == null
|
||||
|| privateKeyFile.isEmpty()
|
||||
|| certFile == null
|
||||
|| certFile.isEmpty()) {
|
||||
byte[] privateKeyBytes, byte[] certificateBytes, String password) {
|
||||
if (privateKeyBytes == null
|
||||
|| privateKeyBytes.length == 0
|
||||
|| certificateBytes == null
|
||||
|| certificateBytes.length == 0) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"PEM signing requires both a private key file and a certificate file");
|
||||
}
|
||||
char[] pw = password != null ? password.toCharArray() : new char[0];
|
||||
try {
|
||||
PrivateKey privateKey = readPemPrivateKey(privateKeyFile.getBytes(), pw);
|
||||
PrivateKey privateKey = readPemPrivateKey(privateKeyBytes, pw);
|
||||
Certificate cert =
|
||||
CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(new ByteArrayInputStream(certFile.getBytes()));
|
||||
.generateCertificate(new ByteArrayInputStream(certificateBytes));
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry("alias", privateKey, pw, new Certificate[] {cert});
|
||||
@@ -947,6 +957,16 @@ public class WorkflowSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] readCredentialFile(MultipartFile file, String description) {
|
||||
try {
|
||||
return WorkflowUploadUtils.readCredentialFile(file);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read {}", description, e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Failed to process certificate credential file");
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a PEM private key (PKCS8/PKCS1, optionally password-encrypted). */
|
||||
private PrivateKey readPemPrivateKey(byte[] pemBytes, char[] password) throws Exception {
|
||||
try (PEMParser pemParser =
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.workflow.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.util.CertificateFileUtils;
|
||||
|
||||
/** Shared size and shape validation for workflow credential uploads. */
|
||||
public final class WorkflowUploadUtils {
|
||||
|
||||
public static final long MAX_CREDENTIAL_FILE_SIZE_BYTES =
|
||||
CertificateFileUtils.MAX_CERTIFICATE_FILE_SIZE_BYTES;
|
||||
public static final int MAX_WET_SIGNATURE_DATA_CHARS = 5 * 1024 * 1024;
|
||||
|
||||
private WorkflowUploadUtils() {}
|
||||
|
||||
public static byte[] readCredentialFile(MultipartFile file) throws IOException {
|
||||
return CertificateFileUtils.read(file);
|
||||
}
|
||||
|
||||
public static void rejectMultipleKeystores(MultipartFile p12File, MultipartFile jksFile) {
|
||||
if (hasContent(p12File) && hasContent(jksFile)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Provide only one certificate keystore");
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateWetSignatureDataSize(String wetSignatureData) {
|
||||
if (wetSignatureData != null && wetSignatureData.length() > MAX_WET_SIGNATURE_DATA_CHARS) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONTENT_TOO_LARGE, "Wet signatures data exceeds the 5 MiB limit");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasContent(MultipartFile file) {
|
||||
return file != null && !file.isEmpty();
|
||||
}
|
||||
}
|
||||
+19
@@ -224,6 +224,11 @@ class PolicyControllerTest {
|
||||
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
|
||||
}
|
||||
|
||||
private void mockOwnedRun(String runId) {
|
||||
when(jobOwnershipService.extractJobId(runId)).thenReturn(runId);
|
||||
when(jobOwnershipService.createScopedJobKey(runId)).thenReturn(runId);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("run (ad-hoc)")
|
||||
class Run {
|
||||
@@ -355,6 +360,7 @@ class PolicyControllerTest {
|
||||
@DisplayName("returns the run view when present")
|
||||
void found() {
|
||||
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null);
|
||||
mockOwnedRun("run-3");
|
||||
when(runRegistry.get("run-3")).thenReturn(run);
|
||||
|
||||
ResponseEntity<PolicyRunView> response = controller.status("run-3");
|
||||
@@ -366,6 +372,7 @@ class PolicyControllerTest {
|
||||
@Test
|
||||
@DisplayName("returns 404 when run is unknown")
|
||||
void notFound() {
|
||||
mockOwnedRun("missing");
|
||||
when(runRegistry.get("missing")).thenReturn(null);
|
||||
when(jobOwnershipService.extractJobId("missing")).thenReturn("missing");
|
||||
when(jobOwnershipService.createScopedJobKey("missing")).thenReturn("missing");
|
||||
@@ -374,6 +381,18 @@ class PolicyControllerTest {
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 404 without reading a run owned by another user")
|
||||
void rejectsRunOwnedByAnotherUser() {
|
||||
when(jobOwnershipService.extractJobId("bob:run-3")).thenReturn("run-3");
|
||||
when(jobOwnershipService.createScopedJobKey("run-3")).thenReturn("alice:run-3");
|
||||
|
||||
ResponseEntity<PolicyRunView> response = controller.status("bob:run-3");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
verify(runRegistry, never()).get("bob:run-3");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+16
-14
@@ -31,6 +31,9 @@ import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.InviteAcceptanceException;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@@ -43,6 +46,7 @@ class InviteLinkControllerMoreTest {
|
||||
@Mock private UserService userService;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private UserLicenseSettingsService userLicenseSettingsService;
|
||||
@Mock private InviteAcceptanceService inviteAcceptanceService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
@@ -64,7 +68,8 @@ class InviteLinkControllerMoreTest {
|
||||
userService,
|
||||
applicationProperties,
|
||||
Optional.of(emailService),
|
||||
userLicenseSettingsService);
|
||||
userLicenseSettingsService,
|
||||
inviteAcceptanceService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@@ -266,9 +271,8 @@ class InviteLinkControllerMoreTest {
|
||||
@Test
|
||||
@DisplayName("returns 404 for an expired token")
|
||||
void expiredToken() throws Exception {
|
||||
InviteToken invite = validInvite("exp");
|
||||
invite.setExpiresAt(LocalDateTime.now().minusHours(1));
|
||||
when(inviteTokenRepository.findByToken("exp")).thenReturn(Optional.of(invite));
|
||||
when(inviteAcceptanceService.accept("exp", null, "secret123"))
|
||||
.thenThrow(InviteAcceptanceException.invalidInvite());
|
||||
|
||||
mockMvc.perform(post("/api/v1/invite/accept/exp").param("password", "secret123"))
|
||||
.andExpect(status().isNotFound())
|
||||
@@ -278,9 +282,11 @@ class InviteLinkControllerMoreTest {
|
||||
@Test
|
||||
@DisplayName("requires an email when the invite has none")
|
||||
void emailRequired() throws Exception {
|
||||
InviteToken invite = validInvite("noemail");
|
||||
invite.setEmail(null);
|
||||
when(inviteTokenRepository.findByToken("noemail")).thenReturn(Optional.of(invite));
|
||||
when(inviteAcceptanceService.accept("noemail", null, "secret123"))
|
||||
.thenThrow(
|
||||
new InviteAcceptanceException(
|
||||
org.springframework.http.HttpStatus.BAD_REQUEST,
|
||||
"Email address is required"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/invite/accept/noemail").param("password", "secret123"))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -290,18 +296,14 @@ class InviteLinkControllerMoreTest {
|
||||
@Test
|
||||
@DisplayName("creates the account using the pre-set email")
|
||||
void createsWithPresetEmail() throws Exception {
|
||||
InviteToken invite = validInvite("preset");
|
||||
invite.setEmail("preset@ex.com");
|
||||
invite.setTeamId(3L);
|
||||
when(inviteTokenRepository.findByToken("preset")).thenReturn(Optional.of(invite));
|
||||
when(userService.usernameExistsIgnoreCase("preset@ex.com")).thenReturn(false);
|
||||
when(inviteAcceptanceService.accept("preset", null, "secret123"))
|
||||
.thenReturn(new AcceptanceResult("preset@ex.com", Role.USER.getRoleId()));
|
||||
|
||||
mockMvc.perform(post("/api/v1/invite/accept/preset").param("password", "secret123"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.username").value("preset@ex.com"));
|
||||
|
||||
verify(userService).saveUserCore(any());
|
||||
verify(inviteTokenRepository).save(invite);
|
||||
verify(inviteAcceptanceService).accept("preset", null, "secret123");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-11
@@ -29,6 +29,8 @@ import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -41,6 +43,7 @@ class InviteLinkControllerTest {
|
||||
@Mock private UserService userService;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private UserLicenseSettingsService userLicenseSettingsService;
|
||||
@Mock private InviteAcceptanceService inviteAcceptanceService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
@@ -62,7 +65,8 @@ class InviteLinkControllerTest {
|
||||
userService,
|
||||
applicationProperties,
|
||||
Optional.of(emailService),
|
||||
userLicenseSettingsService);
|
||||
userLicenseSettingsService,
|
||||
inviteAcceptanceService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@@ -165,14 +169,8 @@ class InviteLinkControllerTest {
|
||||
|
||||
@Test
|
||||
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
|
||||
InviteToken invite = new InviteToken();
|
||||
invite.setToken("abc");
|
||||
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
|
||||
invite.setRole(Role.USER.getRoleId());
|
||||
invite.setUsed(false);
|
||||
invite.setEmail(null); // email required from request
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
when(inviteAcceptanceService.accept("abc", "new@example.com", "password123"))
|
||||
.thenReturn(new AcceptanceResult("new@example.com", Role.USER.getRoleId()));
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/accept/abc")
|
||||
@@ -182,7 +180,6 @@ class InviteLinkControllerTest {
|
||||
.andExpect(jsonPath("$.message").value("Account created successfully"))
|
||||
.andExpect(jsonPath("$.username").value("new@example.com"));
|
||||
|
||||
verify(userService).saveUserCore(any());
|
||||
verify(inviteTokenRepository).save(invite);
|
||||
verify(inviteAcceptanceService).accept("abc", "new@example.com", "password123");
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
|
||||
class ParticipantRequestSecurityFilterTest {
|
||||
|
||||
private static final String VALIDATE_PATH = "/api/v1/workflow/participant/validate-certificate";
|
||||
|
||||
private final ParticipantRequestSecurityFilter filter = new ParticipantRequestSecurityFilter();
|
||||
|
||||
@Test
|
||||
void oversizedUpload_isRejectedBeforeFilterChain() throws Exception {
|
||||
MockHttpServletRequest request = multipartRequest(VALIDATE_PATH);
|
||||
request.setContent(
|
||||
new byte
|
||||
[(int) ParticipantRequestSecurityFilter.MAX_MULTIPART_REQUEST_SIZE_BYTES
|
||||
+ 1]);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadWithoutContentLength_isRejectedBeforeFilterChain() throws Exception {
|
||||
MockHttpServletRequest request = multipartRequest(VALIDATE_PATH);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(411);
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundedUpload_reachesFilterChain() throws Exception {
|
||||
MockHttpServletRequest request = multipartRequest(VALIDATE_PATH);
|
||||
request.setContent(new byte[1024]);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedCertificateValidation_isLimitedBeforeFilterChain() throws Exception {
|
||||
MockHttpServletRequest request =
|
||||
multipartRequest("/api/v1/security/cert-sign/validate-certificate");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(411);
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedSignRequest_isLimitedBeforeFilterChain() throws Exception {
|
||||
MockHttpServletRequest request =
|
||||
multipartRequest("/api/v1/security/cert-sign/sign-requests/session-1/sign");
|
||||
request.setContent(
|
||||
new byte
|
||||
[(int) ParticipantRequestSecurityFilter.MAX_MULTIPART_REQUEST_SIZE_BYTES
|
||||
+ 1]);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodedParticipantUpload_isStillLimited() throws Exception {
|
||||
MockHttpServletRequest request =
|
||||
multipartRequest("/api/v1/workflow/participant/validate-certificat%65");
|
||||
request.setContent(
|
||||
new byte
|
||||
[(int) ParticipantRequestSecurityFilter.MAX_MULTIPART_REQUEST_SIZE_BYTES
|
||||
+ 1]);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedMobileUpload_isRejectedBeforeMultipartParsing() throws Exception {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("POST", "/api/v1/mobile-scanner/upload/session-1") {
|
||||
@Override
|
||||
public long getContentLengthLong() {
|
||||
return ParticipantRequestSecurityFilter.MAX_MOBILE_UPLOAD_REQUEST_SIZE_BYTES
|
||||
+ 1;
|
||||
}
|
||||
};
|
||||
request.setContentType("multipart/form-data; boundary=test");
|
||||
request.setRemoteAddr("192.0.2.1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateLimit_isAppliedBeforeParticipantRequestHandling() throws Exception {
|
||||
FilterChain chain = org.mockito.Mockito.mock(FilterChain.class);
|
||||
|
||||
for (int requestNumber = 1; requestNumber <= 21; requestNumber++) {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("GET", "/api/v1/workflow/participant/details");
|
||||
request.setRemoteAddr("192.0.2.10");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
if (requestNumber == 21) {
|
||||
assertThat(response.getStatus()).isEqualTo(429);
|
||||
assertThat(response.getHeader("Retry-After")).isEqualTo("60");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private MockHttpServletRequest multipartRequest(String path) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", path);
|
||||
request.setContentType("multipart/form-data; boundary=test");
|
||||
request.setRemoteAddr("192.0.2.1");
|
||||
return request;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.AcceptanceResult;
|
||||
import stirling.software.proprietary.security.service.InviteAcceptanceService.InviteAcceptanceException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InviteAcceptanceServiceTest {
|
||||
|
||||
@Mock private InviteTokenRepository inviteTokenRepository;
|
||||
@Mock private UserService userService;
|
||||
@InjectMocks private InviteAcceptanceService service;
|
||||
|
||||
@Test
|
||||
void consumesInviteAndCreatesUserWhileHoldingLockedToken() throws Exception {
|
||||
InviteToken invite = validInvite("token");
|
||||
invite.setTeamId(7L);
|
||||
when(inviteTokenRepository.findByTokenForUpdate("token")).thenReturn(Optional.of(invite));
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
|
||||
AcceptanceResult result = service.accept("token", "NEW@example.com", "password123");
|
||||
|
||||
assertEquals("new@example.com", result.username());
|
||||
assertTrue(invite.isUsed());
|
||||
ArgumentCaptor<SaveUserRequest> request = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(request.capture());
|
||||
assertEquals("new@example.com", request.getValue().getUsername());
|
||||
assertEquals(7L, request.getValue().getTeamId());
|
||||
verify(inviteTokenRepository).save(invite);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInviteThatWasConsumedByEarlierRequest() throws Exception {
|
||||
InviteToken invite = validInvite("token");
|
||||
invite.setUsed(true);
|
||||
when(inviteTokenRepository.findByTokenForUpdate("token")).thenReturn(Optional.of(invite));
|
||||
|
||||
InviteAcceptanceException exception =
|
||||
assertThrows(
|
||||
InviteAcceptanceException.class,
|
||||
() -> service.accept("token", "second@example.com", "password123"));
|
||||
|
||||
assertEquals(org.springframework.http.HttpStatus.NOT_FOUND, exception.getStatus());
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repositoryLookupUsesPessimisticWriteLock() throws Exception {
|
||||
Method method = InviteTokenRepository.class.getMethod("findByTokenForUpdate", String.class);
|
||||
|
||||
assertEquals(LockModeType.PESSIMISTIC_WRITE, method.getAnnotation(Lock.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptanceRollsBackForCheckedFailures() throws Exception {
|
||||
Method method =
|
||||
InviteAcceptanceService.class.getMethod(
|
||||
"accept", String.class, String.class, String.class);
|
||||
|
||||
Transactional transactional = method.getAnnotation(Transactional.class);
|
||||
assertTrue(java.util.List.of(transactional.rollbackFor()).contains(Exception.class));
|
||||
}
|
||||
|
||||
private static InviteToken validInvite(String token) {
|
||||
InviteToken invite = new InviteToken();
|
||||
invite.setToken(token);
|
||||
invite.setExpiresAt(LocalDateTime.now().plusHours(1));
|
||||
invite.setRole(Role.USER.getRoleId());
|
||||
return invite;
|
||||
}
|
||||
}
|
||||
+30
-12
@@ -35,8 +35,10 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo;
|
||||
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
|
||||
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator.FinalizedWorkflow;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
// Direct handler invocations (no MockMvc) for SigningSessionController; previously 0% covered.
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -45,7 +47,7 @@ class SigningSessionControllerTest {
|
||||
|
||||
@Mock private WorkflowSessionService workflowSessionService;
|
||||
@Mock private UserService userService;
|
||||
@Mock private SigningFinalizationService signingFinalizationService;
|
||||
@Mock private WorkflowFinalizationCoordinator workflowFinalizationCoordinator;
|
||||
@Mock private CertificateSubmissionValidator certificateSubmissionValidator;
|
||||
|
||||
private SigningSessionController controller;
|
||||
@@ -56,7 +58,7 @@ class SigningSessionControllerTest {
|
||||
new SigningSessionController(
|
||||
workflowSessionService,
|
||||
userService,
|
||||
signingFinalizationService,
|
||||
workflowFinalizationCoordinator,
|
||||
certificateSubmissionValidator);
|
||||
}
|
||||
|
||||
@@ -382,26 +384,22 @@ class SigningSessionControllerTest {
|
||||
@Test
|
||||
void success_returnsSignedPdf() throws Exception {
|
||||
User owner = user("alice");
|
||||
WorkflowSession session = ownedSession("s1", owner);
|
||||
when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner));
|
||||
when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner))
|
||||
.thenReturn(session);
|
||||
when(workflowSessionService.getOriginalFile("s1")).thenReturn(new byte[] {1});
|
||||
when(signingFinalizationService.finalizeDocument(eq(session), any()))
|
||||
.thenReturn(new byte[] {2, 3});
|
||||
when(workflowFinalizationCoordinator.finalizeSession("s1", owner))
|
||||
.thenReturn(new FinalizedWorkflow(new byte[] {2, 3}, "doc_shared_signed.pdf"));
|
||||
|
||||
ResponseEntity<byte[]> response = controller.finalizeSession("s1", principal("alice"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(workflowSessionService).finalizeSession("s1", owner);
|
||||
verify(workflowSessionService).deleteOriginalFile(session);
|
||||
assertThat(response.getBody()).containsExactly(2, 3);
|
||||
verify(workflowFinalizationCoordinator).finalizeSession("s1", owner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceError_returns500() throws Exception {
|
||||
User owner = user("alice");
|
||||
when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner));
|
||||
when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner))
|
||||
when(workflowFinalizationCoordinator.finalizeSession("s1", owner))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
|
||||
assertThat(controller.finalizeSession("s1", principal("alice")).getStatusCode())
|
||||
@@ -672,5 +670,25 @@ class SigningSessionControllerTest {
|
||||
assertThat(response.getBody().valid()).isFalse();
|
||||
assertThat(response.getBody().error()).isEqualTo("bad password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedCertificate_throwsPayloadTooLargeBeforeValidation() {
|
||||
MockMultipartFile p12 =
|
||||
new MockMultipartFile(
|
||||
"p12File",
|
||||
"c.p12",
|
||||
"application/octet-stream",
|
||||
new byte[(int) WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1]);
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||
() ->
|
||||
controller.validateCertificate(
|
||||
"P12", "pw", p12, null, principal("alice")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
|
||||
|
||||
org.mockito.Mockito.verifyNoInteractions(certificateSubmissionValidator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+119
@@ -13,10 +13,13 @@ 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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
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;
|
||||
@@ -32,6 +35,7 @@ import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepo
|
||||
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
|
||||
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -136,6 +140,25 @@ class WorkflowParticipantControllerMoreTest {
|
||||
org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(
|
||||
value = WorkflowStatus.class,
|
||||
names = {"COMPLETED", "CANCELLED"})
|
||||
void inactiveSession_isReturnedWithoutUpdatingParticipantStatus(
|
||||
WorkflowStatus workflowStatus) {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
|
||||
p.getWorkflowSession().setStatus(workflowStatus);
|
||||
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
|
||||
|
||||
ResponseEntity<WorkflowSessionResponse> response = controller.getSessionByToken(TOKEN);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(workflowSessionService, org.mockito.Mockito.never())
|
||||
.updateParticipantStatus(
|
||||
org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -165,6 +188,19 @@ class WorkflowParticipantControllerMoreTest {
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getParticipantDetails expired token throws 403")
|
||||
void getParticipantDetails_expiredToken() {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.VIEWED);
|
||||
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
|
||||
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
|
||||
|
||||
assertThatThrownBy(() -> controller.getParticipantDetails(TOKEN))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// submitSignature
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -239,6 +275,52 @@ class WorkflowParticipantControllerMoreTest {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.SIGNED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedCertificate_throwsPayloadTooLargeWithoutReadingFile() throws Exception {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
|
||||
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
|
||||
MultipartFile certificateFile = org.mockito.Mockito.mock(MultipartFile.class);
|
||||
when(certificateFile.getSize())
|
||||
.thenReturn(WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1);
|
||||
|
||||
SignatureSubmissionRequest r = request(TOKEN);
|
||||
r.setCertType("P12");
|
||||
r.setP12File(certificateFile);
|
||||
|
||||
assertThatThrownBy(() -> controller.submitSignature(r))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
|
||||
|
||||
verify(certificateFile, org.mockito.Mockito.never()).getBytes();
|
||||
verify(participantRepository, org.mockito.Mockito.never()).save(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void certificateFile_isReadOnlyOnceForValidationAndEncryption() throws Exception {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
|
||||
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
|
||||
when(participantRepository.save(org.mockito.ArgumentMatchers.any()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
MultipartFile certificateFile = org.mockito.Mockito.mock(MultipartFile.class);
|
||||
byte[] certificateBytes =
|
||||
"certificate".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
when(certificateFile.getSize()).thenReturn((long) certificateBytes.length);
|
||||
when(certificateFile.getBytes()).thenReturn(certificateBytes);
|
||||
when(metadataEncryptionService.encryptBytes(certificateBytes)).thenReturn("encrypted");
|
||||
|
||||
SignatureSubmissionRequest r = request(TOKEN);
|
||||
r.setCertType("P12");
|
||||
r.setP12File(certificateFile);
|
||||
|
||||
controller.submitSignature(r);
|
||||
|
||||
verify(certificateFile).getBytes();
|
||||
verify(certificateSubmissionValidator)
|
||||
.validateAndExtractInfo(certificateBytes, "P12", null);
|
||||
verify(metadataEncryptionService).encryptBytes(certificateBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -259,6 +341,20 @@ class WorkflowParticipantControllerMoreTest {
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredToken_throwsForbiddenWithoutChangingParticipant() {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
|
||||
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
|
||||
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
|
||||
|
||||
assertThatThrownBy(() -> controller.declineParticipation(TOKEN, null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.PENDING);
|
||||
verify(participantRepository, org.mockito.Mockito.never()).save(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyCompleted_throwsBadRequest() {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.DECLINED);
|
||||
@@ -270,6 +366,29 @@ class WorkflowParticipantControllerMoreTest {
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(
|
||||
value = WorkflowStatus.class,
|
||||
names = {"COMPLETED", "CANCELLED"})
|
||||
void inactiveSession_throwsBadRequestWithoutChangingParticipant(
|
||||
WorkflowStatus workflowStatus) {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
|
||||
p.getWorkflowSession().setStatus(workflowStatus);
|
||||
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
|
||||
|
||||
assertThatThrownBy(() -> controller.declineParticipation(TOKEN, "reason"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.PENDING);
|
||||
verify(workflowSessionService, org.mockito.Mockito.never())
|
||||
.addParticipantNotification(
|
||||
org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
verify(participantRepository, org.mockito.Mockito.never()).save(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withReason_setsDeclinedAndNotifies() {
|
||||
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
|
||||
|
||||
+46
@@ -27,6 +27,7 @@ import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepo
|
||||
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
|
||||
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -138,6 +139,51 @@ class WorkflowParticipantValidateCertificateTest {
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedCertificate_returns413BeforeValidation() throws Exception {
|
||||
when(participantRepository.findByShareToken(VALID_TOKEN))
|
||||
.thenReturn(Optional.of(activeParticipant()));
|
||||
|
||||
MockMultipartFile certFile =
|
||||
new MockMultipartFile(
|
||||
"p12File",
|
||||
"cert.p12",
|
||||
"application/octet-stream",
|
||||
new byte[(int) WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1]);
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/workflow/participant/validate-certificate")
|
||||
.file(certFile)
|
||||
.param("participantToken", VALID_TOKEN)
|
||||
.param("certType", "P12")
|
||||
.param("password", "pass"))
|
||||
.andExpect(status().isPayloadTooLarge());
|
||||
|
||||
org.mockito.Mockito.verifyNoInteractions(certificateSubmissionValidator);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleCertificateFiles_returns400() throws Exception {
|
||||
when(participantRepository.findByShareToken(VALID_TOKEN))
|
||||
.thenReturn(Optional.of(activeParticipant()));
|
||||
|
||||
MockMultipartFile p12File =
|
||||
new MockMultipartFile(
|
||||
"p12File", "cert.p12", "application/octet-stream", DUMMY_CERT);
|
||||
MockMultipartFile jksFile =
|
||||
new MockMultipartFile(
|
||||
"jksFile", "cert.jks", "application/octet-stream", DUMMY_CERT);
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/workflow/participant/validate-certificate")
|
||||
.file(p12File)
|
||||
.file(jksFile)
|
||||
.param("participantToken", VALID_TOKEN)
|
||||
.param("certType", "P12")
|
||||
.param("password", "pass"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ---- Invalid / expired token → 403 ----
|
||||
|
||||
@Test
|
||||
|
||||
+11
-2
@@ -82,8 +82,17 @@ class UserServerCertificateServiceTest {
|
||||
|
||||
String stored = captor.getValue().getKeystorePassword();
|
||||
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
|
||||
// The raw predictable prefix must not appear in the stored value
|
||||
assertThat(stored).doesNotContain("stirling-user-cert-");
|
||||
String generatedPassword = encryptionService.decrypt(stored);
|
||||
assertThat(generatedPassword)
|
||||
.hasSize(43)
|
||||
.doesNotStartWith("stirling-user-cert-")
|
||||
.isNotEqualTo("stirling-user-cert-" + user.getId());
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(
|
||||
new ByteArrayInputStream(captor.getValue().getKeystoreData()),
|
||||
generatedPassword.toCharArray());
|
||||
assertThat(keyStore.aliases().hasMoreElements()).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowFinalizationCoordinator.FinalizedWorkflow;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WorkflowFinalizationCoordinatorTest {
|
||||
|
||||
@Mock private WorkflowSessionService workflowSessionService;
|
||||
@Mock private SigningFinalizationService signingFinalizationService;
|
||||
|
||||
@Test
|
||||
void inactiveSession_isRejectedBeforePdfProcessing() throws Exception {
|
||||
User owner = owner();
|
||||
WorkflowSession session = session();
|
||||
session.setStatus(WorkflowStatus.COMPLETED);
|
||||
when(workflowSessionService.getSessionWithParticipantsForOwnerForUpdate("s1", owner))
|
||||
.thenReturn(session);
|
||||
WorkflowFinalizationCoordinator coordinator = coordinator();
|
||||
|
||||
assertThatThrownBy(() -> coordinator.finalizeSession("s1", owner))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
verify(signingFinalizationService, never())
|
||||
.finalizeDocument(
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
verify(workflowSessionService, never())
|
||||
.storeProcessedFile(
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeSession_isLockedBeforeProcessingAndCompletedInOrder() throws Exception {
|
||||
User owner = owner();
|
||||
WorkflowSession session = session();
|
||||
byte[] original = new byte[] {1};
|
||||
byte[] finalized = new byte[] {2};
|
||||
when(workflowSessionService.getSessionWithParticipantsForOwnerForUpdate("s1", owner))
|
||||
.thenReturn(session);
|
||||
when(workflowSessionService.getOriginalFile("s1")).thenReturn(original);
|
||||
when(signingFinalizationService.finalizeDocument(session, original)).thenReturn(finalized);
|
||||
WorkflowFinalizationCoordinator coordinator = coordinator();
|
||||
|
||||
FinalizedWorkflow result = coordinator.finalizeSession("s1", owner);
|
||||
|
||||
assertThat(result.pdf()).isSameAs(finalized);
|
||||
assertThat(result.filename()).isEqualTo("document_shared_signed.pdf");
|
||||
InOrder order = inOrder(workflowSessionService, signingFinalizationService);
|
||||
order.verify(workflowSessionService)
|
||||
.getSessionWithParticipantsForOwnerForUpdate("s1", owner);
|
||||
order.verify(workflowSessionService).getOriginalFile("s1");
|
||||
order.verify(signingFinalizationService).finalizeDocument(session, original);
|
||||
order.verify(workflowSessionService)
|
||||
.storeProcessedFile(session, finalized, "document_shared_signed.pdf");
|
||||
order.verify(workflowSessionService).finalizeSession("s1", owner);
|
||||
order.verify(signingFinalizationService).clearSensitiveMetadata(session);
|
||||
order.verify(workflowSessionService).deleteOriginalFile(session);
|
||||
}
|
||||
|
||||
private WorkflowFinalizationCoordinator coordinator() {
|
||||
return new WorkflowFinalizationCoordinator(
|
||||
workflowSessionService, signingFinalizationService);
|
||||
}
|
||||
|
||||
private User owner() {
|
||||
User owner = new User();
|
||||
owner.setId(1L);
|
||||
owner.setUsername("owner");
|
||||
return owner;
|
||||
}
|
||||
|
||||
private WorkflowSession session() {
|
||||
WorkflowSession session = new WorkflowSession();
|
||||
session.setSessionId("s1");
|
||||
session.setDocumentName("document.pdf");
|
||||
session.setStatus(WorkflowStatus.IN_PROGRESS);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
+68
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
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 static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -297,6 +298,21 @@ class WorkflowSessionServiceMoreTest {
|
||||
@DisplayName("removeParticipant")
|
||||
class RemoveParticipant {
|
||||
|
||||
@Test
|
||||
void inactiveSession_throwsBadRequestWithoutDeletingAuditRecord() {
|
||||
User owner = user("alice", 1L);
|
||||
WorkflowSession s = session("s1", owner);
|
||||
s.setStatus(WorkflowStatus.COMPLETED);
|
||||
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
|
||||
|
||||
assertThatThrownBy(() -> service.removeParticipant("s1", 5L, owner))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
verify(workflowParticipantRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void participantNotFound_throwsNotFound() {
|
||||
User owner = user("alice", 1L);
|
||||
@@ -534,6 +550,23 @@ class WorkflowSessionServiceMoreTest {
|
||||
verify(workflowParticipantRepository).save(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSignRequestDetail_inactiveSessionDoesNotTransitionToViewed() {
|
||||
User user = user("alice", 1L);
|
||||
User owner = user("owner", 2L);
|
||||
WorkflowSession s = session("s1", owner);
|
||||
s.setCreatedAt(LocalDateTime.now());
|
||||
s.setStatus(WorkflowStatus.COMPLETED);
|
||||
WorkflowParticipant p = participant(user, ParticipantStatus.NOTIFIED);
|
||||
s.addParticipant(p);
|
||||
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
|
||||
|
||||
service.getSignRequestDetail("s1", user);
|
||||
|
||||
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.NOTIFIED);
|
||||
verify(workflowParticipantRepository, never()).save(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSignRequestDetail_readsAppearanceFromMetadata() {
|
||||
User user = user("alice", 1L);
|
||||
@@ -600,6 +633,23 @@ class WorkflowSessionServiceMoreTest {
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSignRequestDocument_expiredParticipantThrowsForbidden() {
|
||||
User user = user("alice", 1L);
|
||||
WorkflowSession s = session("s1", user("owner", 2L));
|
||||
WorkflowParticipant p = participant(user, ParticipantStatus.PENDING);
|
||||
p.setExpiresAt(LocalDateTime.now().minusMinutes(1));
|
||||
s.addParticipant(p);
|
||||
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
|
||||
|
||||
assertThatThrownBy(() -> service.getSignRequestDocument("s1", user))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
|
||||
verifyNoInteractions(storageProvider);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -610,6 +660,24 @@ class WorkflowSessionServiceMoreTest {
|
||||
@DisplayName("declineSignRequest")
|
||||
class DeclineSignRequest {
|
||||
|
||||
@Test
|
||||
void inactiveSessionDoesNotChangeParticipant() {
|
||||
User user = user("alice", 1L);
|
||||
WorkflowSession s = session("s1", user("owner", 2L));
|
||||
s.setStatus(WorkflowStatus.CANCELLED);
|
||||
WorkflowParticipant p = participant(user, ParticipantStatus.PENDING);
|
||||
s.addParticipant(p);
|
||||
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s));
|
||||
|
||||
assertThatThrownBy(() -> service.declineSignRequest("s1", user))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
assertThat(p.getStatus()).isEqualTo(ParticipantStatus.PENDING);
|
||||
verify(workflowParticipantRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadySigned_throwsBadRequest() {
|
||||
User user = user("alice", 1L);
|
||||
|
||||
+83
@@ -21,6 +21,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -42,6 +43,7 @@ import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowType;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||
import stirling.software.proprietary.workflow.util.WorkflowUploadUtils;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -56,6 +58,7 @@ class WorkflowSessionServiceTest {
|
||||
@Mock private ObjectMapper objectMapper;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private MetadataEncryptionService metadataEncryptionService;
|
||||
@Mock private CertificateSubmissionValidator certificateSubmissionValidator;
|
||||
|
||||
@InjectMocks private WorkflowSessionService service;
|
||||
|
||||
@@ -111,6 +114,86 @@ class WorkflowSessionServiceTest {
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo(ParticipantStatus.SIGNED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void signDocument_inactiveSessionDoesNotChangeParticipant() {
|
||||
User user = user("alice");
|
||||
WorkflowParticipant participant = pendingParticipant(user);
|
||||
WorkflowSession session = sessionWithParticipant("inactive", participant);
|
||||
session.setStatus(WorkflowStatus.COMPLETED);
|
||||
|
||||
SignDocumentRequest req = new SignDocumentRequest();
|
||||
req.setCertType("SERVER");
|
||||
|
||||
assertThatThrownBy(() -> service.signDocument("inactive", user, req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(participant.getStatus()).isEqualTo(ParticipantStatus.PENDING);
|
||||
verify(workflowParticipantRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signDocument_oversizedCredentialIsRejectedWithoutReadingIt() throws Exception {
|
||||
User user = user("alice");
|
||||
WorkflowParticipant participant = pendingParticipant(user);
|
||||
sessionWithParticipant("oversized", participant);
|
||||
MultipartFile credential = mock(MultipartFile.class);
|
||||
when(credential.getSize())
|
||||
.thenReturn(WorkflowUploadUtils.MAX_CREDENTIAL_FILE_SIZE_BYTES + 1);
|
||||
|
||||
SignDocumentRequest req = new SignDocumentRequest();
|
||||
req.setCertType("PKCS12");
|
||||
req.setP12File(credential);
|
||||
|
||||
assertThatThrownBy(() -> service.signDocument("oversized", user, req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
|
||||
|
||||
verify(credential, never()).getBytes();
|
||||
verify(workflowParticipantRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signDocument_readsCredentialOnlyOnceForValidationAndEncryption() throws Exception {
|
||||
User user = user("alice");
|
||||
WorkflowParticipant participant = pendingParticipant(user);
|
||||
sessionWithParticipant("single-read", participant);
|
||||
MultipartFile credential = mock(MultipartFile.class);
|
||||
byte[] bytes = new byte[] {1, 2, 3};
|
||||
when(credential.getSize()).thenReturn((long) bytes.length);
|
||||
when(credential.getBytes()).thenReturn(bytes);
|
||||
when(metadataEncryptionService.encryptBytes(bytes)).thenReturn("encrypted");
|
||||
|
||||
SignDocumentRequest req = new SignDocumentRequest();
|
||||
req.setCertType("PKCS12");
|
||||
req.setP12File(credential);
|
||||
|
||||
service.signDocument("single-read", user, req);
|
||||
|
||||
verify(credential).getBytes();
|
||||
verify(certificateSubmissionValidator).validateAndExtractInfo(bytes, "PKCS12", null);
|
||||
verify(metadataEncryptionService).encryptBytes(bytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void signDocument_oversizedWetSignatureDataIsRejected() {
|
||||
User user = user("alice");
|
||||
WorkflowParticipant participant = pendingParticipant(user);
|
||||
sessionWithParticipant("wet-signature", participant);
|
||||
|
||||
SignDocumentRequest req = new SignDocumentRequest();
|
||||
req.setCertType("SERVER");
|
||||
req.setWetSignaturesData("x".repeat(WorkflowUploadUtils.MAX_WET_SIGNATURE_DATA_CHARS + 1));
|
||||
|
||||
assertThatThrownBy(() -> service.signDocument("wet-signature", user, req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
|
||||
|
||||
verify(workflowParticipantRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// signDocument — certificate metadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -27,11 +27,11 @@ const databaseManagementService = {
|
||||
},
|
||||
|
||||
async createBackup(): Promise<void> {
|
||||
await apiClient.get("/api/v1/database/createDatabaseBackup");
|
||||
await apiClient.post("/api/v1/database/createDatabaseBackup");
|
||||
},
|
||||
|
||||
async importFromFileName(fileName: string): Promise<void> {
|
||||
await apiClient.get(
|
||||
await apiClient.post(
|
||||
`/api/v1/database/import-database-file/${encodeURIComponent(fileName)}`,
|
||||
);
|
||||
},
|
||||
@@ -44,7 +44,7 @@ const databaseManagementService = {
|
||||
},
|
||||
|
||||
async deleteBackup(fileName: string): Promise<void> {
|
||||
await apiClient.get(
|
||||
await apiClient.delete(
|
||||
`/api/v1/database/delete/${encodeURIComponent(fileName)}`,
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user