feat: add namespace lifecycle governance

This commit is contained in:
yun-zhi-ztl 2026-03-16 11:42:17 +08:00 • committed by Xudong Sun
parent 1418bc4840
commit 7501bb3cf2
8 changed files with 468 additions and 2 deletions

View file

@ -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; }

View file

@ -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;
}
}

View file

@ -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("\"", "\\\"") + "\"}"
);
}
}

View file

@ -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<NamespaceMember> 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());
}
}
}

View file

@ -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());
}
}
}

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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";