downloadResponse() {
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType("application/zip"))
+ .body(new InputStreamResource(
+ new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8))));
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java
index a2268a67..aaa74655 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java
@@ -36,6 +36,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -44,6 +45,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;
@@ -106,6 +108,12 @@ class PromotionApprovalFlowIntegrationTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(graph.request().getId()))
+ .andExpect(jsonPath("$.data.sourceSkillDisplayName").value(org.hamcrest.Matchers.startsWith("Promote Skill")))
+ .andExpect(jsonPath("$.data.sourceSkillSummary").value("Used to verify promotion approval flow."))
+ .andExpect(jsonPath("$.data.sourceVersionFileCount").value(0))
+ .andExpect(jsonPath("$.data.sourceVersionTotalSize").value(0))
+ .andExpect(jsonPath("$.data.sourceSkillDownloadCount").value(0))
+ .andExpect(jsonPath("$.data.sourceSkillStarCount").value(0))
.andExpect(jsonPath("$.data.status").value("APPROVED"))
.andExpect(jsonPath("$.data.reviewedBy").value(REVIEWER_ID))
.andExpect(jsonPath("$.data.reviewComment").value("ship it"));
@@ -187,6 +195,75 @@ class PromotionApprovalFlowIntegrationTest {
assertThat(savedRequest.getTargetSkillId()).isNull();
}
+ @Test
+ @Transactional
+ void listPromotions_sortsApprovedAndRejectedHistoryByReviewedAtWithNullsLastAndTieBreaker() throws Exception {
+ when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SUPER_ADMIN"));
+
+ assertHistorySortForStatus(ReviewTaskStatus.APPROVED, "APPROVED");
+ assertHistorySortForStatus(ReviewTaskStatus.REJECTED, "REJECTED");
+ }
+
+ private void assertHistorySortForStatus(ReviewTaskStatus reviewStatus, String statusParam) throws Exception {
+ promotionRequestRepository.deleteAll();
+ promotionRequestRepository.flush();
+
+ PromotionGraph latest = createPromotionGraph();
+ PromotionGraph sameTimeOlderId = createPromotionGraph();
+ PromotionGraph sameTimeNewerId = createPromotionGraph();
+ PromotionGraph legacyNullReviewedAt = createPromotionGraph();
+
+ Instant sameReviewedAt = Instant.parse("2026-06-18T08:00:00Z");
+ markPromotionHistory(latest.request(), reviewStatus, Instant.parse("2026-06-18T09:00:00Z"));
+ markPromotionHistory(sameTimeOlderId.request(), reviewStatus, sameReviewedAt);
+ markPromotionHistory(sameTimeNewerId.request(), reviewStatus, sameReviewedAt);
+ markPromotionHistory(legacyNullReviewedAt.request(), reviewStatus, null);
+
+ mockMvc.perform(get("/api/web/promotions")
+ .param("status", statusParam)
+ .param("page", "0")
+ .param("size", "2")
+ .param("sortBy", "reviewedAt")
+ .param("sortDirection", "DESC")
+ .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId()))
+ .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId()));
+
+ mockMvc.perform(get("/api/web/promotions")
+ .param("status", statusParam)
+ .param("page", "1")
+ .param("size", "2")
+ .param("sortBy", "reviewedAt")
+ .param("sortDirection", "DESC")
+ .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId()))
+ .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId()));
+
+ mockMvc.perform(get("/api/web/promotions")
+ .param("status", statusParam)
+ .param("page", "0")
+ .param("size", "2")
+ .param("sortBy", "reviewedAt")
+ .param("sortDirection", "ASC")
+ .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId()))
+ .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId()));
+
+ mockMvc.perform(get("/api/web/promotions")
+ .param("status", statusParam)
+ .param("page", "1")
+ .param("size", "2")
+ .param("sortBy", "reviewedAt")
+ .param("sortDirection", "ASC")
+ .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId()))
+ .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId()));
+ }
+
private PromotionGraph createPromotionGraph() {
return createPromotionGraph(SUBMITTER_ID);
}
@@ -236,6 +313,14 @@ class PromotionApprovalFlowIntegrationTest {
}
}
+ private void markPromotionHistory(PromotionRequest request, ReviewTaskStatus status, Instant reviewedAt) {
+ request.setStatus(status);
+ request.setReviewedBy(REVIEWER_ID);
+ request.setReviewComment(status == ReviewTaskStatus.APPROVED ? "approved" : "rejected");
+ request.setReviewedAt(reviewedAt);
+ promotionRequestRepository.saveAndFlush(request);
+ }
+
private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
new file mode 100644
index 00000000..db8982b7
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
@@ -0,0 +1,137 @@
+package com.iflytek.skillhub.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter;
+import com.iflytek.skillhub.auth.token.ApiTokenScopeService;
+import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.skillhub.dto.ApiResponseFactory;
+import jakarta.servlet.FilterChain;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.MDC;
+import org.springframework.context.i18n.LocaleContextHolder;
+import org.springframework.context.support.ResourceBundleMessageSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+class ApiAccessDeniedHandlerTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
+ private ApiAccessDeniedHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+ messageSource.setBasename("messages");
+ messageSource.setDefaultEncoding("UTF-8");
+ ApiResponseFactory responseFactory = new ApiResponseFactory(
+ messageSource,
+ Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)
+ );
+ handler = new ApiAccessDeniedHandler(
+ objectMapper,
+ responseFactory,
+ new SensitiveLogSanitizer()
+ );
+ MDC.put("requestId", "req-610");
+ LocaleContextHolder.setLocale(Locale.ENGLISH);
+ }
+
+ @AfterEach
+ void tearDown() {
+ MDC.clear();
+ LocaleContextHolder.resetLocaleContext();
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void shouldExposeLocalizedApiTokenScopeReasonAndRequestId() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ ApiTokenScopeService scopeService =
+ new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry());
+ ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
+ PlatformPrincipal principal = new PlatformPrincipal(
+ "user-1",
+ "Alice",
+ "alice@example.com",
+ "",
+ "api_token",
+ Set.of("USER")
+ );
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(
+ principal,
+ null,
+ List.of(new SimpleGrantedAuthority("SCOPE_skill:read"))
+ )
+ );
+ FilterChain chain = (servletRequest, servletResponse) -> {
+ throw new AssertionError("Denied request must not continue");
+ };
+
+ filter.doFilter(request, response, chain);
+
+ JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
+ assertThat(response.getStatus()).isEqualTo(403);
+ assertThat(body.path("msg").asText())
+ .isEqualTo("API token is missing required scope: skill:publish");
+ assertThat(body.path("requestId").asText()).isEqualTo("req-610");
+ }
+
+ @Test
+ void shouldTranslateSafeApiTokenReason() throws Exception {
+ LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE);
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/whoami");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ ApiTokenScopeService scopeService =
+ new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry());
+ ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
+ PlatformPrincipal principal = new PlatformPrincipal(
+ "user-1",
+ "Alice",
+ "alice@example.com",
+ "",
+ "api_token",
+ Set.of("USER")
+ );
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(principal, null, List.of())
+ );
+
+ filter.doFilter(request, response, (servletRequest, servletResponse) -> {
+ throw new AssertionError("Denied request must not continue");
+ });
+
+ JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
+ assertThat(body.path("msg").asText())
+ .isEqualTo("API 令牌无法访问接口:/api/cli/v1/whoami");
+ }
+
+ @Test
+ void shouldHideGenericAccessDeniedExceptionMessage() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/admin");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ handler.handle(request, response, new AccessDeniedException("internal authorization detail"));
+
+ JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
+ assertThat(body.path("msg").asText()).isEqualTo("Forbidden");
+ assertThat(response.getContentAsString()).doesNotContain("internal authorization detail");
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java
index 7f2cfc22..8296f940 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java
@@ -89,6 +89,20 @@ class AdminUserAppServiceTest {
() -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN")));
}
+ @Test
+ void updateUserRole_nonSuperAdminCannotReplaceExistingSuperAdminRole() {
+ when(userAccountRepository.findById("user-1"))
+ .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE)));
+ when(userRoleBindingRepository.findByUserId("user-1"))
+ .thenReturn(List.of(new UserRoleBinding("user-1", role("SUPER_ADMIN"))));
+
+ assertThrows(DomainForbiddenException.class,
+ () -> service.updateUserRole("user-1", "USER", Set.of("USER_ADMIN")));
+
+ verify(userRoleBindingRepository, never()).deleteByUserId(any());
+ verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class));
+ }
+
@Test
void updateUserRole_rejectsSystemAccount() {
when(userAccountRepository.findById("builtin-skill-publisher"))
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java
index 35ca8d75..e9ef372f 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java
@@ -16,6 +16,31 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2Clien
class AuthMethodCatalogTest {
+ @Test
+ void catalogsShouldHideEmptyAndPlaceholderOAuthProviders() {
+ OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties();
+ oauthProperties.getRegistration().put("valid", registration("production-client", "Valid"));
+ oauthProperties.getRegistration().put("missing", registration(null, "Missing"));
+ oauthProperties.getRegistration().put("blank", registration(" ", "Blank"));
+ oauthProperties.getRegistration().put("placeholder", registration("PLACEHOLDER", "Placeholder"));
+ oauthProperties.getRegistration().put("local", registration("local-placeholder", "Local"));
+
+ AuthMethodCatalog catalog = new AuthMethodCatalog(
+ oauthProperties,
+ new DirectAuthProperties(),
+ new AuthSessionBootstrapProperties(),
+ List.of(),
+ List.of()
+ );
+
+ assertThat(catalog.listOAuthProviders(null))
+ .extracting(provider -> provider.id())
+ .containsExactly("valid");
+ assertThat(catalog.listMethods(null))
+ .extracting(method -> method.id())
+ .containsExactly("local-password", "oauth-valid");
+ }
+
@Test
void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() {
OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties();
@@ -122,4 +147,11 @@ class AuthMethodCatalogTest {
"bootstrap-private-sso:private-sso"
);
}
+
+ private static OAuth2ClientProperties.Registration registration(String clientId, String clientName) {
+ OAuth2ClientProperties.Registration registration = new OAuth2ClientProperties.Registration();
+ registration.setClientId(clientId);
+ registration.setClientName(clientName);
+ return registration;
+ }
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java
new file mode 100644
index 00000000..257d33d2
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java
@@ -0,0 +1,269 @@
+package com.iflytek.skillhub.service;
+
+import com.iflytek.skillhub.SkillhubApplication;
+import com.iflytek.skillhub.TestRedisConfig;
+import com.iflytek.skillhub.domain.label.LabelDefinition;
+import com.iflytek.skillhub.domain.label.LabelDefinitionRepository;
+import com.iflytek.skillhub.domain.label.LabelTranslation;
+import com.iflytek.skillhub.domain.label.LabelTranslationRepository;
+import com.iflytek.skillhub.domain.label.LabelType;
+import com.iflytek.skillhub.domain.namespace.Namespace;
+import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
+import com.iflytek.skillhub.domain.namespace.NamespaceRole;
+import com.iflytek.skillhub.domain.namespace.NamespaceType;
+import com.iflytek.skillhub.domain.skill.Skill;
+import com.iflytek.skillhub.domain.skill.SkillRepository;
+import com.iflytek.skillhub.domain.skill.SkillVisibility;
+import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
+import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
+import com.iflytek.skillhub.search.SearchEmbeddingService;
+import com.iflytek.skillhub.search.SearchRebuildService;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Reproduces the bug where attaching a skill label does not update the search
+ * index. The label keyword should appear in the rebuilt search document after
+ * {@code attachLabel} commits.
+ *
+ * With the upstream (synchronous) {@code LabelSearchSyncService.rebuildSkill},
+ * the rebuild runs inside the {@code afterCommit} callback on the request thread,
+ * where the {@code @Transactional index()} write does not persist — so the keyword
+ * never lands in the index and this test fails. Adding {@code @Async} moves the
+ * rebuild to a fresh thread/transaction and the keyword appears.
+ */
+@SpringBootTest(classes = SkillhubApplication.class)
+@ActiveProfiles("test")
+@Import(TestRedisConfig.class)
+class LabelSearchSyncIntegrationTest {
+
+ @Autowired
+ private SkillLabelAppService skillLabelAppService;
+
+ @Autowired
+ private NamespaceRepository namespaceRepository;
+
+ @Autowired
+ private SkillRepository skillRepository;
+
+ @Autowired
+ private LabelDefinitionRepository labelDefinitionRepository;
+
+ @Autowired
+ private LabelTranslationRepository labelTranslationRepository;
+
+ @Autowired
+ private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository;
+
+ @Autowired
+ private SearchRebuildService searchRebuildService;
+
+ @Autowired
+ private TransactionTemplate transactionTemplate;
+
+ @MockBean
+ private SearchEmbeddingService searchEmbeddingService;
+
+ @BeforeEach
+ void setUp() {
+ when(searchEmbeddingService.embed(anyString())).thenReturn("");
+ when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d);
+ }
+
+ @Test
+ void attachingLabel_updatesSearchIndexWithLabelKeyword() throws Exception {
+ String suffix = UUID.randomUUID().toString().substring(0, 8);
+ String ownerId = "owner-" + suffix;
+ // ASCII display name so the tokenizer keeps it as a single searchable token.
+ String labelDisplayName = "MachineLearning" + suffix;
+ String labelSlug = "ml-" + suffix;
+
+ Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId);
+ namespace.setType(NamespaceType.GLOBAL);
+ namespace = namespaceRepository.save(namespace);
+
+ Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
+ skill.setDisplayName("Skill " + suffix);
+ skill.setSummary("A skill used to reproduce the label search sync bug.");
+ skill.setCreatedBy(ownerId);
+ skill.setUpdatedBy(ownerId);
+ skill = skillRepository.save(skill);
+ skillRepository.flush();
+
+ LabelDefinition label = labelDefinitionRepository.save(
+ new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId));
+ labelTranslationRepository.saveAll(List.of(
+ new LabelTranslation(label.getId(), "en", labelDisplayName)));
+ labelTranslationRepository.flush();
+
+ // Baseline: nothing indexed yet.
+ assertThat(skillSearchDocumentJpaRepository.findBySkillId(skill.getId())).isEmpty();
+
+ // Act: attach the label as the skill owner (passes resolve + permission checks).
+ Map ownerRoles = Map.of(namespace.getId(), NamespaceRole.OWNER);
+ skillLabelAppService.attachLabel(
+ namespace.getSlug(),
+ skill.getSlug(),
+ labelSlug,
+ ownerId,
+ ownerRoles,
+ new AuditRequestContext("127.0.0.1", "junit"));
+
+ // Assert: the rebuilt search document must contain the label keyword.
+ SkillSearchDocumentEntity indexed = awaitIndexedDocument(skill.getId());
+ assertThat(indexed.getKeywords())
+ .as("label keyword should be indexed after attachLabel commits")
+ .contains(labelDisplayName);
+ }
+
+ @Test
+ void detachingLabel_removesKeywordFromSearchIndex() throws Exception {
+ Fixture f = createFixture();
+
+ skillLabelAppService.attachLabel(
+ f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext());
+ SkillSearchDocumentEntity afterAttach = awaitIndexedDocument(f.skillId);
+ assertThat(afterAttach.getKeywords())
+ .as("precondition: label keyword indexed after attach")
+ .contains(f.labelDisplayName);
+
+ // Act: detach the same label.
+ skillLabelAppService.detachLabel(
+ f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext());
+
+ // Assert: the rebuilt document must no longer contain the label keyword.
+ awaitKeywordAbsent(f.skillId, f.labelDisplayName);
+ }
+
+ /**
+ * Guards against the {@code CallerRunsPolicy} regression: when the executor is
+ * saturated, {@code rebuildSkill} runs synchronously on the request thread inside
+ * the {@code afterCommit} phase — the exact context where the index write used to be
+ * dropped. This exercises that path directly (no async hop) and asserts the document
+ * is still persisted, proving the fix relies on {@code REQUIRES_NEW}, not on the
+ * executor having spare capacity.
+ */
+ @Test
+ void syncRebuildInAfterCommitPhase_persistsIndex() throws Exception {
+ Fixture f = createFixture();
+
+ // Establish the skill-label association and a baseline index via the normal path.
+ skillLabelAppService.attachLabel(
+ f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext());
+ awaitIndexedDocument(f.skillId);
+
+ // Clear the index so we can observe the synchronous rebuild in isolation.
+ transactionTemplate.executeWithoutResult(
+ status -> skillSearchDocumentJpaRepository.deleteBySkillId(f.skillId));
+ assertThat(skillSearchDocumentJpaRepository.findBySkillId(f.skillId)).isEmpty();
+
+ // Rebuild synchronously on the caller thread, inside a post-commit synchronization
+ // (mirrors the CallerRuns fallback from afterCommit(() -> rebuildSkill(...))).
+ transactionTemplate.executeWithoutResult(status ->
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ searchRebuildService.rebuildBySkill(f.skillId);
+ }
+ }));
+
+ SkillSearchDocumentEntity indexed = skillSearchDocumentJpaRepository.findBySkillId(f.skillId)
+ .orElseThrow(() -> new AssertionError(
+ "synchronous rebuild in afterCommit phase must persist the index document"));
+ assertThat(indexed.getKeywords())
+ .as("label keyword must be indexed even on the synchronous caller-runs path")
+ .contains(f.labelDisplayName);
+ }
+
+ private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException {
+ Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
+ Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);
+ while (indexed.isEmpty() && Instant.now().isBefore(deadline)) {
+ Thread.sleep(100L);
+ indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);
+ }
+ return indexed.orElseThrow(
+ () -> new AssertionError("Expected search document for skill " + skillId));
+ }
+
+ private void awaitKeywordAbsent(Long skillId, String keyword) throws InterruptedException {
+ Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
+ while (Instant.now().isBefore(deadline)) {
+ Optional indexed =
+ skillSearchDocumentJpaRepository.findBySkillId(skillId);
+ if (indexed.isPresent() && !indexed.get().getKeywords().contains(keyword)) {
+ return;
+ }
+ Thread.sleep(100L);
+ }
+ String keywords = skillSearchDocumentJpaRepository.findBySkillId(skillId)
+ .map(SkillSearchDocumentEntity::getKeywords)
+ .orElse("");
+ throw new AssertionError(
+ "Expected keyword '" + keyword + "' to be removed from index for skill "
+ + skillId + " but keywords were: " + keywords);
+ }
+
+ private AuditRequestContext auditContext() {
+ return new AuditRequestContext("127.0.0.1", "junit");
+ }
+
+ private Fixture createFixture() {
+ String suffix = UUID.randomUUID().toString().substring(0, 8);
+ String ownerId = "owner-" + suffix;
+ // ASCII display name so the tokenizer keeps it as a single searchable token.
+ String labelDisplayName = "MachineLearning" + suffix;
+ String labelSlug = "ml-" + suffix;
+
+ Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId);
+ namespace.setType(NamespaceType.GLOBAL);
+ namespace = namespaceRepository.save(namespace);
+
+ Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
+ skill.setDisplayName("Skill " + suffix);
+ skill.setSummary("A skill used to reproduce the label search sync bug.");
+ skill.setCreatedBy(ownerId);
+ skill.setUpdatedBy(ownerId);
+ skill = skillRepository.save(skill);
+ skillRepository.flush();
+
+ LabelDefinition label = labelDefinitionRepository.save(
+ new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId));
+ labelTranslationRepository.saveAll(List.of(
+ new LabelTranslation(label.getId(), "en", labelDisplayName)));
+ labelTranslationRepository.flush();
+
+ return new Fixture(
+ namespace.getSlug(), skill.getSlug(), skill.getId(),
+ labelSlug, labelDisplayName, ownerId,
+ Map.of(namespace.getId(), NamespaceRole.OWNER));
+ }
+
+ private record Fixture(
+ String namespaceSlug,
+ String skillSlug,
+ Long skillId,
+ String labelSlug,
+ String labelDisplayName,
+ String ownerId,
+ Map ownerRoles) {
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java
index abede8fc..bc824b89 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java
@@ -136,9 +136,15 @@ class PromotionPortalAppServiceTest {
return new PromotionResponseDto(
request.getId(),
request.getSourceSkillId(),
+ "Skill A",
+ "Skill A summary",
"team-a",
"skill-a",
"1.0.0",
+ 3,
+ 2048L,
+ 7L,
+ 2,
"global",
request.getTargetSkillId(),
request.getStatus().name(),
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java
index fdac46f8..3cd408e4 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java
@@ -8,7 +8,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
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.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.search.SearchQuery;
@@ -22,11 +24,13 @@ import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
@@ -96,8 +100,6 @@ class SkillSearchAppServiceTest {
when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill));
when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace));
when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of());
- when(skillVersionRepository.findBySkillIdInAndStatus(List.of(11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
- .thenReturn(List.of());
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null);
@@ -145,8 +147,6 @@ class SkillSearchAppServiceTest {
when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(visibleSkill));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of());
- when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
- .thenReturn(List.of());
SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of());
@@ -164,6 +164,9 @@ class SkillSearchAppServiceTest {
setField(second, "id", 11L);
second.setLatestVersionId(102L);
+ SkillVersion firstVersion = publishedVersion(10L, 101L, "1.0.0");
+ SkillVersion secondVersion = publishedVersion(11L, 102L, "2.0.0");
+
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
setField(namespace, "id", 1L);
namespace.setStatus(NamespaceStatus.ACTIVE);
@@ -172,18 +175,112 @@ class SkillSearchAppServiceTest {
.thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20));
when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(first, second));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
- when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of());
- when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED))
- .thenReturn(List.of());
+ when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of(firstVersion, secondVersion));
SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
assertEquals(2, response.items().size());
+ assertEquals("1.0.0", response.items().get(0).publishedVersion().version());
+ assertEquals("2.0.0", response.items().get(1).publishedVersion().version());
verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L));
- verify(skillVersionRepository, times(1))
+ verify(skillVersionRepository, times(0))
.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED);
}
+ @Test
+ void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() {
+ Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC);
+ setField(skill, "id", 10L);
+
+ SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0");
+
+ Namespace namespace = new Namespace("global", "Global", "owner-1");
+ setField(namespace, "id", 1L);
+ namespace.setStatus(NamespaceStatus.ACTIVE);
+
+ when(searchQueryService.search(any()))
+ .thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
+ when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
+ when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
+ org.mockito.Mockito.lenient()
+ .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
+ .thenReturn(List.of(oldInstallable));
+
+ SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
+
+ assertEquals(1, response.items().size());
+ assertEquals("missing-latest", response.items().getFirst().slug());
+ assertNull(response.items().getFirst().publishedVersion());
+ verify(skillVersionRepository, times(0))
+ .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED);
+ }
+
+ @Test
+ void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsYanked() {
+ Skill skill = new Skill(1L, "yanked-latest", "owner-1", SkillVisibility.PUBLIC);
+ setField(skill, "id", 10L);
+ skill.setLatestVersionId(101L);
+
+ SkillVersion latest = publishedVersion(10L, 101L, "1.0.0");
+ latest.setYankedAt(Instant.parse("2026-06-12T00:00:00Z"));
+ SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0");
+
+ Namespace namespace = new Namespace("global", "Global", "owner-1");
+ setField(namespace, "id", 1L);
+ namespace.setStatus(NamespaceStatus.ACTIVE);
+
+ when(searchQueryService.search(any()))
+ .thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
+ when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
+ when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
+ when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(latest));
+ org.mockito.Mockito.lenient()
+ .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
+ .thenReturn(List.of(oldInstallable));
+
+ SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
+
+ assertEquals(1, response.items().size());
+ assertEquals("yanked-latest", response.items().getFirst().slug());
+ assertNull(response.items().getFirst().publishedVersion());
+ verify(skillVersionRepository, times(0))
+ .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED);
+ }
+
+ @Test
+ void search_shouldNotFallbackToOlderPublishedVersionWhenLatestDownloadUnavailable() {
+ Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC);
+ setField(skill, "id", 10L);
+ skill.setLatestVersionId(101L);
+
+ SkillVersion version = new SkillVersion(10L, "1.0.0", "owner-1");
+ setField(version, "id", 101L);
+ version.setStatus(SkillVersionStatus.PUBLISHED);
+ version.setDownloadReady(false);
+ SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0");
+
+ Namespace namespace = new Namespace("global", "Global", "owner-1");
+ setField(namespace, "id", 1L);
+ namespace.setStatus(NamespaceStatus.ACTIVE);
+
+ when(searchQueryService.search(any()))
+ .thenReturn(new SearchResult(List.of(10L), 1, 0, 20));
+ when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill));
+ when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));
+ when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version));
+ org.mockito.Mockito.lenient()
+ .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED))
+ .thenReturn(List.of(oldInstallable));
+
+ SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null);
+
+ assertEquals(1, response.items().size());
+ assertEquals("not-ready", response.items().getFirst().slug());
+ assertNull(response.items().getFirst().publishedVersion());
+ verify(skillVersionRepository, times(0))
+ .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED);
+ }
+
@Test
void search_shouldNormalizeAndPassLabelSlugs() {
when(searchQueryService.search(any()))
@@ -240,4 +337,12 @@ class SkillSearchAppServiceTest {
throw new RuntimeException(e);
}
}
+
+ private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) {
+ SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1");
+ setField(version, "id", versionId);
+ version.setStatus(SkillVersionStatus.PUBLISHED);
+ version.setDownloadReady(true);
+ return version;
+ }
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java
index b7fbe1d7..0c75ca34 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java
@@ -1,9 +1,18 @@
package com.iflytek.skillhub.service.cli;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
+import com.iflytek.skillhub.domain.namespace.Namespace;
+import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
+import com.iflytek.skillhub.domain.namespace.NamespaceService;
+import com.iflytek.skillhub.auth.rbac.RbacService;
+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.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
+import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
@@ -15,6 +24,9 @@ import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillDeleteAppService;
import com.iflytek.skillhub.service.SkillSearchAppService;
+import com.iflytek.skillhub.search.SearchQuery;
+import com.iflytek.skillhub.search.SearchQueryService;
+import com.iflytek.skillhub.search.SearchResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -39,6 +51,11 @@ class CliSkillAppServiceTest {
@Mock SkillDownloadService skillDownloadService;
@Mock SkillDeleteAppService skillDeleteAppService;
@Mock SkillPublishService skillPublishService;
+ @Mock SkillRepository skillRepository;
+ @Mock NamespaceRepository namespaceRepository;
+ @Mock SkillVersionRepository skillVersionRepository;
+ @Mock NamespaceService namespaceService;
+ @Mock RbacService rbacService;
private CliSkillAppService service;
@@ -62,7 +79,7 @@ class CliSkillAppServiceTest {
)),
1L, 0, 20
);
- given(skillSearchAppService.search("pdf", null, "newest", 0, 20, null, null))
+ given(skillSearchAppService.searchInstallableLatest("pdf", null, "newest", 0, 20, null, null))
.willReturn(searchResponse);
var result = service.search("pdf", 20, null, null);
@@ -76,6 +93,118 @@ class CliSkillAppServiceTest {
assertEquals(20, result.limit());
}
+ @Test
+ void search_mapsInstallableSearchTotalFromQueryStage() {
+ var searchResponse = new SkillSearchAppService.SearchResponse(
+ List.of(
+ new SkillSummaryResponse(
+ 2L, "ready", "Ready", "Installable",
+ "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0,
+ "global", Instant.now(), false,
+ new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"),
+ new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"),
+ null, "PUBLISHED"
+ )
+ ),
+ 1L, 0, 20
+ );
+ given(skillSearchAppService.searchInstallableLatest("demo", null, "newest", 0, 20, null, null))
+ .willReturn(searchResponse);
+
+ var result = service.search("demo", 20, null, null);
+
+ assertEquals(1, result.items().size());
+ assertEquals("ready", result.items().getFirst().slug());
+ assertEquals(1L, result.total());
+ }
+
+ @Test
+ void search_limitOneSkipsUninstallableMatchAndReturnsNextInstallableWithFilteredTotal() {
+ Skill unavailableFirstMatch = new Skill(1L, "draft-first", "owner-1", SkillVisibility.PUBLIC);
+ setField(unavailableFirstMatch, "id", 1L);
+ assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of());
+ }
+
+ @Test
+ void search_limitOneSkipsYankedLatestMatchAndReturnsNextInstallableWithFilteredTotal() {
+ Skill unavailableFirstMatch = new Skill(1L, "yanked-first", "owner-1", SkillVisibility.PUBLIC);
+ setField(unavailableFirstMatch, "id", 1L);
+ unavailableFirstMatch.setLatestVersionId(10L);
+ SkillVersion yanked = publishedVersion(1L, 10L, "1.0.0");
+ yanked.setYankedAt(Instant.parse("2026-06-12T00:00:00Z"));
+
+ assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(yanked));
+ }
+
+ @Test
+ void search_limitOneSkipsDownloadUnavailableLatestAndReturnsNextInstallableWithFilteredTotal() {
+ Skill unavailableFirstMatch = new Skill(1L, "not-ready-first", "owner-1", SkillVisibility.PUBLIC);
+ setField(unavailableFirstMatch, "id", 1L);
+ unavailableFirstMatch.setLatestVersionId(10L);
+ SkillVersion notReady = publishedVersion(1L, 10L, "1.0.0");
+ notReady.setDownloadReady(false);
+
+ assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(notReady));
+ }
+
+ private void assertLimitOneSkipsUninstallableFirstMatch(
+ Skill unavailableFirstMatch,
+ List unavailableLatestVersions) {
+ SearchQueryService rankedSearch = query -> requiresInstallableLatest(query)
+ ? new SearchResult(List.of(2L), 1L, 0, 1)
+ : new SearchResult(List.of(1L), 2L, 0, 1);
+ SkillSearchAppService realSearchAppService = new SkillSearchAppService(
+ rankedSearch,
+ skillRepository,
+ namespaceRepository,
+ namespaceService,
+ new SkillLifecycleProjectionService(skillVersionRepository),
+ rbacService
+ );
+ CliSkillAppService realService = new CliSkillAppService(
+ realSearchAppService,
+ skillQueryService,
+ skillDownloadService,
+ skillDeleteAppService,
+ skillPublishService
+ );
+
+ Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC);
+ setField(installableSecondMatch, "id", 2L);
+ installableSecondMatch.setLatestVersionId(20L);
+
+ Namespace namespace = new Namespace("global", "Global", "owner-1");
+ setField(namespace, "id", 1L);
+ SkillVersion installableVersion = publishedVersion(2L, 20L, "1.0.0");
+
+ org.mockito.Mockito.lenient()
+ .when(skillRepository.findByIdIn(List.of(1L)))
+ .thenReturn(List.of(unavailableFirstMatch));
+ org.mockito.Mockito.lenient()
+ .when(skillRepository.findByIdIn(List.of(2L)))
+ .thenReturn(List.of(installableSecondMatch));
+ org.mockito.Mockito.lenient()
+ .when(namespaceRepository.findByIdIn(List.of(1L)))
+ .thenReturn(List.of(namespace));
+ org.mockito.Mockito.lenient()
+ .when(skillVersionRepository.findByIdIn(List.of()))
+ .thenReturn(List.of());
+ org.mockito.Mockito.lenient()
+ .when(skillVersionRepository.findByIdIn(List.of(10L)))
+ .thenReturn(unavailableLatestVersions);
+ org.mockito.Mockito.lenient()
+ .when(skillVersionRepository.findByIdIn(List.of(20L)))
+ .thenReturn(List.of(installableVersion));
+
+ var result = realService.search("demo", 1, null, null);
+
+ assertEquals(1, result.items().size());
+ assertEquals("ready-second", result.items().getFirst().slug());
+ assertEquals("1.0.0", result.items().getFirst().latestVersion());
+ assertEquals(1L, result.total());
+ assertEquals(1, result.limit());
+ }
+
@Test
void resolve_delegatesToQueryService() {
given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of()))
@@ -125,4 +254,30 @@ class CliSkillAppServiceTest {
assertEquals("1.0.0", response.version());
assertEquals("PUBLIC", response.visibility());
}
+
+ private boolean requiresInstallableLatest(SearchQuery query) {
+ try {
+ return (boolean) query.getClass().getMethod("requireInstallableLatest").invoke(query);
+ } catch (ReflectiveOperationException e) {
+ return false;
+ }
+ }
+
+ private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) {
+ SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1");
+ setField(version, "id", versionId);
+ version.setStatus(SkillVersionStatus.PUBLISHED);
+ version.setDownloadReady(true);
+ return version;
+ }
+
+ private void setField(Object target, String fieldName, Object value) {
+ try {
+ java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java
index e838c9a8..5061f854 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java
@@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.device;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.beans.factory.annotation.Value;
@@ -33,14 +34,17 @@ public class DeviceAuthService {
private final RedisTemplate redisTemplate;
private final ApiTokenService apiTokenService;
+ private final ObjectMapper objectMapper;
private final String verificationUri;
private final SecureRandom random = new SecureRandom();
public DeviceAuthService(RedisTemplate redisTemplate,
ApiTokenService apiTokenService,
+ ObjectMapper objectMapper,
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
this.redisTemplate = redisTemplate;
this.apiTokenService = apiTokenService;
+ this.objectMapper = objectMapper;
this.verificationUri = verificationUri;
}
@@ -71,7 +75,7 @@ public class DeviceAuthService {
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
}
- DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
+ DeviceCodeData data = readDeviceCodeData(deviceCode);
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
}
@@ -97,7 +101,7 @@ public class DeviceAuthService {
* into an API token exactly once.
*/
public DeviceTokenResponse pollToken(String deviceCode) {
- DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
+ DeviceCodeData data = readDeviceCodeData(deviceCode);
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
@@ -147,6 +151,17 @@ public class DeviceAuthService {
}
}
+ /**
+ * Reads device-code state from Redis. The shared template's JSON value
+ * serializer carries no type information, so values deserialize as plain
+ * maps; convert explicitly instead of casting (a direct cast throws
+ * {@code ClassCastException} on every read).
+ */
+ private DeviceCodeData readDeviceCodeData(String deviceCode) {
+ Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
+ return raw == null ? null : objectMapper.convertValue(raw, DeviceCodeData.class);
+ }
+
private String generateRandomDeviceCode() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java
index 015896b7..7c44a22d 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java
@@ -19,7 +19,9 @@ public class DeviceCodeData implements Serializable {
}
public String getDeviceCode() { return deviceCode; }
+ public void setDeviceCode(String deviceCode) { this.deviceCode = deviceCode; }
public String getUserCode() { return userCode; }
+ public void setUserCode(String userCode) { this.userCode = userCode; }
public DeviceCodeStatus getStatus() { return status; }
public void setStatus(DeviceCodeStatus status) { this.status = status; }
public String getUserId() { return userId; }
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java
index 8346b9c2..a80d44ac 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java
@@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
+
+ boolean existsByUserId(String userId);
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java
new file mode 100644
index 00000000..62646a53
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java
@@ -0,0 +1,42 @@
+package com.iflytek.skillhub.auth.token;
+
+import org.springframework.security.access.AccessDeniedException;
+
+/**
+ * Marks an API-token authorization failure whose structured reason is safe to expose to clients.
+ */
+public final class ApiTokenAccessDeniedException extends AccessDeniedException {
+
+ private final String messageCode;
+ private final Object[] messageArgs;
+
+ private ApiTokenAccessDeniedException(String logMessage, String messageCode, Object... messageArgs) {
+ super(logMessage);
+ this.messageCode = messageCode;
+ this.messageArgs = messageArgs.clone();
+ }
+
+ static ApiTokenAccessDeniedException missingScope(String requiredScope) {
+ return new ApiTokenAccessDeniedException(
+ "Missing API token scope: " + requiredScope,
+ "error.apiToken.scope.missing",
+ requiredScope
+ );
+ }
+
+ static ApiTokenAccessDeniedException unsupportedEndpoint(String path) {
+ return new ApiTokenAccessDeniedException(
+ "API token cannot access endpoint: " + path,
+ "error.apiToken.endpoint.unsupported",
+ path
+ );
+ }
+
+ public String getMessageCode() {
+ return messageCode;
+ }
+
+ public Object[] getMessageArgs() {
+ return messageArgs.clone();
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java
index 6f594c1e..8b24aa86 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java
@@ -10,9 +10,12 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -37,49 +40,74 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
private final UserAccountRepository userRepo;
private final UserRoleBindingRepository roleBindingRepo;
private final ApiTokenScopeService apiTokenScopeService;
+ private final AuthenticationEntryPoint authenticationEntryPoint;
+ @Autowired
public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo,
- ApiTokenScopeService apiTokenScopeService) {
+ ApiTokenScopeService apiTokenScopeService,
+ AuthenticationEntryPoint authenticationEntryPoint) {
this.apiTokenService = apiTokenService;
this.userRepo = userRepo;
this.roleBindingRepo = roleBindingRepo;
this.apiTokenScopeService = apiTokenScopeService;
+ this.authenticationEntryPoint = authenticationEntryPoint;
+ }
+
+ ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
+ UserAccountRepository userRepo,
+ UserRoleBindingRepository roleBindingRepo,
+ ApiTokenScopeService apiTokenScopeService) {
+ this(apiTokenService, userRepo, roleBindingRepo, apiTokenScopeService,
+ (request, response, authException) ->
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()));
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader(AUTH_HEADER);
- if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
- String rawToken = authHeader.substring(BEARER_PREFIX.length());
- apiTokenService.validateToken(rawToken).ifPresent(token -> {
- userRepo.findById(token.getUserId()).ifPresent(user -> {
- if (!user.isActive()) {
- return;
- }
- Set roles = roleBindingRepo.findByUserId(user.getId()).stream()
- .map(rb -> rb.getRole().getCode())
- .collect(Collectors.toSet());
- roles = PlatformRoleDefaults.withDefaultUserRole(roles);
- Set scopes = apiTokenScopeService.parseScopes(token.getScopeJson());
- PlatformPrincipal principal = new PlatformPrincipal(
- user.getId(), user.getDisplayName(), user.getEmail(),
- user.getAvatarUrl(), "api_token", roles
- );
- List authorities = new ArrayList<>();
- authorities.addAll(roles.stream()
- .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
- .toList());
- authorities.addAll(scopes.stream()
- .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
- .toList());
- var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
- SecurityContextHolder.getContext().setAuthentication(auth);
- apiTokenService.touchLastUsed(token);
- });
- });
+ if (authHeader != null && isBearerAuthorization(authHeader)) {
+ String rawToken = extractBearerToken(authHeader);
+ if (rawToken == null) {
+ rejectBearer(request, response);
+ return;
+ }
+
+ var token = apiTokenService.validateToken(rawToken);
+ if (token.isEmpty()) {
+ rejectBearer(request, response);
+ return;
+ }
+
+ ApiToken apiToken = token.get();
+ var user = userRepo.findById(apiToken.getUserId());
+ if (user.isEmpty() || !user.get().isActive()) {
+ rejectBearer(request, response);
+ return;
+ }
+
+ UserAccount userAccount = user.get();
+ Set roles = roleBindingRepo.findByUserId(userAccount.getId()).stream()
+ .map(rb -> rb.getRole().getCode())
+ .collect(Collectors.toSet());
+ roles = PlatformRoleDefaults.withDefaultUserRole(roles);
+ Set scopes = apiTokenScopeService.parseScopes(apiToken.getScopeJson());
+ PlatformPrincipal principal = new PlatformPrincipal(
+ userAccount.getId(), userAccount.getDisplayName(), userAccount.getEmail(),
+ userAccount.getAvatarUrl(), "api_token", roles
+ );
+ List authorities = new ArrayList<>();
+ authorities.addAll(roles.stream()
+ .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
+ .toList());
+ authorities.addAll(scopes.stream()
+ .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
+ .toList());
+ var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ apiTokenService.touchLastUsed(apiToken);
}
filterChain.doFilter(request, response);
}
@@ -91,4 +119,30 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
|| path.startsWith("/api/web/")
|| path.startsWith("/api/cli/"));
}
+
+ private boolean isBearerAuthorization(String authHeader) {
+ if (!authHeader.regionMatches(true, 0, "Bearer", 0, "Bearer".length())) {
+ return false;
+ }
+ return authHeader.length() == "Bearer".length()
+ || Character.isWhitespace(authHeader.charAt("Bearer".length()));
+ }
+
+ private String extractBearerToken(String authHeader) {
+ if (authHeader.length() <= BEARER_PREFIX.length() - 1
+ || authHeader.charAt(BEARER_PREFIX.length() - 1) != ' ') {
+ return null;
+ }
+ String rawToken = authHeader.substring(BEARER_PREFIX.length()).trim();
+ return rawToken.isEmpty() ? null : rawToken;
+ }
+
+ private void rejectBearer(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
+ SecurityContextHolder.clearContext();
+ authenticationEntryPoint.commence(
+ request,
+ response,
+ new BadCredentialsException("Invalid bearer token")
+ );
+ }
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java
index 97145f5d..5182ce7f 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java
@@ -5,7 +5,6 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -59,11 +58,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
return;
}
- accessDeniedHandler.handle(
- request,
- response,
- new AccessDeniedException(decision.message())
- );
+ ApiTokenAccessDeniedException exception = decision.requiredScope() != null
+ ? ApiTokenAccessDeniedException.missingScope(decision.requiredScope())
+ : ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI());
+ accessDeniedHandler.handle(request, response, exception);
}
@Override
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java
new file mode 100644
index 00000000..fca992b2
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java
@@ -0,0 +1,106 @@
+package com.iflytek.skillhub.auth.device;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DeviceAuthServiceTest {
+
+ private static final String DEVICE_CODE = "device-code-1";
+ private static final String USER_CODE = "ABCD-2345";
+
+ @Mock
+ private RedisTemplate redisTemplate;
+
+ @Mock
+ private ValueOperations valueOperations;
+
+ @Mock
+ private ApiTokenService apiTokenService;
+
+ private DeviceAuthService service;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+ service = new DeviceAuthService(redisTemplate, apiTokenService, new ObjectMapper(), "/cli/auth");
+ }
+
+ /**
+ * The shared RedisTemplate's JSON serializer keeps no type information, so
+ * stored DeviceCodeData comes back as a plain map. A typed cast used to
+ * throw ClassCastException on every poll; the service must convert instead.
+ */
+ private static Map storedDeviceCode(DeviceCodeStatus status, String userId) {
+ Map raw = new LinkedHashMap<>();
+ raw.put("deviceCode", DEVICE_CODE);
+ raw.put("userCode", USER_CODE);
+ raw.put("status", status.name());
+ raw.put("userId", userId);
+ return raw;
+ }
+
+ @Test
+ void pollTokenReturnsPendingWhenRedisValueIsUntypedMap() {
+ when(valueOperations.get("device:code:" + DEVICE_CODE))
+ .thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null));
+
+ DeviceTokenResponse response = service.pollToken(DEVICE_CODE);
+
+ assertThat(response.error()).isEqualTo("authorization_pending");
+ }
+
+ @Test
+ void pollTokenRedeemsAuthorizedCodeFromUntypedMap() {
+ when(valueOperations.get("device:code:" + DEVICE_CODE))
+ .thenReturn(storedDeviceCode(DeviceCodeStatus.AUTHORIZED, "usr_1"));
+ when(valueOperations.setIfAbsent(eq("device:claim:" + DEVICE_CODE), any(), anyLong(), any()))
+ .thenReturn(Boolean.TRUE);
+ when(apiTokenService.rotateToken(eq("usr_1"), any(), any()))
+ .thenReturn(new ApiTokenService.TokenCreateResult("sk_test_token", null));
+
+ DeviceTokenResponse response = service.pollToken(DEVICE_CODE);
+
+ assertThat(response.accessToken()).isEqualTo("sk_test_token");
+ }
+
+ @Test
+ void pollTokenRejectsUnknownDeviceCode() {
+ when(valueOperations.get("device:code:" + DEVICE_CODE)).thenReturn(null);
+
+ assertThatThrownBy(() -> service.pollToken(DEVICE_CODE))
+ .isInstanceOf(DomainBadRequestException.class);
+ }
+
+ @Test
+ void authorizeDeviceCodeMarksPendingCodeFromUntypedMap() {
+ when(valueOperations.get("device:usercode:" + USER_CODE)).thenReturn(DEVICE_CODE);
+ when(valueOperations.get("device:code:" + DEVICE_CODE))
+ .thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null));
+
+ service.authorizeDeviceCode(USER_CODE, "usr_1");
+
+ verify(valueOperations).set(startsWith("device:code:"), any(DeviceCodeData.class), anyLong(), any());
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
index 6b9f4344..b6eaf5af 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
@@ -230,6 +230,20 @@ class LocalAuthServiceTest {
assertThat(principal.platformRoles()).containsExactly("USER");
}
+ @Test
+ void changePassword_withoutLocalCredential_rejectsRequest() {
+ given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.changePassword("oauth-only", "old", "Newpass123!"))
+ .isInstanceOf(AuthFlowException.class)
+ .hasMessageContaining("error.auth.local.notEnabled")
+ .extracting("status")
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+
+ verify(passwordEncoder, never()).matches(any(), any());
+ verify(credentialRepository, never()).save(any(LocalCredential.class));
+ }
+
@Test
void register_rejectsInvalidEmailFormat() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java
index e82f7a1a..d9f030a9 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java
@@ -17,11 +17,13 @@ import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
+import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -89,12 +91,117 @@ class ApiTokenAuthenticationFilterTest {
request.setRequestURI("/api/v1/publish");
request.addHeader("Authorization", "Bearer raw-token");
- filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
+ assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+ assertNull(chain.getRequest());
verify(apiTokenService, never()).touchLastUsed(token);
}
+ @Test
+ void shouldRejectUnknownBearerTokenOnCliReadRoutes() throws Exception {
+ when(apiTokenService.validateToken("unknown-token")).thenReturn(Optional.empty());
+
+ for (String route : cliReadRoutes()) {
+ SecurityContextHolder.clearContext();
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", route);
+ request.addHeader("Authorization", "Bearer unknown-token");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), route);
+ assertNull(SecurityContextHolder.getContext().getAuthentication(), route);
+ assertNull(chain.getRequest(), route);
+ }
+ }
+
+ @Test
+ void shouldRejectBearerTokenWhenUserIsMissing() throws Exception {
+ ApiToken token = new ApiToken("missing-user", "cli", "sk_test", "hash", "[]");
+
+ when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token));
+ when(userAccountRepository.findById("missing-user")).thenReturn(Optional.empty());
+
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
+ request.addHeader("Authorization", "Bearer raw-token");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ assertNull(chain.getRequest());
+ verify(apiTokenService, never()).touchLastUsed(token);
+ }
+
+ @Test
+ void shouldRejectEmptyBearerTokenWithoutValidatingIt() throws Exception {
+ when(apiTokenService.validateToken("")).thenReturn(Optional.empty());
+
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
+ request.addHeader("Authorization", "Bearer ");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ assertNull(chain.getRequest());
+ verify(apiTokenService, never()).validateToken(any());
+ }
+
+ @Test
+ void shouldRejectMalformedBearerHeaderWithoutValidatingIt() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
+ request.addHeader("Authorization", "Bearer");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ assertNull(chain.getRequest());
+ verify(apiTokenService, never()).validateToken(any());
+ }
+
+ @Test
+ void shouldAllowAnonymousCliReadsWhenAuthorizationHeaderIsAbsent() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(MockHttpServletResponse.SC_OK, response.getStatus());
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ assertNotNull(chain.getRequest());
+ verify(apiTokenService, never()).validateToken(any());
+ }
+
+ @Test
+ void shouldIgnoreNonBearerAuthorizationHeader() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
+ request.addHeader("Authorization", "Basic abc123");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(MockHttpServletResponse.SC_OK, response.getStatus());
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ assertNotNull(chain.getRequest());
+ verify(apiTokenService, never()).validateToken(any());
+ }
+
@Test
void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception {
ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]");
@@ -113,4 +220,13 @@ class ApiTokenAuthenticationFilterTest {
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
verify(apiTokenService).touchLastUsed(token);
}
+
+ private static List cliReadRoutes() {
+ return Stream.of(
+ "/api/cli/v1/skills/search",
+ "/api/cli/v1/skills/global/demo/resolve",
+ "/api/cli/v1/skills/global/demo/download",
+ "/api/cli/v1/skills/global/demo/versions/1.0.0/download"
+ ).toList();
+ }
}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java
index 788e0291..085016f4 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java
@@ -17,8 +17,10 @@ import org.springframework.security.web.access.AccessDeniedHandler;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -38,7 +40,9 @@ class ApiTokenScopeFilterTest {
@Test
void shouldDenyApiTokenWithoutRequiredScope() throws Exception {
+ AtomicReference deniedException = new AtomicReference<>();
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
+ deniedException.set(accessDeniedException);
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
};
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
@@ -69,6 +73,12 @@ class ApiTokenScopeFilterTest {
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
+ ApiTokenAccessDeniedException exception = assertInstanceOf(
+ ApiTokenAccessDeniedException.class,
+ deniedException.get()
+ );
+ assertEquals("error.apiToken.scope.missing", exception.getMessageCode());
+ assertEquals("skill:publish", exception.getMessageArgs()[0]);
verify(chain, never()).doFilter(request, response);
}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java
index 2e4de008..d9ed2c75 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java
@@ -10,9 +10,14 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
+import java.util.HexFormat;
+import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
@@ -124,4 +129,39 @@ class ApiTokenServiceTest {
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.token.name.duplicate");
}
+
+ @Test
+ void validateToken_returnsEmptyForUnknownToken() {
+ when(tokenRepo.findByTokenHash(sha256("missing-token"))).thenReturn(Optional.empty());
+
+ assertThat(service.validateToken("missing-token")).isEmpty();
+ }
+
+ @Test
+ void validateToken_returnsEmptyForExpiredToken() {
+ ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("expired-token"), "[]");
+ token.setExpiresAt(Instant.parse("2026-03-17T23:59:59Z"));
+ when(tokenRepo.findByTokenHash(sha256("expired-token"))).thenReturn(Optional.of(token));
+
+ assertThat(service.validateToken("expired-token")).isEmpty();
+ }
+
+ @Test
+ void validateToken_returnsEmptyForRevokedToken() {
+ ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("revoked-token"), "[]");
+ token.setRevokedAt(Instant.parse("2026-03-17T23:59:59Z"));
+ when(tokenRepo.findByTokenHash(sha256("revoked-token"))).thenReturn(Optional.of(token));
+
+ assertThat(service.validateToken("revoked-token")).isEmpty();
+ }
+
+ private static String sha256(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java
index 05d07f04..8bfdf245 100644
--- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java
@@ -14,6 +14,8 @@ public interface PromotionRequestRepository {
Optional