mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-15 09:11:18 +00:00
Merge pull request #961 from iflytek/fix/issue-959-social-access-20261015
fix(social): enforce skill access for stars and ratings
This commit is contained in:
commit
911b0544cf
16 changed files with 597 additions and 102 deletions
|
|
@ -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` 只操作当前用户自己的收藏,可在失去访问权限后继续幂等清理,不返回目标技能信息。
|
||||
本次不改变归档技能和未发布技能的既有可见性规则,也不增加数据库业务约束。
|
||||
|
||||
### 已上传版本与审核提交
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
|
|
|||
|
|
@ -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<PageResponse<SkillSummaryResponse>> listMyStars(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "12") int size,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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")
|
||||
|
|
|
|||
|
|
@ -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<Void> 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<Long, NamespaceRole> namespaceRoles) {
|
||||
socialAppService.rate(skillId, principal.userId(), request.score(), namespaceRoles, principal.platformRoles());
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
|
||||
@GetMapping("/{skillId}/rating")
|
||||
public ApiResponse<SkillRatingStatusResponse> getUserRating(
|
||||
@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
if (principal == null) {
|
||||
return ok("response.success.read", new SkillRatingStatusResponse((short) 0, false));
|
||||
}
|
||||
Optional<Short> rating = skillRatingService.getUserRating(skillId, principal.userId());
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
|
||||
Optional<Short> rating = socialAppService.getUserRating(skillId,
|
||||
principal != null ? principal.userId() : null, namespaceRoles,
|
||||
principal != null ? principal.platformRoles() : null);
|
||||
return ok(
|
||||
"response.success.read",
|
||||
new SkillRatingStatusResponse(
|
||||
|
|
|
|||
|
|
@ -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<Void> starSkill(
|
||||
@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
skillStarService.star(skillId, principal.userId());
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<Boolean> 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<Long, NamespaceRole> namespaceRoles) {
|
||||
boolean starred = socialAppService.isStarred(skillId,
|
||||
principal != null ? principal.userId() : null, namespaceRoles,
|
||||
principal != null ? principal.platformRoles() : null);
|
||||
return ok("response.success.read", starred);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Skill> findAccessible(String userId, Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> 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> skill, Root<SkillStar> star,
|
||||
String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> 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<Long, NamespaceRole> roles = namespaceRoles != null ? namespaceRoles : Map.of();
|
||||
List<Long> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Skill> findAccessible(String userId, Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles, Pageable pageable);
|
||||
}
|
||||
|
|
@ -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<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
|
||||
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
|
||||
userId,
|
||||
PageRequest.of(page, size)
|
||||
);
|
||||
List<com.iflytek.skillhub.domain.social.SkillStar> stars = starPage.getContent();
|
||||
|
||||
List<Long> skillIds = stars.stream()
|
||||
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
|
||||
.distinct()
|
||||
.toList();
|
||||
java.util.Map<Long, Skill> 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<Skill> orderedSkills = stars.stream()
|
||||
.map(star -> skillsById.get(star.getSkillId()))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.toList();
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId);
|
||||
|
||||
return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize());
|
||||
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
Page<Skill> skills = starredSkillQueryRepository.findAccessible(
|
||||
userId, namespaceRoles, platformRoles, PageRequest.of(page, size));
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skills.getContent(), userId);
|
||||
return new PageResponse<>(items, skills.getTotalElements(), skills.getNumber(), skills.getSize());
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMySubscriptions(String userId, int page, int size) {
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
|
||||
requireAccess(skillId, userId, namespaceRoles, platformRoles);
|
||||
stars.star(skillId, userId);
|
||||
}
|
||||
|
||||
public boolean isStarred(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
|
||||
requireAccess(skillId, userId, namespaceRoles, platformRoles);
|
||||
return userId != null && stars.isStarred(skillId, userId);
|
||||
}
|
||||
|
||||
public void rate(Long skillId, String userId, short score, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
|
||||
requireAccess(skillId, userId, namespaceRoles, platformRoles);
|
||||
ratings.rate(skillId, userId, score);
|
||||
}
|
||||
|
||||
public Optional<Short> getUserRating(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
|
||||
requireAccess(skillId, userId, namespaceRoles, platformRoles);
|
||||
return userId != null ? ratings.getUserRating(skillId, userId) : Optional.empty();
|
||||
}
|
||||
|
||||
private void requireAccess(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Skill> 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<Long, NamespaceRole> roles, Set<String> platformRoles) {
|
||||
var checker = new VisibilityChecker();
|
||||
List<Long> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> roles, Set<String> 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<Long, NamespaceRole> roles, Set<String> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
|
||||
return (
|
||||
<Card
|
||||
className="group relative h-full cursor-pointer overflow-hidden rounded-md border border-border/60 bg-card p-4 text-card-foreground transition-[transform,box-shadow,border-color] duration-150 ease-out card-hover focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15"
|
||||
className="group relative h-full min-w-0 cursor-pointer overflow-hidden rounded-md border border-border/60 bg-card p-4 text-card-foreground transition-[transform,box-shadow,border-color] duration-150 ease-out card-hover focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15"
|
||||
style={{ borderColor: 'hsl(var(--border-card))', boxShadow: 'var(--shadow-card)' }}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
|
|
@ -51,15 +51,24 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
tabIndex={isInteractive ? 0 : undefined}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
|
||||
<h3 className="font-semibold text-base group-hover:text-primary transition-colors duration-150" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
<div className="mb-3 flex min-w-0 items-start gap-3">
|
||||
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="line-clamp-2 text-base font-semibold [overflow-wrap:anywhere] group-hover:text-primary transition-colors duration-150"
|
||||
style={{ color: 'hsl(var(--foreground))' }}
|
||||
title={skill.displayName}
|
||||
>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
<div className="mt-2 grid justify-items-start">
|
||||
<NamespaceBadge
|
||||
className="block max-w-full truncate"
|
||||
type="TEAM"
|
||||
name={`@${skill.namespace}`}
|
||||
title={`@${skill.namespace}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -77,11 +86,11 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
{complianceItems.slice(0, 2).map((item, index) => (
|
||||
<span
|
||||
key={`${item.standard ?? 'standard'}-${item.controlId ?? index}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
|
||||
title={item.title}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{[item.standard, item.controlId].filter(Boolean).join(' · ')}
|
||||
<ShieldCheck className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{[item.standard, item.controlId].filter(Boolean).join(' · ')}</span>
|
||||
</span>
|
||||
))}
|
||||
{complianceItems.length > 2 ? (
|
||||
|
|
@ -92,9 +101,9 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
|
||||
{headlineVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono text-[11px] font-semibold" style={{ color: 'hsl(215 30% 35%)' }}>
|
||||
<span title={`v${headlineVersion.version}`} className="max-w-full truncate px-2.5 py-1 rounded-full bg-secondary/60 font-mono text-[11px] font-semibold text-secondary-foreground">
|
||||
v{headlineVersion.version}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -122,15 +131,15 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
)}
|
||||
</div>
|
||||
{(skill.ownerDisplayName || skill.updatedAt) && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="mt-2 pt-2 border-t border-border/50 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
|
||||
{skill.ownerDisplayName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{skill.ownerDisplayName}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1" title={skill.ownerDisplayName}>
|
||||
<User className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{skill.ownerDisplayName}</span>
|
||||
</span>
|
||||
)}
|
||||
{skill.updatedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex shrink-0 items-center gap-1 whitespace-nowrap">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(skill.updatedAt, i18n.language)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
|
|||
const isSuite = resource.resourceType === 'SUITE'
|
||||
return (
|
||||
<Card
|
||||
className="group h-full cursor-pointer p-5 transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70"
|
||||
className="group h-full min-w-0 cursor-pointer p-5 transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70"
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
|
|
@ -22,22 +22,24 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
|
|||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0">
|
||||
<span className="mb-2 inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-1 text-xs font-medium">
|
||||
{isSuite ? <Boxes className="h-3.5 w-3.5" /> : <Wrench className="h-3.5 w-3.5" />}
|
||||
{t(isSuite ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')}
|
||||
</span>
|
||||
<h3 className="break-words text-lg font-semibold group-hover:text-primary">{resource.displayName}</h3>
|
||||
<h3 className="line-clamp-2 text-lg font-semibold [overflow-wrap:anywhere] group-hover:text-primary" title={resource.displayName}>{resource.displayName}</h3>
|
||||
</div>
|
||||
<div className="mt-2 grid justify-items-start">
|
||||
<NamespaceBadge
|
||||
className="block max-w-full truncate"
|
||||
type={resource.namespace === 'global' ? 'GLOBAL' : 'TEAM'}
|
||||
name={`@${resource.namespace}`}
|
||||
title={`@${resource.namespace}`}
|
||||
/>
|
||||
</div>
|
||||
<NamespaceBadge
|
||||
className="max-w-[50%] shrink-0 truncate"
|
||||
type={resource.namespace === 'global' ? 'GLOBAL' : 'TEAM'}
|
||||
name={`@${resource.namespace}`}
|
||||
title={`@${resource.namespace}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="line-clamp-2 min-h-10 text-sm text-muted-foreground">
|
||||
<p className="line-clamp-2 min-h-10 text-sm [overflow-wrap:anywhere] text-muted-foreground">
|
||||
{resource.summary || t('suite.noSummary')}
|
||||
</p>
|
||||
{isSuite && resource.labels?.length ? (
|
||||
|
|
@ -53,7 +55,7 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
className="max-w-[50%] truncate rounded-full bg-secondary/70 px-2.5 py-1 font-mono"
|
||||
title={`v${resource.version}`}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue