diff --git a/pom.xml b/pom.xml index a22f338..a136f37 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.22-4a6c2e7 + 1.23-e544bc9 dev.vality diff --git a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java index 8b9f2ff..1de20a9 100644 --- a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -5,7 +5,6 @@ import dev.vality.orgmanagement.Member; import dev.vality.orgmanagement.MemberRole; import dev.vality.orgmanagement.Organization; -import dev.vality.orgmanagement.OrganizationRole; import dev.vality.orgmanagement.OrganizationStatus; import dev.vality.orgmanagement.RoleAssignment; import dev.vality.orgmanagement.RoleScope; @@ -14,7 +13,6 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.entity.MemberRoleEntity; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.entity.OrganizationRoleEntity; import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; import dev.vality.orgmanager.service.dto.UserDto; @@ -29,7 +27,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; @Component public class AdminManagementConverter { @@ -158,15 +155,6 @@ public InvitationStatus effectiveInvitationStatus(InvitationEntity entity) { return storedStatus; } - public OrganizationRole toOrganizationRole(OrganizationRoleEntity entity) { - List scopeIds = collectionOrEmpty(entity.getPossibleScopes()).stream() - .map(scope -> scope.getId()) - .filter(Objects::nonNull) - .sorted() - .toList(); - return new OrganizationRole(entity.getRoleId(), entity.getName(), scopeIds); - } - private RoleScope toRoleScope(String scopeId, String resourceId) { RoleScope scope = new RoleScope(scopeId); if (resourceId != null) { diff --git a/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java b/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java deleted file mode 100644 index 2031b65..0000000 --- a/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.vality.orgmanager.repository; - -import dev.vality.orgmanager.entity.ScopeEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface ScopeRepository extends JpaRepository { -} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java index f8edae1..c3fec6c 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -5,20 +5,14 @@ import dev.vality.orgmanagement.RoleScope; import dev.vality.orgmanager.entity.MemberRoleEntity; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.entity.OrganizationRoleEntity; -import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.repository.OrganizationRepository; -import dev.vality.orgmanager.repository.OrganizationRoleRepository; import dev.vality.orgmanager.util.JsonCodec; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.Collection; import java.util.List; -import java.util.Objects; -import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -28,7 +22,6 @@ public class AdminCommonService { public static final int MAX_PAGE_LIMIT = 1000; private final OrganizationRepository organizationRepository; - private final OrganizationRoleRepository organizationRoleRepository; private final JsonCodec jsonCodec; public OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { @@ -55,30 +48,14 @@ public String requireText(String value, String field) throws InvalidRequest { } /** - * Проверяет, что назначение роли опирается на каталог ролей организации: сама роль в нём есть, - * а указанный scope_id входит в её список допустимых областей действия. + * Область действия без resource_id не ограничивает роль, поэтому такое назначение отклоняется. */ - public void validateRoleAssignment(String organizationId, String roleId, RoleScope scope) - throws InvalidRequest { + public void validateRoleAssignment(String roleId, RoleScope scope) throws InvalidRequest { requireText(roleId, "Role id"); - OrganizationRoleEntity role = organizationRoleRepository - .findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseThrow(() -> new InvalidRequest( - "Role " + roleId + " is not present in the organization role catalog")); - if (scope == null) { - return; + if (scope != null) { + requireText(scope.getScopeId(), "Scope id"); + requireText(scope.getResourceId(), "Scope resource id"); } - String scopeId = requireText(scope.getScopeId(), "Scope id"); - if (!allowedScopeIds(role).contains(scopeId)) { - throw new InvalidRequest("Scope " + scopeId + " is not allowed for role " + roleId); - } - } - - public Set allowedScopeIds(OrganizationRoleEntity role) { - return collectionOrEmpty(role.getPossibleScopes()).stream() - .map(ScopeEntity::getId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); } public MemberRoleEntity toMemberRoleEntity(String organizationId, String roleId, RoleScope scope) { diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 6cd3936..5d30cd3 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -67,7 +67,7 @@ public Invitation create(String organizationId, CreateInvitationRequest request) String metadata = commonService.toStoredMetadata(request.getMetadata()); Set roles = new LinkedHashSet<>(); for (RoleAssignment role : collectionOrEmpty(request.getRoles())) { - commonService.validateRoleAssignment(organizationId, role.getRoleId(), role.getScope()); + commonService.validateRoleAssignment(role.getRoleId(), role.getScope()); roles.add(commonService.toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())); } LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java index 62ad852..427557d 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -4,8 +4,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; - @Service @RequiredArgsConstructor public class AdminManagementService implements AdminManagementSrv.Iface { @@ -64,23 +62,6 @@ public User getUser(String userId) throws UnknownUser { return adminUserService.get(userId); } - @Override - public OrganizationRole getOrganizationRole(String organizationId, String roleId) - throws OrganizationNotFound, RoleNotFound { - return adminOrganizationService.getRole(organizationId, roleId); - } - - @Override - public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { - return adminOrganizationService.listRoles(organizationId); - } - - @Override - public OrganizationRole setOrganizationRole(String organizationId, SetOrganizationRoleRequest request) - throws OrganizationNotFound, InvalidRequest { - return adminOrganizationService.setRole(organizationId, request); - } - @Override public Member getMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { return adminMemberService.get(organizationId, userId); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java index f8a50fc..6ad56fc 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -133,7 +133,7 @@ public MemberRole assignRole( organizationId, userId, request.getRoleId()); OrganizationEntity organization = commonService.findOrganization(organizationId); MemberEntity member = findMember(organization, userId); - commonService.validateRoleAssignment(organizationId, request.getRoleId(), request.getScope()); + commonService.validateRoleAssignment(request.getRoleId(), request.getScope()); MemberRoleEntity role = commonService.toMemberRoleEntity( organizationId, request.getRoleId(), request.getScope()); role = memberRoleRepository.save(role); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index fd108b0..daf38fb 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -8,18 +8,11 @@ import dev.vality.orgmanagement.ModifyOrganizationRequest; import dev.vality.orgmanagement.Organization; import dev.vality.orgmanagement.OrganizationNotFound; -import dev.vality.orgmanagement.OrganizationRole; import dev.vality.orgmanagement.PartyAlreadyBound; -import dev.vality.orgmanagement.RoleNotFound; -import dev.vality.orgmanagement.SetOrganizationRoleRequest; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.entity.OrganizationRoleEntity; -import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.entity.StoredOrganizationStatus; import dev.vality.orgmanager.repository.OrganizationRepository; -import dev.vality.orgmanager.repository.OrganizationRoleRepository; -import dev.vality.orgmanager.repository.ScopeRepository; import dev.vality.orgmanager.service.dto.AdminPage; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; @@ -34,19 +27,15 @@ import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import java.util.UUID; -import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; import static java.util.Objects.requireNonNullElseGet; /** - * Организации и их роли в административном контракте. + * Организации в административном контракте. */ @Slf4j @Service @@ -54,8 +43,6 @@ public class AdminOrganizationService { private final OrganizationRepository organizationRepository; - private final OrganizationRoleRepository organizationRoleRepository; - private final ScopeRepository scopeRepository; private final AdminManagementConverter converter; private final AdminCommonService commonService; @@ -143,62 +130,6 @@ public Organization activate(String organizationId) throws OrganizationNotFound, return changeStatus(organizationId, StoredOrganizationStatus.DEACTIVATED, StoredOrganizationStatus.ACTIVE); } - @Transactional(readOnly = true) - public OrganizationRole getRole(String organizationId, String roleId) - throws OrganizationNotFound, RoleNotFound { - log.info("Get organization role: organizationId={}, roleId={}", organizationId, roleId); - commonService.findOrganization(organizationId); - OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseThrow(RoleNotFound::new); - return converter.toOrganizationRole(role); - } - - @Transactional(readOnly = true) - public List listRoles(String organizationId) throws OrganizationNotFound { - log.info("List organization roles: organizationId={}", organizationId); - OrganizationEntity organization = commonService.findOrganization(organizationId); - return collectionOrEmpty(organization.getRoles()).stream() - .sorted(Comparator.comparing(OrganizationRoleEntity::getRoleId)) - .map(converter::toOrganizationRole) - .toList(); - } - - /** - * Создаёт либо обновляет роль в каталоге ролей организации. Каталог задаёт, какие роли - * и области действия допустимы в AssignMemberRole и CreateInvitation - */ - @Transactional - public OrganizationRole setRole(String organizationId, SetOrganizationRoleRequest request) - throws OrganizationNotFound, InvalidRequest { - log.info("Set organization role: organizationId={}, request={}", organizationId, request); - if (request == null) { - throw new InvalidRequest("Request must not be null"); - } - commonService.findOrganization(organizationId); - String roleId = commonService.requireText(request.getRoleId(), "Role id"); - String name = commonService.requireText(request.getName(), "Role name"); - OrganizationRoleEntity role = organizationRoleRepository - .findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseGet(() -> OrganizationRoleEntity.builder() - .id(UUID.randomUUID().toString()) - .organizationId(organizationId) - .roleId(roleId) - .build()); - role.setName(name); - role.setPossibleScopes(resolveScopes(request.getScopeIds())); - return converter.toOrganizationRole(organizationRoleRepository.save(role)); - } - - private Set resolveScopes(List scopeIds) throws InvalidRequest { - Set scopes = new LinkedHashSet<>(); - for (String scopeId : scopeIds == null ? List.of() : scopeIds) { - commonService.requireText(scopeId, "Scope id"); - scopes.add(scopeRepository.findById(scopeId) - .orElseGet(() -> scopeRepository.save(ScopeEntity.builder().id(scopeId).build()))); - } - return scopes; - } - private Specification specification(ListOrganizationsRequest request) { return (root, query, builder) -> { List predicates = new ArrayList<>(); diff --git a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java index e522acd..9af2518 100644 --- a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java +++ b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java @@ -48,10 +48,17 @@ public void setUp() throws Exception { invitationRepository.deleteAll(); memberContextRepository.deleteAll(); organizationRoleRepository.deleteAll(); - organizationRepository.deleteAll(); + // Связи с каскадом PERSIST иначе отменяют удаление при flush + var organizations = organizationRepository.findAll(); + organizations.forEach(it -> it.getMembers().clear()); + organizationRepository.saveAll(organizations); var members = memberRepository.findAll(); - members.forEach(it -> it.getRoles().clear()); + members.forEach(it -> { + it.getRoles().clear(); + it.getOrganizations().clear(); + }); memberRepository.saveAll(members); + organizationRepository.deleteAll(); memberRepository.deleteAll(); memberRoleRepository.deleteAll(); } diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index e0e03f9..96b46a3 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -7,24 +7,22 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.entity.MemberRoleEntity; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.entity.OrganizationRoleEntity; -import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.repository.InvitationRepository; import dev.vality.orgmanager.repository.MemberRepository; import dev.vality.orgmanager.repository.MemberRoleRepository; import dev.vality.orgmanager.repository.OrganizationRepository; -import dev.vality.orgmanager.repository.OrganizationRoleRepository; -import dev.vality.orgmanager.repository.ScopeRepository; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; import dev.vality.orgmanager.service.dto.UserDto; import dev.vality.orgmanager.util.JsonCodec; 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 org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; import tools.jackson.databind.json.JsonMapper; @@ -35,6 +33,8 @@ import java.util.Optional; import java.util.Set; +import static dev.vality.orgmanager.service.AdminCommonService.DEFAULT_PAGE_LIMIT; +import static dev.vality.orgmanager.service.AdminCommonService.MAX_PAGE_LIMIT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -59,10 +59,6 @@ class AdminManagementServiceTest { @Mock private MemberRoleRepository memberRoleRepository; @Mock - private OrganizationRoleRepository organizationRoleRepository; - @Mock - private ScopeRepository scopeRepository; - @Mock private InvitationRepository invitationRepository; @Mock private InviteTokenProperties inviteTokenProperties; @@ -76,13 +72,10 @@ void setUp() { AdminManagementConverter converter = new AdminManagementConverter(); AdminCommonService commonService = new AdminCommonService( organizationRepository, - organizationRoleRepository, new JsonCodec(JsonMapper.builder().build())); service = new AdminManagementService( new AdminOrganizationService( organizationRepository, - organizationRoleRepository, - scopeRepository, converter, commonService), new AdminUserService(memberRepository, converter), @@ -225,55 +218,6 @@ void shouldValidateOrganizationStatusTransitions() throws Exception { assertEquals(OrganizationStatus.active, service.activateOrganization("org").getStatus()); } - @Test - void shouldRejectUnknownRoleWithRoleNotFound() { - when(organizationRepository.findById("org")) - .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); - - assertThrows(RoleNotFound.class, () -> service.getOrganizationRole("org", "manager")); - } - - @Test - void shouldCreateRoleInCatalogAndThenUpdateIt() throws Exception { - when(organizationRepository.findById("org")) - .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); - when(organizationRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(scopeRepository.findById("Shop")).thenReturn(Optional.empty()); - when(scopeRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - - var created = service.setOrganizationRole( - "org", - new SetOrganizationRoleRequest("manager", "Manager", List.of("Shop"))); - - assertEquals("manager", created.getId()); - assertEquals("Manager", created.getName()); - assertEquals(List.of("Shop"), created.getScopeIds()); - - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); - - var updated = service.setOrganizationRole( - "org", - new SetOrganizationRoleRequest("manager", "Shop manager", new ArrayList<>())); - - assertEquals("Shop manager", updated.getName()); - assertTrue(updated.getScopeIds().isEmpty()); - } - - @Test - void shouldRejectRoleWithBlankName() { - when(organizationRepository.findById("org")) - .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - - assertThrows(InvalidRequest.class, () -> service.setOrganizationRole( - "org", - new SetOrganizationRoleRequest("manager", " ", List.of()))); - } - @Test void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -281,8 +225,6 @@ void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager"))); var member = service.addMember("org", new AddMemberRequest("user", "user@example.com")); var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); @@ -321,37 +263,24 @@ void shouldRejectMemberWithoutEmail() { } @Test - void shouldRejectRoleThatIsMissingFromCatalog() { + void shouldAssignArbitraryRoleAndScope() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() .id("user") .roles(new HashSet<>()) .build()))); when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); - - assertThrows(InvalidRequest.class, - () -> service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager"))); - verifyNoInteractions(memberRoleRepository); - } - - @Test - void shouldRejectScopeThatRoleDoesNotAllow() { - OrganizationEntity organization = organization("org", OrganizationStatus.active); - organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() - .id("user") - .roles(new HashSet<>()) - .build()))); - when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + var assigned = service.assignMemberRole( "org", "user", - new AssignMemberRoleRequest("manager").setScope(new RoleScope("Wallet")))); - verifyNoInteractions(memberRoleRepository); + new AssignMemberRoleRequest("CustomRole").setScope(new RoleScope("Wallet").setResourceId("w-1"))); + + assertEquals("CustomRole", assigned.getRoleId()); + assertEquals("Wallet", assigned.getScope().getScopeId()); + assertEquals("w-1", assigned.getScope().getResourceId()); } @Test @@ -454,17 +383,32 @@ void shouldCreateInvitationAndSendMail() throws Exception { } @Test - void shouldRejectInvitationWithRoleOutsideCatalog() { + void shouldCreateInvitationWithScopedRole() throws Exception { when(organizationRepository.findById("org")) .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); + when(inviteTokenProperties.getLifeTimeInDays()).thenReturn(7L); + when(invitationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); CreateInvitationRequest request = new CreateInvitationRequest( "user@example.com", - List.of(new RoleAssignment("manager"))); + List.of(new RoleAssignment("Manager").setScope(new RoleScope("Shop").setResourceId("shop-1")))); + + var result = service.createInvitation("org", request); + + assertEquals(1, result.getRoles().size()); + assertEquals("Manager", result.getRoles().get(0).getRoleId()); + assertEquals("shop-1", result.getRoles().get(0).getScope().getResourceId()); + } + + @Test + void shouldRejectInvitationWithBlankRole() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + CreateInvitationRequest request = new CreateInvitationRequest( + "user@example.com", + List.of(new RoleAssignment(" "))); assertThrows(InvalidRequest.class, () -> service.createInvitation("org", request)); - verifyNoInteractions(mailMessageSender); + verifyNoInteractions(mailMessageSender, invitationRepository); } @Test @@ -621,6 +565,250 @@ void shouldRejectBlankUserIdWithoutQuery() { verifyNoInteractions(memberRepository); } + @Test + void shouldRejectUnknownOrganizationForEveryOrganizationScopedMethod() { + when(organizationRepository.findById("gone")).thenReturn(Optional.empty()); + when(organizationRepository.existsById("gone")).thenReturn(false); + + assertThrows(OrganizationNotFound.class, () -> service.getOrganization("gone")); + assertThrows(OrganizationNotFound.class, () -> service.modifyOrganization( + "gone", + new ModifyOrganizationRequest().setName("renamed"))); + assertThrows(OrganizationNotFound.class, () -> service.deactivateOrganization("gone")); + assertThrows(OrganizationNotFound.class, () -> service.activateOrganization("gone")); + assertThrows(OrganizationNotFound.class, () -> service.getMember("gone", "user")); + assertThrows(OrganizationNotFound.class, () -> service.listMembers("gone", new ListMembersRequest())); + assertThrows(OrganizationNotFound.class, () -> service.addMember( + "gone", + new AddMemberRequest("user", "user@example.com"))); + assertThrows(OrganizationNotFound.class, () -> service.removeMember("gone", "user")); + assertThrows(OrganizationNotFound.class, () -> service.assignMemberRole( + "gone", + "user", + new AssignMemberRoleRequest("manager"))); + assertThrows(OrganizationNotFound.class, () -> service.removeMemberRole("gone", "user", "role")); + assertThrows(OrganizationNotFound.class, () -> service.createInvitation( + "gone", + new CreateInvitationRequest("user@example.com", new ArrayList<>()))); + assertThrows(OrganizationNotFound.class, () -> service.getInvitation("gone", "invitation")); + assertThrows(OrganizationNotFound.class, + () -> service.listInvitations("gone", new ListInvitationsRequest())); + assertThrows(OrganizationNotFound.class, () -> service.revokeInvitation( + "gone", + "invitation", + new RevokeInvitationRequest("obsolete"))); + + verifyNoInteractions(memberRepository, memberRoleRepository, invitationRepository, mailMessageSender); + } + + @Test + void shouldRejectUnknownMemberForEveryMemberScopedMethod() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(MemberNotFound.class, () -> service.getMember("org", "ghost")); + assertThrows(MemberNotFound.class, () -> service.removeMember("org", "ghost")); + assertThrows(MemberNotFound.class, () -> service.assignMemberRole( + "org", + "ghost", + new AssignMemberRoleRequest("manager"))); + assertThrows(MemberNotFound.class, () -> service.removeMemberRole("org", "ghost", "role")); + + verifyNoInteractions(memberRepository, memberRoleRepository); + } + + /** + * Удаление участника гасит только его роли в этой организации: роли в других + * организациях остаются активными, сама запись пользователя не удаляется. + */ + @Test + void shouldRemoveMemberAndDeactivateOnlyItsRolesInThisOrganization() throws Exception { + MemberRoleEntity here = MemberRoleEntity.builder() + .id("role-here") + .organizationId("org") + .roleId("manager") + .active(true) + .build(); + MemberRoleEntity elsewhere = MemberRoleEntity.builder() + .id("role-elsewhere") + .organizationId("another-org") + .roleId("manager") + .active(true) + .build(); + MemberEntity member = MemberEntity.builder() + .id("user") + .email("user@example.com") + .roles(new HashSet<>(Set.of(here, elsewhere))) + .build(); + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(member))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.removeMember("org", "user"); + + assertFalse(here.isActive()); + assertTrue(elsewhere.isActive()); + assertEquals(Set.of(elsewhere), member.getRoles()); + assertTrue(organization.getMembers().isEmpty()); + verify(memberRoleRepository).saveAll(List.of(here)); + assertThrows(MemberNotFound.class, () -> service.getMember("org", "user")); + } + + @Test + void shouldGetInvitationWithinItsOrganization() throws Exception { + InvitationEntity pending = invitation("pending", LocalDateTime.now().plusDays(1)); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("pending", "org")).thenReturn(Optional.of(pending)); + + var result = service.getInvitation("org", "pending"); + + assertEquals("pending", result.getId()); + assertEquals("org", result.getOrganizationId()); + assertEquals("user@example.com", result.getEmail()); + assertEquals(InvitationStatus.pending, result.getStatus()); + assertFalse(result.isSetMetadata()); + } + + @Test + void shouldRejectInvitationThatBelongsToAnotherOrganization() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("alien", "org")).thenReturn(Optional.empty()); + + assertThrows(InvitationNotFound.class, () -> service.getInvitation("org", "alien")); + } + + /** + * Просрочка считается по expires_at, не дожидаясь фоновой задачи. + */ + @Test + void shouldReportPendingInvitationAsExpiredBeforeSchedulerSweepsIt() throws Exception { + InvitationEntity stale = invitation("stale", LocalDateTime.now().minusMinutes(1)); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("stale", "org")).thenReturn(Optional.of(stale)); + + assertEquals(InvitationStatus.expired, service.getInvitation("org", "stale").getStatus()); + } + + @Test + void shouldRejectRevokingInvitationThatIsNotPending() { + InvitationEntity revoked = invitation("revoked", LocalDateTime.now().plusDays(1), "Revoked"); + InvitationEntity accepted = invitation("accepted", LocalDateTime.now().plusDays(1), "Accepted"); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("revoked", "org")).thenReturn(Optional.of(revoked)); + when(invitationRepository.findByIdAndOrganizationId("accepted", "org")).thenReturn(Optional.of(accepted)); + + assertThrows(InvalidInvitationState.class, + () -> service.revokeInvitation("org", "revoked", new RevokeInvitationRequest("obsolete"))); + assertThrows(InvalidInvitationState.class, + () -> service.revokeInvitation("org", "accepted", new RevokeInvitationRequest("obsolete"))); + verify(invitationRepository, never()).save(any()); + } + + @Test + void shouldRejectInvitationWithMetadataThatRestWouldFailToRead() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidRequest.class, () -> service.createInvitation( + "org", + new CreateInvitationRequest("user@example.com", new ArrayList<>()).setMetadata("[1,2]"))); + verifyNoInteractions(mailMessageSender, invitationRepository); + } + + @Test + void shouldKeepScopeResourceIdOnAssignedRole() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var assigned = service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope("Shop").setResourceId("shop-1"))); + + assertEquals("manager", assigned.getRoleId()); + assertEquals("Shop", assigned.getScope().getScopeId()); + assertEquals("shop-1", assigned.getScope().getResourceId()); + } + + @Test + void shouldRejectBlankIdentifiersInRoleAssignment() { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + + assertThrows(InvalidRequest.class, + () -> service.assignMemberRole("org", "user", new AssignMemberRoleRequest(" "))); + assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope(" ").setResourceId("shop-1")))); + assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope("Shop")))); + verifyNoInteractions(memberRoleRepository); + } + + @Test + void shouldRejectMemberWithBlankUserId() { + assertThrows(InvalidRequest.class, + () -> service.addMember("org", new AddMemberRequest(" ", "user@example.com"))); + verifyNoInteractions(organizationRepository, memberRepository); + } + + @Test + void shouldRejectActivatingAlreadyActiveOrganization() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidOrganizationState.class, () -> service.activateOrganization("org")); + verify(organizationRepository, never()).save(any()); + } + + /** + * Размер страницы доходит до запроса с ограничением сверху и с лишней строкой, + * по которой считается continuation token. + */ + @SuppressWarnings("unchecked") + @Test + void shouldCapRequestedPageSizeAndAlwaysAskForOneExtraRow() throws Exception { + final ArgumentCaptor users = ArgumentCaptor.forClass(Pageable.class); + final ArgumentCaptor members = ArgumentCaptor.forClass(Pageable.class); + final ArgumentCaptor organizations = ArgumentCaptor.forClass(Pageable.class); + when(memberRepository.getUserPage(isNull(), isNull(), any(Pageable.class))).thenReturn(List.of()); + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))).thenReturn(List.of()); + when(organizationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + service.listUsers(new ListUsersRequest().setLimit(MAX_PAGE_LIMIT + 100)); + service.listMembers("org", new ListMembersRequest()); + service.listOrganizations(new ListOrganizationsRequest().setLimit(-5)); + + verify(memberRepository).getUserPage(isNull(), isNull(), users.capture()); + verify(memberRepository).getOrgMemberIds(eq("org"), isNull(), members.capture()); + verify(organizationRepository).findAll(any(Specification.class), organizations.capture()); + assertEquals(MAX_PAGE_LIMIT + 1, users.getValue().getPageSize()); + assertEquals(DEFAULT_PAGE_LIMIT + 1, members.getValue().getPageSize()); + assertEquals(DEFAULT_PAGE_LIMIT + 1, organizations.getValue().getPageSize()); + assertEquals(Sort.by(Sort.Direction.DESC, "id"), organizations.getValue().getSort()); + } + private UserDto userRow(String userId, String email) { return new UserDto() { @Override @@ -674,20 +862,6 @@ public String getResourceId() { }; } - private OrganizationRoleEntity role(String roleId, String name, String... scopeIds) { - Set scopes = new HashSet<>(); - for (String scopeId : scopeIds) { - scopes.add(ScopeEntity.builder().id(scopeId).build()); - } - return OrganizationRoleEntity.builder() - .id("catalog-" + roleId) - .organizationId("org") - .roleId(roleId) - .name(name) - .possibleScopes(scopes) - .build(); - } - private OrganizationEntity organization(String id, OrganizationStatus status) { return OrganizationEntity.builder() .id(id) @@ -702,6 +876,10 @@ private OrganizationEntity organization(String id, OrganizationStatus status) { } private InvitationEntity invitation(String id, LocalDateTime expiresAt) { + return invitation(id, expiresAt, "Pending"); + } + + private InvitationEntity invitation(String id, LocalDateTime expiresAt, String status) { return InvitationEntity.builder() .id(id) .organizationId("org") @@ -709,7 +887,7 @@ private InvitationEntity invitation(String id, LocalDateTime expiresAt) { .expiresAt(expiresAt) .inviteeContactEmail("user@example.com") .inviteeRoles(new HashSet<>()) - .status("Pending") + .status(status) .build(); } }