Merge pull request #536 from iflytek/fix/review-super-admin-self-promotion

fix(review): allow super admin self promotion review
This commit is contained in:
dongmucat 2026-06-17 11:25:09 +08:00 committed by GitHub
commit e4e1639833
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 364 additions and 23 deletions

View file

@ -112,7 +112,7 @@
| skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 |
| namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 |
| SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 |
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可review 场景下还能审自己的提交 | 可 | 可 |
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 |
### 对象存储写入策略

View file

@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制RBAC系统。
| 角色 | 代码 | 实际能力 |
|------|------|----------|
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
| 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本yank、处理技能举报可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report不能分配平台角色、不能看审计日志、不能管理用户。 |
| 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 |
| 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs``/actuator/prometheus`;治理工作台中只能看 activity不能处理 review/promotion/report也不能管理用户或技能。 |

View file

@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles:
| Role | Code | Effective behavior |
|------|------|--------------------|
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. |
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. |
| Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. |
| User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. |
| Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. |

View file

@ -75,7 +75,8 @@ public class PromotionPortalAppService {
comment,
platformRoles(userId)
);
recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, detailWithComment(comment));
recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext,
detailWithComment(comment, promotion.getSubmittedBy().equals(userId)));
return governanceQueryRepository.getPromotionResponse(promotion);
}
@ -89,7 +90,8 @@ public class PromotionPortalAppService {
comment,
platformRoles(userId)
);
recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, detailWithComment(comment));
recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext,
detailWithComment(comment, promotion.getSubmittedBy().equals(userId)));
return governanceQueryRepository.getPromotionResponse(promotion);
}
@ -159,10 +161,26 @@ public class PromotionPortalAppService {
);
}
private String detailWithComment(String comment) {
if (comment == null || comment.isBlank()) {
private String detailWithComment(String comment, boolean selfReview) {
boolean hasComment = comment != null && !comment.isBlank();
if (!hasComment && !selfReview) {
return null;
}
return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}";
StringBuilder detail = new StringBuilder("{");
if (hasComment) {
detail.append("\"comment\":\"").append(escapeJson(comment)).append("\"");
}
if (selfReview) {
if (hasComment) {
detail.append(",");
}
detail.append("\"selfReview\":true");
}
detail.append("}");
return detail.toString();
}
private String escapeJson(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
}

View file

@ -38,6 +38,9 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@ -53,6 +56,8 @@ class PromotionApprovalFlowIntegrationTest {
private static final String SUBMITTER_ID = "promotion-owner";
private static final String REVIEWER_ID = "docker-admin";
private static final String SELF_SUPER_ADMIN_ID = "self-super-admin";
private static final String SELF_SKILL_ADMIN_ID = "self-skill-admin";
@Autowired
private MockMvc mockMvc;
@ -127,31 +132,86 @@ class PromotionApprovalFlowIntegrationTest {
assertThat(targetVersions.get(0).getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
}
@Test
void approvePromotion_allowsSuperAdminToApproveOwnPromotionThroughV1Route() throws Exception {
when(rbacService.getUserRoleCodes(SELF_SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN"));
PromotionGraph graph = createPromotionGraph(SELF_SUPER_ADMIN_ID);
mockMvc.perform(post("/api/v1/promotions/" + graph.request().getId() + "/approve")
.contentType("application/json")
.content("{\"comment\":\"self approve\"}")
.with(authentication(portalAuth(SELF_SUPER_ADMIN_ID, "SUPER_ADMIN")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(graph.request().getId()))
.andExpect(jsonPath("$.data.status").value("APPROVED"))
.andExpect(jsonPath("$.data.reviewedBy").value(SELF_SUPER_ADMIN_ID))
.andExpect(jsonPath("$.data.submittedBy").value(SELF_SUPER_ADMIN_ID));
PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId()))
.stream()
.findFirst()
.orElseThrow();
assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED);
assertThat(savedRequest.getReviewedBy()).isEqualTo(SELF_SUPER_ADMIN_ID);
assertThat(savedRequest.getTargetSkillId()).isNotNull();
verify(governanceNotificationService).notifyUser(
eq(SELF_SUPER_ADMIN_ID),
eq("PROMOTION"),
eq("PROMOTION_REQUEST"),
eq(graph.request().getId()),
eq("Promotion approved"),
any()
);
}
@Test
void approvePromotion_rejectsSkillAdminSelfApprovalThroughWebRoute() throws Exception {
when(rbacService.getUserRoleCodes(SELF_SKILL_ADMIN_ID)).thenReturn(Set.of("SKILL_ADMIN"));
PromotionGraph graph = createPromotionGraph(SELF_SKILL_ADMIN_ID);
mockMvc.perform(post("/api/web/promotions/" + graph.request().getId() + "/approve")
.contentType("application/json")
.content("{\"comment\":\"self approve\"}")
.with(authentication(portalAuth(SELF_SKILL_ADMIN_ID, "SKILL_ADMIN")))
.with(csrf()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId()))
.stream()
.findFirst()
.orElseThrow();
assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.PENDING);
assertThat(savedRequest.getTargetSkillId()).isNull();
}
private PromotionGraph createPromotionGraph() {
return createPromotionGraph(SUBMITTER_ID);
}
private PromotionGraph createPromotionGraph(String submitterId) {
String suffix = UUID.randomUUID().toString().substring(0, 8);
userAccountRepository.saveAndFlush(
new UserAccount(SUBMITTER_ID, "Promotion Owner", "owner-" + suffix + "@example.com", null)
);
userAccountRepository.saveAndFlush(
new UserAccount(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com", null)
);
saveUserIfAbsent(submitterId, "Promotion Owner", "owner-" + suffix + "@example.com");
saveUserIfAbsent(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com");
Namespace globalNamespace = new Namespace("global-" + suffix, "Global " + suffix, REVIEWER_ID);
globalNamespace.setType(NamespaceType.GLOBAL);
globalNamespace = namespaceRepository.saveAndFlush(globalNamespace);
Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, SUBMITTER_ID);
Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, submitterId);
teamNamespace = namespaceRepository.saveAndFlush(teamNamespace);
Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, SUBMITTER_ID, SkillVisibility.PUBLIC);
Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, submitterId, SkillVisibility.PUBLIC);
sourceSkill.setDisplayName("Promote Skill " + suffix);
sourceSkill.setSummary("Used to verify promotion approval flow.");
sourceSkill.setCreatedBy(SUBMITTER_ID);
sourceSkill.setUpdatedBy(SUBMITTER_ID);
sourceSkill.setCreatedBy(submitterId);
sourceSkill.setUpdatedBy(submitterId);
sourceSkill = skillRepository.saveAndFlush(sourceSkill);
SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", SUBMITTER_ID);
SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", submitterId);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
sourceVersion.setPublishedAt(Instant.now());
sourceVersion.setRequestedVisibility(SkillVisibility.PUBLIC);
@ -160,16 +220,22 @@ class PromotionApprovalFlowIntegrationTest {
sourceVersion = skillVersionRepository.saveAndFlush(sourceVersion);
sourceSkill.setLatestVersionId(sourceVersion.getId());
sourceSkill.setUpdatedBy(SUBMITTER_ID);
sourceSkill.setUpdatedBy(submitterId);
sourceSkill = skillRepository.saveAndFlush(sourceSkill);
PromotionRequest request = promotionRequestRepository.saveAndFlush(
new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), SUBMITTER_ID)
new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), submitterId)
);
return new PromotionGraph(globalNamespace, sourceSkill, sourceVersion, request);
}
private void saveUserIfAbsent(String userId, String displayName, String email) {
if (!userAccountRepository.existsById(userId)) {
userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null));
}
}
private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,

View file

@ -187,6 +187,32 @@ class NotificationEventListenerTest {
eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L));
}
@Test
void onPromotionApproved_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception {
Skill skill = mockSkill(1L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
mockNamespace();
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
listener.onPromotionApproved(new PromotionApprovedEvent(200L, 1L, "self-admin", "self-admin"));
verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION),
eq("PROMOTION_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L));
}
@Test
void onPromotionRejected_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception {
Skill skill = mockSkill(1L);
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
mockNamespace();
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
listener.onPromotionRejected(new PromotionRejectedEvent(200L, 1L, "self-admin", "self-admin", "not ready"));
verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION),
eq("PROMOTION_REJECTED"), anyString(), anyString(), eq("SKILL"), eq(1L));
}
@Test
void onReportResolved_shouldDispatchToReporter() throws Exception {
Skill skill = mockSkill(1L);

View file

@ -0,0 +1,170 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.PromotionService;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.lang.reflect.Field;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PromotionPortalAppServiceTest {
private static final Long PROMOTION_ID = 1L;
private static final String SUPER_ADMIN_ID = "super-admin";
private static final String REVIEWER_ID = "reviewer";
private static final String SUBMITTER_ID = "submitter";
@Mock
private PromotionService promotionService;
@Mock
private PromotionRequestRepository promotionRequestRepository;
@Mock
private GovernanceQueryRepository governanceQueryRepository;
@Mock
private RbacService rbacService;
@Mock
private AuditLogService auditLogService;
private PromotionPortalAppService service;
@BeforeEach
void setUp() {
service = new PromotionPortalAppService(
promotionService,
promotionRequestRepository,
governanceQueryRepository,
rbacService,
auditLogService
);
}
@Test
void approvePromotion_recordsSelfReviewAuditDetailForSuperAdminSelfApproval() {
PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID);
when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN"));
when(promotionService.approvePromotion(PROMOTION_ID, SUPER_ADMIN_ID, "ship", Set.of("SUPER_ADMIN")))
.thenReturn(promotion);
when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion));
service.approvePromotion(
PROMOTION_ID,
"ship",
SUPER_ADMIN_ID,
new AuditRequestContext("127.0.0.1", "JUnit")
);
verify(auditLogService).record(
eq(SUPER_ADMIN_ID),
eq("PROMOTION_APPROVE"),
eq("PROMOTION_REQUEST"),
eq(PROMOTION_ID),
eq(null),
eq("127.0.0.1"),
eq("JUnit"),
eq("{\"comment\":\"ship\",\"selfReview\":true}")
);
}
@Test
void rejectPromotion_recordsSelfReviewAuditDetailWithoutComment() {
PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID);
when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN"));
when(promotionService.rejectPromotion(PROMOTION_ID, SUPER_ADMIN_ID, null, Set.of("SUPER_ADMIN")))
.thenReturn(promotion);
when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion));
service.rejectPromotion(
PROMOTION_ID,
null,
SUPER_ADMIN_ID,
new AuditRequestContext("127.0.0.1", "JUnit")
);
verify(auditLogService).record(
eq(SUPER_ADMIN_ID),
eq("PROMOTION_REJECT"),
eq("PROMOTION_REQUEST"),
eq(PROMOTION_ID),
eq(null),
eq("127.0.0.1"),
eq("JUnit"),
eq("{\"selfReview\":true}")
);
}
@Test
void approvePromotion_keepsExistingAuditDetailForReviewerApprovingOthersPromotion() {
PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUBMITTER_ID);
when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SKILL_ADMIN"));
when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ship", Set.of("SKILL_ADMIN")))
.thenReturn(promotion);
when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion));
service.approvePromotion(
PROMOTION_ID,
"ship",
REVIEWER_ID,
new AuditRequestContext("127.0.0.1", "JUnit")
);
verify(auditLogService).record(
eq(REVIEWER_ID),
eq("PROMOTION_APPROVE"),
eq("PROMOTION_REQUEST"),
eq(PROMOTION_ID),
eq(null),
eq("127.0.0.1"),
eq("JUnit"),
eq("{\"comment\":\"ship\"}")
);
}
private PromotionResponseDto response(PromotionRequest request) {
return new PromotionResponseDto(
request.getId(),
request.getSourceSkillId(),
"team-a",
"skill-a",
"1.0.0",
"global",
request.getTargetSkillId(),
request.getStatus().name(),
request.getSubmittedBy(),
"Submitter",
request.getReviewedBy(),
null,
request.getReviewComment(),
request.getSubmittedAt(),
request.getReviewedAt()
);
}
private PromotionRequest promotionRequest(Long id, String submittedBy) {
PromotionRequest request = new PromotionRequest(10L, 20L, 30L, submittedBy);
setId(request, id);
return request;
}
private void setId(Object entity, Long id) {
try {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);
idField.set(entity, id);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View file

@ -108,7 +108,7 @@ public class ReviewPermissionChecker {
String userId,
Set<String> platformRoles) {
if (request.getSubmittedBy().equals(userId)) {
return false;
return platformRoles.contains("SUPER_ADMIN");
}
return hasPlatformReviewRole(platformRoles);
}

View file

@ -401,6 +401,51 @@ class PromotionServiceTest {
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any());
}
@Test
void shouldNotifySubmitterWhenSuperAdminApprovesOwnPromotion() {
PromotionRequest request = createPendingPromotion();
PromotionRequest approvedRequest = approvedPromotion(request, "self approve");
approvedRequest.setReviewedBy(USER_ID);
Skill sourceSkill = createSourceSkill();
SkillVersion sourceVersion = createPublishedVersion();
Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", USER_ID, SkillVisibility.PUBLIC);
setField(newSkill, "id", NEW_SKILL_ID);
SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), USER_ID);
setField(newVersion, "id", NEW_VERSION_ID);
when(promotionRequestRepository.findById(PROMOTION_ID))
.thenReturn(Optional.of(request), Optional.of(approvedRequest));
when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true);
when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.APPROVED, USER_ID, "self approve", null, request.getVersion()))
.thenReturn(1);
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(skillRepository.save(any(Skill.class))).thenReturn(newSkill);
when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion);
when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of());
when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest);
promotionService.approvePromotion(PROMOTION_ID, USER_ID, "self approve", Set.of("SUPER_ADMIN"));
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any());
}
@Test
void shouldNotifySubmitterWhenSuperAdminRejectsOwnPromotion() {
PromotionRequest request = createPendingPromotion();
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request));
when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true);
when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.REJECTED, USER_ID, "self reject", null, request.getVersion()))
.thenReturn(1);
promotionService.rejectPromotion(PROMOTION_ID, USER_ID, "self reject", Set.of("SUPER_ADMIN"));
verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any());
}
}
@Nested

View file

@ -234,10 +234,26 @@ class ReviewPermissionCheckerTest {
}
@Test
void cannotReviewOwnPromotion() {
void skillAdminCannotReviewOwnPromotion() {
String userId = "user-2";
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId);
assertFalse(checker.canReviewPromotion(req, userId,
Set.of("SKILL_ADMIN")));
}
@Test
void regularUserCannotReviewOwnPromotion() {
String userId = "user-2";
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId);
assertFalse(checker.canReviewPromotion(req, userId,
Set.of()));
}
@Test
void superAdminCanReviewOwnPromotion() {
String userId = "user-2";
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId);
assertTrue(checker.canReviewPromotion(req, userId,
Set.of("SUPER_ADMIN")));
}
}