diff --git a/pom.xml b/pom.xml index 979a69ab..86a4b895 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.19-e5d3e5f + 1.21-23aa5d8 dev.vality diff --git a/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java b/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java index d58fc071..90e79679 100644 --- a/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java @@ -1,13 +1,13 @@ package dev.vality.orgmanager.repository; import dev.vality.orgmanager.entity.InvitationEntity; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; import org.springframework.stereotype.Repository; -import jakarta.persistence.QueryHint; - import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -15,7 +15,8 @@ import static org.hibernate.jpa.QueryHints.HINT_FETCH_SIZE; @Repository -public interface InvitationRepository extends JpaRepository { +public interface InvitationRepository + extends JpaRepository, JpaSpecificationExecutor { List findByOrganizationIdAndStatus(String organizationId, String status); diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index a8b2cb83..cc254610 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -2,10 +2,12 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; @Repository @@ -33,10 +35,21 @@ public interface MemberRepository extends JpaRepository { List getOrgMemberList(String orgId); /** - * То же, что {@link #getOrgMemberList(String)}, но джойн ролей внешний: участник без активных - * ролей в организации тоже попадает в выдачу. Административный контракт умеет добавлять - * участника без роли (AddMember), и такой участник не должен пропадать из списка. - * У строк для участника без ролей memberRoleId == null. + * Страница идентификаторов участников организации, отсортированная по возрастанию id. + * continuationToken == null — первая страница, иначе участники строго после указанного id. + */ + @NativeQuery("SELECT m.id " + + " FROM org_manager.member_to_organization mto " + + " JOIN org_manager.member m " + + " ON m.id = mto.member_id " + + " WHERE mto.organization_id = ?1 " + + " AND (CAST(?2 AS VARCHAR) IS NULL OR m.id > ?2) " + + " ORDER BY m.id ") + List getOrgMemberIds(String orgId, String continuationToken, Pageable pageable); + + /** + * То же, что {@link #getOrgMemberList(String)}, но джойн ролей внешний и выборка ограничена + * заданными участниками: участник без активных ролей в организации тоже попадает в выдачу. */ @NativeQuery("SELECT m.id, " + " m.email, " + @@ -55,8 +68,9 @@ public interface MemberRepository extends JpaRepository { " AND mr.active IS TRUE " + " AND mr.organization_id = mto.organization_id " + " WHERE mto.organization_id = ?1 " + + " AND m.id IN (?2) " + " ORDER BY m.id, mr.id ") - List getOrgMemberListWithRoles(String orgId); + List getOrgMemberListWithRoles(String orgId, Collection memberIds); boolean existsById(String id); diff --git a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java index 808daa29..bfbe3acf 100644 --- a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java @@ -2,15 +2,17 @@ import dev.vality.orgmanager.entity.OrganizationEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.NativeQuery; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.Set; @Repository -public interface OrganizationRepository extends JpaRepository { +public interface OrganizationRepository + extends JpaRepository, JpaSpecificationExecutor { @NativeQuery(" SELECT * FROM org_manager.organization AS o " + " WHERE o.id IN " + @@ -39,15 +41,6 @@ public interface OrganizationRepository extends JpaRepository findAllByOrderByIdDesc(Pageable pageable); - - List findByIdLessThanOrderByIdDesc(String id, Pageable pageable); - - List findByStatusOrderByIdDesc(String status, Pageable pageable); - - List findByStatusAndIdLessThanOrderByIdDesc( - String status, - String id, - Pageable pageable); + Optional findByParty(String party); } diff --git a/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java b/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java new file mode 100644 index 00000000..2031b652 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java @@ -0,0 +1,9 @@ +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 ae334e54..f8edae19 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -1,23 +1,34 @@ package dev.vality.orgmanager.service; +import dev.vality.orgmanagement.InvalidRequest; import dev.vality.orgmanagement.OrganizationNotFound; 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 public class AdminCommonService { + public static final int DEFAULT_PAGE_LIMIT = 20; + 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 { @@ -25,15 +36,49 @@ public OrganizationEntity findOrganization(String organizationId) throws Organiz .orElseThrow(OrganizationNotFound::new); } - public String toStoredMetadata(String metadata) { + public String toStoredMetadata(String metadata) throws InvalidRequest { if (metadata == null) { return null; } try { return jsonCodec.toJson(jsonCodec.toMap(metadata)); } catch (RuntimeException exception) { - throw new IllegalArgumentException("Metadata is expected to be a JSON object", exception); + throw new InvalidRequest("Metadata is expected to be a JSON object"); + } + } + + public String requireText(String value, String field) throws InvalidRequest { + if (value == null || value.isBlank()) { + throw new InvalidRequest(field + " must not be blank"); } + return value; + } + + /** + * Проверяет, что назначение роли опирается на каталог ролей организации: сама роль в нём есть, + * а указанный scope_id входит в её список допустимых областей действия. + */ + public void validateRoleAssignment(String organizationId, 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; + } + 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) { @@ -50,4 +95,11 @@ public MemberRoleEntity toMemberRoleEntity(String organizationId, String roleId, public static Collection collectionOrEmpty(Collection collection) { return collection == null ? List.of() : collection; } + + public static int pageLimit(int requested) { + if (requested <= 0) { + return DEFAULT_PAGE_LIMIT; + } + return Math.min(requested, MAX_PAGE_LIMIT); + } } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 098fd975..6cd39365 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -1,13 +1,16 @@ package dev.vality.orgmanager.service; import dev.vality.orgmanagement.CreateInvitationRequest; -import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.InvalidInvitationState; +import dev.vality.orgmanagement.InvalidRequest; import dev.vality.orgmanagement.Invitation; import dev.vality.orgmanagement.InvitationNotFound; import dev.vality.orgmanagement.InvitationStatus; import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.ListInvitationsResult; import dev.vality.orgmanagement.OrganizationNotFound; import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanagement.RoleAssignment; import dev.vality.orgmanager.config.properties.InviteTokenProperties; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.InvitationEntity; @@ -15,18 +18,31 @@ import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.entity.StoredInviteeContactType; import dev.vality.orgmanager.repository.InvitationRepository; +import dev.vality.orgmanager.service.dto.AdminPage; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Comparator; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; + +import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; +import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; +import static java.util.Objects.requireNonNullElseGet; /** * Приглашения в организации в административном контракте. @@ -43,12 +59,17 @@ public class AdminInvitationService { private final AdminCommonService commonService; @Transactional - public Invitation create(String organizationId, CreateInvitationRequest request) throws OrganizationNotFound { + public Invitation create(String organizationId, CreateInvitationRequest request) + throws OrganizationNotFound, InvalidRequest { log.info("Create invitation: organizationId={}, email={}", organizationId, request.getEmail()); commonService.findOrganization(organizationId); - Set roles = request.getRoles().stream() - .map(role -> commonService.toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())) - .collect(Collectors.toCollection(LinkedHashSet::new)); + String email = commonService.requireText(request.getEmail(), "Email"); + String metadata = commonService.toStoredMetadata(request.getMetadata()); + Set roles = new LinkedHashSet<>(); + for (RoleAssignment role : collectionOrEmpty(request.getRoles())) { + commonService.validateRoleAssignment(organizationId, role.getRoleId(), role.getScope()); + roles.add(commonService.toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())); + } LocalDateTime now = LocalDateTime.now(); InvitationEntity invitation = InvitationEntity.builder() .id(UUID.randomUUID().toString()) @@ -57,9 +78,9 @@ public Invitation create(String organizationId, CreateInvitationRequest request) .expiresAt(now.plusDays(inviteTokenProperties.getLifeTimeInDays())) .acceptToken(UUID.randomUUID().toString()) .inviteeContactType(StoredInviteeContactType.EMAIL.getValue()) - .inviteeContactEmail(request.getEmail()) + .inviteeContactEmail(email) .inviteeRoles(roles) - .metadata(commonService.toStoredMetadata(request.getMetadata())) + .metadata(metadata) .status(StoredInvitationStatus.PENDING.getValue()) .build(); invitation = invitationRepository.save(invitation); @@ -76,26 +97,52 @@ public Invitation get(String organizationId, String invitationId) } @Transactional(readOnly = true) - public List list(String organizationId, ListInvitationsRequest request) throws OrganizationNotFound { + public ListInvitationsResult list(String organizationId, ListInvitationsRequest request) + throws OrganizationNotFound { log.info("List invitations: organizationId={}, request={}", organizationId, request); commonService.findOrganization(organizationId); - InvitationStatus status = request == null ? null : request.getStatus(); - return invitationRepository.findByOrganizationId(organizationId).stream() - .filter(invitation -> status == null || converter.effectiveInvitationStatus(invitation) == status) - .sorted(Comparator.comparing(InvitationEntity::getCreatedAt).reversed()) - .map(converter::toInvitation) - .toList(); + ListInvitationsRequest safeRequest = requireNonNullElseGet(request, ListInvitationsRequest::new); + int limit = pageLimit(safeRequest.getLimit()); + + Optional cursor = Optional.empty(); + if (safeRequest.isSetContinuationToken()) { + cursor = invitationRepository + .findByIdAndOrganizationId(safeRequest.getContinuationToken(), organizationId); + if (cursor.isEmpty()) { + return new ListInvitationsResult(List.of()); + } + } + + Pageable pageable = PageRequest.of(0, limit + 1, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + Specification specification = specification( + organizationId, + safeRequest.isSetStatus() ? safeRequest.getStatus() : null, + LocalDateTime.now(), + cursor.orElse(null)); + AdminPage page = AdminPage.of( + invitationRepository.findAll(specification, pageable).getContent(), + limit, + InvitationEntity::getId); + ListInvitationsResult result = new ListInvitationsResult( + page.items().stream().map(converter::toInvitation).toList()); + page.continuationToken().ifPresent(result::setContinuationToken); + return result; } + /** + * Отозвать можно только приглашение в статусе pending. + */ @Transactional public void revoke(String organizationId, String invitationId, RevokeInvitationRequest request) - throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + throws OrganizationNotFound, InvitationNotFound, InvalidInvitationState { log.info("Revoke invitation: organizationId={}, invitationId={}", organizationId, invitationId); commonService.findOrganization(organizationId); InvitationEntity invitation = findInvitation(organizationId, invitationId); InvitationStatus status = converter.effectiveInvitationStatus(invitation); if (status != InvitationStatus.pending) { - throw new InvalidOrganizationState("Only a pending invitation can be revoked"); + throw new InvalidInvitationState( + "Expected invitation status pending, but was " + status.name()); } invitation.setStatus(StoredInvitationStatus.REVOKED.getValue()); invitation.setRevocationReason(request.getReason()); @@ -103,6 +150,51 @@ public void revoke(String organizationId, String invitationId, RevokeInvitationR invitationRepository.save(invitation); } + /** + * Страница упорядочена по (created_at, id) убыванию, поэтому продолжение читается keyset-ом + * от приглашения, на которое указывает continuation token. + */ + private Specification specification( + String organizationId, + InvitationStatus status, + LocalDateTime now, + InvitationEntity cursor) { + return (root, query, builder) -> { + List predicates = new ArrayList<>(); + predicates.add(builder.equal(root.get("organizationId"), organizationId)); + if (status != null) { + predicates.add(statusPredicate(root, builder, status, now)); + } + if (cursor != null) { + Path createdAt = root.get("createdAt"); + predicates.add(builder.or( + builder.lessThan(createdAt, cursor.getCreatedAt()), + builder.and( + builder.equal(createdAt, cursor.getCreatedAt()), + builder.lessThan(root.get("id"), cursor.getId())))); + } + return builder.and(predicates.toArray(new Predicate[0])); + }; + } + + + private Predicate statusPredicate( + Root root, + CriteriaBuilder builder, + InvitationStatus status, + LocalDateTime now) { + Path stored = root.get("status"); + Predicate storedPending = builder.equal(stored, StoredInvitationStatus.PENDING.getValue()); + return switch (status) { + case pending -> builder.and(storedPending, builder.greaterThan(root.get("expiresAt"), now)); + case expired -> builder.or( + builder.equal(stored, StoredInvitationStatus.EXPIRED.getValue()), + builder.and(storedPending, builder.lessThanOrEqualTo(root.get("expiresAt"), now))); + case accepted -> builder.equal(stored, StoredInvitationStatus.ACCEPTED.getValue()); + case revoked -> builder.equal(stored, StoredInvitationStatus.REVOKED.getValue()); + }; + } + private InvitationEntity findInvitation(String organizationId, String invitationId) throws InvitationNotFound { return invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) .orElseThrow(InvitationNotFound::new); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java index 0c965fca..d129124b 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -1,25 +1,6 @@ package dev.vality.orgmanager.service; -import dev.vality.orgmanagement.AddMemberRequest; -import dev.vality.orgmanagement.AdminManagementSrv; -import dev.vality.orgmanagement.AssignMemberRoleRequest; -import dev.vality.orgmanagement.CreateInvitationRequest; -import dev.vality.orgmanagement.CreateOrganizationRequest; -import dev.vality.orgmanagement.InvalidOrganizationState; -import dev.vality.orgmanagement.Invitation; -import dev.vality.orgmanagement.InvitationNotFound; -import dev.vality.orgmanagement.ListInvitationsRequest; -import dev.vality.orgmanagement.ListOrganizationsRequest; -import dev.vality.orgmanagement.ListOrganizationsResult; -import dev.vality.orgmanagement.Member; -import dev.vality.orgmanagement.MemberNotFound; -import dev.vality.orgmanagement.MemberRole; -import dev.vality.orgmanagement.MemberRoleNotFound; -import dev.vality.orgmanagement.Organization; -import dev.vality.orgmanagement.OrganizationNotFound; -import dev.vality.orgmanagement.OrganizationRole; -import dev.vality.orgmanagement.PartyAlreadyBound; -import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanagement.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,7 +15,8 @@ public class AdminManagementService implements AdminManagementSrv.Iface { private final AdminInvitationService adminInvitationService; @Override - public Organization createOrganization(CreateOrganizationRequest request) throws PartyAlreadyBound { + public Organization createOrganization(CreateOrganizationRequest request) + throws PartyAlreadyBound, InvalidRequest { return adminOrganizationService.create(request); } @@ -43,14 +25,20 @@ public Organization getOrganization(String organizationId) throws OrganizationNo return adminOrganizationService.get(organizationId); } + @Override + public Organization getOrganizationByParty(String partyId) throws OrganizationNotFound { + return adminOrganizationService.getByParty(partyId); + } + @Override public ListOrganizationsResult listOrganizations(ListOrganizationsRequest request) { return adminOrganizationService.list(request); } @Override - public Organization renameOrganization(String organizationId, String name) throws OrganizationNotFound { - return adminOrganizationService.rename(organizationId, name); + public Organization modifyOrganization(String organizationId, ModifyOrganizationRequest request) + throws OrganizationNotFound, InvalidRequest { + return adminOrganizationService.modify(organizationId, request); } @Override @@ -66,7 +54,8 @@ public Organization activateOrganization(String organizationId) } @Override - public OrganizationRole getOrganizationRole(String organizationId, String roleId) throws OrganizationNotFound { + public OrganizationRole getOrganizationRole(String organizationId, String roleId) + throws OrganizationNotFound, RoleNotFound { return adminOrganizationService.getRole(organizationId, roleId); } @@ -75,18 +64,26 @@ public List listOrganizationRoles(String organizationId) throw 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); } @Override - public List listMembers(String organizationId) throws OrganizationNotFound { - return adminMemberService.list(organizationId); + public ListMembersResult listMembers(String organizationId, ListMembersRequest request) + throws OrganizationNotFound { + return adminMemberService.list(organizationId, request); } @Override - public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + public Member addMember(String organizationId, AddMemberRequest request) + throws OrganizationNotFound, InvalidRequest { return adminMemberService.add(organizationId, request); } @@ -97,7 +94,7 @@ public void removeMember(String organizationId, String userId) throws Organizati @Override public MemberRole assignMemberRole(String organizationId, String userId, AssignMemberRoleRequest request) - throws OrganizationNotFound, MemberNotFound { + throws OrganizationNotFound, MemberNotFound, InvalidRequest { return adminMemberService.assignRole(organizationId, userId, request); } @@ -109,7 +106,7 @@ public void removeMemberRole(String organizationId, String userId, String member @Override public Invitation createInvitation(String organizationId, CreateInvitationRequest request) - throws OrganizationNotFound { + throws OrganizationNotFound, InvalidRequest { return adminInvitationService.create(organizationId, request); } @@ -120,14 +117,14 @@ public Invitation getInvitation(String organizationId, String invitationId) } @Override - public List listInvitations(String organizationId, ListInvitationsRequest request) + public ListInvitationsResult listInvitations(String organizationId, ListInvitationsRequest request) throws OrganizationNotFound { return adminInvitationService.list(organizationId, request); } @Override public void revokeInvitation(String organizationId, String invitationId, RevokeInvitationRequest request) - throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + throws OrganizationNotFound, InvitationNotFound, InvalidInvitationState { adminInvitationService.revoke(organizationId, invitationId, request); } } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java index 9b6a2b7b..f8a50fca 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -2,6 +2,9 @@ import dev.vality.orgmanagement.AddMemberRequest; import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.InvalidRequest; +import dev.vality.orgmanagement.ListMembersRequest; +import dev.vality.orgmanagement.ListMembersResult; import dev.vality.orgmanagement.Member; import dev.vality.orgmanagement.MemberNotFound; import dev.vality.orgmanagement.MemberRole; @@ -14,8 +17,10 @@ import dev.vality.orgmanager.repository.MemberRepository; import dev.vality.orgmanager.repository.MemberRoleRepository; import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.service.dto.AdminPage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +30,9 @@ import java.util.Set; import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; +import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; +import static java.util.Objects.requireNonNullElseGet; +import static java.util.function.Function.identity; /** * Участники организаций и их роли в административном контракте. @@ -48,28 +56,40 @@ public Member get(String organizationId, String userId) throws OrganizationNotFo } @Transactional(readOnly = true) - public List list(String organizationId) throws OrganizationNotFound { - log.info("List members: organizationId={}", organizationId); + public ListMembersResult list(String organizationId, ListMembersRequest request) throws OrganizationNotFound { + log.info("List members: organizationId={}, request={}", organizationId, request); if (!organizationRepository.existsById(organizationId)) { throw new OrganizationNotFound(); } - return converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId)); + ListMembersRequest safeRequest = requireNonNullElseGet(request, ListMembersRequest::new); + int limit = pageLimit(safeRequest.getLimit()); + AdminPage page = AdminPage.of( + memberRepository.getOrgMemberIds( + organizationId, + safeRequest.getContinuationToken(), + PageRequest.ofSize(limit + 1)), + limit, + identity()); + ListMembersResult result = new ListMembersResult(converter.toMembers( + memberRepository.getOrgMemberListWithRoles(organizationId, page.items()))); + page.continuationToken().ifPresent(result::setContinuationToken); + return result; } @Transactional - public Member add(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + public Member add(String organizationId, AddMemberRequest request) + throws OrganizationNotFound, InvalidRequest { log.info("Add member: organizationId={}, userId={}", organizationId, request.getUserId()); - // TODO: email в AddMemberRequest опционален, но колонка member.email объявлена NOT NULL + String userId = commonService.requireText(request.getUserId(), "User id"); + String email = commonService.requireText(request.getEmail(), "Email"); OrganizationEntity organization = commonService.findOrganization(organizationId); - MemberEntity member = memberRepository.findById(request.getUserId()) + MemberEntity member = memberRepository.findById(userId) .orElseGet(() -> MemberEntity.builder() - .id(request.getUserId()) + .id(userId) .roles(new HashSet<>()) .organizations(new HashSet<>()) .build()); - if (request.isSetEmail()) { - member.setEmail(request.getEmail()); - } + member.setEmail(email); member = memberRepository.save(member); Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); members.add(member); @@ -108,11 +128,12 @@ public void remove(String organizationId, String userId) throws OrganizationNotF public MemberRole assignRole( String organizationId, String userId, - AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { + AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound, InvalidRequest { log.info("Assign member role: organizationId={}, userId={}, roleId={}", organizationId, userId, request.getRoleId()); OrganizationEntity organization = commonService.findOrganization(organizationId); MemberEntity member = findMember(organization, userId); + commonService.validateRoleAssignment(organizationId, request.getRoleId(), request.getScope()); MemberRoleEntity role = commonService.toMemberRoleEntity( organizationId, request.getRoleId(), request.getScope()); role = memberRoleRepository.save(role); @@ -128,10 +149,6 @@ public void removeRole(String organizationId, String userId, String memberRoleId throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { log.info("Remove member role: organizationId={}, userId={}, memberRoleId={}", organizationId, userId, memberRoleId); - // TODO: REST-слой запрещает снимать последнюю активную роль участника в организации - // (OrganizationService.removeMemberRole -> LastRoleException), административный контракт - // это ограничение не повторяет. Нужно либо добавить проверку и исключение в контракт, - // либо явно задокументировать расхождение в admin_management.thrift. OrganizationEntity organization = commonService.findOrganization(organizationId); MemberEntity member = findMember(organization, userId); MemberRoleEntity role = collectionOrEmpty(member.getRoles()).stream() diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index d28b2da6..fd108b0c 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -2,24 +2,33 @@ import dev.vality.orgmanagement.CreateOrganizationRequest; import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.InvalidRequest; import dev.vality.orgmanagement.ListOrganizationsRequest; import dev.vality.orgmanagement.ListOrganizationsResult; +import dev.vality.orgmanagement.ModifyOrganizationRequest; import dev.vality.orgmanagement.Organization; import dev.vality.orgmanagement.OrganizationNotFound; import dev.vality.orgmanagement.OrganizationRole; -import dev.vality.orgmanagement.OrganizationStatus; 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; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,10 +36,14 @@ 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; /** * Организации и их роли в административном контракте. @@ -40,17 +53,19 @@ @RequiredArgsConstructor public class AdminOrganizationService { - static final int DEFAULT_ORGANIZATION_LIMIT = 20; - static final int MAX_ORGANIZATION_LIMIT = 1000; - private final OrganizationRepository organizationRepository; private final OrganizationRoleRepository organizationRoleRepository; + private final ScopeRepository scopeRepository; private final AdminManagementConverter converter; private final AdminCommonService commonService; @Transactional - public Organization create(CreateOrganizationRequest request) throws PartyAlreadyBound { + public Organization create(CreateOrganizationRequest request) throws PartyAlreadyBound, InvalidRequest { log.info("Create organization: partyId={}, ownerId={}", request.getPartyId(), request.getOwnerId()); + commonService.requireText(request.getPartyId(), "Party id"); + commonService.requireText(request.getOwnerId(), "Owner id"); + String name = commonService.requireText(request.getName(), "Organization name"); + String metadata = commonService.toStoredMetadata(request.getMetadata()); if (organizationRepository.existsByParty(request.getPartyId())) { throw new PartyAlreadyBound(); } @@ -58,8 +73,8 @@ public Organization create(CreateOrganizationRequest request) throws PartyAlread .id(UUID.randomUUID().toString()) .party(request.getPartyId()) .owner(request.getOwnerId()) - .name(request.getName()) - .metadata(commonService.toStoredMetadata(request.getMetadata())) + .name(name) + .metadata(metadata) .createdAt(LocalDateTime.now()) .status(StoredOrganizationStatus.ACTIVE.getValue()) .members(new HashSet<>()) @@ -78,32 +93,41 @@ public Organization get(String organizationId) throws OrganizationNotFound { return converter.toOrganization(commonService.findOrganization(organizationId)); } + @Transactional(readOnly = true) + public Organization getByParty(String partyId) throws OrganizationNotFound { + log.info("Get organization by party: partyId={}", partyId); + return converter.toOrganization(organizationRepository.findByParty(partyId) + .orElseThrow(OrganizationNotFound::new)); + } + @Transactional(readOnly = true) public ListOrganizationsResult list(ListOrganizationsRequest request) { log.info("List organizations: request={}", request); - ListOrganizationsRequest safeRequest = request == null ? new ListOrganizationsRequest() : request; - int limit = organizationLimit(safeRequest); - Pageable pageable = PageRequest.of(0, limit + 1); - List entities = findOrganizations(safeRequest, pageable); - - String continuationToken = null; - if (entities.size() > limit) { - entities = new ArrayList<>(entities.subList(0, limit)); - continuationToken = entities.get(entities.size() - 1).getId(); - } + ListOrganizationsRequest safeRequest = requireNonNullElseGet(request, ListOrganizationsRequest::new); + int limit = pageLimit(safeRequest.getLimit()); + Pageable pageable = PageRequest.of(0, limit + 1, Sort.by(Sort.Direction.DESC, "id")); + AdminPage page = AdminPage.of( + organizationRepository.findAll(specification(safeRequest), pageable).getContent(), + limit, + OrganizationEntity::getId); ListOrganizationsResult result = new ListOrganizationsResult( - entities.stream().map(converter::toOrganization).toList()); - if (continuationToken != null) { - result.setContinuationToken(continuationToken); - } + page.items().stream().map(converter::toOrganization).toList()); + page.continuationToken().ifPresent(result::setContinuationToken); return result; } @Transactional - public Organization rename(String organizationId, String name) throws OrganizationNotFound { - log.info("Rename organization: organizationId={}, name={}", organizationId, name); + public Organization modify(String organizationId, ModifyOrganizationRequest request) + throws OrganizationNotFound, InvalidRequest { + log.info("Modify organization: organizationId={}, request={}", organizationId, request); + ModifyOrganizationRequest safeRequest = request == null ? new ModifyOrganizationRequest() : request; OrganizationEntity organization = commonService.findOrganization(organizationId); - organization.setName(name); + if (safeRequest.isSetName()) { + organization.setName(commonService.requireText(safeRequest.getName(), "Organization name")); + } + if (safeRequest.isSetMetadata()) { + organization.setMetadata(commonService.toStoredMetadata(safeRequest.getMetadata())); + } return converter.toOrganization(organizationRepository.save(organization)); } @@ -120,11 +144,12 @@ public Organization activate(String organizationId) throws OrganizationNotFound, } @Transactional(readOnly = true) - public OrganizationRole getRole(String organizationId, String roleId) throws OrganizationNotFound { + 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(OrganizationNotFound::new); + .orElseThrow(RoleNotFound::new); return converter.toOrganizationRole(role); } @@ -138,26 +163,56 @@ public List listRoles(String organizationId) throws Organizati .toList(); } - private List findOrganizations(ListOrganizationsRequest request, Pageable pageable) { - String token = request.getContinuationToken(); - OrganizationStatus status = request.getStatus(); - if (status != null && token != null) { - return organizationRepository.findByStatusAndIdLessThanOrderByIdDesc(status.name(), token, pageable); - } - if (status != null) { - return organizationRepository.findByStatusOrderByIdDesc(status.name(), pageable); - } - if (token != null) { - return organizationRepository.findByIdLessThanOrderByIdDesc(token, pageable); + /** + * Создаёт либо обновляет роль в каталоге ролей организации. Каталог задаёт, какие роли + * и области действия допустимы в 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"); } - return organizationRepository.findAllByOrderByIdDesc(pageable); + 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 int organizationLimit(ListOrganizationsRequest request) { - if (!request.isSetLimit() || request.getLimit() <= 0) { - return DEFAULT_ORGANIZATION_LIMIT; + 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 Math.min(request.getLimit(), MAX_ORGANIZATION_LIMIT); + return scopes; + } + + private Specification specification(ListOrganizationsRequest request) { + return (root, query, builder) -> { + List predicates = new ArrayList<>(); + if (request.isSetStatus()) { + predicates.add(builder.equal(root.get("status"), request.getStatus().name())); + } + if (request.isSetOwnerId()) { + predicates.add(builder.equal(root.get("owner"), request.getOwnerId())); + } + if (request.isSetContinuationToken()) { + predicates.add(builder.lessThan(root.get("id"), request.getContinuationToken())); + } + return builder.and(predicates.toArray(new Predicate[0])); + }; } private Organization changeStatus( diff --git a/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java b/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java new file mode 100644 index 00000000..83eace2f --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java @@ -0,0 +1,21 @@ +package dev.vality.orgmanager.service.dto; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * Страница выдачи, собранная приёмом «выбрать limit + 1»: лишняя строка означает, что за + * страницей есть продолжение, а последняя оставленная строка даёт continuation token. + * Механика одинакова для всех постраничных методов административного контракта. + */ +public record AdminPage(List items, Optional continuationToken) { + + public static AdminPage of(List fetched, int limit, Function tokenOf) { + if (fetched.size() <= limit) { + return new AdminPage<>(List.copyOf(fetched), Optional.empty()); + } + List items = List.copyOf(fetched.subList(0, limit)); + return new AdminPage<>(items, Optional.of(tokenOf.apply(items.get(items.size() - 1)))); + } +} diff --git a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java index c5a3e1c3..e522acd3 100644 --- a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java +++ b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java @@ -47,13 +47,13 @@ public abstract class AbstractRepositoryTest { public void setUp() throws Exception { invitationRepository.deleteAll(); memberContextRepository.deleteAll(); + organizationRoleRepository.deleteAll(); organizationRepository.deleteAll(); var members = memberRepository.findAll(); members.forEach(it -> it.getRoles().clear()); memberRepository.saveAll(members); memberRepository.deleteAll(); memberRoleRepository.deleteAll(); - organizationRoleRepository.deleteAll(); } @BeforeAll diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index ae94510d..4d914611 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -1,29 +1,20 @@ package dev.vality.orgmanager.service; -import dev.vality.orgmanagement.AddMemberRequest; -import dev.vality.orgmanagement.AssignMemberRoleRequest; -import dev.vality.orgmanagement.CreateInvitationRequest; -import dev.vality.orgmanagement.CreateOrganizationRequest; -import dev.vality.orgmanagement.InvalidOrganizationState; -import dev.vality.orgmanagement.InvitationStatus; -import dev.vality.orgmanagement.ListInvitationsRequest; -import dev.vality.orgmanagement.ListOrganizationsRequest; -import dev.vality.orgmanagement.MemberRoleNotFound; -import dev.vality.orgmanagement.OrganizationNotFound; -import dev.vality.orgmanagement.OrganizationStatus; -import dev.vality.orgmanagement.PartyAlreadyBound; -import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanagement.*; import dev.vality.orgmanager.config.properties.InviteTokenProperties; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.InvitationEntity; 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.util.JsonCodec; import org.junit.jupiter.api.BeforeEach; @@ -31,7 +22,9 @@ import org.junit.jupiter.api.extension.ExtendWith; 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.jpa.domain.Specification; import tools.jackson.databind.json.JsonMapper; import java.time.LocalDateTime; @@ -43,10 +36,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -63,6 +60,8 @@ class AdminManagementServiceTest { @Mock private OrganizationRoleRepository organizationRoleRepository; @Mock + private ScopeRepository scopeRepository; + @Mock private InvitationRepository invitationRepository; @Mock private InviteTokenProperties inviteTokenProperties; @@ -76,11 +75,13 @@ 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 AdminMemberService( @@ -120,6 +121,23 @@ void shouldRejectPartyAlreadyBoundToOrganization() { verifyNoInteractions(memberRepository); } + @Test + void shouldRejectMetadataThatRestWouldFailToRead() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name") + .setMetadata("not a json"); + + assertThrows(InvalidRequest.class, () -> service.createOrganization(request)); + verifyNoInteractions(memberRepository); + } + + @Test + void shouldRejectBlankOrganizationName() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", " "); + + assertThrows(InvalidRequest.class, () -> service.createOrganization(request)); + verifyNoInteractions(organizationRepository); + } + @Test void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { ListOrganizationsRequest request = new ListOrganizationsRequest().setLimit(2); @@ -127,7 +145,8 @@ void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { organization("c", OrganizationStatus.active), organization("b", OrganizationStatus.active), organization("a", OrganizationStatus.active)); - when(organizationRepository.findAllByOrderByIdDesc(any(Pageable.class))).thenReturn(entities); + when(organizationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(entities)); var result = service.listOrganizations(request); @@ -135,6 +154,62 @@ void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { assertEquals("b", result.getContinuationToken()); } + @Test + void shouldNotReturnContinuationTokenOnLastPage() { + when(organizationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(organization("a", OrganizationStatus.active)))); + + var result = service.listOrganizations(new ListOrganizationsRequest().setOwnerId("owner-a")); + + assertEquals(List.of("a"), result.getOrganizations().stream().map(it -> it.getId()).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldGetOrganizationByParty() throws Exception { + when(organizationRepository.findByParty("party-org")).thenReturn(Optional.of( + organization("org", OrganizationStatus.active))); + + assertEquals("org", service.getOrganizationByParty("party-org").getId()); + } + + @Test + void shouldRejectUnknownParty() { + when(organizationRepository.findByParty("party-org")).thenReturn(Optional.empty()); + + assertThrows(OrganizationNotFound.class, () -> service.getOrganizationByParty("party-org")); + } + + @Test + void shouldModifyOnlyRequestedOrganizationFields() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMetadata("{\"source\":\"rest\"}"); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var renamed = service.modifyOrganization("org", new ModifyOrganizationRequest().setName("renamed")); + + assertEquals("renamed", renamed.getName()); + assertEquals("{\"source\":\"rest\"}", renamed.getMetadata()); + + var remetadated = service.modifyOrganization( + "org", + new ModifyOrganizationRequest().setMetadata("{\"source\":\"admin\"}")); + + assertEquals("renamed", remetadated.getName()); + assertEquals("{\"source\":\"admin\"}", remetadated.getMetadata()); + } + + @Test + void shouldRejectModificationWithBrokenMetadata() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidRequest.class, () -> service.modifyOrganization( + "org", + new ModifyOrganizationRequest().setMetadata("not a json"))); + } + @Test void shouldValidateOrganizationStatusTransitions() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -148,6 +223,55 @@ 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); @@ -155,8 +279,10 @@ 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").setEmail("user@example.com")); + var member = service.addMember("org", new AddMemberRequest("user", "user@example.com")); var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); service.removeMemberRole("org", "user", role.getId()); @@ -164,6 +290,67 @@ void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { assertTrue(service.getMember("org", "user").getRoles().isEmpty()); } + @Test + void shouldUpdateEmailWhenMemberIsAddedTwice() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + MemberEntity existing = MemberEntity.builder() + .id("user") + .email("old@example.com") + .roles(new HashSet<>()) + .build(); + organization.setMembers(new HashSet<>(Set.of(existing))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.findById("user")).thenReturn(Optional.of(existing)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var member = service.addMember("org", new AddMemberRequest("user", "new@example.com")); + + assertEquals("new@example.com", member.getEmail()); + assertEquals(1, organization.getMembers().size()); + } + + @Test + void shouldRejectMemberWithoutEmail() { + assertThrows(InvalidRequest.class, + () -> service.addMember("org", new AddMemberRequest().setUserId("user"))); + verifyNoInteractions(organizationRepository); + } + + @Test + void shouldRejectRoleThatIsMissingFromCatalog() { + 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"))); + + assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope("Wallet")))); + verifyNoInteractions(memberRoleRepository); + } + @Test void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { MemberRoleEntity role = MemberRoleEntity.builder() @@ -185,28 +372,69 @@ void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { } @Test - void shouldUseEffectiveInvitationStatusForListingAndRevocation() throws Exception { + void shouldRevokeOnlyPendingInvitation() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); InvitationEntity expired = invitation("expired", LocalDateTime.now().minusMinutes(1)); InvitationEntity pending = invitation("pending", LocalDateTime.now().plusDays(1)); when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(invitationRepository.findByOrganizationId("org")).thenReturn(List.of(expired, pending)); when(invitationRepository.findByIdAndOrganizationId("expired", "org")).thenReturn(Optional.of(expired)); when(invitationRepository.findByIdAndOrganizationId("pending", "org")).thenReturn(Optional.of(pending)); - var result = service.listInvitations( - "org", - new ListInvitationsRequest().setStatus(InvitationStatus.expired)); - - assertEquals(List.of("expired"), result.stream().map(it -> it.getId()).toList()); - assertThrows(InvalidOrganizationState.class, + assertThrows(InvalidInvitationState.class, () -> service.revokeInvitation("org", "expired", new RevokeInvitationRequest("obsolete"))); service.revokeInvitation("org", "pending", new RevokeInvitationRequest("obsolete")); + assertEquals("Revoked", pending.getStatus()); assertEquals("obsolete", pending.getRevocationReason()); verify(invitationRepository).save(pending); } + @Test + void shouldReturnContinuationTokenWhenMoreInvitationsExist() throws Exception { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of( + invitation("newest", LocalDateTime.now().plusDays(1)), + invitation("oldest", LocalDateTime.now().plusDays(1))))); + + var result = service.listInvitations("org", new ListInvitationsRequest().setLimit(1)); + + assertEquals(List.of("newest"), result.getInvitations().stream().map(it -> it.getId()).toList()); + assertEquals("newest", result.getContinuationToken()); + } + + @Test + void shouldReadNextInvitationsPageFromCursorInvitation() throws Exception { + InvitationEntity cursor = invitation("newest", LocalDateTime.now().plusDays(1)); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("newest", "org")).thenReturn(Optional.of(cursor)); + when(invitationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(invitation("oldest", LocalDateTime.now().plusDays(1))))); + + var result = service.listInvitations( + "org", + new ListInvitationsRequest().setLimit(1).setContinuationToken("newest")); + + assertEquals(List.of("oldest"), result.getInvitations().stream().map(it -> it.getId()).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldReturnEmptyPageForUnknownInvitationContinuationToken() throws Exception { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("gone", "org")).thenReturn(Optional.empty()); + + var result = service.listInvitations( + "org", + new ListInvitationsRequest().setContinuationToken("gone")); + + assertTrue(result.getInvitations().isEmpty()); + verify(invitationRepository, never()).findAll(any(Specification.class), any(Pageable.class)); + } + @Test void shouldCreateInvitationAndSendMail() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -222,6 +450,20 @@ void shouldCreateInvitationAndSendMail() throws Exception { verify(mailMessageSender).send(any(String.class), eq("user@example.com")); } + @Test + void shouldRejectInvitationWithRoleOutsideCatalog() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.empty()); + CreateInvitationRequest request = new CreateInvitationRequest( + "user@example.com", + List.of(new RoleAssignment("manager"))); + + assertThrows(InvalidRequest.class, () -> service.createInvitation("org", request)); + verifyNoInteractions(mailMessageSender); + } + @Test void shouldTreatMetadataWrittenByRestAsAbsentWhenItIsJsonNull() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -232,37 +474,73 @@ void shouldTreatMetadataWrittenByRestAsAbsentWhenItIsJsonNull() throws Exception assertFalse(service.getOrganization("org").isSetMetadata()); } - @Test - void shouldRejectMetadataThatRestWouldFailToRead() { - CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name") - .setMetadata("not a json"); - - assertThrows(IllegalArgumentException.class, () -> service.createOrganization(request)); - verifyNoInteractions(memberRepository); - } - @Test void shouldListMembersIncludingOnesWithoutRoles() throws Exception { when(organizationRepository.existsById("org")).thenReturn(true); - when(memberRepository.getOrgMemberListWithRoles("org")).thenReturn(List.of( + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))) + .thenReturn(List.of("user-1", "user-2")); + when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( memberRow("user-1", "user-1@example.com", "role-1", "manager"), memberRow("user-1", "user-1@example.com", "role-2", "accountant"), memberRow("user-2", "user-2@example.com", null, null))); - var members = service.listMembers("org"); + var result = service.listMembers("org", new ListMembersRequest()); + var members = result.getMembers(); assertEquals(List.of("user-1", "user-2"), members.stream().map(it -> it.getId()).toList()); assertEquals(List.of("manager", "accountant"), members.get(0).getRoles().stream().map(it -> it.getRoleId()).toList()); assertTrue(members.get(1).getRoles().isEmpty()); assertEquals("user-2@example.com", members.get(1).getEmail()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldReturnContinuationTokenWhenMoreMembersExist() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))) + .thenReturn(List.of("user-1", "user-2")); + when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( + memberRow("user-1", "user-1@example.com", null, null))); + + var result = service.listMembers("org", new ListMembersRequest().setLimit(1)); + + assertEquals(List.of("user-1"), result.getMembers().stream().map(it -> it.getId()).toList()); + assertEquals("user-1", result.getContinuationToken()); + } + + @Test + void shouldReadNextMembersPageByContinuationToken() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), eq("user-1"), any(Pageable.class))) + .thenReturn(List.of("user-2")); + when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( + memberRow("user-2", "user-2@example.com", null, null))); + + var result = service.listMembers( + "org", + new ListMembersRequest().setLimit(1).setContinuationToken("user-1")); + + assertEquals(List.of("user-2"), result.getMembers().stream().map(it -> it.getId()).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldReturnEmptyPageWhenOrganizationHasNoMembers() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))).thenReturn(List.of()); + + var result = service.listMembers("org", new ListMembersRequest()); + + assertTrue(result.getMembers().isEmpty()); + assertNull(result.getContinuationToken()); } @Test void shouldRejectListingMembersOfUnknownOrganization() { when(organizationRepository.existsById("org")).thenReturn(false); - assertThrows(OrganizationNotFound.class, () -> service.listMembers("org")); + assertThrows(OrganizationNotFound.class, () -> service.listMembers("org", new ListMembersRequest())); verifyNoInteractions(memberRepository); } @@ -305,6 +583,20 @@ 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)