From 7501bb3cf28a8db7a175910093d47354f93b60c5 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 11:42:17 +0800 Subject: [PATCH] feat: add namespace lifecycle governance --- .../skillhub/domain/namespace/Namespace.java | 1 + .../namespace/NamespaceAccessPolicy.java | 48 ++++++ .../namespace/NamespaceGovernanceService.java | 142 ++++++++++++++++++ .../namespace/NamespaceMemberService.java | 23 ++- .../domain/namespace/NamespaceService.java | 20 ++- .../NamespaceGovernanceServiceTest.java | 120 +++++++++++++++ .../namespace/NamespaceMemberServiceTest.java | 80 ++++++++++ .../namespace/NamespaceServiceTest.java | 36 +++++ 8 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java index 55073de8..b480cea1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -65,6 +65,7 @@ public class Namespace { public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public NamespaceStatus getStatus() { return status; } + public void setStatus(NamespaceStatus status) { this.status = status; } public NamespaceType getType() { return type; } public void setType(NamespaceType type) { this.type = type; } public String getAvatarUrl() { return avatarUrl; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java new file mode 100644 index 00000000..d798a7d5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Component; + +@Component +public class NamespaceAccessPolicy { + + public boolean isImmutable(Namespace namespace) { + return namespace.getType() == NamespaceType.GLOBAL; + } + + public boolean canMutateSettings(Namespace namespace) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE; + } + + public boolean canManageMembers(Namespace namespace) { + return canMutateSettings(namespace); + } + + public boolean canTransferOwnership(Namespace namespace) { + return canMutateSettings(namespace); + } + + public boolean canFreeze(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE + && (role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN); + } + + public boolean canUnfreeze(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.FROZEN + && (role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN); + } + + public boolean canArchive(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() != NamespaceStatus.ARCHIVED + && role == NamespaceRole.OWNER; + } + + public boolean canRestore(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ARCHIVED + && role == NamespaceRole.OWNER; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java new file mode 100644 index 00000000..dda4574b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NamespaceGovernanceService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final AuditLogService auditLogService; + + public NamespaceGovernanceService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + NamespaceAccessPolicy namespaceAccessPolicy, + AuditLogService auditLogService) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.auditLogService = auditLogService; + } + + @Transactional + public Namespace freezeNamespace(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canFreeze(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.FROZEN); + Namespace updated = namespaceRepository.save(namespace); + record("FREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + + @Transactional + public Namespace unfreezeNamespace(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canUnfreeze(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("UNFREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + + @Transactional + public Namespace archiveNamespace(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canArchive(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ARCHIVED); + Namespace updated = namespaceRepository.save(namespace); + record("ARCHIVE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + + @Transactional + public Namespace restoreNamespace(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canRestore(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("RESTORE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + + private Namespace loadNamespaceBySlug(String slug) { + Namespace namespace = namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", slug); + } + return namespace; + } + + private NamespaceRole requireRole(Long namespaceId, String userId) { + return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + } + + private void record(String action, + String actorUserId, + Long namespaceId, + String requestId, + String clientIp, + String userAgent, + String reason) { + auditLogService.record( + actorUserId, + action, + "NAMESPACE", + namespaceId, + requestId, + clientIp, + userAgent, + reason == null || reason.isBlank() ? null : "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}" + ); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index 11b55a83..82c1c068 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -13,15 +13,19 @@ public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceService namespaceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, - NamespaceService namespaceService) { + NamespaceService namespaceService, + NamespaceAccessPolicy namespaceAccessPolicy) { this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceService = namespaceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; } @Transactional public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); if (role == NamespaceRole.OWNER) { @@ -38,6 +42,7 @@ public class NamespaceMemberService { @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) @@ -52,6 +57,7 @@ public class NamespaceMemberService { @Transactional public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); if (newRole == NamespaceRole.OWNER) { @@ -67,6 +73,11 @@ public class NamespaceMemberService { @Transactional public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { + Namespace namespace = namespaceService.getNamespace(namespaceId); + if (!namespaceAccessPolicy.canTransferOwnership(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); @@ -92,4 +103,14 @@ public class NamespaceMemberService { public Page listMembers(Long namespaceId, Pageable pageable) { return namespaceMemberRepository.findByNamespaceId(namespaceId, pageable); } + + private void assertMemberMutationAllowed(Long namespaceId) { + Namespace namespace = namespaceService.getNamespace(namespaceId); + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 6062b28b..5eb62521 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -10,11 +10,14 @@ public class NamespaceService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + NamespaceAccessPolicy namespaceAccessPolicy) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceAccessPolicy = namespaceAccessPolicy; } @Transactional @@ -42,6 +45,7 @@ public class NamespaceService { Namespace namespace = namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); assertAdminOrOwner(namespaceId, operatorUserId); + assertMutable(namespace); if (displayName != null) { namespace.setDisplayName(displayName); @@ -61,6 +65,11 @@ public class NamespaceService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + public Namespace getNamespace(Long namespaceId) { + return namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + } + void assertAdminOrOwner(Long namespaceId, String userId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole) @@ -69,4 +78,13 @@ public class NamespaceService { throw new DomainForbiddenException("error.namespace.admin.required"); } } + + void assertMutable(Namespace namespace) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canMutateSettings(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java new file mode 100644 index 00000000..65290270 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceGovernanceServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + + @Mock + private AuditLogService auditLogService; + + @InjectMocks + private NamespaceGovernanceService governanceService; + + @Test + void freezeNamespace_allowsAdminOnActiveTeamNamespace() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canFreeze(namespace, NamespaceRole.ADMIN)).thenReturn(true); + when(namespaceRepository.save(namespace)).thenReturn(namespace); + + Namespace updated = governanceService.freezeNamespace("team-a", "admin-1", null, null, null, null); + + assertEquals(NamespaceStatus.FROZEN, updated.getStatus()); + verify(namespaceRepository).save(namespace); + } + + @Test + void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canArchive(namespace, NamespaceRole.ADMIN)).thenReturn(false); + + assertThrows(DomainForbiddenException.class, + () -> governanceService.archiveNamespace("team-a", "admin-1", "cleanup", null, null, null)); + } + + @Test + void restoreNamespace_movesArchivedNamespaceBackToActive() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canRestore(namespace, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceRepository.save(namespace)).thenReturn(namespace); + + Namespace updated = governanceService.restoreNamespace("team-a", "owner-1", null, null, null); + + assertEquals(NamespaceStatus.ACTIVE, updated.getStatus()); + } + + @Test + void freezeNamespace_rejectsGlobalNamespace() { + Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, + () -> governanceService.freezeNamespace("global", "admin-1", null, null, null, null)); + } + + @Test + void unfreezeNamespace_rejectsIllegalTransition() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, + () -> governanceService.unfreezeNamespace("team-a", "owner-1", null, null, null)); + } + + private Namespace namespace(Long id, String slug, NamespaceType type, NamespaceStatus status) { + Namespace namespace = new Namespace(slug, "Team A", "owner-1"); + setField(namespace, "id", id); + namespace.setType(type); + namespace.setStatus(status); + return namespace; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 380c221e..84d823c1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -20,6 +20,8 @@ class NamespaceMemberServiceTest { private NamespaceMemberRepository namespaceMemberRepository; @Mock private NamespaceService namespaceService; + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; @InjectMocks private NamespaceMemberService namespaceMemberService; @@ -29,7 +31,10 @@ class NamespaceMemberServiceTest { Long namespaceId = 1L; String userId = "user-2"; NamespaceRole role = NamespaceRole.MEMBER; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.empty()); when(namespaceMemberRepository.save(any(NamespaceMember.class))) @@ -43,12 +48,19 @@ class NamespaceMemberServiceTest { @Test void addMember_shouldThrowExceptionForOwnerRole() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.addMember(1L, "user-2", NamespaceRole.OWNER, "user-99")); } @Test void addMember_shouldRequireAdminOrOwner() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); doThrow(new DomainForbiddenException("error.namespace.admin.required")).when(namespaceService).assertAdminOrOwner(1L, "user-99"); assertThrows(DomainForbiddenException.class, () -> @@ -59,6 +71,9 @@ class NamespaceMemberServiceTest { void addMember_shouldThrowExceptionWhenMemberExists() { Long namespaceId = 1L; String userId = "user-2"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(new NamespaceMember())); @@ -66,11 +81,27 @@ class NamespaceMemberServiceTest { namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER, "user-99")); } + @Test + void addMember_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.addMember(namespaceId, "user-2", NamespaceRole.MEMBER, "user-99")); + } + @Test void removeMember_shouldThrowExceptionForOwner() { Long namespaceId = 1L; String userId = "user-2"; NamespaceMember ownerMember = new NamespaceMember(namespaceId, userId, NamespaceRole.OWNER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(ownerMember)); @@ -80,6 +111,9 @@ class NamespaceMemberServiceTest { @Test void removeMember_shouldThrowExceptionWhenMemberNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -87,11 +121,27 @@ class NamespaceMemberServiceTest { namespaceMemberService.removeMember(1L, "user-2", "user-99")); } + @Test + void updateMemberRole_shouldRejectArchivedNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.updateMemberRole(namespaceId, "user-2", NamespaceRole.ADMIN, "user-99")); + } + @Test void updateMemberRole_shouldUpdateRoleSuccessfully() { Long namespaceId = 1L; String userId = "user-2"; NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(member)); when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenReturn(member); @@ -106,6 +156,9 @@ class NamespaceMemberServiceTest { void updateMemberRole_shouldThrowExceptionForOwnerRole() { Long namespaceId = 1L; String userId = "user-2"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER, "user-99")); @@ -113,6 +166,9 @@ class NamespaceMemberServiceTest { @Test void updateMemberRole_shouldThrowExceptionWhenMemberNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -128,7 +184,10 @@ class NamespaceMemberServiceTest { NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); NamespaceMember newOwner = new NamespaceMember(namespaceId, newOwnerId, NamespaceRole.ADMIN); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(currentOwner)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) @@ -143,6 +202,9 @@ class NamespaceMemberServiceTest { @Test void transferOwnership_shouldThrowExceptionWhenCurrentOwnerNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -155,6 +217,9 @@ class NamespaceMemberServiceTest { Long namespaceId = 1L; String currentOwnerId = "user-2"; NamespaceMember notOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.ADMIN); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(notOwner)); @@ -168,7 +233,10 @@ class NamespaceMemberServiceTest { String currentOwnerId = "user-2"; String newOwnerId = "user-3"; NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(currentOwner)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) @@ -178,6 +246,18 @@ class NamespaceMemberServiceTest { namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, newOwnerId)); } + @Test + void transferOwnership_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.transferOwnership(namespaceId, "user-2", "user-3")); + } + @Test void getMemberRole_shouldReturnRole() { Long namespaceId = 1L; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index ecb8d18b..327e95f7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -23,6 +23,9 @@ class NamespaceServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + @InjectMocks private NamespaceService namespaceService; @@ -70,6 +73,8 @@ class NamespaceServiceTest { when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(true); when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace); Namespace result = namespaceService.updateNamespace( @@ -105,6 +110,37 @@ class NamespaceServiceTest { namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); } + @Test + void updateNamespace_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("slug", "Old Name", "user-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + + @Test + void updateNamespace_shouldRejectGlobalNamespaceMutation() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("global", "Global", "system"); + namespace.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + @Test void getNamespaceBySlug_shouldReturnNamespace() { String slug = "test-slug";