mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
# Conflicts: # web/src/api/client.ts # web/src/app/router.tsx # web/src/pages/skill-detail.tsx
This commit is contained in:
commit
c9e7e1ce28
52 changed files with 1200 additions and 399 deletions
|
|
@ -23,7 +23,7 @@ services:
|
|||
REDIS_PORT: 6379
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
SKILLHUB_PUBLIC_BASE_URL: "http://localhost"
|
||||
DEVICE_AUTH_VERIFICATION_URI: "http://localhost/api/device/activate"
|
||||
DEVICE_AUTH_VERIFICATION_URI: "http://localhost/cli/auth"
|
||||
SKILLHUB_STORAGE_PROVIDER: s3
|
||||
STORAGE_BASE_PATH: /var/lib/skillhub/storage
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT: http://minio:9000
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.iflytek.skillhub.compat.dto.ClawHubDeleteResponse;
|
|||
import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubStarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse;
|
||||
|
|
@ -23,8 +24,10 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.MDC;
|
||||
|
|
@ -55,6 +58,7 @@ public class ClawHubCompatController {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillStarService skillStarService;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public ClawHubCompatController(CanonicalSlugMapper mapper,
|
||||
SkillSearchAppService skillSearchAppService,
|
||||
|
|
@ -66,7 +70,8 @@ public class ClawHubCompatController {
|
|||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillStarService skillStarService) {
|
||||
SkillStarService skillStarService,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
this.mapper = mapper;
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillQueryService = skillQueryService;
|
||||
|
|
@ -78,8 +83,10 @@ public class ClawHubCompatController {
|
|||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillStarService = skillStarService;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
@RateLimit(category = "search", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/search")
|
||||
public ClawHubSearchResponse search(
|
||||
@RequestParam String q,
|
||||
|
|
@ -125,6 +132,7 @@ public class ClawHubCompatController {
|
|||
return (starScore + downloadScore) / 100.0;
|
||||
}
|
||||
|
||||
@RateLimit(category = "resolve", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/resolve")
|
||||
public ClawHubResolveResponse resolveByQuery(
|
||||
@RequestParam String slug,
|
||||
|
|
@ -160,6 +168,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubResolveResponse(matchVersion, latestVersion);
|
||||
}
|
||||
|
||||
@RateLimit(category = "resolve", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/resolve/{canonicalSlug}")
|
||||
public ClawHubResolveResponse resolve(
|
||||
@PathVariable String canonicalSlug,
|
||||
|
|
@ -187,6 +196,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubResolveResponse(matchVersion, latestVersion);
|
||||
}
|
||||
|
||||
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/download/{canonicalSlug}")
|
||||
public ResponseEntity<Void> downloadByPath(@PathVariable String canonicalSlug,
|
||||
@RequestParam(defaultValue = "latest") String version) {
|
||||
|
|
@ -199,6 +209,7 @@ public class ClawHubCompatController {
|
|||
.build();
|
||||
}
|
||||
|
||||
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/download")
|
||||
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
|
||||
@RequestParam(defaultValue = "latest") String version) {
|
||||
|
|
@ -215,8 +226,9 @@ public class ClawHubCompatController {
|
|||
.build();
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/skills")
|
||||
public ClawHubSearchResponse listSkills(
|
||||
public ClawHubSkillListResponse listSkills(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int limit,
|
||||
@RequestParam(required = false) String sort,
|
||||
|
|
@ -234,13 +246,59 @@ public class ClawHubCompatController {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
List<ClawHubSearchResponse.ClawHubSearchResult> results = response.items().stream()
|
||||
.map(this::toSearchResult)
|
||||
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
|
||||
.map(this::toSkillListItem)
|
||||
.toList();
|
||||
|
||||
return new ClawHubSearchResponse(results);
|
||||
// Calculate nextCursor: if there are more results, return next page number as cursor
|
||||
String nextCursor = null;
|
||||
long totalResults = response.total();
|
||||
long currentOffset = (long) page * limit;
|
||||
if (currentOffset + items.size() < totalResults) {
|
||||
nextCursor = String.valueOf(page + 1);
|
||||
}
|
||||
|
||||
return new ClawHubSkillListResponse(items, nextCursor);
|
||||
}
|
||||
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
|
||||
long createdAt = 0;
|
||||
long updatedAt = item.updatedAt() != null
|
||||
? item.updatedAt().toInstant(ZoneOffset.UTC).toEpochMilli()
|
||||
: 0;
|
||||
|
||||
ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null;
|
||||
if (item.latestVersion() != null) {
|
||||
latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion(
|
||||
item.latestVersion(),
|
||||
updatedAt, // Use skill's updatedAt as version createdAt
|
||||
"", // changelog not available in summary
|
||||
null // license not available in summary
|
||||
);
|
||||
}
|
||||
|
||||
// Build stats map with non-null values
|
||||
Map<String, Object> stats = new java.util.HashMap<>();
|
||||
if (item.downloadCount() != null) {
|
||||
stats.put("downloads", item.downloadCount());
|
||||
}
|
||||
if (item.starCount() != null) {
|
||||
stats.put("stars", item.starCount());
|
||||
}
|
||||
|
||||
return new ClawHubSkillListResponse.SkillListItem(
|
||||
mapper.toCanonical(item.namespace(), item.slug()),
|
||||
item.displayName(),
|
||||
item.summary(),
|
||||
Map.of(), // tags
|
||||
stats,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
latestVersion
|
||||
);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/skills/{canonicalSlug}")
|
||||
public ClawHubSkillResponse getSkill(
|
||||
@PathVariable String canonicalSlug,
|
||||
|
|
@ -250,8 +308,7 @@ public class ClawHubCompatController {
|
|||
|
||||
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", coord.slug()));
|
||||
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), userId);
|
||||
|
||||
SkillVersion latestVersionEntity = null;
|
||||
if (skill.getLatestVersionId() != null) {
|
||||
|
|
@ -302,6 +359,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubSkillResponse(skillInfo, versionInfo, ownerInfo, moderationInfo);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@DeleteMapping("/skills/{canonicalSlug}")
|
||||
public ClawHubDeleteResponse deleteSkill(
|
||||
@PathVariable String canonicalSlug,
|
||||
|
|
@ -310,6 +368,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubDeleteResponse();
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@PostMapping("/skills/{canonicalSlug}/undelete")
|
||||
public ClawHubDeleteResponse undeleteSkill(
|
||||
@PathVariable String canonicalSlug,
|
||||
|
|
@ -318,6 +377,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubDeleteResponse();
|
||||
}
|
||||
|
||||
@RateLimit(category = "stars", authenticated = 60, anonymous = 20)
|
||||
@PostMapping("/stars/{canonicalSlug}")
|
||||
public ClawHubStarResponse starSkill(
|
||||
@PathVariable String canonicalSlug,
|
||||
|
|
@ -325,8 +385,7 @@ public class ClawHubCompatController {
|
|||
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
|
||||
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug));
|
||||
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId());
|
||||
|
||||
boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId());
|
||||
skillStarService.star(skill.getId(), principal.userId());
|
||||
|
|
@ -334,6 +393,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubStarResponse(true, alreadyStarred);
|
||||
}
|
||||
|
||||
@RateLimit(category = "stars", authenticated = 60, anonymous = 20)
|
||||
@DeleteMapping("/stars/{canonicalSlug}")
|
||||
public ClawHubUnstarResponse unstarSkill(
|
||||
@PathVariable String canonicalSlug,
|
||||
|
|
@ -341,8 +401,7 @@ public class ClawHubCompatController {
|
|||
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
|
||||
Namespace ns = namespaceRepository.findBySlug(coord.namespace())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace()));
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), coord.slug())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", canonicalSlug));
|
||||
Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId());
|
||||
|
||||
boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId());
|
||||
skillStarService.unstar(skill.getId(), principal.userId());
|
||||
|
|
@ -350,6 +409,7 @@ public class ClawHubCompatController {
|
|||
return new ClawHubUnstarResponse(true, alreadyUnstarred);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@PostMapping("/skills")
|
||||
public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson,
|
||||
@RequestParam("files") MultipartFile[] files,
|
||||
|
|
@ -380,6 +440,7 @@ public class ClawHubCompatController {
|
|||
);
|
||||
}
|
||||
|
||||
@RateLimit(category = "publish", authenticated = 60, anonymous = 20)
|
||||
@PostMapping("/publish")
|
||||
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("namespace") String namespace,
|
||||
|
|
@ -413,6 +474,7 @@ public class ClawHubCompatController {
|
|||
return "global";
|
||||
}
|
||||
|
||||
@RateLimit(category = "whoami", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/whoami")
|
||||
public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return new ClawHubWhoamiResponse(
|
||||
|
|
@ -421,4 +483,16 @@ public class ClawHubCompatController {
|
|||
principal.avatarUrl()
|
||||
);
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
try {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.PUBLISHED);
|
||||
} catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException ex) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.AuthMeResponse;
|
||||
|
|
@ -28,6 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class AuthController extends BaseApiController {
|
||||
|
|
@ -36,25 +46,50 @@ public class AuthController extends BaseApiController {
|
|||
private final SessionBootstrapService sessionBootstrapService;
|
||||
private final DirectAuthService directAuthService;
|
||||
private final AuthFailureThrottleService authFailureThrottleService;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public AuthController(ApiResponseFactory responseFactory,
|
||||
AuthMethodCatalog authMethodCatalog,
|
||||
SessionBootstrapService sessionBootstrapService,
|
||||
DirectAuthService directAuthService,
|
||||
AuthFailureThrottleService authFailureThrottleService) {
|
||||
AuthFailureThrottleService authFailureThrottleService,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
PlatformSessionService platformSessionService,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
super(responseFactory);
|
||||
this.authMethodCatalog = authMethodCatalog;
|
||||
this.sessionBootstrapService = sessionBootstrapService;
|
||||
this.directAuthService = directAuthService;
|
||||
this.authFailureThrottleService = authFailureThrottleService;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.platformSessionService = platformSessionService;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<AuthMeResponse> me(@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
Authentication authentication) {
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
if (principal == null || authentication == null || !authentication.isAuthenticated()) {
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
UserAccount user = userAccountRepository.findById(principal.userId()).orElse(null);
|
||||
if (user == null || user.getStatus() == UserStatus.DISABLED) {
|
||||
request.getSession().invalidate();
|
||||
throw new UnauthorizedException("error.auth.required");
|
||||
}
|
||||
Set<String> freshRoles = PlatformRoleDefaults.withDefaultUserRole(
|
||||
userRoleBindingRepository.findByUserId(principal.userId()).stream()
|
||||
.map(binding -> binding.getRole().getCode())
|
||||
.collect(Collectors.toSet()));
|
||||
if (!freshRoles.equals(principal.platformRoles())) {
|
||||
principal = new PlatformPrincipal(
|
||||
principal.userId(), principal.displayName(), principal.email(),
|
||||
principal.avatarUrl(), principal.oauthProvider(), freshRoles);
|
||||
platformSessionService.establishSession(principal, request, false);
|
||||
}
|
||||
return ok("response.success.read", AuthMeResponse.from(principal));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
|
|
@ -21,21 +23,30 @@ import java.util.List;
|
|||
public class TokenController extends BaseApiController {
|
||||
|
||||
private final ApiTokenService apiTokenService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory) {
|
||||
public TokenController(ApiTokenService apiTokenService, ApiResponseFactory responseFactory, ObjectMapper objectMapper) {
|
||||
super(responseFactory);
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<TokenCreateResponse> create(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@Valid @RequestBody TokenCreateRequest request) {
|
||||
String scopeJson = request.scopes() == null || request.scopes().isEmpty()
|
||||
? "[\"skill:read\",\"skill:publish\"]"
|
||||
: request.scopes().toString();
|
||||
String scopeJson;
|
||||
if (request.scopes() == null || request.scopes().isEmpty()) {
|
||||
scopeJson = "[\"skill:read\",\"skill:publish\"]";
|
||||
} else {
|
||||
try {
|
||||
scopeJson = objectMapper.writeValueAsString(request.scopes());
|
||||
} catch (JsonProcessingException e) {
|
||||
scopeJson = "[\"skill:read\",\"skill:publish\"]";
|
||||
}
|
||||
}
|
||||
|
||||
var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson, request.expiresAt());
|
||||
var result = apiTokenService.rotateToken(principal.userId(), request.name(), scopeJson, request.expiresAt());
|
||||
return ok("response.success.created", new TokenCreateResponse(
|
||||
result.rawToken(),
|
||||
result.entity().getId(),
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
import com.iflytek.skillhub.domain.review.ReviewService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
|
|
@ -34,29 +34,29 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
public class SkillLifecycleController extends BaseApiController {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillGovernanceService skillGovernanceService;
|
||||
private final ReviewService reviewService;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public SkillLifecycleController(NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillGovernanceService skillGovernanceService,
|
||||
ReviewService reviewService,
|
||||
SkillPublishService skillPublishService,
|
||||
AuditLogService auditLogService,
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillGovernanceService = skillGovernanceService;
|
||||
this.reviewService = reviewService;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/archive")
|
||||
|
|
@ -66,7 +66,7 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill archived = skillGovernanceService.archiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
|
|
@ -86,7 +86,7 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
Skill restored = skillGovernanceService.unarchiveSkill(
|
||||
skill.getId(),
|
||||
userId,
|
||||
|
|
@ -106,7 +106,7 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
skillGovernanceService.deleteVersion(
|
||||
|
|
@ -128,7 +128,7 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@PathVariable String version,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
reviewService.withdrawReview(skillVersion.getId(), userId);
|
||||
|
|
@ -155,7 +155,7 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
|
||||
SkillPublishService.PublishResult result = skillPublishService.rereleasePublishedVersion(
|
||||
|
|
@ -181,11 +181,18 @@ public class SkillLifecycleController extends BaseApiController {
|
|||
new SkillLifecycleMutationResponse(result.skillId(), result.version().getId(), "RERELEASE_VERSION", result.version().getStatus().name()));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug) {
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
import com.iflytek.skillhub.domain.report.SkillReportService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillReportMutationResponse;
|
||||
|
|
@ -24,17 +24,17 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
public class SkillReportController extends BaseApiController {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillReportService skillReportService;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public SkillReportController(NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillReportService skillReportService,
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillReportService = skillReportService;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/{slug}/reports")
|
||||
|
|
@ -43,7 +43,7 @@ public class SkillReportController extends BaseApiController {
|
|||
@RequestBody SkillReportSubmitRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
Skill skill = findSkill(namespace, slug);
|
||||
Skill skill = findSkill(namespace, slug, userId);
|
||||
var report = skillReportService.submitReport(
|
||||
skill.getId(),
|
||||
userId,
|
||||
|
|
@ -55,11 +55,14 @@ public class SkillReportController extends BaseApiController {
|
|||
return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name()));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug) {
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
|
||||
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace));
|
||||
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespace.getId(),
|
||||
skillSlug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.PUBLISHED);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
|||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
@RequestMapping({"/api/web/skills"})
|
||||
public class SkillSearchController extends BaseApiController {
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
|
|
@ -22,7 +22,6 @@ public class SkillSearchController extends BaseApiController {
|
|||
this.skillSearchAppService = skillSearchAppService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RateLimit(category = "search", authenticated = 60, anonymous = 20)
|
||||
public ApiResponse<SkillSearchAppService.SearchResponse> search(
|
||||
@RequestParam(required = false) String q,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ server:
|
|||
forward-headers-strategy: framework
|
||||
servlet:
|
||||
session:
|
||||
timeout: ${SERVER_SERVLET_SESSION_TIMEOUT:8h}
|
||||
cookie:
|
||||
http-only: true
|
||||
secure: ${SESSION_COOKIE_SECURE:false}
|
||||
same-site: lax
|
||||
max-age: 28800
|
||||
|
||||
spring:
|
||||
messages:
|
||||
|
|
@ -98,7 +98,7 @@ skillhub:
|
|||
max-package-size: 104857600 # 100MB
|
||||
allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg
|
||||
device-auth:
|
||||
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
|
||||
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
|
||||
bootstrap:
|
||||
admin:
|
||||
enabled: ${BOOTSTRAP_ADMIN_ENABLED:false}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
-- V12__skill_owner_uniqueness.sql
|
||||
-- Change skill uniqueness from (namespace_id, slug) to (namespace_id, slug, owner_id)
|
||||
-- to support owner-isolated skill records with the same name
|
||||
|
||||
ALTER TABLE skill DROP CONSTRAINT skill_namespace_id_slug_key;
|
||||
ALTER TABLE skill ADD CONSTRAINT skill_namespace_id_slug_owner_id_key UNIQUE(namespace_id, slug, owner_id);
|
||||
|
|
@ -119,3 +119,5 @@ error.admin.user.role.invalid=Invalid role: {0}
|
|||
error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role
|
||||
error.admin.user.status.invalid=Invalid user status: {0}
|
||||
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
|
||||
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
|
||||
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace
|
||||
|
|
|
|||
|
|
@ -119,3 +119,5 @@ error.admin.user.role.invalid=无效的角色:{0}
|
|||
error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色
|
||||
error.admin.user.status.invalid=无效的用户状态:{0}
|
||||
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
|
||||
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
|
||||
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.device.DeviceCodeResponse;
|
||||
import com.iflytek.skillhub.auth.device.DeviceTokenResponse;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class DeviceAuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Test
|
||||
void requestDeviceCode_returns_code() throws Exception {
|
||||
DeviceCodeResponse response = new DeviceCodeResponse(
|
||||
"device_abc123",
|
||||
"ABCD-1234",
|
||||
"https://skillhub.example.com/device",
|
||||
900,
|
||||
5
|
||||
);
|
||||
|
||||
given(deviceAuthService.generateDeviceCode()).willReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/device/code")
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.deviceCode").value("device_abc123"))
|
||||
.andExpect(jsonPath("$.data.userCode").value("ABCD-1234"))
|
||||
.andExpect(jsonPath("$.data.verificationUri").value("https://skillhub.example.com/device"))
|
||||
.andExpect(jsonPath("$.data.expiresIn").value(900))
|
||||
.andExpect(jsonPath("$.data.interval").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_pending() throws Exception {
|
||||
DeviceTokenResponse response = DeviceTokenResponse.pending();
|
||||
|
||||
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/device/token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"deviceCode\": \"device_abc123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.error").value("authorization_pending"))
|
||||
.andExpect(jsonPath("$.data.accessToken").isEmpty())
|
||||
.andExpect(jsonPath("$.data.tokenType").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_access_token_when_authorized() throws Exception {
|
||||
DeviceTokenResponse response = DeviceTokenResponse.success("sk_device_flow_token");
|
||||
|
||||
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/device/token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"deviceCode\": \"device_abc123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.accessToken").value("sk_device_flow_token"))
|
||||
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||
.andExpect(jsonPath("$.data.error").isEmpty());
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ class SkillLifecycleControllerTest {
|
|||
setSkillId(skill, 1L);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
|
||||
given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup")))
|
||||
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED));
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ class SkillLifecycleControllerTest {
|
|||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
|
||||
given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class)))
|
||||
.willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE));
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ class SkillLifecycleControllerTest {
|
|||
version.setStatus(SkillVersionStatus.DRAFT);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
|
||||
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
|
||||
|
||||
mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0")
|
||||
|
|
@ -162,7 +162,7 @@ class SkillLifecycleControllerTest {
|
|||
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
|
||||
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review")
|
||||
|
|
@ -188,7 +188,7 @@ class SkillLifecycleControllerTest {
|
|||
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
|
||||
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
|
||||
setSkillVersionId(sourceVersion, 2L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class SkillReportControllerTest {
|
|||
ReflectionTestUtils.setField(report, "id", 99L);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
|
||||
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill));
|
||||
given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class)))
|
||||
.willReturn(report);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public class DeviceAuthService {
|
|||
|
||||
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
|
||||
ApiTokenService apiTokenService,
|
||||
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
|
||||
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.verificationUri = verificationUri;
|
||||
|
|
@ -109,7 +109,7 @@ public class DeviceAuthService {
|
|||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
|
||||
}
|
||||
|
||||
String token = apiTokenService.createToken(
|
||||
String token = apiTokenService.rotateToken(
|
||||
data.getUserId(),
|
||||
CLI_DEVICE_TOKEN_NAME,
|
||||
CLI_DEVICE_SCOPE_JSON
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
|
|||
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
|
||||
Page<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable);
|
||||
boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name);
|
||||
Optional<ApiToken> findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(String userId, String name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,26 @@ public class ApiTokenService {
|
|||
return new TokenCreateResult(rawToken, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke existing token with the same name (if any) and create a new one.
|
||||
* Used by device auth flow to avoid duplicate-name errors on repeated logins.
|
||||
*/
|
||||
@Transactional
|
||||
public TokenCreateResult rotateToken(String userId, String name, String scopeJson) {
|
||||
return rotateToken(userId, name, scopeJson, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TokenCreateResult rotateToken(String userId, String name, String scopeJson, String expiresAt) {
|
||||
String normalizedName = normalizeName(name);
|
||||
tokenRepo.findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(userId, normalizedName)
|
||||
.ifPresent(existing -> {
|
||||
existing.setRevokedAt(LocalDateTime.now());
|
||||
tokenRepo.save(existing);
|
||||
});
|
||||
return createToken(userId, name, scopeJson, expiresAt);
|
||||
}
|
||||
|
||||
public Optional<ApiToken> validateToken(String rawToken) {
|
||||
String hash = sha256(rawToken);
|
||||
return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid);
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
package com.iflytek.skillhub.auth.device;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.ApiToken;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DeviceAuthServiceTest {
|
||||
|
||||
@Mock
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Mock
|
||||
private ValueOperations<String, Object> valueOperations;
|
||||
|
||||
@Mock
|
||||
private ApiTokenService apiTokenService;
|
||||
|
||||
private DeviceAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateDeviceCode_returns_valid_response() {
|
||||
// When
|
||||
DeviceCodeResponse response = service.generateDeviceCode();
|
||||
|
||||
// Then
|
||||
assertThat(response.deviceCode()).isNotEmpty();
|
||||
assertThat(response.userCode()).matches("[A-Z2-9]{4}-[A-Z2-9]{4}");
|
||||
assertThat(response.verificationUri()).isEqualTo("https://skillhub.example.com/device");
|
||||
assertThat(response.expiresIn()).isEqualTo(900); // 15 minutes
|
||||
assertThat(response.interval()).isEqualTo(5);
|
||||
|
||||
// Verify Redis storage
|
||||
ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<Object> valueCaptor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(valueOperations, times(2)).set(keyCaptor.capture(), valueCaptor.capture(), eq(15L), eq(TimeUnit.MINUTES));
|
||||
|
||||
// Verify device code key and data
|
||||
assertThat(keyCaptor.getAllValues().get(0)).startsWith("device:code:");
|
||||
DeviceCodeData data = (DeviceCodeData) valueCaptor.getAllValues().get(0);
|
||||
assertThat(data.getDeviceCode()).isEqualTo(response.deviceCode());
|
||||
assertThat(data.getUserCode()).isEqualTo(response.userCode());
|
||||
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.PENDING);
|
||||
|
||||
// Verify user code key and value
|
||||
assertThat(keyCaptor.getAllValues().get(1)).startsWith("device:usercode:");
|
||||
assertThat(valueCaptor.getAllValues().get(1)).isEqualTo(response.deviceCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_pending_when_not_authorized() {
|
||||
// Given
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null);
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
|
||||
// When
|
||||
DeviceTokenResponse response = service.pollToken("device123");
|
||||
|
||||
// Then
|
||||
assertThat(response.error()).isEqualTo("authorization_pending");
|
||||
assertThat(response.accessToken()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_access_token_when_authorized() {
|
||||
// Given
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(true);
|
||||
when(apiTokenService.createToken("42", "CLI Device Flow", "[\"skill:read\",\"skill:publish\"]"))
|
||||
.thenReturn(new ApiTokenService.TokenCreateResult("sk_cli_token", mock(ApiToken.class)));
|
||||
|
||||
// When
|
||||
DeviceTokenResponse response = service.pollToken("device123");
|
||||
|
||||
// Then
|
||||
assertThat(response.accessToken()).isEqualTo("sk_cli_token");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
assertThat(response.error()).isNull();
|
||||
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.USED);
|
||||
verify(valueOperations).set("device:code:device123", data, 1L, TimeUnit.MINUTES);
|
||||
verify(redisTemplate).delete("device:usercode:ABCD-1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_rejects_second_exchange_attempt() {
|
||||
// Given
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(false);
|
||||
|
||||
// When / Then
|
||||
assertThatThrownBy(() -> service.pollToken("device123"))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessageContaining("error.deviceAuth.deviceCode.used");
|
||||
verify(apiTokenService, never()).createToken(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_error_when_expired() {
|
||||
// Given
|
||||
when(valueOperations.get("device:code:expired123")).thenReturn(null);
|
||||
|
||||
// When / Then
|
||||
assertThatThrownBy(() -> service.pollToken("expired123"))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessageContaining("error.deviceAuth.deviceCode.invalid");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeDeviceCode_updates_status() {
|
||||
// Given
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.PENDING, null);
|
||||
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
|
||||
// When
|
||||
service.authorizeDeviceCode("ABCD-1234", "42");
|
||||
|
||||
// Then
|
||||
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED);
|
||||
assertThat(data.getUserId()).isEqualTo("42");
|
||||
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeDeviceCode_rejects_different_user_after_authorization() {
|
||||
// Given
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
|
||||
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
|
||||
// When / Then
|
||||
assertThatThrownBy(() -> service.authorizeDeviceCode("ABCD-1234", "99"))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessageContaining("error.deviceAuth.deviceCode.alreadyAuthorized");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
|
|
@ -153,12 +154,30 @@ public class ReviewService {
|
|||
|
||||
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
if (skillVersion.getStatus() != SkillVersionStatus.PENDING_REVIEW) {
|
||||
throw new DomainBadRequestException("review.not_pending", reviewTaskId);
|
||||
}
|
||||
|
||||
Skill skill = skillRepository.findById(skillVersion.getSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
|
||||
|
||||
// Check no other owner has a published skill with the same slug
|
||||
List<Skill> sameSlugSkills = skillRepository.findByNamespaceIdAndSlug(skill.getNamespaceId(), skill.getSlug());
|
||||
for (Skill other : sameSlugSkills) {
|
||||
if (!other.getId().equals(skill.getId())) {
|
||||
boolean otherHasPublished = !skillVersionRepository
|
||||
.findBySkillIdAndStatus(other.getId(), SkillVersionStatus.PUBLISHED)
|
||||
.isEmpty();
|
||||
if (otherHasPublished) {
|
||||
throw new DomainBadRequestException("error.skill.approve.nameConflict", skill.getSlug());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
skillVersion.setPublishedAt(LocalDateTime.now());
|
||||
skillVersionRepository.save(skillVersion);
|
||||
|
||||
Skill skill = skillRepository.findById(skillVersion.getSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
|
||||
skill.setLatestVersionId(skillVersion.getId());
|
||||
applyPublishedMetadata(skill, skillVersion);
|
||||
skill.setUpdatedBy(reviewerId);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ public interface SkillRepository {
|
|||
Optional<Skill> findById(Long id);
|
||||
List<Skill> findByIdIn(List<Long> ids);
|
||||
List<Skill> findAll();
|
||||
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
|
||||
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
|
||||
Skill save(Skill skill);
|
||||
void delete(Skill skill);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import java.util.Map;
|
|||
public class VisibilityChecker {
|
||||
|
||||
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
if (skill.getLatestVersionId() == null) {
|
||||
return isOwner(skill, currentUserId);
|
||||
}
|
||||
return switch (skill.getVisibility()) {
|
||||
case PUBLIC -> true;
|
||||
case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId());
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public class SkillDownloadService {
|
|||
private final ObjectStorageService objectStorageService;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public SkillDownloadService(
|
||||
NamespaceRepository namespaceRepository,
|
||||
|
|
@ -34,7 +35,8 @@ public class SkillDownloadService {
|
|||
SkillTagRepository skillTagRepository,
|
||||
ObjectStorageService objectStorageService,
|
||||
VisibilityChecker visibilityChecker,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -42,6 +44,7 @@ public class SkillDownloadService {
|
|||
this.objectStorageService = objectStorageService;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
public record DownloadResult(
|
||||
|
|
@ -59,8 +62,7 @@ public class SkillDownloadService {
|
|||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
|
||||
// Visibility check
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
|
||||
|
|
@ -85,8 +87,7 @@ public class SkillDownloadService {
|
|||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
|
||||
// Visibility check
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
|
||||
|
|
@ -107,8 +108,7 @@ public class SkillDownloadService {
|
|||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
|
||||
// Visibility check
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
|
||||
|
|
@ -155,6 +155,14 @@ public class SkillDownloadService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
}
|
||||
|
||||
private void assertPublishedAccessible(Skill skill) {
|
||||
if (skill.getStatus() != SkillStatus.ACTIVE) {
|
||||
throw new DomainBadRequestException("error.skill.status.notActive");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.SlugValidator;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
|
|
@ -188,8 +189,23 @@ public class SkillPublishService {
|
|||
String.join(", ", prePublishValidation.errors()));
|
||||
}
|
||||
|
||||
// 6. Find or create Skill record
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
// 6. Find or create Skill record (with owner isolation)
|
||||
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
|
||||
|
||||
// Check if any other owner's skill has published versions
|
||||
for (Skill existing : existingSkills) {
|
||||
if (!existing.getOwnerId().equals(publisherId)) {
|
||||
boolean hasPublished = !skillVersionRepository
|
||||
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
|
||||
.isEmpty();
|
||||
if (hasPublished) {
|
||||
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create skill for current user
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)
|
||||
.orElseGet(() -> {
|
||||
Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility);
|
||||
newSkill.setCreatedBy(publisherId);
|
||||
|
|
@ -200,6 +216,16 @@ public class SkillPublishService {
|
|||
throw new DomainBadRequestException("error.skill.publish.archived", skillSlug);
|
||||
}
|
||||
|
||||
// 6c. Auto-withdraw pending review versions
|
||||
List<SkillVersion> pendingVersions = skillVersionRepository
|
||||
.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW);
|
||||
for (SkillVersion pending : pendingVersions) {
|
||||
reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING)
|
||||
.ifPresent(reviewTaskRepository::delete);
|
||||
pending.setStatus(SkillVersionStatus.DRAFT);
|
||||
skillVersionRepository.save(pending);
|
||||
}
|
||||
|
||||
// 7. Check version doesn't already exist
|
||||
if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) {
|
||||
throw new DomainBadRequestException("error.skill.version.exists", metadata.version());
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public class SkillQueryService {
|
|||
private final ObjectStorageService objectStorageService;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final PromotionRequestRepository promotionRequestRepository;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public SkillQueryService(
|
||||
NamespaceRepository namespaceRepository,
|
||||
|
|
@ -49,7 +50,8 @@ public class SkillQueryService {
|
|||
SkillTagRepository skillTagRepository,
|
||||
ObjectStorageService objectStorageService,
|
||||
VisibilityChecker visibilityChecker,
|
||||
PromotionRequestRepository promotionRequestRepository) {
|
||||
PromotionRequestRepository promotionRequestRepository,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -58,6 +60,7 @@ public class SkillQueryService {
|
|||
this.objectStorageService = objectStorageService;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
public record SkillDetailDTO(
|
||||
|
|
@ -114,8 +117,7 @@ public class SkillQueryService {
|
|||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
|
||||
// Visibility check
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
|
||||
|
|
@ -182,7 +184,7 @@ public class SkillQueryService {
|
|||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
SkillVersion skillVersion = findVersion(skill, version);
|
||||
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
|
||||
|
|
@ -207,7 +209,7 @@ public class SkillQueryService {
|
|||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
|
||||
SkillVersion skillVersion = findVersion(skill, version);
|
||||
|
|
@ -223,7 +225,7 @@ public class SkillQueryService {
|
|||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
|
||||
return skillFileRepository.findByVersionId(skillVersion.getId());
|
||||
|
|
@ -237,7 +239,7 @@ public class SkillQueryService {
|
|||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
|
||||
SkillVersion skillVersion = findVersion(skill, version);
|
||||
|
|
@ -256,7 +258,7 @@ public class SkillQueryService {
|
|||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
|
||||
SkillFile file = findFile(skillVersion, filePath);
|
||||
|
|
@ -269,7 +271,7 @@ public class SkillQueryService {
|
|||
Map<Long, NamespaceRole> userNsRoles,
|
||||
Pageable pageable) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
List<SkillVersion> visibleVersions;
|
||||
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
|
||||
|
|
@ -313,7 +315,7 @@ public class SkillQueryService {
|
|||
}
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = findSkill(namespace, skillSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
|
||||
SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash);
|
||||
String fingerprint = computeFingerprint(resolved);
|
||||
|
|
@ -340,14 +342,17 @@ public class SkillQueryService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||
}
|
||||
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug) {
|
||||
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
return findSkill(namespace, skillSlug);
|
||||
return resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
}
|
||||
|
||||
private Skill findSkill(Namespace namespace, String skillSlug) {
|
||||
return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
}
|
||||
|
||||
private SkillVersion findVersion(Skill skill, String version) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class SkillSlugResolutionService {
|
||||
|
||||
public enum Preference {
|
||||
CURRENT_USER,
|
||||
PUBLISHED
|
||||
}
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
|
||||
public SkillSlugResolutionService(SkillRepository skillRepository) {
|
||||
this.skillRepository = skillRepository;
|
||||
}
|
||||
|
||||
public Skill resolve(Long namespaceId, String slug, String currentUserId, Preference preference) {
|
||||
List<Skill> skills = skillRepository.findByNamespaceIdAndSlug(namespaceId, slug);
|
||||
if (skills.isEmpty()) {
|
||||
throw new DomainBadRequestException("error.skill.notFound", slug);
|
||||
}
|
||||
|
||||
Optional<Skill> ownSkill = currentUserId == null
|
||||
? Optional.empty()
|
||||
: skills.stream().filter(skill -> currentUserId.equals(skill.getOwnerId())).findFirst();
|
||||
Optional<Skill> publishedSkill = skills.stream()
|
||||
.filter(skill -> skill.getLatestVersionId() != null)
|
||||
.findFirst();
|
||||
|
||||
if (preference == Preference.CURRENT_USER) {
|
||||
return ownSkill.or(() -> publishedSkill)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug));
|
||||
}
|
||||
|
||||
return publishedSkill.or(() -> ownSkill)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", slug));
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ public class SkillTagService {
|
|||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillTagRepository skillTagRepository;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
public SkillTagService(
|
||||
NamespaceRepository namespaceRepository,
|
||||
|
|
@ -30,13 +31,15 @@ public class SkillTagService {
|
|||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillTagRepository skillTagRepository,
|
||||
VisibilityChecker visibilityChecker) {
|
||||
VisibilityChecker visibilityChecker,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillTagRepository = skillTagRepository;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
}
|
||||
|
||||
public List<SkillTag> listTags(String namespaceSlug,
|
||||
|
|
@ -44,8 +47,7 @@ public class SkillTagService {
|
|||
String currentUserId,
|
||||
java.util.Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNamespaceRoles)) {
|
||||
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
|
||||
}
|
||||
|
|
@ -76,8 +78,7 @@ public class SkillTagService {
|
|||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
assertAdminOrOwner(namespace.getId(), operatorId);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId);
|
||||
|
||||
// Find target version
|
||||
SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion)
|
||||
|
|
@ -111,8 +112,7 @@ public class SkillTagService {
|
|||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
assertAdminOrOwner(namespace.getId(), operatorId);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, operatorId);
|
||||
|
||||
SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName));
|
||||
|
|
@ -125,6 +125,14 @@ public class SkillTagService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
return skillSlugResolutionService.resolve(
|
||||
namespaceId,
|
||||
slug,
|
||||
currentUserId,
|
||||
SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
}
|
||||
|
||||
private void assertAdminOrOwner(Long namespaceId, String operatorId) {
|
||||
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorId)
|
||||
.map(member -> member.getRole())
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import org.springframework.context.ApplicationEventPublisher;
|
|||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
|
@ -233,6 +234,7 @@ class ReviewServiceTest {
|
|||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
|
|
@ -263,6 +265,7 @@ class ReviewServiceTest {
|
|||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
|
||||
|
|
@ -316,6 +319,24 @@ class ReviewServiceTest {
|
|||
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectApproveWhenSkillVersionWasWithdrawnBackToDraft() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
sv.setStatus(SkillVersionStatus.DRAFT);
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenNoPermission() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
|
|
@ -356,6 +377,7 @@ class ReviewServiceTest {
|
|||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
|
|
@ -379,6 +401,33 @@ class ReviewServiceTest {
|
|||
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectApproveWhenOtherOwnerHasPublishedSameSlug() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
Skill skill = createSkill(); // owned by USER_ID
|
||||
|
||||
// Another owner's skill with same slug that has a published version
|
||||
Skill otherSkill = new Skill(NAMESPACE_ID, "my-skill", "other-user", SkillVisibility.PUBLIC);
|
||||
setField(otherSkill, "id", 99L);
|
||||
SkillVersion otherPublished = new SkillVersion(99L, "1.0.0", "other-user");
|
||||
otherPublished.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill, otherSkill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(99L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(otherPublished));
|
||||
|
||||
assertThrows(DomainBadRequestException.class,
|
||||
() -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class VisibilityCheckerTest {
|
|||
private Skill publicSkill;
|
||||
private Skill namespaceOnlySkill;
|
||||
private Skill privateSkill;
|
||||
private Skill unpublishedPublicSkill;
|
||||
|
||||
private static final Long NAMESPACE_ID = 1L;
|
||||
private static final String OWNER_ID = "user-100";
|
||||
|
|
@ -26,8 +27,12 @@ class VisibilityCheckerTest {
|
|||
checker = new VisibilityChecker();
|
||||
|
||||
publicSkill = new Skill(NAMESPACE_ID, "public-skill", OWNER_ID, SkillVisibility.PUBLIC);
|
||||
publicSkill.setLatestVersionId(10L);
|
||||
namespaceOnlySkill = new Skill(NAMESPACE_ID, "namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY);
|
||||
namespaceOnlySkill.setLatestVersionId(11L);
|
||||
privateSkill = new Skill(NAMESPACE_ID, "private-skill", OWNER_ID, SkillVisibility.PRIVATE);
|
||||
privateSkill.setLatestVersionId(12L);
|
||||
unpublishedPublicSkill = new Skill(NAMESPACE_ID, "draft-public-skill", OWNER_ID, SkillVisibility.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -99,4 +104,29 @@ class VisibilityCheckerTest {
|
|||
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of());
|
||||
assertFalse(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnpublishedSkillNotAccessibleByAnonymousEvenWhenPublic() {
|
||||
boolean canAccess = checker.canAccess(unpublishedPublicSkill, null, Map.of());
|
||||
assertFalse(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnpublishedSkillNotAccessibleByOtherUserEvenWhenPublic() {
|
||||
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of());
|
||||
assertFalse(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnpublishedSkillNotAccessibleByAdmin() {
|
||||
Map<Long, NamespaceRole> roles = Map.of(NAMESPACE_ID, NamespaceRole.ADMIN);
|
||||
boolean canAccess = checker.canAccess(unpublishedPublicSkill, ADMIN_USER_ID, roles);
|
||||
assertFalse(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnpublishedSkillAccessibleByOwner() {
|
||||
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OWNER_ID, Map.of());
|
||||
assertTrue(canAccess);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream;
|
|||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
|
|
@ -45,9 +46,11 @@ class SkillDownloadServiceTest {
|
|||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private SkillDownloadService service;
|
||||
private SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
|
||||
service = new SkillDownloadService(
|
||||
namespaceRepository,
|
||||
skillRepository,
|
||||
|
|
@ -55,7 +58,8 @@ class SkillDownloadServiceTest {
|
|||
skillTagRepository,
|
||||
objectStorageService,
|
||||
visibilityChecker,
|
||||
eventPublisher
|
||||
eventPublisher,
|
||||
skillSlugResolutionService
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +86,7 @@ class SkillDownloadServiceTest {
|
|||
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
|
||||
when(objectStorageService.exists(storageKey)).thenReturn(true);
|
||||
|
|
@ -124,7 +128,7 @@ class SkillDownloadServiceTest {
|
|||
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
|
||||
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
|
||||
|
|
@ -164,7 +168,7 @@ class SkillDownloadServiceTest {
|
|||
ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now());
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version));
|
||||
when(objectStorageService.exists(storageKey)).thenReturn(true);
|
||||
|
|
@ -196,7 +200,7 @@ class SkillDownloadServiceTest {
|
|||
version.setStatus(SkillVersionStatus.DRAFT);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version));
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ class SkillPublishServiceTest {
|
|||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
|
|
@ -162,7 +163,8 @@ class SkillPublishServiceTest {
|
|||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("smoke-skill-two"))).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("smoke-skill-two"), eq(publisherId))).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("0.2.0"))).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
|
|
@ -205,7 +207,8 @@ class SkillPublishServiceTest {
|
|||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("auto-skill"))).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("auto-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
|
|
@ -253,7 +256,8 @@ class SkillPublishServiceTest {
|
|||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(archivedSkill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(archivedSkill));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
|
||||
namespaceSlug,
|
||||
|
|
@ -283,7 +287,8 @@ class SkillPublishServiceTest {
|
|||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC)));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), anyString())).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
|
|
@ -367,7 +372,8 @@ class SkillPublishServiceTest {
|
|||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("admin-skill"))).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("admin-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
|
|
@ -413,7 +419,8 @@ class SkillPublishServiceTest {
|
|||
|
||||
Skill skill = new Skill(namespace.getId(), "too-long-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 10L);
|
||||
when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), "too-long-skill", publisherId)).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.0.0")).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any())).thenAnswer(invocation -> {
|
||||
SkillVersion version = invocation.getArgument(0);
|
||||
|
|
@ -537,6 +544,133 @@ class SkillPublishServiceTest {
|
|||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String publisherId = "user-200";
|
||||
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
|
||||
|
||||
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
|
||||
List<PackageEntry> entries = List.of(skillMd);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
NamespaceMember member = mock(NamespaceMember.class);
|
||||
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
|
||||
|
||||
// Existing skill owned by another user with a published version
|
||||
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
|
||||
setId(existingSkill, 1L);
|
||||
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
|
||||
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
|
||||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
|
||||
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String publisherId = "user-200";
|
||||
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
|
||||
|
||||
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
|
||||
List<PackageEntry> entries = List.of(skillMd);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
NamespaceMember member = mock(NamespaceMember.class);
|
||||
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
|
||||
|
||||
// Existing skill owned by another user with NO published version
|
||||
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
|
||||
setId(existingSkill, 1L);
|
||||
|
||||
Skill newSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(newSkill, 2L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
|
||||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.empty());
|
||||
when(skillRepository.save(any(Skill.class))).thenReturn(newSkill);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) setId(saved, 10L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
SkillPublishService.PublishResult result = service.publishFromEntries(
|
||||
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
|
||||
);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("test-skill", result.slug());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String publisherId = "user-100";
|
||||
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 2.0.0\n---\nBody";
|
||||
|
||||
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
|
||||
List<PackageEntry> entries = List.of(skillMd);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
NamespaceMember member = mock(NamespaceMember.class);
|
||||
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of());
|
||||
|
||||
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
|
||||
// Existing pending version
|
||||
SkillVersion pendingV1 = new SkillVersion(1L, "1.0.0", publisherId);
|
||||
pendingV1.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
setId(pendingV1, 5L);
|
||||
ReviewTask pendingTask = new ReviewTask(5L, 1L, publisherId);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
|
||||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of(pendingV1));
|
||||
when(reviewTaskRepository.findBySkillVersionIdAndStatus(5L, com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING))
|
||||
.thenReturn(Optional.of(pendingTask));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("2.0.0"))).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) setId(saved, 10L);
|
||||
return saved;
|
||||
});
|
||||
when(skillRepository.save(any())).thenReturn(skill);
|
||||
|
||||
service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of());
|
||||
|
||||
// Verify pending version was withdrawn to DRAFT
|
||||
assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus());
|
||||
verify(reviewTaskRepository).delete(pendingTask);
|
||||
verify(skillVersionRepository).save(pendingV1);
|
||||
}
|
||||
|
||||
private void setId(Object entity, Long id) throws Exception {
|
||||
Field idField = entity.getClass().getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
|
|
|
|||
|
|
@ -52,9 +52,11 @@ class SkillQueryServiceTest {
|
|||
private PromotionRequestRepository promotionRequestRepository;
|
||||
|
||||
private SkillQueryService service;
|
||||
private SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
|
||||
service = new SkillQueryService(
|
||||
namespaceRepository,
|
||||
skillRepository,
|
||||
|
|
@ -63,7 +65,8 @@ class SkillQueryServiceTest {
|
|||
skillTagRepository,
|
||||
objectStorageService,
|
||||
visibilityChecker,
|
||||
promotionRequestRepository
|
||||
promotionRequestRepository,
|
||||
skillSlugResolutionService
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +90,7 @@ class SkillQueryServiceTest {
|
|||
setId(version, 10L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
|
||||
|
||||
|
|
@ -101,6 +104,41 @@ class SkillQueryServiceTest {
|
|||
assertEquals("1.0.0", result.latestVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_PrefersCurrentUsersOwnSkillOverOtherPublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "user-100";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
|
||||
Skill publishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
|
||||
setId(publishedSkill, 1L);
|
||||
publishedSkill.setDisplayName("Published Skill");
|
||||
publishedSkill.setLatestVersionId(11L);
|
||||
|
||||
Skill ownSkill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
|
||||
setId(ownSkill, 2L);
|
||||
ownSkill.setDisplayName("Own Skill");
|
||||
ownSkill.setLatestVersionId(22L);
|
||||
|
||||
SkillVersion ownVersion = new SkillVersion(2L, "2.0.0", userId);
|
||||
setId(ownVersion, 22L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
|
||||
when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
assertEquals(2L, result.id());
|
||||
assertEquals("Own Skill", result.displayName());
|
||||
assertEquals("2.0.0", result.latestVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_AccessDenied() throws Exception {
|
||||
// Arrange
|
||||
|
|
@ -113,9 +151,10 @@ class SkillQueryServiceTest {
|
|||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PRIVATE);
|
||||
setId(skill, 1L);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
|
||||
|
||||
// Act & Assert
|
||||
|
|
@ -134,9 +173,10 @@ class SkillQueryServiceTest {
|
|||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
|
||||
assertThrows(DomainForbiddenException.class, () ->
|
||||
service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of()));
|
||||
|
|
@ -170,6 +210,51 @@ class SkillQueryServiceTest {
|
|||
assertEquals("skill1", result.getContent().get(0).getSlug());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldHideOtherUsersUnpublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String viewerId = "user-300";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.ADMIN);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
Skill unpublishedSkill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC);
|
||||
setId(unpublishedSkill, 1L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(unpublishedSkill));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
service.getSkillDetail(namespaceSlug, skillSlug, viewerId, userNsRoles));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListSkillsByNamespace_ShouldHideOtherUsersUnpublishedSkills() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String userId = "user-100";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
Skill ownUnpublishedSkill = new Skill(1L, "own-skill", userId, SkillVisibility.PUBLIC);
|
||||
setId(ownUnpublishedSkill, 1L);
|
||||
Skill othersUnpublishedSkill = new Skill(1L, "other-skill", "user-200", SkillVisibility.PUBLIC);
|
||||
setId(othersUnpublishedSkill, 2L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
|
||||
.thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill));
|
||||
when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true);
|
||||
when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false);
|
||||
|
||||
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
|
||||
|
||||
assertEquals(1, result.getTotalElements());
|
||||
assertEquals("own-skill", result.getContent().get(0).getSlug());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListFiles() throws Exception {
|
||||
// Arrange
|
||||
|
|
@ -189,7 +274,7 @@ class SkillQueryServiceTest {
|
|||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
|
||||
|
|
@ -219,7 +304,7 @@ class SkillQueryServiceTest {
|
|||
skillVersion.setStatus(SkillVersionStatus.DRAFT);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
|
||||
|
|
@ -246,7 +331,7 @@ class SkillQueryServiceTest {
|
|||
SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
|
||||
|
|
@ -279,7 +364,7 @@ class SkillQueryServiceTest {
|
|||
skillVersion.setManifestJson("[{\"path\":\"SKILL.md\"}]");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
|
||||
|
|
@ -313,7 +398,7 @@ class SkillQueryServiceTest {
|
|||
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
|
||||
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
|
||||
|
|
@ -351,7 +436,7 @@ class SkillQueryServiceTest {
|
|||
rejected.setStatus(SkillVersionStatus.REJECTED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
|
||||
|
||||
|
|
@ -385,7 +470,7 @@ class SkillQueryServiceTest {
|
|||
SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of(version100, version110));
|
||||
|
|
@ -427,7 +512,7 @@ class SkillQueryServiceTest {
|
|||
SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
|
||||
|
|
@ -460,7 +545,7 @@ class SkillQueryServiceTest {
|
|||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
|
@ -487,7 +572,7 @@ class SkillQueryServiceTest {
|
|||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
|
||||
|
|
@ -518,7 +603,7 @@ class SkillQueryServiceTest {
|
|||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING))
|
||||
|
|
@ -548,7 +633,7 @@ class SkillQueryServiceTest {
|
|||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
|
||||
|
|
@ -572,10 +657,16 @@ class SkillQueryServiceTest {
|
|||
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
|
|
@ -607,7 +698,7 @@ class SkillQueryServiceTest {
|
|||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW))
|
||||
.thenReturn(List.of(pending));
|
||||
|
|
@ -642,7 +733,7 @@ class SkillQueryServiceTest {
|
|||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
|
|
@ -674,7 +765,7 @@ class SkillQueryServiceTest {
|
|||
pending.setManifestJson("[{\"path\":\"SKILL.md\"}]");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
|
||||
|
|
@ -709,7 +800,7 @@ class SkillQueryServiceTest {
|
|||
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
|
||||
|
|
@ -737,9 +828,10 @@ class SkillQueryServiceTest {
|
|||
SkillVersion pending = new SkillVersion(1L, version, "owner-1");
|
||||
setId(pending, 11L);
|
||||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
skill.setLatestVersionId(10L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
|
||||
|
|
@ -771,7 +863,7 @@ class SkillQueryServiceTest {
|
|||
rejected.setStatus(SkillVersionStatus.REJECTED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
|
||||
|
||||
|
|
@ -803,9 +895,10 @@ class SkillQueryServiceTest {
|
|||
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package com.iflytek.skillhub.domain.skill.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SkillSlugResolutionServiceTest {
|
||||
|
||||
private final SkillRepository skillRepository = mock(SkillRepository.class);
|
||||
private final SkillSlugResolutionService service = new SkillSlugResolutionService(skillRepository);
|
||||
|
||||
@Test
|
||||
void prefersCurrentUsersOwnSkillWhenRequested() throws Exception {
|
||||
Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L);
|
||||
Skill ownSkill = createSkill(2L, "demo", "user-1", 22L);
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(publishedSkill, ownSkill));
|
||||
|
||||
Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER);
|
||||
|
||||
assertEquals(2L, resolved.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersPublishedSkillForPublicInteractions() throws Exception {
|
||||
Skill ownDraft = createSkill(2L, "demo", "user-1", null);
|
||||
Skill publishedSkill = createSkill(1L, "demo", "user-2", 11L);
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(ownDraft, publishedSkill));
|
||||
|
||||
Skill resolved = service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.PUBLISHED);
|
||||
|
||||
assertEquals(1L, resolved.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwsWhenNoSkillMatchesSlug() {
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of());
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
service.resolve(1L, "demo", "user-1", SkillSlugResolutionService.Preference.CURRENT_USER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwsWhenOnlyUnpublishedSkillsBelongToOtherUsers() throws Exception {
|
||||
Skill otherUsersDraft = createSkill(3L, "demo", "user-2", null);
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "demo")).thenReturn(List.of(otherUsersDraft));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
service.resolve(1L, "demo", null, SkillSlugResolutionService.Preference.CURRENT_USER));
|
||||
}
|
||||
|
||||
private Skill createSkill(Long id, String slug, String ownerId, Long latestVersionId) throws Exception {
|
||||
Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC);
|
||||
Field idField = Skill.class.getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
idField.set(skill, id);
|
||||
skill.setLatestVersionId(latestVersionId);
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,16 +39,19 @@ class SkillTagServiceTest {
|
|||
private VisibilityChecker visibilityChecker;
|
||||
|
||||
private SkillTagService service;
|
||||
private SkillSlugResolutionService skillSlugResolutionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
|
||||
service = new SkillTagService(
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository,
|
||||
skillRepository,
|
||||
skillVersionRepository,
|
||||
skillTagRepository,
|
||||
visibilityChecker
|
||||
visibilityChecker,
|
||||
skillSlugResolutionService
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +76,7 @@ class SkillTagServiceTest {
|
|||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
|
||||
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.OWNER)));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, targetVersion)).thenReturn(Optional.of(version));
|
||||
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.empty());
|
||||
when(skillTagRepository.save(any())).thenReturn(tag);
|
||||
|
|
@ -118,7 +121,7 @@ class SkillTagServiceTest {
|
|||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, operatorId))
|
||||
.thenReturn(Optional.of(new NamespaceMember(1L, operatorId, NamespaceRole.ADMIN)));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(skillTagRepository.findBySkillIdAndTagName(1L, tagName)).thenReturn(Optional.of(tag));
|
||||
|
||||
// Act
|
||||
|
|
@ -175,7 +178,7 @@ class SkillTagServiceTest {
|
|||
SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(skillTagRepository.findBySkillId(1L)).thenReturn(List.of(tag1, tag2));
|
||||
when(visibilityChecker.canAccess(eq(skill), isNull(), eq(java.util.Map.of()))).thenReturn(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,15 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
|
|||
}
|
||||
|
||||
@Override
|
||||
public Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
|
||||
public List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug) {
|
||||
return delegate.findByNamespaceIdAndSlug(namespaceId, slug);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId) {
|
||||
return delegate.findByNamespaceIdAndSlugAndOwnerId(namespaceId, slug, ownerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
|
||||
return delegate.findByNamespaceIdAndStatus(namespaceId, status);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import java.util.Optional;
|
|||
@Repository
|
||||
public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRepository {
|
||||
List<Skill> findByIdIn(List<Long> ids);
|
||||
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
|
||||
|
||||
@Override
|
||||
default List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
|
||||
|
|
|
|||
|
|
@ -28,14 +28,17 @@
|
|||
"react-dropzone": "^15.0.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
|
|
|
|||
58
web/pnpm-lock.yaml
generated
58
web/pnpm-lock.yaml
generated
|
|
@ -56,6 +56,9 @@ importers:
|
|||
rehype-sanitize:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
remark-frontmatter:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
|
|
@ -65,10 +68,16 @@ importers:
|
|||
tailwind-merge:
|
||||
specifier: ^2.2.1
|
||||
version: 2.6.1
|
||||
unist-util-visit:
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.0
|
||||
zustand:
|
||||
specifier: ^5.0.11
|
||||
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
devDependencies:
|
||||
'@types/mdast':
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.14
|
||||
|
|
@ -1362,6 +1371,9 @@ packages:
|
|||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
fault@2.0.1:
|
||||
resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -1394,6 +1406,10 @@ packages:
|
|||
flatted@3.4.1:
|
||||
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
|
||||
|
||||
format@0.2.2:
|
||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||
engines: {node: '>=0.4.x'}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
|
|
@ -1654,6 +1670,9 @@ packages:
|
|||
mdast-util-from-markdown@2.0.3:
|
||||
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
|
||||
|
||||
mdast-util-frontmatter@2.0.1:
|
||||
resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==}
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
|
||||
|
||||
|
|
@ -1700,6 +1719,9 @@ packages:
|
|||
micromark-core-commonmark@2.0.3:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
|
||||
micromark-extension-frontmatter@2.0.0:
|
||||
resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
|
||||
|
||||
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
|
||||
|
||||
|
|
@ -2063,6 +2085,9 @@ packages:
|
|||
rehype-sanitize@6.0.0:
|
||||
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
|
||||
|
||||
remark-frontmatter@5.0.0:
|
||||
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
|
|
@ -3674,6 +3699,10 @@ snapshots:
|
|||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
fault@2.0.1:
|
||||
dependencies:
|
||||
format: 0.2.2
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
|
@ -3703,6 +3732,8 @@ snapshots:
|
|||
|
||||
flatted@3.4.1: {}
|
||||
|
||||
format@0.2.2: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
|
@ -3970,6 +4001,17 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-frontmatter@2.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
escape-string-regexp: 5.0.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
micromark-extension-frontmatter: 2.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
|
@ -4120,6 +4162,13 @@ snapshots:
|
|||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-frontmatter@2.0.0:
|
||||
dependencies:
|
||||
fault: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
|
|
@ -4558,6 +4607,15 @@ snapshots:
|
|||
'@types/hast': 3.0.4
|
||||
hast-util-sanitize: 5.0.2
|
||||
|
||||
remark-frontmatter@5.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-frontmatter: 2.0.1
|
||||
micromark-extension-frontmatter: 2.0.0
|
||||
unified: 11.0.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import { Layout } from './layout'
|
|||
import { getCurrentUser } from '@/api/client'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
|
||||
// Capture original URL before TanStack Router rewrites it
|
||||
const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : ''
|
||||
|
||||
// Export for use in cli-auth page
|
||||
export { ORIGINAL_URL_SEARCH }
|
||||
|
||||
function createLazyRouteComponent<TModule extends Record<string, unknown>>(
|
||||
importer: () => Promise<TModule>,
|
||||
exportName: keyof TModule,
|
||||
|
|
@ -64,7 +70,7 @@ const PromotionsPage = createLazyRouteComponent(
|
|||
)
|
||||
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
|
||||
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
|
||||
const DeviceAuthPage = createLazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage')
|
||||
const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage')
|
||||
const SecuritySettingsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/settings/security'),
|
||||
'SecuritySettingsPage',
|
||||
|
|
@ -269,10 +275,19 @@ const dashboardTokensRoute = createRoute({
|
|||
component: TokensPage,
|
||||
})
|
||||
|
||||
const deviceRoute = createRoute({
|
||||
const cliAuthRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'device',
|
||||
component: DeviceAuthPage,
|
||||
path: 'cli/auth',
|
||||
component: CliAuthPage,
|
||||
validateSearch: (search: Record<string, unknown>): Record<string, string> => {
|
||||
// Preserve all CLI auth parameters - use empty string instead of undefined to prevent TanStack Router from removing them
|
||||
return {
|
||||
redirect_uri: typeof search.redirect_uri === 'string' ? search.redirect_uri : '',
|
||||
label_b64: typeof search.label_b64 === 'string' ? search.label_b64 : '',
|
||||
label: typeof search.label === 'string' ? search.label : '',
|
||||
state: typeof search.state === 'string' ? search.state : '',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const settingsSecurityRoute = createRoute({
|
||||
|
|
@ -337,7 +352,7 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardPromotionsRoute,
|
||||
dashboardStarsRoute,
|
||||
dashboardTokensRoute,
|
||||
deviceRoute,
|
||||
cliAuthRoute,
|
||||
settingsSecurityRoute,
|
||||
settingsAccountsRoute,
|
||||
adminUsersRoute,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export function useUpdateUserRole() {
|
|||
updateUserRole(userId, role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -54,6 +55,7 @@ export function useUpdateUserStatus() {
|
|||
updateUserStatus(userId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -64,6 +66,7 @@ export function useApproveUser() {
|
|||
mutationFn: (userId: string) => adminApi.approveUser(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -74,6 +77,7 @@ export function useDisableUser() {
|
|||
mutationFn: (userId: string) => adminApi.disableUser(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -84,6 +88,7 @@ export function useEnableUser() {
|
|||
mutationFn: (userId: string) => adminApi.enableUser(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { accountApi } from '@/api/client'
|
||||
import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types'
|
||||
|
||||
|
|
@ -15,7 +15,11 @@ export function useVerifyAccountMerge() {
|
|||
}
|
||||
|
||||
export function useConfirmAccountMerge() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkFrontmatter from 'remark-frontmatter'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { Root } from 'mdast'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function remarkStripFrontmatter() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (!parent || index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const nodeType = String(node.type)
|
||||
if (nodeType === 'yaml' || nodeType === 'toml') {
|
||||
parent.children.splice(index, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const containerClassName = [
|
||||
className,
|
||||
|
|
@ -19,7 +37,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
return (
|
||||
<div className={containerClassName}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkFrontmatter, remarkStripFrontmatter, remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
|
|
|
|||
|
|
@ -114,7 +114,18 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
|
|||
const minDateTime = toLocalDateTimeInputValue(new Date())
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) {
|
||||
setCreatedToken(null)
|
||||
setName('')
|
||||
setNameError(null)
|
||||
setExpirationMode('never')
|
||||
setCustomExpiresAt('')
|
||||
setExpiresAtError(null)
|
||||
createMutation.reset()
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
{!createdToken ? (
|
||||
|
|
@ -210,22 +221,22 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<DialogHeader className="min-w-0 text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{t('createToken.successTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
<DialogDescription className="text-center break-words">
|
||||
{t('createToken.successDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="min-w-0 space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('createToken.tokenLabel')}</Label>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-sm break-all">
|
||||
<div className="overflow-hidden rounded-md bg-muted p-3 font-mono text-sm break-all">
|
||||
{createdToken.token}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('createToken.nameDisplay')}</Label>
|
||||
<div className="text-sm">{createdToken.name}</div>
|
||||
<div className="text-sm break-all">{createdToken.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('createToken.expiresAtDisplay')}</Label>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function TokenList() {
|
|||
return { previousPages }
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (tokenPage && tokenPage.items.length === 1 && page > 0) {
|
||||
if (tokenPage && tokenPage.items.length === 0 && page > 0) {
|
||||
setPage(page - 1)
|
||||
}
|
||||
setDeleteDialog({ open: false })
|
||||
|
|
@ -204,7 +204,7 @@ export function TokenList() {
|
|||
<TableBody>
|
||||
{tokens.map((token) => (
|
||||
<TableRow key={token.id}>
|
||||
<TableCell className="font-medium">{token.name}</TableCell>
|
||||
<TableCell className="font-medium max-w-48 break-all">{token.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{token.tokenPrefix}...
|
||||
|
|
@ -257,9 +257,9 @@ export function TokenList() {
|
|||
|
||||
<Dialog open={expirationDialog.open} onOpenChange={(open) => setExpirationDialog((current) => ({ ...current, open }))}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>{t('token.editExpirationTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="break-all">
|
||||
{t('token.editExpirationDescription', { name: expirationDialog.tokenName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -213,6 +213,23 @@
|
|||
"submit": "Authorize Device",
|
||||
"notice": "After authorization, the device will have access to your account"
|
||||
},
|
||||
"cliAuth": {
|
||||
"validating": "Validating...",
|
||||
"pleaseWait": "Please wait",
|
||||
"creatingToken": "Creating token...",
|
||||
"almostThere": "Almost there",
|
||||
"success": "Authorization successful",
|
||||
"redirecting": "Redirecting to CLI...",
|
||||
"fallbackInstructions": "If the browser doesn't redirect automatically, please copy the token below:",
|
||||
"error": "Authorization failed",
|
||||
"notAuthenticated": "You are not logged in",
|
||||
"invalidRedirectUri": "Invalid redirect URI",
|
||||
"missingState": "Missing security parameter",
|
||||
"windowsUrlBug": "Known Windows issue: the browser opened an incomplete URL. Please copy the full URL from the CLI output (the line starting with 'Opening browser:') and paste it into your browser address bar.",
|
||||
"tokenCreationFailed": "Token creation failed",
|
||||
"loginRequired": "Please log in first to authorize CLI access",
|
||||
"goToLogin": "Go to Login"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "View your account, skills, and access credentials in one place",
|
||||
|
|
@ -845,6 +862,7 @@
|
|||
"visibilityOptions": {
|
||||
"public": "Public",
|
||||
"namespaceOnly": "Namespace Only",
|
||||
"loggedInUsersOnly": "Logged-in Users Only",
|
||||
"private": "Private"
|
||||
},
|
||||
"file": "Skill Package File",
|
||||
|
|
|
|||
|
|
@ -211,7 +211,24 @@
|
|||
"defaultError": "授权失败,请检查用户码是否正确",
|
||||
"submitting": "授权中...",
|
||||
"submit": "授权设备",
|
||||
"notice": "授权后,设备将可以访问你的账户"
|
||||
"notice": "授权后,设备将可以访问你的账户"
|
||||
},
|
||||
"cliAuth": {
|
||||
"validating": "验证中...",
|
||||
"pleaseWait": "请稍候",
|
||||
"creatingToken": "创建令牌中...",
|
||||
"almostThere": "马上就好",
|
||||
"success": "授权成功",
|
||||
"redirecting": "正在跳转回 CLI...",
|
||||
"fallbackInstructions": "如果浏览器未自动跳转,请手动复制以下令牌:",
|
||||
"error": "授权失败",
|
||||
"notAuthenticated": "您尚未登录",
|
||||
"invalidRedirectUri": "无效的回调地址",
|
||||
"missingState": "缺少安全参数",
|
||||
"windowsUrlBug": "检测到 Windows 系统的已知问题:浏览器打开的 URL 不完整。请从 CLI 输出中复制完整的 URL(以 'Opening browser:' 开头的那一行),然后粘贴到浏览器地址栏中。",
|
||||
"tokenCreationFailed": "令牌创建失败",
|
||||
"loginRequired": "请先登录以授权 CLI 访问",
|
||||
"goToLogin": "前往登录"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
|
@ -845,6 +862,7 @@
|
|||
"visibilityOptions": {
|
||||
"public": "公开",
|
||||
"namespaceOnly": "仅命名空间",
|
||||
"loggedInUsersOnly": "仅登录用户可见",
|
||||
"private": "私有"
|
||||
},
|
||||
"file": "技能包文件",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,13 @@
|
|||
code, pre, kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Hide browser-native password reveal button (conflicts with custom toggle) */
|
||||
input[type="password"]::-ms-reveal,
|
||||
input[type="password"]::-ms-clear,
|
||||
input[type="password"]::-webkit-credentials-auto-fill-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Dot-grid background texture ─── */
|
||||
|
|
|
|||
223
web/src/pages/cli-auth.tsx
Normal file
223
web/src/pages/cli-auth.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { getCurrentUser, tokenApi } from '@/api/client'
|
||||
import type { User } from '@/api/types'
|
||||
import { ORIGINAL_URL_SEARCH } from '@/app/router'
|
||||
|
||||
// Parse the original URL params captured before TanStack Router rewrites
|
||||
const ORIGINAL_PARAMS = new URLSearchParams(ORIGINAL_URL_SEARCH)
|
||||
|
||||
function isValidRedirectUri(uri: string): boolean {
|
||||
try {
|
||||
const url = new URL(uri)
|
||||
// Only allow localhost/127.0.0.1/::1 on HTTP
|
||||
const validHosts = ['localhost', '127.0.0.1', '[::1]', '::1']
|
||||
return url.protocol === 'http:' && validHosts.includes(url.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function decodeLabel(labelB64?: string, labelPlain?: string): string {
|
||||
if (labelB64) {
|
||||
try {
|
||||
// Base64-URL decode
|
||||
const base64 = labelB64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return atob(base64)
|
||||
} catch {
|
||||
// Fallback to plain label
|
||||
}
|
||||
}
|
||||
return labelPlain || 'CLI token'
|
||||
}
|
||||
|
||||
export function CliAuthPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [user, setUser] = useState<User | null | undefined>(undefined)
|
||||
const [status, setStatus] = useState<'validating' | 'creating' | 'redirecting' | 'error'>('validating')
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
const [token, setToken] = useState<string>('')
|
||||
|
||||
// Use the captured original params from module load time
|
||||
const redirectUri = ORIGINAL_PARAMS.get('redirect_uri')?.trim() || undefined
|
||||
const state = ORIGINAL_PARAMS.get('state')?.trim() || undefined
|
||||
const labelB64 = ORIGINAL_PARAMS.get('label_b64')?.trim() || undefined
|
||||
const labelPlain = ORIGINAL_PARAMS.get('label')?.trim() || undefined
|
||||
const label = decodeLabel(labelB64, labelPlain)
|
||||
|
||||
// Debug: log search params and raw URL
|
||||
console.log('CLI Auth - Original search (from router.tsx):', ORIGINAL_URL_SEARCH)
|
||||
console.log('CLI Auth - Current URL:', typeof window !== 'undefined' ? window.location.href : 'SSR')
|
||||
console.log('CLI Auth - redirectUri:', redirectUri)
|
||||
console.log('CLI Auth - state:', state)
|
||||
console.log('CLI Auth - label:', label)
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status
|
||||
getCurrentUser()
|
||||
.then((currentUser) => {
|
||||
setUser(currentUser)
|
||||
})
|
||||
.catch(() => {
|
||||
setUser(null)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Once we know the user status, proceed with token creation
|
||||
if (user === undefined) {
|
||||
// Still loading
|
||||
return
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
// Not authenticated - user needs to log in
|
||||
setStatus('error')
|
||||
setErrorMessage(t('cliAuth.notAuthenticated'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect_uri
|
||||
if (!redirectUri || !isValidRedirectUri(redirectUri)) {
|
||||
setStatus('error')
|
||||
setErrorMessage(t('cliAuth.invalidRedirectUri'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state
|
||||
if (!state) {
|
||||
setStatus('error')
|
||||
// Special error message for Windows users with missing state
|
||||
if (redirectUri && typeof window !== 'undefined' && navigator.platform.includes('Win')) {
|
||||
setErrorMessage(t('cliAuth.windowsUrlBug'))
|
||||
} else {
|
||||
setErrorMessage(t('cliAuth.missingState'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create token and redirect
|
||||
setStatus('creating')
|
||||
tokenApi
|
||||
.createToken({
|
||||
name: label,
|
||||
scopes: ['skill:read', 'skill:publish'],
|
||||
})
|
||||
.then((response) => {
|
||||
setToken(response.token)
|
||||
setStatus('redirecting')
|
||||
|
||||
// Construct redirect URL with token in hash fragment
|
||||
const registryUrl = window.location.origin
|
||||
const hashParams = new URLSearchParams()
|
||||
hashParams.set('token', response.token)
|
||||
hashParams.set('registry', registryUrl)
|
||||
hashParams.set('state', state)
|
||||
|
||||
const redirectUrl = `${redirectUri}#${hashParams.toString()}`
|
||||
|
||||
// Redirect to CLI's loopback server
|
||||
window.location.assign(redirectUrl)
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatus('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : t('cliAuth.tokenCreationFailed'))
|
||||
})
|
||||
}, [user, redirectUri, state, label, t])
|
||||
|
||||
if (status === 'validating') {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-8 space-y-6 text-center">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent items-center justify-center shadow-glow mb-2 mx-auto">
|
||||
<svg className="w-8 h-8 text-primary-foreground animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.validating')}</h1>
|
||||
<p className="text-muted-foreground">{t('cliAuth.pleaseWait')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'creating') {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-8 space-y-6 text-center">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent items-center justify-center shadow-glow mb-2 mx-auto">
|
||||
<svg className="w-8 h-8 text-primary-foreground animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.creatingToken')}</h1>
|
||||
<p className="text-muted-foreground">{t('cliAuth.almostThere')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'redirecting') {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-8 space-y-6 text-center">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 items-center justify-center shadow-glow mb-2 mx-auto">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.success')}</h1>
|
||||
<p className="text-muted-foreground">{t('cliAuth.redirecting')}</p>
|
||||
|
||||
{token && (
|
||||
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">{t('cliAuth.fallbackInstructions')}</p>
|
||||
<code className="block p-3 bg-background rounded text-xs font-mono break-all">
|
||||
{token}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-4 animate-fade-up">
|
||||
<Card className="w-full max-w-md p-8 space-y-6">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-red-500 to-red-600 items-center justify-center shadow-glow mb-2 mx-auto">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold font-heading">{t('cliAuth.error')}</h1>
|
||||
<p className="text-muted-foreground">{errorMessage}</p>
|
||||
</div>
|
||||
|
||||
{user === null && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
{t('cliAuth.loginRequired')}
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const returnTo = `/cli/auth?${searchParams.toString()}`
|
||||
navigate({ to: '/login', search: { returnTo } })
|
||||
}}
|
||||
>
|
||||
{t('cliAuth.goToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -50,6 +50,10 @@ export function PublishPage() {
|
|||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const publishMutation = usePublishSkill()
|
||||
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
|
||||
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
|
||||
? t('publish.visibilityOptions.loggedInUsersOnly')
|
||||
: t('publish.visibilityOptions.namespaceOnly')
|
||||
|
||||
const handleRemoveSelectedFile = () => {
|
||||
setSelectedFile(null)
|
||||
|
|
@ -153,7 +157,7 @@ export function PublishPage() {
|
|||
onChange={(e) => setVisibility(e.target.value)}
|
||||
>
|
||||
<option value="PUBLIC">{t('publish.visibilityOptions.public')}</option>
|
||||
<option value="NAMESPACE_ONLY">{t('publish.visibilityOptions.namespaceOnly')}</option>
|
||||
<option value="NAMESPACE_ONLY">{namespaceOnlyLabel}</option>
|
||||
<option value="PRIVATE">{t('publish.visibilityOptions.private')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export function LoginPage() {
|
|||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showPassword ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.password ? (
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export function SkillDetailPage() {
|
|||
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW'
|
||||
const canInteract = skill?.canInteract ?? true
|
||||
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED'
|
||||
|
||||
const refreshSkill = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })
|
||||
|
|
@ -687,7 +688,7 @@ export function SkillDetailPage() {
|
|||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleDownload}
|
||||
disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || isPendingPreview}
|
||||
disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || !isVersionDownloadable}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ export function ConfirmDialog({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<DialogHeader className="min-w-0 text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{title}</DialogTitle>
|
||||
{description && <DialogDescription className="text-center">{description}</DialogDescription>}
|
||||
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-center sm:space-x-3">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export function usePublishSkill() {
|
|||
skipGlobalErrorHandler: true,
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue