mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
fileshare (#5414)
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:
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
+105
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -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();
|
||||
}
|
||||
}
|
||||
+573
@@ -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);
|
||||
}
|
||||
}
|
||||
+115
@@ -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();
|
||||
}
|
||||
}
|
||||
+123
@@ -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);
|
||||
}
|
||||
}
|
||||
+183
@@ -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);
|
||||
}
|
||||
}
|
||||
+337
@@ -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();
|
||||
}
|
||||
}
|
||||
+201
@@ -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();
|
||||
}
|
||||
}
|
||||
+572
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user