Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-03-25 11:00:40 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh EthanHealy01 Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 47cad0a131
commit 28613caf8a
181 changed files with 25715 additions and 124 deletions
@@ -6,7 +6,10 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -23,11 +26,23 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
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.UserServerCertificateService;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@@ -40,6 +55,14 @@ class UserServiceTest {
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private PersistentLoginRepository persistentLoginRepository;
@Mock private UserServerCertificateService userServerCertificateService;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@Mock private WorkflowSessionRepository workflowSessionRepository;
@Mock private StoredFileRepository storedFileRepository;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private FileShareRepository fileShareRepository;
@Mock private FileShareAccessRepository fileShareAccessRepository;
@Spy @InjectMocks private UserService userService;
@@ -185,4 +208,86 @@ class UserServiceTest {
assertFalse(userService.isUsernameValid("ALL_USERS"));
assertTrue(userService.isUsernameValid("[email protected]"));
}
@Test
void deleteUser_withRelatedData_cleansUpInCorrectOrder() {
User user = new User();
user.setId(1L);
user.setUsername("target");
FileShare share = new FileShare();
StoredFile ownedFile = new StoredFile();
ownedFile.setOwner(user);
ownedFile.setStorageKey("key-main");
ownedFile.setHistoryStorageKey("key-history");
Set<FileShare> shares = new HashSet<>();
shares.add(share);
ownedFile.setShares(shares);
WorkflowSession session = new WorkflowSession();
session.setOwner(user);
FileShare inboundShare = new FileShare();
when(userRepository.findByUsernameIgnoreCase("target")).thenReturn(Optional.of(user));
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user))
.thenReturn(List.of(session));
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of(ownedFile));
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of(inboundShare));
userService.deleteUser("target");
verify(userServerCertificateService).deleteUserCertificate(1L);
verify(fileShareAccessRepository).deleteByUser(user);
// Inbound share (file shared with this user by others) cleaned up
verify(fileShareAccessRepository).deleteByFileShare(inboundShare);
verify(fileShareRepository).deleteAll(List.of(inboundShare));
// Participant records in others' sessions de-linked (not deleted) to preserve audit trail
verify(workflowParticipantRepository).clearUserReferences(user);
verify(storedFileRepository).clearWorkflowSessionReferencesByOwner(user);
verify(workflowSessionRepository).deleteAll(List.of(session));
verify(fileShareAccessRepository).deleteByFileShare(share);
verify(storedFileRepository).deleteAll(List.of(ownedFile));
verify(userRepository).delete(user);
// Persistent login (remember-me) tokens revoked
verify(persistentLoginRepository).deleteByUsername("target");
// Storage blobs scheduled for physical deletion
verify(storageCleanupEntryRepository, times(2)).save(any());
verify(userService).invalidateUserSessions("target");
}
@Test
void deleteUser_withNoRelatedData_deletesUserSuccessfully() {
User user = new User();
user.setId(2L);
user.setUsername("clean");
when(userRepository.findByUsernameIgnoreCase("clean")).thenReturn(Optional.of(user));
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)).thenReturn(List.of());
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of());
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of());
userService.deleteUser("clean");
verify(userRepository).delete(user);
verify(fileShareAccessRepository, never()).deleteByFileShare(any());
verify(workflowSessionRepository).deleteAll(List.of());
verify(storedFileRepository).deleteAll(List.of());
}
@Test
void deleteUser_internalApiUser_isNotDeleted() {
Authority internalAuth = new Authority();
internalAuth.setAuthority(Role.INTERNAL_API_USER.getRoleId());
User user = new User();
user.setId(3L);
user.setUsername("internal");
user.getAuthorities().add(internalAuth);
when(userRepository.findByUsernameIgnoreCase("internal")).thenReturn(Optional.of(user));
userService.deleteUser("internal");
verify(userRepository, never()).delete(any());
verify(workflowSessionRepository, never()).findByOwnerOrderByCreatedAtDesc(any());
}
}
@@ -0,0 +1,97 @@
package stirling.software.proprietary.storage.converter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonMapConverterTest {
private final JsonMapConverter converter = new JsonMapConverter();
// -------------------------------------------------------------------------
// convertToDatabaseColumn
// -------------------------------------------------------------------------
@Test
void convertToDatabaseColumn_nullMap_returnsNull() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
}
@Test
void convertToDatabaseColumn_emptyMap_returnsNull() {
assertThat(converter.convertToDatabaseColumn(Map.of())).isNull();
}
@Test
void convertToDatabaseColumn_singleEntry_producesValidJson() {
String json = converter.convertToDatabaseColumn(Map.of("key", "value"));
assertThat(json).contains("\"key\"").contains("\"value\"");
}
@Test
void convertToDatabaseColumn_mapWithMixedTypes_roundTrips() {
Map<String, Object> input = Map.of("str", "hello", "num", 42);
String json = converter.convertToDatabaseColumn(input);
Map<String, Object> result = converter.convertToEntityAttribute(json);
assertThat(result.get("str")).isEqualTo("hello");
assertThat(result.get("num")).isEqualTo(42);
}
// -------------------------------------------------------------------------
// convertToEntityAttribute — normal paths
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_nullInput_returnsEmptyMap() {
assertThat(converter.convertToEntityAttribute(null)).isEmpty();
}
@Test
void convertToEntityAttribute_blankInput_returnsEmptyMap() {
assertThat(converter.convertToEntityAttribute(" ")).isEmpty();
}
@Test
void convertToEntityAttribute_validJson_restoresMap() {
Map<String, Object> result = converter.convertToEntityAttribute("{\"foo\":\"bar\"}");
assertThat(result).containsEntry("foo", "bar");
}
@Test
void convertToEntityAttribute_nestedObject_preservesStructure() {
String json = "{\"outer\":{\"inner\":\"value\"}}";
Map<String, Object> result = converter.convertToEntityAttribute(json);
assertThat(result).containsKey("outer");
}
// -------------------------------------------------------------------------
// convertToEntityAttribute — legacy double-encoded fallback
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_doubleEncodedJson_fallbackRecovery() {
// A JSON string node whose text content is itself valid JSON
String doubleEncoded = "\"{\\\"foo\\\":\\\"bar\\\"}\"";
Map<String, Object> result = converter.convertToEntityAttribute(doubleEncoded);
assertThat(result).containsEntry("foo", "bar");
}
// -------------------------------------------------------------------------
// convertToEntityAttribute — malformed input
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_completelyMalformed_returnsEmptyMap() {
Map<String, Object> result = converter.convertToEntityAttribute("not-json-at-all");
assertThat(result).isEmpty();
}
@Test
void convertToEntityAttribute_malformedJson_doesNotThrow() {
assertThatCode(() -> converter.convertToEntityAttribute("{broken"))
.doesNotThrowAnyException();
}
}
@@ -0,0 +1,573 @@
package stirling.software.proprietary.storage.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class FileStorageServiceTest {
@Mock private StoredFileRepository storedFileRepository;
@Mock private FileShareRepository fileShareRepository;
@Mock private FileShareAccessRepository fileShareAccessRepository;
@Mock private UserRepository userRepository;
@Mock private ApplicationProperties applicationProperties;
@Mock private StorageProvider storageProvider;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private ApplicationProperties.Security securityProperties;
@Mock private ApplicationProperties.System systemProperties;
@Mock private ApplicationProperties.Storage storageProperties;
@Mock private ApplicationProperties.Storage.Sharing sharingProperties;
@Mock private ApplicationProperties.Storage.Quotas quotasProperties;
private FileStorageService service;
@BeforeEach
void setUp() {
service =
new FileStorageService(
storedFileRepository,
fileShareRepository,
fileShareAccessRepository,
userRepository,
applicationProperties,
storageProvider,
Optional.empty(),
storageCleanupEntryRepository);
// Default: storage and sharing fully enabled, share links enabled, no expiry
when(applicationProperties.getSecurity()).thenReturn(securityProperties);
when(securityProperties.isEnableLogin()).thenReturn(true);
when(applicationProperties.getStorage()).thenReturn(storageProperties);
when(storageProperties.isEnabled()).thenReturn(true);
when(storageProperties.getSharing()).thenReturn(sharingProperties);
when(sharingProperties.isEnabled()).thenReturn(true);
when(sharingProperties.isLinkEnabled()).thenReturn(true);
when(sharingProperties.getLinkExpirationDays()).thenReturn(0);
when(applicationProperties.getSystem()).thenReturn(systemProperties);
when(systemProperties.getFrontendUrl()).thenReturn("http://localhost:8080");
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private User user(long id) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
return u;
}
private StoredFile ownedFile(User owner) {
StoredFile f = new StoredFile();
f.setId(100L);
f.setOwner(owner);
f.setOriginalFilename("test.pdf");
return f;
}
private FileShare shareFor(StoredFile file, User user, ShareAccessRole role) {
FileShare s = new FileShare();
s.setFile(file);
s.setSharedWithUser(user);
s.setAccessRole(role);
return s;
}
// -------------------------------------------------------------------------
// getAccessibleFile
// -------------------------------------------------------------------------
@Test
void getAccessibleFile_owner_returnsFile() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
assertThat(service.getAccessibleFile(owner, 100L)).isSameAs(f);
}
@Test
void getAccessibleFile_sharedUser_returnsFile() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
f.getShares().add(shareFor(f, requester, ShareAccessRole.VIEWER));
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
assertThat(service.getAccessibleFile(requester, 100L)).isSameAs(f);
}
@Test
void getAccessibleFile_noAccess_throwsForbidden() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
assertThatThrownBy(() -> service.getAccessibleFile(requester, 100L))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void getAccessibleFile_fileNotFound_throwsNotFound() {
User owner = user(1L);
when(storedFileRepository.findByIdWithShares(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getAccessibleFile(owner, 999L))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
// -------------------------------------------------------------------------
// requireEditorAccess
// -------------------------------------------------------------------------
@Test
void requireEditorAccess_owner_passes() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
service.requireEditorAccess(owner, f);
}
@Test
void requireEditorAccess_editorShare_passes() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, requester, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.of(share));
service.requireEditorAccess(requester, f);
}
@Test
void requireEditorAccess_viewerShare_throwsForbidden() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.of(share));
assertThatThrownBy(() -> service.requireEditorAccess(requester, f))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void requireEditorAccess_noShare_throwsForbidden() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> service.requireEditorAccess(requester, f))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// requireReadAccess
// -------------------------------------------------------------------------
@Test
void requireReadAccess_owner_passes() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
service.requireReadAccess(owner, f);
}
@Test
void requireReadAccess_viewerShare_passes() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.of(share));
service.requireReadAccess(requester, f);
}
// -------------------------------------------------------------------------
// shareWithUser
// -------------------------------------------------------------------------
@Test
void shareWithUser_newShare_created() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.empty());
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
FileShare result = service.shareWithUser(owner, f, "user2", ShareAccessRole.VIEWER);
assertThat(result.getSharedWithUser()).isEqualTo(target);
assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER);
verify(fileShareRepository).save(any(FileShare.class));
}
@Test
void shareWithUser_existingShare_updatesRole() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
FileShare existing = shareFor(f, target, ShareAccessRole.VIEWER);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.of(existing));
when(fileShareRepository.save(existing)).thenReturn(existing);
service.shareWithUser(owner, f, "user2", ShareAccessRole.EDITOR);
assertThat(existing.getAccessRole()).isEqualTo(ShareAccessRole.EDITOR);
verify(fileShareRepository).save(existing);
}
@Test
void shareWithUser_selfShare_throwsBadRequest() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(userRepository.findByUsernameIgnoreCase("user1")).thenReturn(Optional.of(owner));
assertThatThrownBy(() -> service.shareWithUser(owner, f, "user1", ShareAccessRole.VIEWER))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void shareWithUser_nonOwner_throwsForbidden() {
User owner = user(1L);
User nonOwner = user(2L);
StoredFile f = ownedFile(owner);
assertThatThrownBy(
() -> service.shareWithUser(nonOwner, f, "user1", ShareAccessRole.VIEWER))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// revokeUserShare
// -------------------------------------------------------------------------
@Test
void revokeUserShare_owner_removesShare() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, target, ShareAccessRole.VIEWER);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.of(share));
service.revokeUserShare(owner, f, "user2");
verify(fileShareRepository).delete(share);
}
@Test
void revokeUserShare_shareNotFound_silentSuccess() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.empty());
service.revokeUserShare(owner, f, "user2");
verify(fileShareRepository, never()).delete(any());
}
@Test
void revokeUserShare_nonOwner_throwsForbidden() {
User owner = user(1L);
User nonOwner = user(2L);
StoredFile f = ownedFile(owner);
assertThatThrownBy(() -> service.revokeUserShare(nonOwner, f, "user2"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// createShareLink
// -------------------------------------------------------------------------
@Test
void createShareLink_owner_tokenGenerated() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
FileShare result = service.createShareLink(owner, f, ShareAccessRole.VIEWER);
assertThat(result.getShareToken()).isNotNull();
assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER);
verify(fileShareRepository).save(any(FileShare.class));
}
@Test
void createShareLink_nonOwner_throwsForbidden() {
User owner = user(1L);
User nonOwner = user(2L);
StoredFile f = ownedFile(owner);
assertThatThrownBy(() -> service.createShareLink(nonOwner, f, ShareAccessRole.VIEWER))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// revokeShareLink
// -------------------------------------------------------------------------
@Test
void revokeShareLink_owner_validToken_deletesShareAndAccessRecords() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, null, ShareAccessRole.VIEWER);
share.setShareToken("test-token");
when(fileShareRepository.findByShareToken("test-token")).thenReturn(Optional.of(share));
service.revokeShareLink(owner, f, "test-token");
verify(fileShareAccessRepository).deleteByFileShare(share);
verify(fileShareRepository).delete(share);
}
@Test
void revokeShareLink_tokenBelongsToOtherFile_throwsForbidden() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
f.setId(1L);
StoredFile otherFile = ownedFile(owner);
otherFile.setId(2L);
FileShare share = shareFor(otherFile, null, ShareAccessRole.VIEWER);
share.setShareToken("token");
when(fileShareRepository.findByShareToken("token")).thenReturn(Optional.of(share));
assertThatThrownBy(() -> service.revokeShareLink(owner, f, "token"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void revokeShareLink_tokenNotFound_throwsNotFound() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(fileShareRepository.findByShareToken("unknown")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.revokeShareLink(owner, f, "unknown"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
// -------------------------------------------------------------------------
// Storage quota enforcement (via storeFile / replaceFile public API)
// -------------------------------------------------------------------------
@Test
void storeFile_nullQuotas_passes() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("k")
.originalFilename("test.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build());
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.storeFile(owner, file);
verify(storageProvider).store(owner, file);
}
@Test
void storeFile_fileTooLarge_throwsPayloadTooLarge() {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(1L); // 1 MB limit
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
User owner = user(1L);
// 2 MB file exceeds the 1 MB limit
MockMultipartFile file =
new MockMultipartFile(
"file", "big.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
assertThatThrownBy(() -> service.storeFile(owner, file))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(413);
}
@Test
void storeFile_perUserQuotaExceeded_throwsPayloadTooLarge() {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(10L); // 10 MB per-user cap
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
User owner = user(1L);
// user already has 9 MB stored; a 2 MB upload pushes to 11 MB > 10 MB cap
when(storedFileRepository.sumStorageBytesByOwner(owner)).thenReturn(9L * 1024 * 1024);
MockMultipartFile file =
new MockMultipartFile(
"file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
assertThatThrownBy(() -> service.storeFile(owner, file))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(413);
}
@Test
void storeFile_globalQuotaExceeded_throwsPayloadTooLarge() {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(100L); // 100 MB global cap
User owner = user(1L);
// system already has 99 MB; a 2 MB upload pushes to 101 MB > 100 MB cap
when(storedFileRepository.sumStorageBytesTotal()).thenReturn(99L * 1024 * 1024);
MockMultipartFile file =
new MockMultipartFile(
"file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
assertThatThrownBy(() -> service.storeFile(owner, file))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(413);
}
@Test
void replaceFile_replacementShrinks_skipsPerUserAndGlobalCheck() throws IOException {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
User owner = user(1L);
// existing file is 5 MB; replacement is 1 MB → delta ≤ 0 → quota repos never queried
StoredFile existing = ownedFile(owner);
existing.setSizeBytes(5L * 1024 * 1024);
existing.setStorageKey("old-key");
MockMultipartFile newFile =
new MockMultipartFile(
"file", "small.pdf", "application/pdf", new byte[1 * 1024 * 1024]);
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("new-key")
.originalFilename("small.pdf")
.contentType("application/pdf")
.sizeBytes(1L * 1024 * 1024)
.build());
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.replaceFile(owner, existing, newFile);
verify(storedFileRepository, never()).sumStorageBytesByOwner(any());
verify(storedFileRepository, never()).sumStorageBytesTotal();
}
// -------------------------------------------------------------------------
// deleteFile — workflow guard
// -------------------------------------------------------------------------
@Test
void deleteFile_fileInActiveWorkflow_throwsBadRequest() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
WorkflowSession session = mock(WorkflowSession.class);
when(session.isActive()).thenReturn(true);
f.setWorkflowSession(session);
assertThatThrownBy(() -> service.deleteFile(owner, f))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
verify(storedFileRepository, never()).delete(any());
}
@Test
void deleteFile_fileNotInAnyWorkflow_deletesSuccessfully() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
// no workflow session set
when(fileShareRepository.findShareLinks(f)).thenReturn(List.of());
service.deleteFile(owner, f);
verify(storedFileRepository).delete(f);
}
@Test
void deleteFile_fileInCompletedWorkflow_deletesSuccessfully() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
WorkflowSession session = mock(WorkflowSession.class);
when(session.isActive()).thenReturn(false);
f.setWorkflowSession(session);
when(fileShareRepository.findShareLinks(f)).thenReturn(List.of());
service.deleteFile(owner, f);
verify(storedFileRepository).delete(f);
}
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.workflow.dto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class WetSignatureMetadataTest {
private static WetSignatureMetadata canvas(double x, double y, double w, double h) {
return new WetSignatureMetadata("canvas", "data:image/png;base64,abc==", 0, x, y, w, h);
}
// -------------------------------------------------------------------------
// validate() — image data prefix
// -------------------------------------------------------------------------
@Test
void validate_canvas_withDataImagePrefix_passes() {
assertThatCode(() -> canvas(0.0, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException();
}
@Test
void validate_image_withDataImagePrefix_passes() {
WetSignatureMetadata sig =
new WetSignatureMetadata(
"image", "data:image/jpeg;base64,xyz==", 0, 0.1, 0.1, 0.3, 0.3);
assertThatCode(sig::validate).doesNotThrowAnyException();
}
@Test
void validate_canvas_withoutDataImagePrefix_throws() {
WetSignatureMetadata sig =
new WetSignatureMetadata(
"canvas", "raw-base64-without-prefix", 0, 0.0, 0.0, 0.5, 0.5);
assertThatThrownBy(sig::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("data:image/");
}
@Test
void validate_text_doesNotRequireDataImagePrefix() {
WetSignatureMetadata sig =
new WetSignatureMetadata("text", "John Doe", 0, 0.1, 0.1, 0.3, 0.2);
assertThatCode(sig::validate).doesNotThrowAnyException();
}
// -------------------------------------------------------------------------
// validate() — cross-field boundary checks (x + width, y + height)
// -------------------------------------------------------------------------
@Test
void validate_xPlusWidthExactlyOne_passes() {
assertThatCode(() -> canvas(0.5, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException();
}
@Test
void validate_xPlusWidthExceedsOne_throws() {
assertThatThrownBy(() -> canvas(0.6, 0.0, 0.5, 0.5).validate())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("right edge");
}
@Test
void validate_yPlusHeightExactlyOne_passes() {
assertThatCode(() -> canvas(0.0, 0.5, 0.5, 0.5).validate()).doesNotThrowAnyException();
}
@Test
void validate_yPlusHeightExceedsOne_throws() {
assertThatThrownBy(() -> canvas(0.0, 0.6, 0.5, 0.5).validate())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("bottom edge");
}
@Test
void validate_originWithFullPageSize_passes() {
assertThatCode(() -> canvas(0.0, 0.0, 1.0, 1.0).validate()).doesNotThrowAnyException();
}
// -------------------------------------------------------------------------
// MAX_SIGNATURES_PER_PARTICIPANT constant
// -------------------------------------------------------------------------
@Test
void maxSignaturesConstant_isPositive() {
assertThat(WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT).isGreaterThan(0);
}
// -------------------------------------------------------------------------
// extractBase64Data()
// -------------------------------------------------------------------------
@Test
void extractBase64Data_stripsDataUrlPrefix() {
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
sig.setData("data:image/png;base64,iVBORw0KGgo=");
assertThat(sig.extractBase64Data()).isEqualTo("iVBORw0KGgo=");
}
@Test
void extractBase64Data_noComma_returnsDataUnchanged() {
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
sig.setData("plainbase64withoutcomma");
assertThat(sig.extractBase64Data()).isEqualTo("plainbase64withoutcomma");
}
@Test
void extractBase64Data_null_returnsNull() {
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
sig.setData(null);
assertThat(sig.extractBase64Data()).isNull();
}
}
@@ -0,0 +1,123 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
class MetadataEncryptionServiceTest {
private MetadataEncryptionService service;
@BeforeEach
void setUp() {
service = serviceWithKey("test-encryption-key-for-unit-tests-only");
}
private static MetadataEncryptionService serviceWithKey(String key) {
AutomaticallyGenerated generated = new AutomaticallyGenerated();
generated.setKey(key);
ApplicationProperties props = new ApplicationProperties();
props.setAutomaticallyGenerated(generated);
return new MetadataEncryptionService(props);
}
// -------------------------------------------------------------------------
// Null / empty passthrough
// -------------------------------------------------------------------------
@Test
void encrypt_null_returnsNull() {
assertThat(service.encrypt(null)).isNull();
}
@Test
void decrypt_null_returnsNull() {
assertThat(service.decrypt(null)).isNull();
}
// -------------------------------------------------------------------------
// Legacy plaintext backwards-compatibility
// -------------------------------------------------------------------------
@Test
void decrypt_plaintextWithoutPrefix_returnsUnchanged() {
assertThat(service.decrypt("plaintext-password")).isEqualTo("plaintext-password");
}
@Test
void decrypt_emptyStringWithoutPrefix_returnsUnchanged() {
assertThat(service.decrypt("")).isEqualTo("");
}
// -------------------------------------------------------------------------
// Encrypt / decrypt round-trip
// -------------------------------------------------------------------------
@Test
void encrypt_producesEncPrefix() {
String encrypted = service.encrypt("secret");
assertThat(encrypted).startsWith(MetadataEncryptionService.ENC_PREFIX);
}
@Test
void roundTrip_restoresOriginalValue() {
String original = "my-keystore-password";
String encrypted = service.encrypt(original);
assertThat(service.decrypt(encrypted)).isEqualTo(original);
}
@Test
void roundTrip_emptyString() {
String encrypted = service.encrypt("");
assertThat(service.decrypt(encrypted)).isEqualTo("");
}
@Test
void roundTrip_specialCharactersAndUnicode() {
String original = "p@$$w0rd!£€#\u00e9";
assertThat(service.decrypt(service.encrypt(original))).isEqualTo(original);
}
// -------------------------------------------------------------------------
// IV randomisation — each call must produce a distinct ciphertext
// -------------------------------------------------------------------------
@Test
void encrypt_sameInput_producesDifferentCiphertexts() {
String a = service.encrypt("same-value");
String b = service.encrypt("same-value");
assertThat(a).isNotEqualTo(b);
}
// -------------------------------------------------------------------------
// Key dependency — different keys must produce different ciphertexts
// -------------------------------------------------------------------------
@Test
void encrypt_differentKey_producesIncompatibleCiphertext() {
MetadataEncryptionService otherService = serviceWithKey("completely-different-key");
String encryptedByOther = otherService.encrypt("secret");
// The original service cannot decrypt what the other service encrypted
assertThatThrownBy(() -> service.decrypt(encryptedByOther))
.isInstanceOf(IllegalStateException.class);
}
// -------------------------------------------------------------------------
// Missing key guard
// -------------------------------------------------------------------------
@Test
void encrypt_missingKey_throwsIllegalState() {
MetadataEncryptionService noKeyService = serviceWithKey(null);
assertThatThrownBy(() -> noKeyService.encrypt("anything"))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,183 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfSigningService;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class SigningFinalizationServiceTest {
@Mock private WorkflowParticipantRepository participantRepository;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private ObjectMapper objectMapper;
@Mock private PdfSigningService pdfSigningService;
@Mock private MetadataEncryptionService metadataEncryptionService;
@Mock private ServerCertificateServiceInterface serverCertificateService;
@Mock private UserServerCertificateService userServerCertificateService;
@InjectMocks private SigningFinalizationService service;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private WorkflowSession sessionWithParticipants(WorkflowParticipant... participants) {
WorkflowSession session = new WorkflowSession();
session.setSessionId("test-session");
List<WorkflowParticipant> list = new ArrayList<>();
for (WorkflowParticipant p : participants) {
list.add(p);
}
session.setParticipants(list);
return session;
}
private WorkflowParticipant participantWithMetadata(Map<String, Object> metadata) {
WorkflowParticipant p = new WorkflowParticipant();
p.setStatus(ParticipantStatus.SIGNED);
p.setEmail("[email protected]");
p.setParticipantMetadata(new HashMap<>(metadata));
return p;
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata — individual key removal
// -------------------------------------------------------------------------
@Test
void clearSensitiveMetadata_removesWetSignaturesKey() {
WorkflowParticipant p =
participantWithMetadata(Map.of("wetSignatures", List.of("sig1"), "other", "keep"));
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p.getParticipantMetadata()).containsKey("other");
verify(participantRepository, times(1)).save(p);
}
@Test
void clearSensitiveMetadata_removesCertificateSubmissionKey() {
WorkflowParticipant p =
participantWithMetadata(
Map.of("certificateSubmission", Map.of("certType", "SERVER")));
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
verify(participantRepository, times(1)).save(p);
}
@Test
void clearSensitiveMetadata_removesBothKeys_savesOnce() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("wetSignatures", List.of("sig1"));
metadata.put("certificateSubmission", Map.of("certType", "SERVER"));
metadata.put("showLogo", true);
WorkflowParticipant p = participantWithMetadata(metadata);
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
assertThat(p.getParticipantMetadata()).containsKey("showLogo");
verify(participantRepository, times(1)).save(p);
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata — no-op cases (save must NOT be called)
// -------------------------------------------------------------------------
@Test
void clearSensitiveMetadata_noSensitiveKeys_doesNotSave() {
WorkflowParticipant p = participantWithMetadata(Map.of("showLogo", true, "pageNumber", 1));
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
@Test
void clearSensitiveMetadata_nullMetadata_doesNotSave() {
WorkflowParticipant p = new WorkflowParticipant();
p.setStatus(ParticipantStatus.SIGNED);
p.setParticipantMetadata(null);
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
@Test
void clearSensitiveMetadata_emptyMetadata_doesNotSave() {
WorkflowParticipant p = participantWithMetadata(Map.of());
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata — multiple participants
// -------------------------------------------------------------------------
@Test
void clearSensitiveMetadata_multipleParticipants_allWithSensitiveData_allCleared() {
WorkflowParticipant p1 = participantWithMetadata(Map.of("wetSignatures", List.of("s1")));
WorkflowParticipant p2 =
participantWithMetadata(Map.of("certificateSubmission", Map.of("k", "v")));
WorkflowParticipant p3 =
participantWithMetadata(Map.of("wetSignatures", List.of("s3"), "extra", "keep"));
WorkflowSession session = sessionWithParticipants(p1, p2, p3);
service.clearSensitiveMetadata(session);
assertThat(p1.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p2.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
assertThat(p3.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p3.getParticipantMetadata()).containsKey("extra");
verify(participantRepository, times(3)).save(any());
}
@Test
void clearSensitiveMetadata_mixedParticipants_onlySavesModified() {
WorkflowParticipant withSensitive =
participantWithMetadata(Map.of("wetSignatures", List.of("s1")));
WorkflowParticipant withoutSensitive = participantWithMetadata(Map.of("showLogo", false));
WorkflowSession session = sessionWithParticipants(withSensitive, withoutSensitive);
service.clearSensitiveMetadata(session);
verify(participantRepository, times(1)).save(withSensitive);
verify(participantRepository, never()).save(withoutSensitive);
}
}
@@ -0,0 +1,337 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareRepository;
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.model.WorkflowStatus;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.UnifiedAccessControlService.AccessValidationResult;
@ExtendWith(MockitoExtension.class)
class UnifiedAccessControlServiceTest {
@Mock private FileShareRepository fileShareRepository;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@InjectMocks private UnifiedAccessControlService service;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private User user(long id) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
return u;
}
private FileShare share(StoredFile file, User sharedWith, LocalDateTime expiresAt) {
FileShare s = new FileShare();
s.setFile(file);
s.setShareToken("tok-" + System.nanoTime());
s.setAccessRole(ShareAccessRole.EDITOR);
s.setSharedWithUser(sharedWith);
s.setExpiresAt(expiresAt);
return s;
}
private WorkflowParticipant participant(
User user, ParticipantStatus status, boolean sessionActive, LocalDateTime expiresAt) {
WorkflowSession session = new WorkflowSession();
session.setStatus(sessionActive ? WorkflowStatus.IN_PROGRESS : WorkflowStatus.COMPLETED);
session.setFinalized(false);
StoredFile file = new StoredFile();
session.setOriginalFile(file);
WorkflowParticipant p = new WorkflowParticipant();
p.setUser(user);
p.setStatus(status);
p.setAccessRole(ShareAccessRole.EDITOR);
p.setExpiresAt(expiresAt);
p.setWorkflowSession(session);
return p;
}
// -------------------------------------------------------------------------
// validateToken — file share branch
// -------------------------------------------------------------------------
@Test
void validateToken_genericShare_noExpiry_noUserRestriction_allowed() {
StoredFile file = new StoredFile();
FileShare s = share(file, null, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", null);
assertThat(result.isAllowed()).isTrue();
assertThat(result.isWorkflowAccess()).isFalse();
}
@Test
void validateToken_genericShare_expired_denied() {
StoredFile file = new StoredFile();
FileShare s = share(file, null, LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", null);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("expired");
}
@Test
void validateToken_userSpecificShare_wrongUser_denied() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
FileShare s = share(file, owner, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", requester);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("denied");
}
@Test
void validateToken_userSpecificShare_correctUser_allowed() {
User u = user(1L);
StoredFile file = new StoredFile();
FileShare s = share(file, u, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isTrue();
}
// -------------------------------------------------------------------------
// validateToken — workflow participant branch
// -------------------------------------------------------------------------
@Test
void validateToken_participantToken_notFileShare_activeParticipant_allowed() {
User u = user(1L);
WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, true, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isTrue();
assertThat(result.isWorkflowAccess()).isTrue();
}
@Test
void validateToken_participantToken_expired_denied() {
User u = user(1L);
WorkflowParticipant p =
participant(
u, ParticipantStatus.PENDING, true, LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("expired");
}
@Test
void validateToken_participantToken_sessionInactive_denied() {
User u = user(1L);
WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, false, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("no longer active");
}
@Test
void validateToken_participantToken_wrongUser_denied() {
User owner = user(1L);
User requester = user(2L);
WorkflowParticipant p = participant(owner, ParticipantStatus.PENDING, true, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", requester);
assertThat(result.isAllowed()).isFalse();
}
@Test
void validateToken_unknownToken_denied() {
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.empty());
AccessValidationResult result = service.validateToken("tok", null);
assertThat(result.isAllowed()).isFalse();
}
// -------------------------------------------------------------------------
// getEffectiveRole
// -------------------------------------------------------------------------
@Test
void getEffectiveRole_signed_returnsViewer() {
WorkflowParticipant p = participant(null, ParticipantStatus.SIGNED, true, null);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
}
@Test
void getEffectiveRole_declined_returnsViewer() {
WorkflowParticipant p = participant(null, ParticipantStatus.DECLINED, true, null);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
}
@Test
void getEffectiveRole_pending_returnsAssignedRole() {
WorkflowParticipant p = participant(null, ParticipantStatus.PENDING, true, null);
p.setAccessRole(ShareAccessRole.EDITOR);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.EDITOR);
}
@Test
void getEffectiveRole_notified_returnsAssignedRole() {
WorkflowParticipant p = participant(null, ParticipantStatus.NOTIFIED, true, null);
p.setAccessRole(ShareAccessRole.VIEWER);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
}
@Test
void getEffectiveRole_viewed_returnsAssignedRole() {
WorkflowParticipant p = participant(null, ParticipantStatus.VIEWED, true, null);
p.setAccessRole(ShareAccessRole.COMMENTER);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.COMMENTER);
}
// -------------------------------------------------------------------------
// canAccessFile
// -------------------------------------------------------------------------
@Test
void canAccessFile_owner_returnsTrue() {
User u = user(1L);
StoredFile file = new StoredFile();
file.setOwner(u);
assertThat(service.canAccessFile(u, file)).isTrue();
}
@Test
void canAccessFile_nonOwner_withValidShare_returnsTrue() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
file.setOwner(owner);
FileShare s = share(file, requester, null);
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.of(s));
assertThat(service.canAccessFile(requester, file)).isTrue();
}
@Test
void canAccessFile_nonOwner_withExpiredShare_returnsFalse() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
file.setOwner(owner);
FileShare s = share(file, requester, LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.of(s));
assertThat(service.canAccessFile(requester, file)).isFalse();
}
@Test
void canAccessFile_nonOwner_noShare_returnsFalse() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
file.setOwner(owner);
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.empty());
assertThat(service.canAccessFile(requester, file)).isFalse();
}
@Test
void canAccessFile_workflowParticipant_activeSession_returnsTrue() {
User owner = user(1L);
User requester = user(2L);
WorkflowSession session = new WorkflowSession();
session.setStatus(WorkflowStatus.IN_PROGRESS);
session.setFinalized(false);
StoredFile file = new StoredFile();
file.setOwner(owner);
file.setWorkflowSession(session);
WorkflowParticipant p = participant(requester, ParticipantStatus.PENDING, true, null);
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.empty());
when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester))
.thenReturn(Optional.of(p));
assertThat(service.canAccessFile(requester, file)).isTrue();
}
@Test
void canAccessFile_workflowParticipant_expiredParticipant_returnsFalse() {
User owner = user(1L);
User requester = user(2L);
WorkflowSession session = new WorkflowSession();
session.setStatus(WorkflowStatus.IN_PROGRESS);
session.setFinalized(false);
StoredFile file = new StoredFile();
file.setOwner(owner);
file.setWorkflowSession(session);
WorkflowParticipant p =
participant(
requester,
ParticipantStatus.PENDING,
true,
LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.empty());
when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester))
.thenReturn(Optional.of(p));
assertThat(service.canAccessFile(requester, file)).isFalse();
}
}
@@ -0,0 +1,201 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Optional;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository;
@ExtendWith(MockitoExtension.class)
class UserServerCertificateServiceTest {
@Mock private UserServerCertificateRepository certificateRepository;
@Mock private UserRepository userRepository;
private MetadataEncryptionService encryptionService;
private UserServerCertificateService service;
@BeforeEach
void setUp() {
AutomaticallyGenerated generated = new AutomaticallyGenerated();
generated.setKey("test-key-for-unit-tests-only");
ApplicationProperties props = new ApplicationProperties();
props.setAutomaticallyGenerated(generated);
encryptionService = new MetadataEncryptionService(props);
service =
new UserServerCertificateService(
certificateRepository, userRepository, encryptionService);
}
private User user(long id) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
return u;
}
// -------------------------------------------------------------------------
// generateUserCertificate — password must be stored encrypted
// -------------------------------------------------------------------------
@Test
void generateUserCertificate_keystorePasswordStoredEncrypted() throws Exception {
User user = user(1L);
when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.generateUserCertificate(user);
ArgumentCaptor<UserServerCertificateEntity> captor =
ArgumentCaptor.forClass(UserServerCertificateEntity.class);
verify(certificateRepository).save(captor.capture());
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-");
}
// -------------------------------------------------------------------------
// uploadUserCertificate — password must be stored encrypted
// -------------------------------------------------------------------------
@Test
void uploadUserCertificate_keystorePasswordStoredEncrypted() throws Exception {
User user = user(2L);
String uploadPassword = "my-upload-password";
byte[] p12Bytes = buildP12(uploadPassword);
when(certificateRepository.findByUserId(2L)).thenReturn(Optional.empty());
when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.uploadUserCertificate(user, new ByteArrayInputStream(p12Bytes), uploadPassword);
ArgumentCaptor<UserServerCertificateEntity> captor =
ArgumentCaptor.forClass(UserServerCertificateEntity.class);
verify(certificateRepository).save(captor.capture());
String stored = captor.getValue().getKeystorePassword();
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
assertThat(stored).doesNotContain(uploadPassword);
}
// -------------------------------------------------------------------------
// getUserKeystorePassword — must decrypt before returning
// -------------------------------------------------------------------------
@Test
void getUserKeystorePassword_returnsDecryptedPlaintext() {
String original = "plain-password";
String encrypted = encryptionService.encrypt(original);
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setKeystorePassword(encrypted);
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
assertThat(service.getUserKeystorePassword(1L)).isEqualTo(original);
}
@Test
void getUserKeystorePassword_legacyPlaintext_returnedUnchanged() {
// Backwards-compatibility: values without enc: prefix pass through unchanged
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setKeystorePassword("legacy-plain");
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
assertThat(service.getUserKeystorePassword(1L)).isEqualTo("legacy-plain");
}
// -------------------------------------------------------------------------
// getUserKeyStore — decrypted password must successfully open the keystore
// -------------------------------------------------------------------------
@Test
void getUserKeyStore_decryptsPasswordToLoadKeystore() throws Exception {
String keystorePassword = "keystore-pass";
byte[] p12Bytes = buildP12(keystorePassword);
String encryptedPassword = encryptionService.encrypt(keystorePassword);
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setKeystoreData(p12Bytes);
entity.setKeystorePassword(encryptedPassword);
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
KeyStore ks = service.getUserKeyStore(1L);
assertThat(ks).isNotNull();
assertThat(ks.aliases().hasMoreElements()).isTrue();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Builds a minimal PKCS12 keystore containing a self-signed RSA certificate, using the same
* BouncyCastle provider that is already on the classpath.
*/
private static byte[] buildP12(String password) throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC");
kpg.initialize(2048, new SecureRandom());
KeyPair kp = kpg.generateKeyPair();
org.bouncycastle.asn1.x500.X500Name subject =
new org.bouncycastle.asn1.x500.X500Name("CN=test");
BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter = new Date(notBefore.getTime() + 365L * 24 * 60 * 60 * 1000);
JcaX509v3CertificateBuilder builder =
new JcaX509v3CertificateBuilder(
subject, serial, notBefore, notAfter, subject, kp.getPublic());
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(kp.getPrivate());
X509Certificate cert =
new JcaX509CertificateConverter()
.setProvider(new BouncyCastleProvider())
.getCertificate(builder.build(signer));
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry("alias", kp.getPrivate(), password.toCharArray(), new Certificate[] {cert});
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ks.store(baos, password.toCharArray());
return baos.toByteArray();
}
}
@@ -0,0 +1,572 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
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.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Storage;
import stirling.software.common.model.ApplicationProperties.Storage.Signing;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.dto.SignDocumentRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
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.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class WorkflowSessionServiceTest {
@Mock private WorkflowSessionRepository workflowSessionRepository;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@Mock private StoredFileRepository storedFileRepository;
@Mock private UserRepository userRepository;
@Mock private StorageProvider storageProvider;
@Mock private ObjectMapper objectMapper;
@Mock private ApplicationProperties applicationProperties;
@Mock private MetadataEncryptionService metadataEncryptionService;
@InjectMocks private WorkflowSessionService service;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private WorkflowSession sessionWithParticipant(
String sessionId, WorkflowParticipant participant) {
WorkflowSession session = new WorkflowSession();
session.setSessionId(sessionId);
List<WorkflowParticipant> participants = new ArrayList<>();
participants.add(participant);
session.setParticipants(participants);
when(workflowSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(session));
return session;
}
private WorkflowParticipant pendingParticipant(User user) {
WorkflowParticipant p = new WorkflowParticipant();
p.setUser(user);
p.setStatus(ParticipantStatus.PENDING);
return p;
}
private User user(String username) {
User u = new User();
u.setUsername(username);
return u;
}
// -------------------------------------------------------------------------
// signDocument — status transition
// -------------------------------------------------------------------------
@Test
void signDocument_transitionsParticipantToSigned() {
User user = user("alice");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("s1", participant);
when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw");
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
service.signDocument("s1", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(ParticipantStatus.SIGNED);
}
// -------------------------------------------------------------------------
// signDocument — certificate metadata
// -------------------------------------------------------------------------
@Test
void signDocument_storesCertTypeAndEncryptedPasswordInMetadata() {
User user = user("bob");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("s2", participant);
when(metadataEncryptionService.encrypt("secret")).thenReturn("enc:secret");
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("USER_CERT");
req.setPassword("secret");
service.signDocument("s2", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
Map<String, Object> meta = captor.getValue().getParticipantMetadata();
assertThat(meta).containsKey("certificateSubmission");
@SuppressWarnings("unchecked")
Map<String, Object> cert = (Map<String, Object>) meta.get("certificateSubmission");
assertThat(cert.get("certType")).isEqualTo("USER_CERT");
// Raw password must not be stored — only the encrypted form
assertThat(cert.get("password")).isEqualTo("enc:secret");
assertThat(cert.get("password")).isNotEqualTo("secret");
}
@Test
void signDocument_preservesExistingParticipantMetadata() {
User user = user("carol");
WorkflowParticipant participant = pendingParticipant(user);
Map<String, Object> existing = new HashMap<>();
existing.put("showLogo", true);
existing.put("pageNumber", 1);
participant.setParticipantMetadata(existing);
sessionWithParticipant("s3", participant);
when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw");
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
service.signDocument("s3", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
Map<String, Object> meta = captor.getValue().getParticipantMetadata();
// Owner-configured appearance settings must survive the sign operation
assertThat(meta.get("showLogo")).isEqualTo(true);
assertThat(meta.get("pageNumber")).isEqualTo(1);
assertThat(meta).containsKey("certificateSubmission");
}
// -------------------------------------------------------------------------
// signDocument — guard conditions
// -------------------------------------------------------------------------
@Test
void signDocument_throwsBadRequest_whenAlreadySigned() {
User user = user("dave");
WorkflowParticipant participant = pendingParticipant(user);
participant.setStatus(ParticipantStatus.SIGNED);
sessionWithParticipant("s4", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("s4", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void signDocument_throwsBadRequest_whenAlreadyDeclined() {
User user = user("eve");
WorkflowParticipant participant = pendingParticipant(user);
participant.setStatus(ParticipantStatus.DECLINED);
sessionWithParticipant("s5", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("s5", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void signDocument_throwsForbidden_whenUserIsNotParticipant() {
User owner = user("frank");
owner.setId(1L);
User intruder = user("intruder");
intruder.setId(2L);
WorkflowParticipant participant = pendingParticipant(owner);
sessionWithParticipant("s6", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("s6", intruder, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
verify(workflowParticipantRepository, never()).save(any());
}
// -------------------------------------------------------------------------
// createSession — validation guards
// -------------------------------------------------------------------------
private void stubSigningEnabled() {
Storage storage = mock(Storage.class);
Signing signing = mock(Signing.class);
when(applicationProperties.getStorage()).thenReturn(storage);
when(storage.isEnabled()).thenReturn(true);
when(storage.getSigning()).thenReturn(signing);
when(signing.isEnabled()).thenReturn(true);
}
@Test
void createSession_nullFile_throwsBadRequest() {
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
assertThatThrownBy(() -> service.createSession(user("owner"), null, request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createSession_emptyFile_throwsBadRequest() {
MockMultipartFile empty =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[0]);
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
assertThatThrownBy(() -> service.createSession(user("owner"), empty, request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createSession_nullWorkflowType_throwsBadRequest() {
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(null);
assertThatThrownBy(() -> service.createSession(user("owner"), file, request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createSession_validRequest_sessionSavedWithOwnerAndInProgressStatus() throws IOException {
User owner = user("alice");
MockMultipartFile file =
new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[] {1, 2});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
request.setDocumentName("My Doc");
StoredObject storedObject =
StoredObject.builder()
.storageKey("key-1")
.originalFilename("doc.pdf")
.contentType("application/pdf")
.sizeBytes(2L)
.build();
when(storageProvider.store(any(), any())).thenReturn(storedObject);
StoredFile savedFile = new StoredFile();
when(storedFileRepository.save(any())).thenReturn(savedFile);
WorkflowSession savedSession = new WorkflowSession();
savedSession.setSessionId("s-abc");
savedSession.setParticipants(new ArrayList<>());
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
WorkflowSession result = service.createSession(owner, file, request);
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
verify(workflowSessionRepository).save(captor.capture());
assertThat(captor.getValue().getOwner()).isEqualTo(owner);
assertThat(captor.getValue().getStatus()).isEqualTo(WorkflowStatus.IN_PROGRESS);
assertThat(result).isNotNull();
}
@Test
void createSession_documentNameFromRequest() throws IOException {
User owner = user("alice");
MockMultipartFile file =
new MockMultipartFile("file", "original.pdf", "application/pdf", new byte[] {1});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
request.setDocumentName("Custom Name");
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("k")
.originalFilename("original.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build());
when(storedFileRepository.save(any())).thenReturn(new StoredFile());
WorkflowSession savedSession = new WorkflowSession();
savedSession.setSessionId("s-1");
savedSession.setParticipants(new ArrayList<>());
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
service.createSession(owner, file, request);
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
verify(workflowSessionRepository).save(captor.capture());
assertThat(captor.getValue().getDocumentName()).isEqualTo("Custom Name");
}
@Test
void createSession_documentNameFallsBackToOriginalFilename() throws IOException {
User owner = user("alice");
MockMultipartFile file =
new MockMultipartFile("file", "uploaded.pdf", "application/pdf", new byte[] {1});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
request.setDocumentName(null);
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("k")
.originalFilename("uploaded.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build());
when(storedFileRepository.save(any())).thenReturn(new StoredFile());
WorkflowSession savedSession = new WorkflowSession();
savedSession.setSessionId("s-2");
savedSession.setParticipants(new ArrayList<>());
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
service.createSession(owner, file, request);
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
verify(workflowSessionRepository).save(captor.capture());
assertThat(captor.getValue().getDocumentName()).isEqualTo("uploaded.pdf");
}
// -------------------------------------------------------------------------
// getSessionForOwner
// -------------------------------------------------------------------------
@Test
void getSessionForOwner_ownerMatch_returnsSession() {
User owner = user("alice");
owner.setId(1L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s1");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(session));
WorkflowSession result = service.getSessionForOwner("s1", owner);
assertThat(result).isSameAs(session);
}
@Test
void getSessionForOwner_sessionNotFound_throwsNotFound() {
when(workflowSessionRepository.findBySessionId("missing")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getSessionForOwner("missing", user("alice")))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getSessionForOwner_wrongOwner_throwsForbidden() {
User owner = user("alice");
owner.setId(1L);
User intruder = user("bob");
intruder.setId(2L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s2");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s2")).thenReturn(Optional.of(session));
assertThatThrownBy(() -> service.getSessionForOwner("s2", intruder))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
// -------------------------------------------------------------------------
// deleteSession
// -------------------------------------------------------------------------
@Test
void deleteSession_ownerAuthorized_deletesSession() {
User owner = user("alice");
owner.setId(1L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s3");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s3")).thenReturn(Optional.of(session));
service.deleteSession("s3", owner);
verify(workflowSessionRepository).delete(session);
// session.save() must NOT be called — that would UPDATE original_file_id to NULL,
// violating the NOT NULL constraint; the row is simply deleted instead
verify(workflowSessionRepository, never()).save(any());
}
@Test
void deleteSession_withBothFiles_nullsBackRefsAndDeletesInOrder() {
User owner = user("alice");
owner.setId(1L);
StoredFile originalFile = new StoredFile();
originalFile.setStorageKey("key-orig");
StoredFile processedFile = new StoredFile();
processedFile.setStorageKey("key-proc");
WorkflowSession session = new WorkflowSession();
session.setSessionId("s3b");
session.setOwner(owner);
session.setOriginalFile(originalFile);
session.setProcessedFile(processedFile);
when(workflowSessionRepository.findBySessionId("s3b")).thenReturn(Optional.of(session));
service.deleteSession("s3b", owner);
// Only the back-reference (StoredFile → session) must be nulled; session.originalFile
// is NOT nulled because that would emit UPDATE original_file_id=NULL (NOT NULL violation)
assertThat(originalFile.getWorkflowSession()).isNull();
assertThat(processedFile.getWorkflowSession()).isNull();
// Order: save StoredFiles (clear back-refs) → delete session → delete StoredFile rows
InOrder inOrder = inOrder(workflowSessionRepository, storedFileRepository);
inOrder.verify(storedFileRepository).save(originalFile);
inOrder.verify(storedFileRepository).save(processedFile);
inOrder.verify(workflowSessionRepository).delete(session);
inOrder.verify(storedFileRepository).delete(originalFile);
inOrder.verify(storedFileRepository).delete(processedFile);
}
@Test
void deleteSession_finalizedSession_throwsBadRequest() {
User owner = user("alice");
owner.setId(1L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s3c");
session.setOwner(owner);
session.setFinalized(true);
when(workflowSessionRepository.findBySessionId("s3c")).thenReturn(Optional.of(session));
assertThatThrownBy(() -> service.deleteSession("s3c", owner))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowSessionRepository, never()).delete(any());
}
@Test
void deleteSession_storageErrorOnOriginalFile_stillDeletesSession() throws Exception {
User owner = user("alice");
owner.setId(1L);
StoredFile originalFile = new StoredFile();
originalFile.setStorageKey("key-orig");
WorkflowSession session = new WorkflowSession();
session.setSessionId("s4");
session.setOwner(owner);
session.setOriginalFile(originalFile);
when(workflowSessionRepository.findBySessionId("s4")).thenReturn(Optional.of(session));
doThrow(new RuntimeException("storage unavailable"))
.when(storageProvider)
.delete("key-orig");
service.deleteSession("s4", owner);
// Storage failure is non-fatal — back-ref is still cleared and DB records still deleted
verify(storedFileRepository).save(originalFile); // back-ref cleared
verify(workflowSessionRepository).delete(session);
verify(storedFileRepository).delete(originalFile);
}
@Test
void deleteSession_notOwner_throwsForbidden() {
User owner = user("alice");
owner.setId(1L);
User other = user("bob");
other.setId(2L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s5");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s5")).thenReturn(Optional.of(session));
assertThatThrownBy(() -> service.deleteSession("s5", other))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
verify(workflowSessionRepository, never()).delete(any());
}
// -------------------------------------------------------------------------
// listUserSessions
// -------------------------------------------------------------------------
@Test
void listUserSessions_returnsAllSessionsForOwner() {
User owner = user("alice");
WorkflowSession s1 = new WorkflowSession();
WorkflowSession s2 = new WorkflowSession();
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner))
.thenReturn(List.of(s1, s2));
List<WorkflowSession> result = service.listUserSessions(owner);
assertThat(result).containsExactly(s1, s2);
}
@Test
void listUserSessions_noSessions_returnsEmptyList() {
User owner = user("alice");
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner))
.thenReturn(List.of());
assertThat(service.listUserSessions(owner)).isEmpty();
}
}