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/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/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-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/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/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/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-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java
index c204306a..610c8203 100644
--- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java
@@ -62,6 +62,12 @@ public class SkillPublishService {
private static final DateTimeFormatter AUTO_VERSION_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneId.systemDefault());
+ private static final Set REPLACEABLE_VERSION_STATUSES = Set.of(
+ SkillVersionStatus.DRAFT,
+ SkillVersionStatus.SCAN_FAILED,
+ SkillVersionStatus.UPLOADED,
+ SkillVersionStatus.REJECTED
+ );
private static final Logger log = LoggerFactory.getLogger(SkillPublishService.class);
public record PublishResult(
@@ -566,7 +572,7 @@ public class SkillPublishService {
}
private void deleteReplaceableVersionArtifacts(Skill skill, SkillVersion version, String namespaceSlug) {
- if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
+ if (!REPLACEABLE_VERSION_STATUSES.contains(version.getStatus())) {
throw new DomainBadRequestException("error.skill.version.exists", version.getVersion());
}
@@ -577,8 +583,10 @@ public class SkillPublishService {
skillRepository.flush();
}
- reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING)
- .ifPresent(reviewTaskRepository::delete);
+ // Every review task referencing this version has to go, not just a PENDING one:
+ // a rejected version still owns a REJECTED task whose foreign key blocks the
+ // skill_version delete below, which surfaces to the caller as an HTTP 500.
+ reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId()));
List files = skillFileRepository.findByVersionId(version.getId());
List storageKeys = new ArrayList<>();
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java
index 73f118fd..a75f971f 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java
@@ -260,7 +260,7 @@ class SkillPublishServiceTest {
}
@Test
- void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception {
+ void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
@@ -275,9 +275,9 @@ class SkillPublishServiceTest {
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
- SkillVersion draftVersion = new SkillVersion(1L, "1.0.0", publisherId);
- draftVersion.setStatus(SkillVersionStatus.DRAFT);
- setId(draftVersion, 8L);
+ SkillVersion rejectedVersion = new SkillVersion(1L, "1.0.0", publisherId);
+ rejectedVersion.setStatus(SkillVersionStatus.REJECTED);
+ setId(rejectedVersion, 8L);
SkillFile oldFile = new SkillFile(8L, "SKILL.md", (long) skillMdContent.length(), "text/markdown", "abc", "skills/1/8/SKILL.md");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
@@ -288,7 +288,7 @@ class SkillPublishServiceTest {
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());
- when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(draftVersion));
+ when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(rejectedVersion));
when(skillFileRepository.findByVersionId(8L)).thenReturn(List.of(oldFile));
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@@ -309,10 +309,60 @@ class SkillPublishServiceTest {
assertEquals("1.0.0", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
+ verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L));
verify(skillFileRepository).deleteByVersionId(8L);
- verify(skillVersionRepository).delete(draftVersion);
+ verify(skillVersionRepository).delete(rejectedVersion);
verify(skillVersionRepository).flush();
verify(objectStorageService).deleteObjects(List.of("skills/1/8/SKILL.md", "packages/1/8/bundle.zip"));
+
+ ArgumentCaptor reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class);
+ verify(reviewTaskRepository).save(reviewTaskCaptor.capture());
+ assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId());
+ assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy());
+ }
+
+ @Test
+ void testPublishFromEntries_ShouldRejectReplacementOfYankedVersion() throws Exception {
+ String namespaceSlug = "test-ns";
+ String publisherId = "user-100";
+ 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 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());
+
+ Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ SkillVersion yankedVersion = new SkillVersion(1L, "1.0.0", publisherId);
+ yankedVersion.setStatus(SkillVersionStatus.YANKED);
+ setId(yankedVersion, 8L);
+
+ 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.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(yankedVersion));
+
+ DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () ->
+ service.publishFromEntries(
+ namespaceSlug,
+ entries,
+ publisherId,
+ SkillVisibility.PUBLIC,
+ Set.of()
+ ));
+
+ assertEquals("error.skill.version.exists", exception.messageCode());
+ verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList());
+ verify(skillVersionRepository, never()).delete(any());
+ verify(skillFileRepository, never()).deleteByVersionId(any());
}
@Test
diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java
index bac3cce1..304b5ac0 100644
--- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java
+++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java
@@ -6,6 +6,7 @@ import com.iflytek.skillhub.search.SearchEmbeddingService;
import com.iflytek.skillhub.search.SearchIndexService;
import com.iflytek.skillhub.search.SkillSearchDocument;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@@ -32,7 +33,7 @@ public class PostgresFullTextIndexService implements SearchIndexService {
}
@Override
- @Transactional
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
public void index(SkillSearchDocument document) {
SkillSearchDocument normalizedDocument = normalize(document);
Optional existing = repository.findBySkillId(document.skillId());
diff --git a/web/Dockerfile b/web/Dockerfile
index e301f7c8..2ed67ae0 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -7,6 +7,7 @@ COPY . .
RUN pnpm build
FROM nginx:alpine
+ENV SKILLHUB_TRUST_FORWARDED_PROTO=false
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/src/docs/skill.md.template /usr/share/nginx/html/registry/skill.md.template
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts
index f255ea4a..71cd9ebd 100644
--- a/web/e2e/helpers/test-data-builder.ts
+++ b/web/e2e/helpers/test-data-builder.ts
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import type { APIRequestContext, Page, TestInfo } from '@playwright/test'
+import type { components } from '../../src/api/generated/schema'
import { csrfHeaders } from './csrf'
type CleanupTask = () => Promise
@@ -32,14 +33,9 @@ export interface SeededReviewData {
skill: SeededSkill
}
-interface ReviewTaskSummary {
- id: number
- namespace: string
- skillSlug: string
- status: string
- submittedBy: string
- version: string
-}
+type ReviewTaskResponse = components['schemas']['ReviewTaskResponse']
+type SkillVersionResponse = components['schemas']['SkillVersionResponse']
+type SkillVersionStatus = NonNullable
interface NamespaceCandidate {
userId: string
@@ -463,19 +459,17 @@ export class E2eTestDataBuilder {
async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise {
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
- const page = await parseEnvelope<{
- items: ReviewTaskSummary[]
- }>(
+ const page = await parseEnvelope(
await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'),
)
- const matched = page.items.find((item) =>
+ const matched = page.items?.find((item) =>
item.namespace === namespaceSlug &&
item.skillSlug === skillSlug &&
item.version === version &&
item.status === 'PENDING',
)
- if (matched) {
+ if (matched?.id != null) {
return matched.id
}
} catch {
@@ -488,6 +482,38 @@ export class E2eTestDataBuilder {
throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`)
}
+ async waitForVersionStatus(
+ namespaceSlug: string,
+ skillSlug: string,
+ version: string,
+ expectedStatus: SkillVersionStatus,
+ ): Promise {
+ for (let attempt = 0; attempt < 60; attempt += 1) {
+ try {
+ const page = await parseEnvelope(
+ await this.request.get(
+ `/api/web/skills/${encodeURIComponent(namespaceSlug)}/${encodeURIComponent(skillSlug)}/versions?page=0&size=100`,
+ ),
+ )
+
+ const matched = page.items?.find((item) =>
+ item.version === version && item.status === expectedStatus,
+ )
+ if (matched?.id != null) {
+ return matched.id
+ }
+ } catch {
+ // Security scanning and version projection can complete asynchronously.
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 1_000))
+ }
+
+ throw new Error(
+ `Timed out waiting for ${namespaceSlug}/${skillSlug}@${version} to reach ${expectedStatus}`,
+ )
+ }
+
async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise {
let lastError: unknown
for (let attempt = 0; attempt < 60; attempt += 1) {
@@ -512,6 +538,15 @@ export class E2eTestDataBuilder {
throw lastError instanceof Error ? lastError : new Error('approveReview timed out')
}
+ async rejectReview(reviewTaskId: number, comment = 'Rejected by Playwright E2E'): Promise {
+ await parseEnvelope(
+ await this.request.post(`/api/web/reviews/${reviewTaskId}/reject`, {
+ data: { comment },
+ headers: await csrfHeaders(this.page),
+ }),
+ )
+ }
+
async searchNamespaceMemberCandidates(slug: string, search: string): Promise {
const query = new URLSearchParams({ search })
return parseEnvelope(
diff --git a/web/e2e/rejected-version-republish.spec.ts b/web/e2e/rejected-version-republish.spec.ts
new file mode 100644
index 00000000..cbbcada4
--- /dev/null
+++ b/web/e2e/rejected-version-republish.spec.ts
@@ -0,0 +1,85 @@
+import { expect, test } from '@playwright/test'
+import { setEnglishLocale } from './helpers/auth-fixtures'
+import { loginWithCredentials, registerSession } from './helpers/session'
+import { E2eTestDataBuilder } from './helpers/test-data-builder'
+
+function getOptionalEnv(name: string): string | undefined {
+ const value = process.env[name]?.trim()
+ return value ? value : undefined
+}
+
+function adminCredentials() {
+ return {
+ username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
+ password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
+ }
+}
+
+test.describe('Rejected version replacement (Real API)', () => {
+ test.describe.configure({ timeout: 150_000 })
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ await setEnglishLocale(page)
+ await registerSession(page, testInfo)
+ })
+
+ test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => {
+ const publisherBuilder = new E2eTestDataBuilder(page, testInfo)
+ await publisherBuilder.init()
+
+ const adminContext = await browser.newContext()
+ const adminPage = await adminContext.newPage()
+ const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
+ await loginWithCredentials(adminPage, adminCredentials(), testInfo)
+ await adminBuilder.init()
+
+ try {
+ const namespace = await publisherBuilder.ensureWritableNamespace()
+ const skillName = `replace-rejected-${Date.now().toString(36)}`
+ const firstPublish = await publisherBuilder.publishSkill(namespace.slug, {
+ name: skillName,
+ version: '1.0.0',
+ })
+ const rejectedReviewId = await adminBuilder.waitForPendingReview(
+ namespace.slug,
+ firstPublish.slug,
+ firstPublish.version,
+ )
+ await publisherBuilder.waitForVersionStatus(
+ namespace.slug,
+ firstPublish.slug,
+ firstPublish.version,
+ 'PENDING_REVIEW',
+ )
+ await adminBuilder.rejectReview(rejectedReviewId)
+
+ const replacement = await publisherBuilder.publishSkill(namespace.slug, {
+ name: skillName,
+ description: 'Replacement after review rejection',
+ version: '1.0.0',
+ })
+ const replacementReviewId = await adminBuilder.waitForPendingReview(
+ namespace.slug,
+ replacement.slug,
+ replacement.version,
+ )
+ await publisherBuilder.waitForVersionStatus(
+ namespace.slug,
+ replacement.slug,
+ replacement.version,
+ 'PENDING_REVIEW',
+ )
+
+ expect(replacement.skillId).toBe(firstPublish.skillId)
+ expect(replacement.version).toBe(firstPublish.version)
+ expect(replacementReviewId).not.toBe(rejectedReviewId)
+
+ const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`)
+ expect(replacedReviewResponse.status()).toBe(404)
+ } finally {
+ await adminBuilder.cleanup()
+ await adminContext.close()
+ await publisherBuilder.cleanup()
+ }
+ })
+})
diff --git a/web/nginx.conf.template b/web/nginx.conf.template
index fe0300b6..25db2869 100644
--- a/web/nginx.conf.template
+++ b/web/nginx.conf.template
@@ -10,6 +10,17 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
+ # Ignore client-supplied forwarded proto by default. Operators may explicitly trust a
+ # sanitizing upstream proxy; only canonical http/https values are then accepted.
+ set $proxy_x_forwarded_proto $scheme;
+ set $forwarded_proto_source "${SKILLHUB_TRUST_FORWARDED_PROTO}:$http_x_forwarded_proto";
+ if ($forwarded_proto_source ~* "^true:https$") {
+ set $proxy_x_forwarded_proto https;
+ }
+ if ($forwarded_proto_source ~* "^true:http$") {
+ set $proxy_x_forwarded_proto http;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
@@ -19,27 +30,31 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /oauth2/ {
proxy_pass ${SKILLHUB_API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /login/oauth2/ {
proxy_pass ${SKILLHUB_API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /.well-known/ {
proxy_pass ${SKILLHUB_API_UPSTREAM};
proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /assets/ {
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index c9b3bac8..ec9749a5 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -4,6 +4,7 @@ import { Layout } from './layout'
import { getCurrentUser } from '@/api/client'
import { RoleGuard } from '@/shared/components/role-guard'
import { createRequireAuth } from '@/shared/lib/auth-route'
+import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
/**
@@ -25,7 +26,15 @@ function createLazyRouteComponent>(
// Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave
// consistently across public and dashboard pages.
const LazyComponent = lazy(async () => {
- const module = await importer()
+ const module = await importer().catch((error) => {
+ if (recoverFromDynamicImportError(error)) {
+ return new Promise(() => {})
+ }
+ throw error
+ })
+ // Router resolution can finish before React.lazy imports the route module. Only clear the
+ // one-time reload guard after the chunk itself has loaded successfully.
+ clearDynamicImportReloadGuard()
return { default: module[exportName] as ComponentType> }
})
diff --git a/web/src/shared/lib/dynamic-import-recovery.test.ts b/web/src/shared/lib/dynamic-import-recovery.test.ts
new file mode 100644
index 00000000..8cba7774
--- /dev/null
+++ b/web/src/shared/lib/dynamic-import-recovery.test.ts
@@ -0,0 +1,87 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ clearDynamicImportReloadGuard,
+ isDynamicImportFetchError,
+ recoverFromDynamicImportError,
+} from './dynamic-import-recovery'
+
+const values = new Map()
+const reload = vi.fn()
+const sessionStorage = {
+ get length() {
+ return values.size
+ },
+ clear: vi.fn(() => values.clear()),
+ getItem: vi.fn((key: string) => values.get(key) ?? null),
+ key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
+ removeItem: vi.fn((key: string) => values.delete(key)),
+ setItem: vi.fn((key: string, value: string) => values.set(key, value)),
+} satisfies Storage
+
+describe('dynamic import recovery', () => {
+ beforeEach(() => {
+ values.clear()
+ reload.mockClear()
+ vi.stubGlobal('window', {
+ location: { reload },
+ sessionStorage,
+ })
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it.each([
+ 'Failed to fetch dynamically imported module: /assets/login.js',
+ 'error loading dynamically imported module: /assets/login.js',
+ 'Importing a module script failed',
+ 'ChunkLoadError: Loading chunk 42 failed',
+ ])('recognizes a stale dynamic import error: %s', (message) => {
+ expect(isDynamicImportFetchError(new Error(message))).toBe(true)
+ })
+
+ it('ignores unrelated errors', () => {
+ expect(isDynamicImportFetchError(new Error('Request failed with status 500'))).toBe(false)
+ })
+
+ it('recognizes errors whose name is ChunkLoadError', () => {
+ const error = new Error('Loading chunk 42 failed')
+ error.name = 'ChunkLoadError'
+
+ expect(isDynamicImportFetchError(error)).toBe(true)
+ })
+
+ it('reloads only once while the recovery guard is active', () => {
+ const error = new Error('Failed to fetch dynamically imported module')
+
+ expect(recoverFromDynamicImportError(error)).toBe(true)
+ expect(recoverFromDynamicImportError(error)).toBe(false)
+ expect(recoverFromDynamicImportError(error)).toBe(false)
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('allows recovery again after a dynamic import succeeds', () => {
+ const error = new Error('Failed to fetch dynamically imported module')
+
+ expect(recoverFromDynamicImportError(error)).toBe(true)
+ clearDynamicImportReloadGuard()
+ expect(recoverFromDynamicImportError(error)).toBe(true)
+ expect(reload).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not mask the original import error when session storage is unavailable', () => {
+ vi.stubGlobal('window', {
+ location: { reload },
+ get sessionStorage() {
+ throw new DOMException('Access denied', 'SecurityError')
+ },
+ })
+
+ const error = new Error('Failed to fetch dynamically imported module')
+
+ expect(recoverFromDynamicImportError(error)).toBe(false)
+ expect(() => clearDynamicImportReloadGuard()).not.toThrow()
+ expect(reload).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/src/shared/lib/dynamic-import-recovery.ts b/web/src/shared/lib/dynamic-import-recovery.ts
new file mode 100644
index 00000000..27c04264
--- /dev/null
+++ b/web/src/shared/lib/dynamic-import-recovery.ts
@@ -0,0 +1,48 @@
+const RELOAD_GUARD_KEY = 'skillhub:dynamic-import-reload'
+
+function resolveErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message
+ }
+ return String(error ?? '')
+}
+
+export function isDynamicImportFetchError(error: unknown): boolean {
+ const message = resolveErrorMessage(error)
+ return (error instanceof Error && error.name === 'ChunkLoadError')
+ || message.includes('Failed to fetch dynamically imported module')
+ || message.includes('error loading dynamically imported module')
+ || message.includes('Importing a module script failed')
+ || message.includes('ChunkLoadError')
+}
+
+export function recoverFromDynamicImportError(error: unknown): boolean {
+ if (typeof window === 'undefined' || !isDynamicImportFetchError(error)) {
+ return false
+ }
+
+ let sessionStorage: Storage
+ try {
+ sessionStorage = window.sessionStorage
+ if (sessionStorage.getItem(RELOAD_GUARD_KEY) === '1') {
+ return false
+ }
+ sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
+ } catch {
+ return false
+ }
+
+ window.location.reload()
+ return true
+}
+
+export function clearDynamicImportReloadGuard(): void {
+ if (typeof window === 'undefined') {
+ return
+ }
+ try {
+ window.sessionStorage.removeItem(RELOAD_GUARD_KEY)
+ } catch {
+ // Session storage can be unavailable in restricted browsing contexts.
+ }
+}