From 0a9fcaef79c814255afd02a9eb96b114aba4ad89 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:22:47 +0800 Subject: [PATCH] fix(social): enforce skill access for stars and ratings Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/06-api-design.md | 19 ++- .../controller/portal/MeController.java | 8 +- .../portal/SkillRatingController.java | 25 ++-- .../portal/SkillStarController.java | 23 ++- .../JpaStarredSkillQueryRepository.java | 73 +++++++++ .../StarredSkillQueryRepository.java | 14 ++ .../skillhub/service/MySkillAppService.java | 40 ++--- .../service/SkillSocialAppService.java | 59 ++++++++ .../skillhub/controller/MeControllerTest.java | 2 +- .../controller/SkillRatingControllerTest.java | 47 ++++++ .../controller/SkillStarControllerTest.java | 60 ++++++++ .../StarredSkillAccessPostgresTest.java | 139 ++++++++++++++++++ .../service/MySkillAppServiceTest.java | 29 +--- .../service/SkillSocialAppServiceTest.java | 92 ++++++++++++ 14 files changed, 557 insertions(+), 73 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaStarredSkillQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/StarredSkillQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSocialAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/StarredSkillAccessPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSocialAppServiceTest.java diff --git a/docs/06-api-design.md b/docs/06-api-design.md index a8efd2f9d..cc5965e51 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -199,15 +199,28 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/v1/skills/{namespace}/{slug}/star` | 收藏 | -| DELETE | `/api/v1/skills/{namespace}/{slug}/star` | 取消收藏 | -| POST | `/api/v1/skills/{namespace}/{slug}/rating` | 评分 | +| PUT | `/api/v1/skills/{skillId}/star` | 收藏 | +| GET | `/api/v1/skills/{skillId}/star` | 当前用户收藏状态 | +| DELETE | `/api/v1/skills/{skillId}/star` | 取消本人收藏(幂等) | +| PUT | `/api/v1/skills/{skillId}/rating` | 评分 | +| GET | `/api/v1/skills/{skillId}/rating` | 当前用户评分状态 | | GET | `/api/v1/skills/{skillId}/reviews/me` | 当前用户的评分与文字评价 | | PUT | `/api/v1/skills/{skillId}/reviews/me` | 新增或更新当前用户评价(`score` 1-5,`reviewText` 最长 2000) | | DELETE | `/api/v1/skills/{skillId}/reviews/me` | 删除文字评价并保留星级评分 | | GET | `/api/v1/me/stars` | 我的收藏列表 | | GET | `/api/v1/me/skills` | 我发布的技能列表 | +收藏与评分同样支持 `/api/web` 路由。上述 `GET` / `PUT star`、`GET` / `PUT rating` +在读取状态或写入前,按技能详情的对象可见性策略检查当前用户、空间角色和平台角色。 +存在但不可访问的技能返回 `403`,不存在返回 `404`;拒绝不会新增收藏、评分或统计事件。 +合法的所有者、空间管理员及超级管理员仍按现有访问策略处理,不统一禁止私有技能互动。 + +`GET /me/stars` 在数据库计数、排序与分页之前过滤当前无权访问的技能。 +技能转私有、被隐藏或用户失去空间角色后,旧收藏不能继续返回技能名称、摘要等信息。 +收藏关系仍保留,恢复访问后可再次显示;不因当前不可访问而自动删除历史收藏或评分。 +`DELETE star` 只操作当前用户自己的收藏,可在失去访问权限后继续幂等清理,不返回目标技能信息。 +本次不改变归档技能和未发布技能的既有可见性规则,也不增加数据库业务约束。 + ### 草稿与审核提交(Phase 3 引入) | 方法 | 路径 | 说明 | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index 0dd5f2ba8..78822894e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -8,6 +8,9 @@ import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.service.MySkillAppService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import java.util.Map; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -51,12 +54,13 @@ public class MeController extends BaseApiController { public ApiResponse> listMyStars( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size, - @AuthenticationPrincipal PlatformPrincipal principal) { + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size)); + return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size, namespaceRoles, principal.platformRoles())); } @GetMapping("/subscriptions") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index 8ad74f97a..8e53379d2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -6,7 +6,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SkillRatingRequest; import com.iflytek.skillhub.dto.SkillRatingStatusResponse; -import com.iflytek.skillhub.domain.social.SkillRatingService; +import com.iflytek.skillhub.service.SkillSocialAppService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import java.util.Map; import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -19,31 +21,32 @@ import java.util.Optional; @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillRatingController extends BaseApiController { - private final SkillRatingService skillRatingService; + private final SkillSocialAppService socialAppService; public SkillRatingController(ApiResponseFactory responseFactory, - SkillRatingService skillRatingService) { + SkillSocialAppService socialAppService) { super(responseFactory); - this.skillRatingService = skillRatingService; + this.socialAppService = socialAppService; } @PutMapping("/{skillId}/rating") public ApiResponse rateSkill( @PathVariable Long skillId, @Valid @RequestBody SkillRatingRequest request, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillRatingService.rate(skillId, principal.userId(), request.score()); + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + socialAppService.rate(skillId, principal.userId(), request.score(), namespaceRoles, principal.platformRoles()); return ok("response.success.updated", null); } @GetMapping("/{skillId}/rating") public ApiResponse getUserRating( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { - return ok("response.success.read", new SkillRatingStatusResponse((short) 0, false)); - } - Optional rating = skillRatingService.getUserRating(skillId, principal.userId()); + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + Optional rating = socialAppService.getUserRating(skillId, + principal != null ? principal.userId() : null, namespaceRoles, + principal != null ? principal.platformRoles() : null); return ok( "response.success.read", new SkillRatingStatusResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index 5d95837e8..aca8ca28a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.service.SkillSocialAppService; +import java.util.Map; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -16,18 +19,22 @@ import org.springframework.web.bind.annotation.*; public class SkillStarController extends BaseApiController { private final SkillStarService skillStarService; + private final SkillSocialAppService socialAppService; public SkillStarController(ApiResponseFactory responseFactory, - SkillStarService skillStarService) { + SkillStarService skillStarService, + SkillSocialAppService socialAppService) { super(responseFactory); this.skillStarService = skillStarService; + this.socialAppService = socialAppService; } @PutMapping("/{skillId}/star") public ApiResponse starSkill( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillStarService.star(skillId, principal.userId()); + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + socialAppService.star(skillId, principal.userId(), namespaceRoles, principal.platformRoles()); return ok("response.success.updated", null); } @@ -42,11 +49,11 @@ public class SkillStarController extends BaseApiController { @GetMapping("/{skillId}/star") public ApiResponse checkStarred( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { - return ok("response.success.read", false); - } - boolean starred = skillStarService.isStarred(skillId, principal.userId()); + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + boolean starred = socialAppService.isStarred(skillId, + principal != null ? principal.userId() : null, namespaceRoles, + principal != null ? principal.platformRoles() : null); return ok("response.success.read", starred); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaStarredSkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaStarredSkillQueryRepository.java new file mode 100644 index 000000000..42e73c6bd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaStarredSkillQueryRepository.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.social.SkillStar; +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaStarredSkillQueryRepository implements StarredSkillQueryRepository { + private final EntityManager entityManager; + + public JpaStarredSkillQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Page findAccessible(String userId, Map namespaceRoles, + Set platformRoles, Pageable pageable) { + var builder = entityManager.getCriteriaBuilder(); + var query = builder.createQuery(Skill.class); + var skill = query.from(Skill.class); + var star = query.from(SkillStar.class); + query.select(skill).where(predicate(builder, skill, star, userId, namespaceRoles, platformRoles)) + .orderBy(builder.desc(star.get("createdAt")), builder.desc(star.get("id"))); + var result = entityManager.createQuery(query) + .setFirstResult(Math.toIntExact(pageable.getOffset())) + .setMaxResults(pageable.getPageSize()).getResultList(); + + var countQuery = builder.createQuery(Long.class); + var countSkill = countQuery.from(Skill.class); + var countStar = countQuery.from(SkillStar.class); + countQuery.select(builder.count(countSkill)) + .where(predicate(builder, countSkill, countStar, userId, namespaceRoles, platformRoles)); + return new PageImpl<>(result, pageable, entityManager.createQuery(countQuery).getSingleResult()); + } + + private Predicate predicate(CriteriaBuilder builder, Root skill, Root star, + String userId, Map namespaceRoles, Set platformRoles) { + Predicate relation = builder.and(builder.equal(star.get("userId"), userId), + builder.equal(star.get("skillId"), skill.get("id"))); + if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) return relation; + + // Keep this SQL projection equivalent to VisibilityChecker, including unpublished and hidden skills. + Map roles = namespaceRoles != null ? namespaceRoles : Map.of(); + List adminNamespaces = roles.entrySet().stream() + .filter(entry -> entry.getValue() == NamespaceRole.ADMIN || entry.getValue() == NamespaceRole.OWNER) + .map(Map.Entry::getKey).toList(); + Predicate owner = builder.equal(skill.get("ownerId"), userId); + Predicate admin = adminNamespaces.isEmpty() ? builder.disjunction() : skill.get("namespaceId").in(adminNamespaces); + Predicate member = roles.isEmpty() ? builder.disjunction() : skill.get("namespaceId").in(roles.keySet()); + Predicate visibility = builder.or( + builder.equal(skill.get("visibility"), SkillVisibility.PUBLIC), + builder.and(builder.equal(skill.get("visibility"), SkillVisibility.NAMESPACE_ONLY), member), + builder.and(builder.equal(skill.get("visibility"), SkillVisibility.PRIVATE), builder.or(owner, admin))); + Predicate accessible = builder.or( + builder.and(builder.isTrue(skill.get("hidden")), builder.or(owner, admin)), + builder.and(builder.isFalse(skill.get("hidden")), builder.or( + builder.and(builder.isNull(skill.get("latestVersionId")), owner), + builder.and(builder.isNotNull(skill.get("latestVersionId")), visibility)))); + return builder.and(relation, accessible); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/StarredSkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/StarredSkillQueryRepository.java new file mode 100644 index 000000000..f3bcfa3d7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/StarredSkillQueryRepository.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import java.util.Map; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** Starred skills filtered by current object access before counting and paging. */ +public interface StarredSkillQueryRepository { + Page findAccessible(String userId, Map namespaceRoles, + Set platformRoles, Pageable pageable); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 7e96572e0..312fe33af 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,12 +1,15 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.repository.StarredSkillQueryRepository; +import java.util.Map; +import java.util.Set; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; -import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; @@ -35,7 +38,7 @@ public class MySkillAppService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; - private final SkillStarRepository skillStarRepository; + private final StarredSkillQueryRepository starredSkillQueryRepository; private final SkillSubscriptionRepository skillSubscriptionRepository; private final MySkillQueryRepository mySkillQueryRepository; private final HiddenSkillQueryRepository hiddenSkillQueryRepository; @@ -45,7 +48,7 @@ public class MySkillAppService { public MySkillAppService( SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - SkillStarRepository skillStarRepository, + StarredSkillQueryRepository starredSkillQueryRepository, SkillSubscriptionRepository skillSubscriptionRepository, MySkillQueryRepository mySkillQueryRepository, HiddenSkillQueryRepository hiddenSkillQueryRepository, @@ -53,7 +56,7 @@ public class MySkillAppService { NamespaceRepository namespaceRepository) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; - this.skillStarRepository = skillStarRepository; + this.starredSkillQueryRepository = starredSkillQueryRepository; this.skillSubscriptionRepository = skillSubscriptionRepository; this.mySkillQueryRepository = mySkillQueryRepository; this.hiddenSkillQueryRepository = hiddenSkillQueryRepository; @@ -113,28 +116,13 @@ public class MySkillAppService { return hiddenSkillQueryRepository.search(normalizeKeyword(keyword), namespaceId, pageable); } - public PageResponse listMyStars(String userId, int page, int size) { - Page starPage = skillStarRepository.findByUserId( - userId, - PageRequest.of(page, size) - ); - List stars = starPage.getContent(); - - List skillIds = stars.stream() - .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) - .distinct() - .toList(); - java.util.Map skillsById = skillIds.isEmpty() - ? java.util.Map.of() - : skillRepository.findByIdIn(skillIds).stream() - .collect(java.util.stream.Collectors.toMap(Skill::getId, java.util.function.Function.identity())); - List orderedSkills = stars.stream() - .map(star -> skillsById.get(star.getSkillId())) - .filter(java.util.Objects::nonNull) - .toList(); - List items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId); - - return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize()); + public PageResponse listMyStars(String userId, int page, int size, + Map namespaceRoles, + Set platformRoles) { + Page skills = starredSkillQueryRepository.findAccessible( + userId, namespaceRoles, platformRoles, PageRequest.of(page, size)); + List items = mySkillQueryRepository.getSkillSummaries(skills.getContent(), userId); + return new PageResponse<>(items, skills.getTotalElements(), skills.getNumber(), skills.getSize()); } public PageResponse listMySubscriptions(String userId, int page, int size) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSocialAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSocialAppService.java new file mode 100644 index 000000000..e0d8bcac0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSocialAppService.java @@ -0,0 +1,59 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.social.SkillRatingService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** Object-authorized entry points for skill stars and ratings. */ +@Service +public class SkillSocialAppService { + private final SkillRepository skills; + private final VisibilityChecker visibility; + private final SkillStarService stars; + private final SkillRatingService ratings; + + public SkillSocialAppService(SkillRepository skills, VisibilityChecker visibility, + SkillStarService stars, SkillRatingService ratings) { + this.skills = skills; + this.visibility = visibility; + this.stars = stars; + this.ratings = ratings; + } + + public void star(Long skillId, String userId, Map namespaceRoles, Set platformRoles) { + requireAccess(skillId, userId, namespaceRoles, platformRoles); + stars.star(skillId, userId); + } + + public boolean isStarred(Long skillId, String userId, Map namespaceRoles, Set platformRoles) { + requireAccess(skillId, userId, namespaceRoles, platformRoles); + return userId != null && stars.isStarred(skillId, userId); + } + + public void rate(Long skillId, String userId, short score, Map namespaceRoles, Set platformRoles) { + requireAccess(skillId, userId, namespaceRoles, platformRoles); + ratings.rate(skillId, userId, score); + } + + public Optional getUserRating(Long skillId, String userId, Map namespaceRoles, Set platformRoles) { + requireAccess(skillId, userId, namespaceRoles, platformRoles); + return userId != null ? ratings.getUserRating(skillId, userId) : Optional.empty(); + } + + private void requireAccess(Long skillId, String userId, Map namespaceRoles, Set platformRoles) { + Skill skill = skills.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId)); + if (!visibility.canAccess(skill, userId, namespaceRoles, platformRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillId); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index 715986cd3..6d46a01cc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -125,7 +125,7 @@ class MeControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - given(mySkillAppService.listMyStars("user-42", 0, 12)) + given(mySkillAppService.listMyStars("user-42", 0, 12, java.util.Map.of(), Set.of("USER"))) .willReturn(new PageResponse<>(List.of(), 0, 0, 12)); mockMvc.perform(get("/api/v1/me/stars").with(authentication(auth))) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 4b3b5681b..8d39e068b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -4,6 +4,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.social.SkillRatingService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -40,6 +46,16 @@ class SkillRatingControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private SkillRepository skills; + + @BeforeEach + void targetExists() { + Skill skill = new Skill(1L, "restricted-target", "owner", SkillVisibility.PRIVATE); + skill.setLatestVersionId(11L); + when(skills.findById(10L)).thenReturn(Optional.of(skill)); + } + @Test void rate_skill_returns_envelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -115,4 +131,35 @@ class SkillRatingControllerTest { .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(401)); } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1", "/api/web"}) + void inaccessibleSkillCannotBeReadOrMutated(String prefix) throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("outsider", "Outsider", null, null, "local", Set.of("USER")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + mockMvc.perform(get(prefix + "/skills/10/rating").with(authentication(auth))) + .andExpect(status().isForbidden()) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target")))); + mockMvc.perform(put(prefix + "/skills/10/rating").with(authentication(auth)).with(csrf()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}")) + .andExpect(status().isForbidden()) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target")))); + org.mockito.Mockito.verifyNoInteractions(skillRatingService); + } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1", "/api/web"}) + void namespaceAdministratorUsesTrustedMembership(String prefix) throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("ns-admin", "Admin", null, null, "local", Set.of("USER")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + when(namespaceMemberRepository.findByUserId("ns-admin")).thenReturn(List.of( + new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "ns-admin", + com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN))); + mockMvc.perform(put(prefix + "/skills/10/rating").with(authentication(auth)).with(csrf()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}")) + .andExpect(status().isOk()); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index a605b7cc8..665876712 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -4,6 +4,13 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.social.SkillStarService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -38,6 +45,16 @@ class SkillStarControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private SkillRepository skills; + + @BeforeEach + void targetExists() { + Skill skill = new Skill(1L, "restricted-target", "owner", SkillVisibility.PRIVATE); + skill.setLatestVersionId(11L); + when(skills.findById(10L)).thenReturn(Optional.of(skill)); + } + @Test void star_skill_returns_envelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -159,4 +176,47 @@ class SkillStarControllerTest { verify(skillStarService).star(eq(10L), eq("user-42")); } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1", "/api/web"}) + void inaccessibleSkillCannotBeReadOrMutated(String prefix) throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("outsider", "Outsider", null, null, "local", Set.of("USER")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + mockMvc.perform(get(prefix + "/skills/10/star").with(authentication(auth))) + .andExpect(status().isForbidden()) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target")))); + mockMvc.perform(put(prefix + "/skills/10/star").with(authentication(auth)).with(csrf()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}")) + .andExpect(status().isForbidden()) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target")))); + org.mockito.Mockito.verifyNoInteractions(skillStarService); + } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1", "/api/web"}) + void losingAccessStillAllowsRemovingOwnStar(String prefix) throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("outsider", "Outsider", null, null, "local", Set.of("USER")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + mockMvc.perform(delete(prefix + "/skills/10/star").with(authentication(auth)).with(csrf())) + .andExpect(status().isOk()); + verify(skillStarService).unstar(10L, "outsider"); + org.mockito.Mockito.verifyNoInteractions(skills); + } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1", "/api/web"}) + void namespaceAdministratorUsesTrustedMembership(String prefix) throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("ns-admin", "Admin", null, null, "local", Set.of("USER")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + when(namespaceMemberRepository.findByUserId("ns-admin")).thenReturn(List.of( + new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "ns-admin", + com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN))); + mockMvc.perform(put(prefix + "/skills/10/star").with(authentication(auth)).with(csrf()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}")) + .andExpect(status().isOk()); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/StarredSkillAccessPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/StarredSkillAccessPostgresTest.java new file mode 100644 index 000000000..6d6c724fa --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/StarredSkillAccessPostgresTest.java @@ -0,0 +1,139 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.social.SkillStar; +import com.iflytek.skillhub.domain.user.UserAccount; +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import(JpaStarredSkillQueryRepository.class) +class StarredSkillAccessPostgresTest { + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void postgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired private EntityManager em; + @Autowired private JpaStarredSkillQueryRepository query; + private Long namespaceId; + private final List fixtures = new ArrayList<>(); + + @BeforeEach + void setup() { + em.persist(new UserAccount("owner", "Owner", null, null)); + em.persist(new UserAccount("viewer", "Viewer", null, null)); + Namespace namespace = new Namespace("social-access", "Social access", "owner"); + em.persist(namespace); + em.flush(); + namespaceId = namespace.getId(); + for (SkillVisibility visibility : SkillVisibility.values()) { + for (boolean published : List.of(false, true)) { + for (boolean hidden : List.of(false, true)) { + Skill skill = new Skill(namespaceId, visibility.name().toLowerCase() + "-" + published + "-" + hidden, + "owner", visibility); + skill.setDisplayName("Secret metadata"); + skill.setSummary("Must not leak through a historical star"); + skill.setHidden(hidden); + em.persist(skill); + em.flush(); + if (published) { + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + em.persist(version); + em.flush(); + skill.setLatestVersionId(version.getId()); + } + em.persist(new SkillStar(skill.getId(), "viewer")); + em.persist(new SkillStar(skill.getId(), "owner")); + fixtures.add(skill); + } + } + } + em.flush(); + } + + @Test + void databasePolicyMatchesVisibilityCheckerForEveryLifecycleAndRole() { + assertPolicy("viewer", Map.of(), Set.of()); + assertPolicy("owner", Map.of(), Set.of()); + for (NamespaceRole role : NamespaceRole.values()) { + assertPolicy("viewer", Map.of(namespaceId, role), Set.of()); + } + assertPolicy("viewer", Map.of(), Set.of("SUPER_ADMIN")); + assertPolicy("viewer", Map.of(), Set.of("SKILL_ADMIN")); + } + + @Test + void filteringPrecedesCountAndPaginationWithoutDeletingHistoricalStars() { + var first = query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(0, 1)); + assertThat(first.getTotalElements()).isEqualTo(1); + assertThat(first.getContent()).extracting(Skill::getVisibility).containsExactly(SkillVisibility.PUBLIC); + var beyond = query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(1, 1)); + assertThat(beyond.getContent()).isEmpty(); + assertThat(beyond.getTotalElements()).isEqualTo(1); + assertThat(em.createQuery("select count(s) from SkillStar s where s.userId = 'viewer'", Long.class) + .getSingleResult()).isEqualTo(12); + } + + @Test + void membershipRevocationAndVisibilityReductionRemoveMetadataImmediately() { + assertThat(query.findAccessible("viewer", Map.of(namespaceId, NamespaceRole.MEMBER), Set.of(), PageRequest.of(0, 50)) + .getTotalElements()).isEqualTo(2); + assertThat(query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(0, 50)).getTotalElements()).isEqualTo(1); + fixtures.stream().filter(skill -> skill.getVisibility() == SkillVisibility.PUBLIC) + .forEach(skill -> skill.setVisibility(SkillVisibility.PRIVATE)); + em.flush(); + assertThat(query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(0, 50)).getTotalElements()).isZero(); + } + + private void assertPolicy(String user, Map roles, Set platformRoles) { + var checker = new VisibilityChecker(); + List expected = fixtures.stream().filter(skill -> checker.canAccess(skill, user, roles, platformRoles)) + .map(Skill::getId).toList(); + var result = query.findAccessible(user, roles, platformRoles, PageRequest.of(0, 50)); + assertThat(result.getTotalElements()).isEqualTo(expected.size()); + assertThat(result.getContent()).extracting(Skill::getId).containsExactlyInAnyOrderElementsOf(expected); + for (int i = 0; i < expected.size(); i++) { + var page = query.findAccessible(user, roles, platformRoles, PageRequest.of(i, 1)); + assertThat(page.getTotalElements()).isEqualTo(expected.size()); + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().getFirst().getId()).isEqualTo(result.getContent().get(i).getId()); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index 8daa73ad5..43829f2a0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -13,8 +13,8 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; -import com.iflytek.skillhub.domain.social.SkillStar; -import com.iflytek.skillhub.domain.social.SkillStarRepository; +import com.iflytek.skillhub.repository.StarredSkillQueryRepository; +import java.util.Map; import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; import com.iflytek.skillhub.repository.JpaMySkillQueryRepository; import com.iflytek.skillhub.repository.HiddenSkillQueryRepository; @@ -50,7 +50,7 @@ class MySkillAppServiceTest { private SkillVersionRepository skillVersionRepository; @Mock - private SkillStarRepository skillStarRepository; + private StarredSkillQueryRepository starredSkillQueryRepository; @Mock private SkillSubscriptionRepository skillSubscriptionRepository; @@ -76,7 +76,7 @@ class MySkillAppServiceTest { service = new MySkillAppService( skillRepository, skillVersionRepository, - skillStarRepository, + starredSkillQueryRepository, skillSubscriptionRepository, mySkillQueryRepository, hiddenSkillQueryRepository, @@ -87,22 +87,6 @@ class MySkillAppServiceTest { @Test void listMyStars_loadsAllPagesOfStars() { - SkillStar firstStar = new SkillStar(1L, "user-1"); - ReflectionTestUtils.setField(firstStar, "createdAt", Instant.parse("2026-03-14T10:00:00Z")); - SkillStar secondStar = new SkillStar(2L, "user-1"); - ReflectionTestUtils.setField(secondStar, "createdAt", Instant.parse("2026-03-14T11:00:00Z")); - - given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 1))) - .willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 1), 2)); - - Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC); - firstSkill.setDisplayName("First Skill"); - firstSkill.setSummary("first summary"); - ReflectionTestUtils.setField(firstSkill, "id", 1L); - ReflectionTestUtils.setField(firstSkill, "starCount", 1); - ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L); - ReflectionTestUtils.setField(firstSkill, "updatedAt", Instant.parse("2026-03-14T10:00:00Z")); - Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC); secondSkill.setDisplayName("Second Skill"); secondSkill.setSummary("second summary"); @@ -111,12 +95,13 @@ class MySkillAppServiceTest { ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L); ReflectionTestUtils.setField(secondSkill, "updatedAt", Instant.parse("2026-03-14T11:00:00Z")); - given(skillRepository.findByIdIn(List.of(2L))).willReturn(List.of(secondSkill)); + given(starredSkillQueryRepository.findAccessible("user-1", Map.of(), Set.of(), PageRequest.of(1, 1))) + .willReturn(new PageImpl<>(List.of(secondSkill), PageRequest.of(1, 1), 2)); given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of()); given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of()); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); - var stars = service.listMyStars("user-1", 1, 1); + var stars = service.listMyStars("user-1", 1, 1, Map.of(), Set.of()); assertThat(stars.total()).isEqualTo(2); assertThat(stars.page()).isEqualTo(1); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSocialAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSocialAppServiceTest.java new file mode 100644 index 000000000..3bed043c6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSocialAppServiceTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.social.SkillRatingService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SkillSocialAppServiceTest { + private final SkillRepository skills = mock(SkillRepository.class); + private final SkillStarService stars = mock(SkillStarService.class); + private final SkillRatingService ratings = mock(SkillRatingService.class); + private final SkillSocialAppService service = new SkillSocialAppService(skills, new VisibilityChecker(), stars, ratings); + private Skill skill; + + @BeforeEach + void setup() { + skill = new Skill(7L, "secret", "owner", SkillVisibility.PRIVATE); + skill.setLatestVersionId(11L); + when(skills.findById(1L)).thenReturn(Optional.of(skill)); + } + + @ParameterizedTest + @EnumSource(SkillVisibility.class) + void deniedAccessNeverReadsOrWritesSocialData(SkillVisibility visibility) { + skill.setVisibility(visibility); + if (visibility == SkillVisibility.PUBLIC) skill.setLatestVersionId(null); + assertRejected("outsider", Map.of(), Set.of()); + } + + @Test + void hiddenSkillDeniesOrdinaryMember() { + skill.setVisibility(SkillVisibility.PUBLIC); + skill.setHidden(true); + assertRejected("member", Map.of(7L, NamespaceRole.MEMBER), Set.of()); + } + + @Test + void missingObjectHasNoSocialSideEffects() { + when(skills.findById(1L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.star(1L, "viewer", Map.of(), Set.of())).isInstanceOf(DomainNotFoundException.class); + verifyNoInteractions(stars, ratings); + } + + @Test + void legitimateRolesRetainAccess() { + allow("owner", Map.of(), Set.of()); + allow("admin", Map.of(7L, NamespaceRole.ADMIN), Set.of()); + allow("ns-owner", Map.of(7L, NamespaceRole.OWNER), Set.of()); + allow("platform-admin", Map.of(), Set.of("SUPER_ADMIN")); + } + + @Test + void publishedPublicAndNamespaceMemberAccessRetained() { + skill.setVisibility(SkillVisibility.PUBLIC); + allow("viewer", Map.of(), Set.of()); + skill.setVisibility(SkillVisibility.NAMESPACE_ONLY); + allow("member", Map.of(7L, NamespaceRole.MEMBER), Set.of()); + } + + private void allow(String user, Map roles, Set platformRoles) { + service.star(1L, user, roles, platformRoles); + service.rate(1L, user, (short) 4, roles, platformRoles); + service.isStarred(1L, user, roles, platformRoles); + service.getUserRating(1L, user, roles, platformRoles); + verify(stars).star(1L, user); + verify(stars).isStarred(1L, user); + verify(ratings).rate(1L, user, (short) 4); + verify(ratings).getUserRating(1L, user); + } + + private void assertRejected(String user, Map roles, Set platformRoles) { + assertThatThrownBy(() -> service.star(1L, user, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class); + assertThatThrownBy(() -> service.rate(1L, user, (short) 4, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class); + assertThatThrownBy(() -> service.isStarred(1L, user, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class); + assertThatThrownBy(() -> service.getUserRating(1L, user, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class); + verifyNoInteractions(stars, ratings); + } +}