diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index e0719939..3cd6a7cb 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -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 场景下还能审自己的提交 | 可 | 可 | ### 对象存储写入策略 diff --git a/document/docs/02-administration/security/authorization.md b/document/docs/02-administration/security/authorization.md index 36d7fef3..75ff506e 100644 --- a/document/docs/02-administration/security/authorization.md +++ b/document/docs/02-administration/security/authorization.md @@ -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,也不能管理用户或技能。 | diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md index 4c0359be..ee411ca4 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md @@ -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. | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index 16a1e95b..ef4b1888 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -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("\"", "\\\""); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index 44d39712..a2268a67 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..ee943c4f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -144,6 +144,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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java new file mode 100644 index 00000000..abede8fc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -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); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index c3c125b1..df86ec75 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -108,7 +108,7 @@ public class ReviewPermissionChecker { String userId, Set platformRoles) { if (request.getSubmittedBy().equals(userId)) { - return false; + return platformRoles.contains("SUPER_ADMIN"); } return hasPlatformReviewRole(platformRoles); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 19b1887f..1b10f394 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -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 diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 25ae2f70..faf94008 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -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"))); + } }