From 9bb6b31db70daa0d4a42bf95e064e6bf60cc3bb6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 5 Jun 2026 16:16:55 +0800 Subject: [PATCH 01/26] feat(bootstrap): sync built-in skills from cloud manifest Signed-off-by: dongmucat <1127093059@qq.com> --- .../iflytek/skillhub/SkillhubApplication.java | 7 +- .../bootstrap/BuiltinSkillInitializer.java | 333 +++++++++++++++++ .../bootstrap/BuiltinSkillManifestLoader.java | 108 ++++++ .../BuiltinSkillPackageExtractor.java | 90 +++++ .../bootstrap/BuiltinSkillProperties.java | 17 + .../BuiltinSkillRemotePackageDownloader.java | 142 +++++++ .../src/main/resources/application.yml | 2 + .../resources/builtin-skills/manifest.json | 3 + .../BuiltinSkillInitializerTest.java | 347 ++++++++++++++++++ .../BuiltinSkillManifestLoaderTest.java | 157 ++++++++ .../BuiltinSkillPackageExtractorTest.java | 84 +++++ .../BuiltinSkillPropertiesBindingTest.java | 47 +++ ...iltinSkillRemotePackageDownloaderTest.java | 219 +++++++++++ 13 files changed, 1555 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java create mode 100644 server/skillhub-app/src/main/resources/builtin-skills/manifest.json create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index bef19708..71ee7de8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub; +import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties; import com.iflytek.skillhub.config.ProfileFieldPolicyProperties; import com.iflytek.skillhub.config.ProfileModerationProperties; import org.springframework.boot.SpringApplication; @@ -10,7 +11,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties * Main Spring Boot entry point for the SkillHub backend application. */ @SpringBootApplication -@EnableConfigurationProperties({ProfileModerationProperties.class, ProfileFieldPolicyProperties.class}) +@EnableConfigurationProperties({ + BuiltinSkillProperties.class, + ProfileModerationProperties.class, + ProfileFieldPolicyProperties.class +}) public class SkillhubApplication { public static void main(String[] args) { SpringApplication.run(SkillhubApplication.class, args); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java new file mode 100644 index 00000000..7ff69a68 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -0,0 +1,333 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +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.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Best-effort startup synchronizer for remotely hosted built-in skill packages. + */ +@Component +public class BuiltinSkillInitializer implements ApplicationRunner { + + static final String GLOBAL_NAMESPACE = "global"; + static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher"; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); + private static final Set SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN"); + + private final BuiltinSkillProperties properties; + private final BuiltinSkillManifestLoader manifestLoader; + private final BuiltinSkillRemotePackageDownloader downloader; + private final BuiltinSkillPackageExtractor extractor; + private final SkillMetadataParser metadataParser; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final SkillPublishService skillPublishService; + + public BuiltinSkillInitializer( + BuiltinSkillProperties properties, + BuiltinSkillManifestLoader manifestLoader, + BuiltinSkillRemotePackageDownloader downloader, + BuiltinSkillPackageExtractor extractor, + SkillMetadataParser metadataParser, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + SkillPublishService skillPublishService) { + this.properties = properties; + this.manifestLoader = manifestLoader; + this.downloader = downloader; + this.extractor = extractor; + this.metadataParser = metadataParser; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.skillPublishService = skillPublishService; + } + + @Override + public void run(ApplicationArguments args) { + if (!properties.isEnabled()) { + log.info("Built-in skill startup synchronization is disabled"); + return; + } + + Optional namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE); + if (namespace.isEmpty()) { + log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization", + GLOBAL_NAMESPACE); + return; + } + + List items = manifestLoader.load(); + if (items.isEmpty()) { + log.info("No built-in skill manifest items to synchronize"); + return; + } + + try { + ensureSystemPublisher(namespace.get()); + } catch (RuntimeException exception) { + log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}", + exception.getMessage(), exception); + return; + } + + for (ManifestItem item : items) { + try { + syncItem(namespace.get(), item); + } catch (Exception exception) { + log.error( + "Failed to synchronize built-in skill slug={} version={}: {}", + item.slug(), + item.version(), + exception.getMessage(), + exception + ); + } + } + } + + private void ensureSystemPublisher(Namespace namespace) { + userAccountRepository.findById(SYSTEM_PUBLISHER_ID) + .orElseGet(() -> userAccountRepository.save(new UserAccount( + SYSTEM_PUBLISHER_ID, + "Built-in Skill Publisher", + null, + null + ))); + + if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember( + namespace.getId(), + SYSTEM_PUBLISHER_ID, + NamespaceRole.OWNER + )); + } + } + + private void syncItem(Namespace namespace, ManifestItem item) throws Exception { + Optional packageBytes = downloader.download(URI.create(item.url())); + if (packageBytes.isEmpty()) { + log.warn("Skipping built-in skill slug={} version={} because package download failed", + item.slug(), item.version()); + return; + } + + SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get()); + List entries = extractionResult.entries(); + SkillMetadata metadata = parseSkillMetadata(entries); + String packageSlug = SlugValidator.slugify(metadata.name()); + if (!item.slug().equals(packageSlug)) { + log.warn( + "Skipping built-in skill manifest slug={} version={} because package slug is {}", + item.slug(), + item.version(), + packageSlug + ); + return; + } + if (!item.version().equals(metadata.version())) { + log.warn( + "Skipping built-in skill slug={} because manifest version {} does not match package version {}", + item.slug(), + item.version(), + metadata.version() + ); + return; + } + + if (shouldSkipExisting(namespace.getId(), item, entries)) { + return; + } + + try { + skillPublishService.publishFromEntries( + GLOBAL_NAMESPACE, + entries, + SYSTEM_PUBLISHER_ID, + SkillVisibility.PUBLIC, + SYSTEM_PUBLISHER_ROLES, + false + ); + log.info("Published built-in skill slug={} version={} to @{}", + item.slug(), item.version(), GLOBAL_NAMESPACE); + } catch (RuntimeException exception) { + if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) { + log.info("Built-in skill slug={} version={} was published concurrently, skipping", + item.slug(), item.version()); + return; + } + log.error("Failed to publish built-in skill slug={} version={}: {}", + item.slug(), item.version(), exception.getMessage(), exception); + } + } + + private SkillMetadata parseSkillMetadata(List entries) { + PackageEntry skillMd = entries.stream() + .filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH)); + return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); + } + + private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + boolean hasNonBuiltinOwner = existingSkills.stream() + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); + if (hasNonBuiltinOwner) { + log.warn("Skipping built-in skill slug={} because the slug is already owned by another user", + item.slug()); + return true; + } + + Optional builtinSkill = existingSkills.stream() + .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .findFirst(); + if (builtinSkill.isEmpty()) { + return false; + } + + Optional existingVersion = skillVersionRepository + .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); + if (existingVersion.isEmpty()) { + return false; + } + + SkillVersion version = existingVersion.get(); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + log.info("Skipping built-in skill slug={} version={} because existing version status is {}", + item.slug(), item.version(), version.getStatus()); + return true; + } + + String packageFingerprint = computeFingerprint(entries); + String existingFingerprint = computeFingerprint(version); + if (packageFingerprint.equals(existingFingerprint)) { + log.info("Skipping built-in skill slug={} version={} because it is already published", + item.slug(), item.version()); + } else { + log.warn( + "Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}", + item.slug(), + item.version(), + existingFingerprint, + packageFingerprint + ); + } + return true; + } + + private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List entries) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + for (Skill skill : existingSkills) { + if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) { + continue; + } + Optional version = skillVersionRepository + .findBySkillIdAndVersion(skill.getId(), item.version()); + if (version.isPresent() && version.get().getStatus() == SkillVersionStatus.PUBLISHED) { + String packageFingerprint = computeFingerprint(entries); + String existingFingerprint = computeFingerprint(version.get()); + if (packageFingerprint.equals(existingFingerprint)) { + return true; + } + log.warn( + "Built-in skill slug={} version={} was published concurrently with different content: existing={}, package={}", + item.slug(), + item.version(), + existingFingerprint, + packageFingerprint + ); + return false; + } + } + return false; + } + + private String computeFingerprint(SkillVersion version) { + List files = skillFileRepository.findByVersionId(version.getId()).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + return computeFingerprintFromFileDigests(files.stream() + .map(file -> new FileDigest(file.getFilePath(), file.getSha256())) + .toList()); + } + + private String computeFingerprint(List entries) { + return computeFingerprintFromFileDigests(entries.stream() + .map(entry -> new FileDigest(entry.path(), sha256(entry.content()))) + .toList()); + } + + private String computeFingerprintFromFileDigests(List files) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (FileDigest file : files.stream().sorted(Comparator.comparing(FileDigest::path)).toList()) { + String line = file.path() + ":" + file.sha256() + "\n"; + digest.update(line.getBytes(StandardCharsets.UTF_8)); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute built-in skill fingerprint", exception); + } + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute built-in skill file digest", exception); + } + } + + private record FileDigest(String path, String sha256) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java new file mode 100644 index 00000000..487179a4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class BuiltinSkillManifestLoader { + + static final String MANIFEST_LOCATION = "classpath:builtin-skills/manifest.json"; + static final int MAX_ITEMS = 100; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class); + + private final ObjectMapper objectMapper; + private final ResourceLoader resourceLoader; + + public BuiltinSkillManifestLoader(ObjectMapper objectMapper, ResourceLoader resourceLoader) { + this.objectMapper = objectMapper; + this.resourceLoader = resourceLoader; + } + + public List load() { + Resource resource = resourceLoader.getResource(MANIFEST_LOCATION); + if (!resource.exists()) { + log.warn("Built-in skill manifest not found at {}", MANIFEST_LOCATION); + return List.of(); + } + + JsonNode root; + try (InputStream inputStream = resource.getInputStream()) { + root = objectMapper.readTree(inputStream); + } catch (IOException | RuntimeException ex) { + log.warn("Failed to read built-in skill manifest at {}: {}", MANIFEST_LOCATION, ex.getMessage()); + return List.of(); + } + + if (root == null || root.isNull()) { + log.warn("Built-in skill manifest at {} is empty", MANIFEST_LOCATION); + return List.of(); + } + + JsonNode skillsNode = root.path("skills"); + if (!skillsNode.isArray()) { + log.warn("Built-in skill manifest at {} does not contain an array field 'skills'", MANIFEST_LOCATION); + return List.of(); + } + + List items = new ArrayList<>(); + Set seenSlugVersions = new HashSet<>(); + int totalEntries = skillsNode.size(); + if (totalEntries > MAX_ITEMS) { + log.warn("Built-in skill manifest has {} entries, only the first {} entries will be processed", + totalEntries, MAX_ITEMS); + } + int limit = Math.min(totalEntries, MAX_ITEMS); + for (int index = 0; index < limit; index++) { + JsonNode itemNode = skillsNode.get(index); + String slug = text(itemNode, "slug"); + String version = text(itemNode, "version"); + String url = text(itemNode, "url"); + if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) { + log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index); + continue; + } + try { + SlugValidator.validate(slug); + } catch (RuntimeException ex) { + log.warn("Skipping built-in skill manifest item {} because slug is invalid [slug={}]: {}", + index, slug, ex.getMessage()); + continue; + } + + String key = slug + "\n" + version; + if (!seenSlugVersions.add(key)) { + log.warn("Skipping duplicate built-in skill manifest item for slug={} version={}", slug, version); + continue; + } + + items.add(new ManifestItem(slug, version, url)); + } + return List.copyOf(items); + } + + private static String text(JsonNode node, String fieldName) { + JsonNode value = node.get(fieldName); + if (value == null || !value.isTextual()) { + return ""; + } + return value.asText().trim(); + } + + public record ManifestItem(String slug, String version, String url) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java new file mode 100644 index 00000000..9cd44cd6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -0,0 +1,90 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class BuiltinSkillPackageExtractor { + + private final SkillPackageArchiveExtractor archiveExtractor; + + public BuiltinSkillPackageExtractor(SkillPackageArchiveExtractor archiveExtractor) { + this.archiveExtractor = archiveExtractor; + } + + public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { + assertRootSkillMd(zipBytes); + SkillPackageArchiveExtractor.ExtractionResult result = + archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); + boolean hasRootSkillMd = result.entries().stream() + .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); + if (!hasRootSkillMd) { + throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); + } + return result; + } + + private void assertRootSkillMd(byte[] zipBytes) throws IOException { + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (!entry.isDirectory() && SkillPackagePolicy.SKILL_MD_PATH.equals(entry.getName())) { + return; + } + zipInputStream.closeEntry(); + } + } + throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); + } + + private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile { + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return "builtin-skill.zip"; + } + + @Override + public String getContentType() { + return "application/zip"; + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public byte[] getBytes() { + return bytes.clone(); + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(java.io.File dest) throws IOException { + throw new UnsupportedOperationException("Built-in skill zip adapter is read-only"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java new file mode 100644 index 00000000..6fd3626d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.bootstrap; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "skillhub.builtin-skills") +public class BuiltinSkillProperties { + + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java new file mode 100644 index 00000000..c2819d3f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +@Component +public class BuiltinSkillRemotePackageDownloader { + + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + static final String ALLOWED_HOST = "bjcdn.openstorage.cn"; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillRemotePackageDownloader.class); + private static final Pattern IPV4_LITERAL = Pattern.compile("\\d{1,3}(\\.\\d{1,3}){3}"); + + private final long maxPackageSize; + private final HttpClient httpClient; + + @Autowired + public BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties) { + this( + properties, + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + ); + } + + BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties, HttpClient httpClient) { + this.maxPackageSize = properties.getMaxPackageSize(); + this.httpClient = httpClient; + } + + public Optional download(URI uri) { + if (!isAllowedUrl(uri)) { + log.warn("Skipping built-in skill package download because URL is not allowed: {}", safeUrl(uri)); + return Optional.empty(); + } + + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + log.warn("Failed to download built-in skill package from {}: HTTP {}", + safeUrl(uri), + response.statusCode()); + return Optional.empty(); + } + try (InputStream body = response.body()) { + return readBounded(body); + } + } catch (IOException ex) { + log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); + return Optional.empty(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while downloading built-in skill package from {}", safeUrl(uri)); + return Optional.empty(); + } catch (RuntimeException ex) { + log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); + return Optional.empty(); + } + } + + HttpClient httpClient() { + return httpClient; + } + + static boolean isAllowedUrl(URI uri) { + if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (uri.getRawUserInfo() != null) { + return false; + } + int port = uri.getPort(); + if (port != -1 && port != 443) { + return false; + } + String host = uri.getHost(); + if (host == null) { + return false; + } + String normalizedHost = host.toLowerCase(Locale.ROOT); + if (isDisallowedHostLiteral(normalizedHost)) { + return false; + } + return normalizedHost.equals(ALLOWED_HOST) || normalizedHost.endsWith("." + ALLOWED_HOST); + } + + private Optional readBounded(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long totalRead = 0; + int read; + while ((read = inputStream.read(buffer)) != -1) { + totalRead += read; + if (totalRead > maxPackageSize) { + log.warn("Built-in skill package download exceeded max package size: {} bytes (max: {})", + totalRead, + maxPackageSize); + return Optional.empty(); + } + outputStream.write(buffer, 0, read); + } + return Optional.of(outputStream.toByteArray()); + } + + private static boolean isDisallowedHostLiteral(String host) { + return "localhost".equals(host) + || IPV4_LITERAL.matcher(host).matches() + || host.contains(":"); + } + + private static String safeUrl(URI uri) { + if (uri == null) { + return ""; + } + String host = uri.getHost(); + String path = uri.getRawPath(); + return (host == null ? "" : host) + (path == null ? "" : path); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a592b035..7d9a1cbc 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -93,6 +93,8 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + builtin-skills: + enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json new file mode 100644 index 00000000..2b485e41 --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -0,0 +1,3 @@ +{ + "skills": [] +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java new file mode 100644 index 00000000..4e3322bb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -0,0 +1,347 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +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.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@ExtendWith(MockitoExtension.class) +class BuiltinSkillInitializerTest { + + private static final String GLOBAL = "global"; + private static final String PUBLISHER = "builtin-skill-publisher"; + private static final ManifestItem ITEM = new ManifestItem( + "agentguard", + "1.0.0", + "https://bjcdn.openstorage.cn/skills/agentguard.zip" + ); + + @Mock private BuiltinSkillManifestLoader manifestLoader; + @Mock private BuiltinSkillRemotePackageDownloader downloader; + @Mock private BuiltinSkillPackageExtractor extractor; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private UserAccountRepository userAccountRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SkillFileRepository skillFileRepository; + @Mock private SkillPublishService skillPublishService; + + private BuiltinSkillProperties properties; + private BuiltinSkillInitializer initializer; + private Namespace globalNamespace; + + @BeforeEach + void setUp() { + properties = new BuiltinSkillProperties(); + initializer = new BuiltinSkillInitializer( + properties, + manifestLoader, + downloader, + extractor, + new SkillMetadataParser(), + namespaceRepository, + namespaceMemberRepository, + userAccountRepository, + skillRepository, + skillVersionRepository, + skillFileRepository, + skillPublishService + ); + globalNamespace = new Namespace(GLOBAL, "Global", "system"); + ReflectionTestUtils.setField(globalNamespace, "id", 1L); + } + + @Test + void skipsWhenDisabled() { + properties.setEnabled(false); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(manifestLoader, never()).load(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsAllItemsWhenGlobalNamespaceDoesNotExist() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.empty()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(manifestLoader, never()).load(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsExistingSkillOwnedByAnotherUser() throws Exception { + Skill otherSkill = skill(100L, "agentguard", "someone-else"); + givenExtractedPackage(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(otherSkill)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsPublishedSameVersionWhenFingerprintMatches() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsPublishedSameVersionWhenFingerprintDiffers() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + givenExtractedPackage(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( + new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("changed"), "storage-key") + )); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsExistingSameVersionWhenNotPublished() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion uploaded = version(200L, 100L, "1.0.0", SkillVersionStatus.UPLOADED); + givenExtractedPackage(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsWhenManifestSlugDoesNotMatchPackageMetadata() throws Exception { + givenExtractedPackage(packageEntries("other-skill", "1.0.0", "same")); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { + givenExtractedPackage(packageEntries("agentguard", "1.0.1", "same")); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor> entriesCaptor = ArgumentCaptor.captor(); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), + entriesCaptor.capture(), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false) + ); + assertThat(entriesCaptor.getValue()).isEqualTo(entries); + } + + @Test + void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exception { + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.empty()); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo(PUBLISHER); + assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.OWNER); + } + + @Test + void treatsConcurrentDuplicatePublishedVersionAsCompleted() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + List entries = packageEntries("agentguard", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + .thenReturn(List.of()) + .thenReturn(List.of(builtinSkill)); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + } + + @Test + void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted() throws Exception { + Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + givenExtractedPackage(packageEntries("agentguard", "1.0.0", "new-content")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + .thenReturn(List.of()) + .thenReturn(List.of(builtinSkill)); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( + new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("old-content"), "storage-key") + )); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(skillFileRepository).findByVersionId(200L); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + } + + private void givenExtractedPackage() throws Exception { + givenExtractedPackage(packageEntries("agentguard", "1.0.0", "same")); + } + + private void givenExtractedPackage(List entries) throws Exception { + byte[] bytes = "zip".getBytes(StandardCharsets.UTF_8); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + lenient().when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + lenient().when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(downloader.download(URI.create(ITEM.url()))).thenReturn(Optional.of(bytes)); + when(extractor.extract(bytes)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of())); + } + + private static UserAccount systemPublisher() { + return new UserAccount(PUBLISHER, "Built-in Skill Publisher", null, null); + } + + private static Skill skill(Long id, String slug, String ownerId) { + Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", id); + return skill; + } + + private static SkillVersion version(Long id, Long skillId, String version, SkillVersionStatus status) { + SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER); + ReflectionTestUtils.setField(skillVersion, "id", id); + skillVersion.setStatus(status); + return skillVersion; + } + + private static List packageEntries(String name, String version, String readme) { + byte[] skillMd = (""" + --- + name: %s + description: Built-in guardrails + version: %s + --- + # %s + """).formatted(name, version, name).getBytes(StandardCharsets.UTF_8); + byte[] readmeBytes = readme.getBytes(StandardCharsets.UTF_8); + return List.of( + new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown"), + new PackageEntry("README.md", readmeBytes, readmeBytes.length, "text/markdown") + ); + } + + private static List skillFilesFor(List entries, Long versionId) { + return entries.stream() + .map(entry -> new SkillFile( + versionId, + entry.path(), + entry.size(), + entry.contentType(), + sha256(entry.content()), + "storage-key/" + entry.path() + )) + .toList(); + } + + private static String sha256(String content) { + return sha256(content.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java new file mode 100644 index 00000000..39ea4b38 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java @@ -0,0 +1,157 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ResourceLoader; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +class BuiltinSkillManifestLoaderTest { + + @Test + void loadsManifestItemsInOrder() { + BuiltinSkillManifestLoader loader = loaderWith(""" + { + "skills": [ + {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/agentguard.zip"}, + {"slug": "agentguard", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/agentguard.zip"} + ] + } + """); + + List items = loader.load(); + + assertThat(items) + .extracting(BuiltinSkillManifestLoader.ManifestItem::version) + .containsExactly("1.0.0", "1.1.0"); + } + + @Test + void returnsEmptyListWhenManifestIsMissing() { + BuiltinSkillManifestLoader loader = new BuiltinSkillManifestLoader( + new ObjectMapper(), + new ResourceLoader() { + @Override + public org.springframework.core.io.Resource getResource(String location) { + return new MissingResource(); + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + } + ); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void returnsEmptyListWhenManifestIsMalformed() { + BuiltinSkillManifestLoader loader = loaderWith("{not-json"); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void returnsEmptyListWhenManifestIsEmpty() { + BuiltinSkillManifestLoader loader = loaderWith(""); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void skipsItemsWithMissingHumanFieldsAndDuplicateSlugVersion() { + BuiltinSkillManifestLoader loader = loaderWith(""" + { + "skills": [ + {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, + {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, + {"slug": "InvalidUppercase", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/invalid.zip"}, + {"slug": "", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/blank.zip"}, + {"slug": "missing-version", "url": "https://bjcdn.openstorage.cn/missing-version.zip"}, + {"slug": "missing-url", "version": "1.0.0"}, + {"slug": "valid-after-invalid", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/valid.zip"} + ] + } + """); + + List items = loader.load(); + + assertThat(items) + .extracting(BuiltinSkillManifestLoader.ManifestItem::url) + .containsExactly( + "https://bjcdn.openstorage.cn/first.zip", + "https://bjcdn.openstorage.cn/valid.zip" + ); + } + + @Test + void capsManifestEntriesAtOneHundredRawEntries() { + StringBuilder json = new StringBuilder("{\"skills\":["); + for (int i = 0; i < 101; i++) { + if (i > 0) { + json.append(','); + } + if (i == 0) { + json.append("{\"slug\":\"\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/blank.zip\"}"); + } else { + json.append("{\"slug\":\"skill-").append(i) + .append("\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/skill-") + .append(i) + .append(".zip\"}"); + } + } + json.append("]}"); + + BuiltinSkillManifestLoader loader = loaderWith(json.toString()); + + assertThat(loader.load()).hasSize(99); + } + + private BuiltinSkillManifestLoader loaderWith(String content) { + ResourceLoader resourceLoader = new ResourceLoader() { + @Override + public org.springframework.core.io.Resource getResource(String location) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public boolean exists() { + return true; + } + + @Override + public String getDescription() { + return "test manifest"; + } + }; + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + }; + return new BuiltinSkillManifestLoader(new ObjectMapper(), resourceLoader); + } + + static class MissingResource extends ByteArrayResource { + + MissingResource() { + super(new byte[0]); + } + + @Override + public boolean exists() { + return false; + } + + @Override + public String getDescription() { + return "missing manifest"; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java new file mode 100644 index 00000000..99f710b9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -0,0 +1,84 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +class BuiltinSkillPackageExtractorTest { + + private final BuiltinSkillPackageExtractor extractor = new BuiltinSkillPackageExtractor( + new SkillPackageArchiveExtractor(new SkillPublishProperties()) + ); + + @Test + void extractsZipBytesThroughArchiveExtractor() throws Exception { + byte[] zip = zip( + entry("SKILL.md", """ + --- + name: agentguard + version: 1.0.0 + --- + # AgentGuard + """), + entry("README.md", "# Readme") + ); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extract(zip); + + assertThat(result.entries()) + .extracting(entry -> entry.path()) + .containsExactly("SKILL.md", "README.md"); + } + + @Test + void rejectsZipWithoutRootSkillMd() throws Exception { + byte[] zip = zip(entry("README.md", "# Readme")); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + } + + @Test + void rejectsZipWithOnlyNestedSkillMd() throws Exception { + byte[] zip = zip(entry("agentguard/SKILL.md", """ + --- + name: agentguard + version: 1.0.0 + --- + # AgentGuard + """)); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + } + + private static ZipSource entry(String path, String content) { + return new ZipSource(path, content.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] zip(ZipSource... sources) throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (ZipSource source : sources) { + zipOutputStream.putNextEntry(new ZipEntry(source.path())); + zipOutputStream.write(source.content()); + zipOutputStream.closeEntry(); + } + } + return outputStream.toByteArray(); + } + + record ZipSource(String path, byte[] content) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java new file mode 100644 index 00000000..174b8347 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +import java.util.Map; + +class BuiltinSkillPropertiesBindingTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfig.class); + + @Test + void enabledDefaultsToTrue() { + contextRunner.run((context) -> { + BuiltinSkillProperties properties = context.getBean(BuiltinSkillProperties.class); + + assertThat(properties.isEnabled()).isTrue(); + }); + } + + @Test + void bindsEnabledFromEnvironmentStyleProperty() { + contextRunner + .withInitializer((context) -> context.getEnvironment().getPropertySources().addFirst( + new SystemEnvironmentPropertySource( + "test-env", + Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false") + ) + )) + .run((context) -> { + BuiltinSkillProperties properties = context.getBean(BuiltinSkillProperties.class); + + assertThat(properties.isEnabled()).isFalse(); + }); + } + + @Configuration + @EnableConfigurationProperties(BuiltinSkillProperties.class) + static class TestConfig { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java new file mode 100644 index 00000000..9dbf2cdf --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java @@ -0,0 +1,219 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +class BuiltinSkillRemotePackageDownloaderTest { + + @Test + void acceptsAllowedHttpsCdnHostsOnly() { + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://bjcdn.openstorage.cn/a.zip"))) + .isTrue(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://assets.bjcdn.openstorage.cn/a.zip"))) + .isTrue(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("http://bjcdn.openstorage.cn/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://evil.com/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://user:pass@bjcdn.openstorage.cn/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://bjcdn.openstorage.cn:8443/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://127.0.0.1/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://localhost/a.zip"))) + .isFalse(); + } + + @Test + void defaultHttpClientDoesNotFollowRedirects() { + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader(new SkillPublishProperties()); + + assertThat(downloader.httpClient().followRedirects()).isEqualTo(HttpClient.Redirect.NEVER); + assertThat(downloader.httpClient().connectTimeout()).contains(Duration.ofSeconds(5)); + } + + @Test + void downloadsAllowedUrlWithThirtySecondRequestTimeout() { + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1, 2, 3}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).contains(new byte[] {1, 2, 3}); + assertThat(client.lastRequest.timeout()).contains(Duration.ofSeconds(30)); + } + + @Test + void rejectsRedirectResponsesWithoutReadingLocation() { + FakeHttpClient client = new FakeHttpClient(302, new byte[] {1}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(client.sendCalls).isEqualTo(1); + } + + @Test + void rejectedUrlDoesNotSendHttpRequest() { + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://example.com/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(client.sendCalls).isZero(); + } + + @Test + void stopsReadingWhenResponseExceedsMaxPackageSize() { + SkillPublishProperties properties = new SkillPublishProperties(); + properties.setMaxPackageSize(2); + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1, 2, 3}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader(properties, client); + + assertThat(downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))).isEmpty(); + } + + static class FakeHttpClient extends HttpClient { + + private final int statusCode; + private final byte[] body; + private HttpRequest lastRequest; + private int sendCalls; + + FakeHttpClient(int statusCode, byte[] body) { + this.statusCode = statusCode; + this.body = body; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.of(Duration.ofSeconds(5)); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return null; + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException { + lastRequest = request; + sendCalls++; + @SuppressWarnings("unchecked") + T responseBody = (T) new ByteArrayInputStream(body); + return new FakeResponse<>(request, statusCode, responseBody); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler + ) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler + ) { + throw new UnsupportedOperationException(); + } + } + + record FakeResponse(HttpRequest request, int statusCode, T body) implements HttpResponse { + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(java.util.Map.of(), (name, value) -> true); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + } +} From 0f752e23058442444e5df4f0b18876928649d948 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 5 Jun 2026 17:16:25 +0800 Subject: [PATCH 02/26] docs(bootstrap): add built-in skill cloud setup guide Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 264 ++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/20-cloud-url-builtin-skills-setup.md diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md new file mode 100644 index 00000000..1ddc054a --- /dev/null +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -0,0 +1,264 @@ +# 云存储链接内置 Skills 配置指南 + +本文说明如何通过仓库内 manifest 配置 SkillHub 内置 Skills,以及应用启动时这些 Skills 如何从云存储同步到 `@global` 空间。 + +适用场景: + +- 希望 SkillHub 新部署实例默认带有一批官方内置 Skills。 +- 不希望把完整 Skill 包目录长期放在代码仓库和镜像中。 +- 内置 Skill 包已经上传到官方可控的云存储域名。 + +## 1. 方案概览 + +内置 Skills 不再以本地目录包的形式直接随仓库维护。当前方案只在仓库中维护一个 manifest 文件,应用启动时根据 manifest 中的云存储 URL 下载 zip 包,并通过 SkillHub 现有发布链路发布到 `@global`。 + +流程: + +```text +维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用启动 -> 读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 +``` + +核心文件: + +```text +server/skillhub-app/src/main/resources/builtin-skills/manifest.json +``` + +首版 manifest 只需要维护三个字段: + +- `slug`:Skill 在 `@global` 下的 slug。 +- `version`:期望同步的 Skill 版本。 +- `url`:Skill zip 包的云存储 HTTPS 链接。 + +## 2. Manifest 配置 + +manifest 文件格式如下: + +```json +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.0.0.zip" + } + ] +} +``` + +可以配置多个 Skills,也可以为同一个 `slug` 配置多个版本: + +```json +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.0.0.zip" + }, + { + "slug": "skillhub-hello", + "version": "1.1.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.1.0.zip" + }, + { + "slug": "skillhub-guide", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-guide-1.0.0.zip" + } + ] +} +``` + +配置要求: + +- `skills` 必须是数组。 +- 每一项必须同时填写 `slug`、`version`、`url`。 +- `slug` 必须符合 SkillHub slug 规则。 +- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。 +- manifest 最多处理前 100 条 entries。 +- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。 + +## 3. Skill 包要求 + +manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议: + +- zip 根目录必须包含 `SKILL.md`。 +- `SKILL.md` frontmatter 中必须包含合法的 `name`、`description`、`version` 等元数据。 +- `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`。 +- `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`。 +- 包内容仍会经过 SkillHub 现有发布校验,包括文件数量、文件大小、扩展名、文件类型等规则。 + +示例: + +```text +skillhub-hello-1.0.0.zip +├── SKILL.md +├── README.md +└── scripts/ + └── check.js +``` + +不推荐的结构: + +```text +skillhub-hello-1.0.0.zip +└── skillhub-hello/ + └── SKILL.md +``` + +原因是内置 Skill 同步要求根目录存在 `SKILL.md`,不会把嵌套目录中的 `SKILL.md` 当作入口。 + +## 4. URL 安全限制 + +内置 Skill 同步由后端在启动时主动下载远程文件,因此 URL 有严格限制。 + +首版只允许: + +- `https://` 协议。 +- host 为 `bjcdn.openstorage.cn`。 +- host 为 `bjcdn.openstorage.cn` 的子域名,例如 `assets.bjcdn.openstorage.cn`。 +- 默认 HTTPS 端口,或显式 `:443`。 + +以下 URL 会被跳过: + +- `http://...` +- 非 `bjcdn.openstorage.cn` 及其子域名。 +- 带 userinfo 的 URL,例如 `https://user:pass@bjcdn.openstorage.cn/file.zip`。 +- 非 443 端口,例如 `https://bjcdn.openstorage.cn:8443/file.zip`。 +- `localhost`、IP 地址、IPv6 literal 等 host。 +- 需要 HTTP redirect 才能拿到文件的链接。 + +如果某一项 URL 不符合规则,SkillHub 会记录日志并跳过该项,不会阻塞应用启动。 + +## 5. 启动同步流程 + +应用启动时同步器只执行一次。 + +详细流程: + +1. 检查 `skillhub.builtin-skills.enabled` 是否开启。 +2. 读取 `classpath:builtin-skills/manifest.json`。 +3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 +4. 确保系统发布者 `builtin-skill-publisher` 存在,并且是 `@global` 的 `OWNER`。 +5. 按 manifest 顺序处理每一个 item。 +6. 下载对应 zip 包。 +7. 解包并校验根目录 `SKILL.md`。 +8. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 +9. 检查是否已存在同名 Skill 或同版本。 +10. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 +11. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 + +同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。 + +## 6. 幂等与冲突处理 + +内置 Skill 同步支持重复启动和多次部署。 + +幂等键: + +```text +@global/{slug} + version +``` + +行为说明: + +| 场景 | 行为 | +|---|---| +| `@global/{slug}` 不存在 | 发布 manifest 中的 Skill | +| `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | +| 同版本已存在且已发布,内容一致 | 跳过 | +| 同版本已存在且已发布,但内容不一致 | 跳过并记录 warning | +| 同版本已存在但不是 `PUBLISHED` | 跳过并记录日志 | +| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 跳过并记录 warning | + +这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 + +如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 + +## 7. 开关配置 + +内置 Skill 同步默认开启。 + +Spring 配置项: + +```yaml +skillhub: + builtin-skills: + enabled: true +``` + +环境变量: + +```dotenv +SKILLHUB_BUILTIN_SKILLS_ENABLED=true +``` + +如需禁用启动同步: + +```dotenv +SKILLHUB_BUILTIN_SKILLS_ENABLED=false +``` + +禁用后,应用启动时不会读取 manifest,也不会下载或发布任何内置 Skill。 + +## 8. 维护流程 + +新增一个内置 Skill 的推荐步骤: + +1. 准备 Skill 包,并确认 zip 根目录包含 `SKILL.md`。 +2. 检查 `SKILL.md` 中的 `name` 和 `version`。 +3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。 +4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。 +5. 确保 manifest 中的 `slug` 等于 `SKILL.md name` 归一化后的 slug。 +6. 确保 manifest 中的 `version` 等于 `SKILL.md version`。 +7. 本地或测试环境启动 SkillHub,查看后端日志确认同步结果。 +8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。 + +更新一个已有内置 Skill 的推荐步骤: + +1. 不要覆盖已经发布过的旧版本 zip 内容。 +2. 在 `SKILL.md` 中提升 `version`。 +3. 重新打包并上传新的 zip 文件。 +4. 在 manifest 中新增一条同 `slug`、新 `version` 的记录。 +5. 保留旧版本记录,除非产品明确不再需要该旧版本在新实例中预置。 + +不推荐: + +- 修改旧版本 zip 内容但保持同一个 `version`。 +- 把 URL 指向会发生内容变化的临时对象。 +- 使用需要登录、签名跳转或重定向的下载链接。 + +## 9. 日志与排查 + +启动时可以通过后端日志观察同步结果。 + +常见日志含义: + +| 日志含义 | 处理建议 | +|---|---| +| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath | +| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 | +| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | +| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | +| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | +| package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | +| manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | +| slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | +| published fingerprint differs | 同一内置版本已存在但内容不同,需要人工确认是否错误覆盖了远程包 | + +如果某个 manifest item 失败,后续 item 仍会继续处理,应用启动也会继续。 + +## 10. 验收检查 + +配置或新增内置 Skill 后,建议至少完成以下检查: + +- manifest JSON 格式合法。 +- 每个 item 都包含 `slug`、`version`、`url`。 +- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。 +- zip 根目录包含 `SKILL.md`。 +- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。 +- `SKILL.md version` 与 manifest `version` 一致。 +- 启动日志没有该 item 的 warning 或 error。 +- Web UI 中可以看到 `@global/{slug}`。 +- Skill 可被匿名或登录用户按公开 Skill 规则发现。 From 4c4a888b01e0547d3675c4b157c5bac7dcc265c1 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 14:01:19 +0800 Subject: [PATCH 03/26] fix(bootstrap): harden built-in skill startup sync Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 29 ++--- .../bootstrap/BuiltinSkillInitializer.java | 70 ++++++++++-- .../V43__user_account_system_account.sql | 19 +++ .../BuiltinSkillInitializerTest.java | 108 ++++++++++-------- .../skillhub/domain/user/UserAccount.java | 10 ++ 5 files changed, 169 insertions(+), 67 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 1ddc054a..a2d537fa 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -140,14 +140,17 @@ skillhub-hello-1.0.0.zip 1. 检查 `skillhub.builtin-skills.enabled` 是否开启。 2. 读取 `classpath:builtin-skills/manifest.json`。 3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 -4. 确保系统发布者 `builtin-skill-publisher` 存在,并且是 `@global` 的 `OWNER`。 -5. 按 manifest 顺序处理每一个 item。 -6. 下载对应 zip 包。 -7. 解包并校验根目录 `SKILL.md`。 -8. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 -9. 检查是否已存在同名 Skill 或同版本。 -10. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 -11. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 +4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。 +5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。 +6. 确保系统发布者是 `@global` 的 `OWNER`。 +7. 按 manifest 顺序处理每一个 item。 +8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 +9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 +10. 解包并校验根目录 `SKILL.md`。 +11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 +12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。 +13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 +14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。 @@ -167,10 +170,9 @@ skillhub-hello-1.0.0.zip |---|---| | `@global/{slug}` 不存在 | 发布 manifest 中的 Skill | | `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | -| 同版本已存在且已发布,内容一致 | 跳过 | -| 同版本已存在且已发布,但内容不一致 | 跳过并记录 warning | -| 同版本已存在但不是 `PUBLISHED` | 跳过并记录日志 | -| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 跳过并记录 warning | +| 同版本已存在且已发布 | 下载前跳过 | +| 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | +| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 下载前跳过并记录 warning | 这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 @@ -238,6 +240,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | 日志含义 | 处理建议 | |---|---| | manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath | +| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 | | slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 | | slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | | URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | @@ -245,7 +248,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | | slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | -| published fingerprint differs | 同一内置版本已存在但内容不同,需要人工确认是否错误覆盖了远程包 | +| published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | 如果某个 manifest item 失败,后续 item 仍会继续处理,应用启动也会继续。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 7ff69a68..63ae3981 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -111,7 +111,9 @@ public class BuiltinSkillInitializer implements ApplicationRunner { } try { - ensureSystemPublisher(namespace.get()); + if (!ensureSystemPublisher(namespace.get())) { + return; + } } catch (RuntimeException exception) { log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}", exception.getMessage(), exception); @@ -133,14 +135,25 @@ public class BuiltinSkillInitializer implements ApplicationRunner { } } - private void ensureSystemPublisher(Namespace namespace) { - userAccountRepository.findById(SYSTEM_PUBLISHER_ID) - .orElseGet(() -> userAccountRepository.save(new UserAccount( - SYSTEM_PUBLISHER_ID, - "Built-in Skill Publisher", - null, - null - ))); + private boolean ensureSystemPublisher(Namespace namespace) { + Optional existingPublisher = userAccountRepository.findById(SYSTEM_PUBLISHER_ID); + UserAccount publisher; + if (existingPublisher.isPresent()) { + publisher = existingPublisher.get(); + } else { + publisher = UserAccount.systemAccount( + SYSTEM_PUBLISHER_ID, + "Built-in Skill Publisher", + null, + null + ); + userAccountRepository.save(publisher); + } + if (!publisher.isSystemAccount()) { + log.error("Built-in skill publisher account id '{}' already exists but is not a system account; " + + "skipping built-in skill synchronization", SYSTEM_PUBLISHER_ID); + return false; + } if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) { namespaceMemberRepository.save(new NamespaceMember( @@ -149,9 +162,14 @@ public class BuiltinSkillInitializer implements ApplicationRunner { NamespaceRole.OWNER )); } + return true; } private void syncItem(Namespace namespace, ManifestItem item) throws Exception { + if (shouldSkipBeforeDownload(namespace.getId(), item)) { + return; + } + Optional packageBytes = downloader.download(URI.create(item.url())); if (packageBytes.isEmpty()) { log.warn("Skipping built-in skill slug={} version={} because package download failed", @@ -217,6 +235,40 @@ public class BuiltinSkillInitializer implements ApplicationRunner { return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); } + private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + boolean hasNonBuiltinOwner = existingSkills.stream() + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); + if (hasNonBuiltinOwner) { + log.warn("Skipping built-in skill slug={} before download because the slug is already owned by another user", + item.slug()); + return true; + } + + Optional builtinSkill = existingSkills.stream() + .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .findFirst(); + if (builtinSkill.isEmpty()) { + return false; + } + + Optional existingVersion = skillVersionRepository + .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); + if (existingVersion.isEmpty()) { + return false; + } + + SkillVersion version = existingVersion.get(); + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + log.info("Skipping built-in skill slug={} version={} before download because it is already published", + item.slug(), item.version()); + } else { + log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}", + item.slug(), item.version(), version.getStatus()); + } + return true; + } + private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); boolean hasNonBuiltinOwner = existingSkills.stream() diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql new file mode 100644 index 00000000..c525bfd2 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -0,0 +1,19 @@ +ALTER TABLE user_account + ADD COLUMN system_account BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE user_account +SET system_account = TRUE +WHERE id = 'builtin-skill-publisher' + AND display_name = 'Built-in Skill Publisher' + AND email IS NULL + AND avatar_url IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM local_credential + WHERE local_credential.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_binding + WHERE identity_binding.user_id = user_account.id + ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 4e3322bb..7afa9cd9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -52,9 +52,9 @@ class BuiltinSkillInitializerTest { private static final String GLOBAL = "global"; private static final String PUBLISHER = "builtin-skill-publisher"; private static final ManifestItem ITEM = new ManifestItem( - "agentguard", + "skillhub-hello", "1.0.0", - "https://bjcdn.openstorage.cn/skills/agentguard.zip" + "https://bjcdn.openstorage.cn/skills/skillhub-hello.zip" ); @Mock private BuiltinSkillManifestLoader manifestLoader; @@ -114,57 +114,64 @@ class BuiltinSkillInitializerTest { } @Test - void skipsExistingSkillOwnedByAnotherUser() throws Exception { - Skill otherSkill = skill(100L, "agentguard", "someone-else"); - givenExtractedPackage(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(otherSkill)); + void skipsSynchronizationWhenPublisherIdIsOccupiedByNonSystemAccount() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)) + .thenReturn(Optional.of(new UserAccount(PUBLISHER, "Human User", "human@example.com", null))); initializer.run(new DefaultApplicationArguments(new String[0])); + verify(namespaceMemberRepository, never()).save(any()); + verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @Test - void skipsPublishedSameVersionWhenFingerprintMatches() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + void skipsPublishedSameVersionBeforeDownloadingPackage() { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - List entries = packageEntries("agentguard", "1.0.0", "same"); - givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); - when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); initializer.run(new DefaultApplicationArguments(new String[0])); + verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @Test - void skipsPublishedSameVersionWhenFingerprintDiffers() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); - SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - givenExtractedPackage(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); - when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); - when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( - new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("changed"), "storage-key") - )); - - initializer.run(new DefaultApplicationArguments(new String[0])); - - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); - } - - @Test - void skipsExistingSameVersionWhenNotPublished() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + void skipsExistingSameVersionWhenNotPublishedBeforeDownloadingPackage() { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion uploaded = version(200L, 100L, "1.0.0", SkillVersionStatus.UPLOADED); - givenExtractedPackage(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of(builtinSkill)); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); initializer.run(new DefaultApplicationArguments(new String[0])); + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + } + + @Test + void skipsExistingSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { + Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + givenManifestAndSystemPublisher(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @@ -179,7 +186,7 @@ class BuiltinSkillInitializerTest { @Test void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { - givenExtractedPackage(packageEntries("agentguard", "1.0.1", "same")); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.1", "same")); initializer.run(new DefaultApplicationArguments(new String[0])); @@ -188,9 +195,9 @@ class BuiltinSkillInitializerTest { @Test void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { - List entries = packageEntries("agentguard", "1.0.0", "same"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); initializer.run(new DefaultApplicationArguments(new String[0])); @@ -208,17 +215,18 @@ class BuiltinSkillInitializerTest { @Test void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exception { - List entries = packageEntries("agentguard", "1.0.0", "same"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.empty()); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); initializer.run(new DefaultApplicationArguments(new String[0])); ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); verify(userAccountRepository).save(userCaptor.capture()); assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER); + assertThat(userCaptor.getValue().isSystemAccount()).isTrue(); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); verify(namespaceMemberRepository).save(memberCaptor.capture()); @@ -229,11 +237,12 @@ class BuiltinSkillInitializerTest { @Test void treatsConcurrentDuplicatePublishedVersionAsCompleted() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - List entries = packageEntries("agentguard", "1.0.0", "same"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( @@ -250,10 +259,11 @@ class BuiltinSkillInitializerTest { @Test void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted() throws Exception { - Skill builtinSkill = skill(100L, "agentguard", PUBLISHER); + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); - givenExtractedPackage(packageEntries("agentguard", "1.0.0", "new-content")); - when(skillRepository.findByNamespaceIdAndSlug(1L, "agentguard")) + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "new-content")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( @@ -272,7 +282,7 @@ class BuiltinSkillInitializerTest { } private void givenExtractedPackage() throws Exception { - givenExtractedPackage(packageEntries("agentguard", "1.0.0", "same")); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "same")); } private void givenExtractedPackage(List entries) throws Exception { @@ -286,8 +296,16 @@ class BuiltinSkillInitializerTest { when(extractor.extract(bytes)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of())); } + private void givenManifestAndSystemPublisher() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + } + private static UserAccount systemPublisher() { - return new UserAccount(PUBLISHER, "Built-in Skill Publisher", null, null); + return UserAccount.systemAccount(PUBLISHER, "Built-in Skill Publisher", null, null); } private static Skill skill(Long id, String slug, String ownerId) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java index 0ef2c569..19e6961f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -27,6 +27,9 @@ public class UserAccount { @Column(name = "merged_to_user_id") private String mergedToUserId; + @Column(name = "system_account", nullable = false) + private boolean systemAccount = false; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -43,6 +46,12 @@ public class UserAccount { this.status = UserStatus.ACTIVE; } + public static UserAccount systemAccount(String id, String displayName, String email, String avatarUrl) { + UserAccount user = new UserAccount(id, displayName, email, avatarUrl); + user.systemAccount = true; + return user; + } + @PrePersist void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); @@ -65,6 +74,7 @@ public class UserAccount { public void setStatus(UserStatus status) { this.status = status; } public String getMergedToUserId() { return mergedToUserId; } public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; } + public boolean isSystemAccount() { return systemAccount; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } public boolean isActive() { return this.status == UserStatus.ACTIVE; } From 5cc934a294494277585568e18c927602229b5610 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 15:20:10 +0800 Subject: [PATCH 04/26] fix(bootstrap): harden builtin skill startup sync Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 9 ++- .../bootstrap/BuiltinSkillInitializer.java | 33 ++++++-- .../skillhub/service/AdminUserAppService.java | 8 ++ .../V43__user_account_system_account.sql | 5 ++ .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../BuiltinSkillInitializerTest.java | 79 +++++++++++++++---- .../BuiltinSkillManifestLoaderTest.java | 8 +- .../BuiltinSkillPackageExtractorTest.java | 10 +-- .../db/FlywayMigrationGuardrailTest.java | 16 ++++ .../service/AdminUserAppServiceTest.java | 35 ++++++++ 11 files changed, 170 insertions(+), 35 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index a2d537fa..7e5c5eb2 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -15,7 +15,7 @@ 流程: ```text -维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用启动 -> 读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 +维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用 ready -> 后台读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 ``` 核心文件: @@ -133,7 +133,7 @@ skillhub-hello-1.0.0.zip ## 5. 启动同步流程 -应用启动时同步器只执行一次。 +应用 ready 后同步器会在后台执行一次,不阻塞应用 ready。 详细流程: @@ -175,6 +175,7 @@ skillhub-hello-1.0.0.zip | `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 下载前跳过并记录 warning | 这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 +同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 @@ -202,7 +203,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=true SKILLHUB_BUILTIN_SKILLS_ENABLED=false ``` -禁用后,应用启动时不会读取 manifest,也不会下载或发布任何内置 Skill。 +禁用后,应用 ready 后不会读取 manifest,也不会下载或发布任何内置 Skill。 ## 8. 维护流程 @@ -250,7 +251,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | -如果某个 manifest item 失败,后续 item 仍会继续处理,应用启动也会继续。 +如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 ## 10. 验收检查 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 63ae3981..d6626087 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -25,8 +25,9 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import java.net.URI; @@ -42,7 +43,7 @@ import java.util.Set; * Best-effort startup synchronizer for remotely hosted built-in skill packages. */ @Component -public class BuiltinSkillInitializer implements ApplicationRunner { +public class BuiltinSkillInitializer { static final String GLOBAL_NAMESPACE = "global"; static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher"; @@ -90,8 +91,13 @@ public class BuiltinSkillInitializer implements ApplicationRunner { this.skillPublishService = skillPublishService; } - @Override - public void run(ApplicationArguments args) { + @EventListener(ApplicationReadyEvent.class) + @Async("skillhubEventExecutor") + public void synchronizeAfterApplicationReady() { + synchronize(); + } + + void synchronize() { if (!properties.isEnabled()) { log.info("Built-in skill startup synchronization is disabled"); return; @@ -170,7 +176,12 @@ public class BuiltinSkillInitializer implements ApplicationRunner { return; } - Optional packageBytes = downloader.download(URI.create(item.url())); + Optional packageUri = parsePackageUri(item); + if (packageUri.isEmpty()) { + return; + } + + Optional packageBytes = downloader.download(packageUri.get()); if (packageBytes.isEmpty()) { log.warn("Skipping built-in skill slug={} version={} because package download failed", item.slug(), item.version()); @@ -226,6 +237,16 @@ public class BuiltinSkillInitializer implements ApplicationRunner { } } + private Optional parsePackageUri(ManifestItem item) { + try { + return Optional.of(URI.create(item.url())); + } catch (IllegalArgumentException exception) { + log.warn("Skipping built-in skill slug={} version={} because URL is not allowed: {}", + item.slug(), item.version(), exception.getMessage()); + return Optional.empty(); + } + } + private SkillMetadata parseSkillMetadata(List entries) { PackageEntry skillMd = entries.stream() .filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 656cefa0..b684c744 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -81,6 +81,7 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserRole(String userId, String roleCode, Set actorPlatformRoles) { UserAccount user = loadUser(userId); + rejectSystemAccountMutation(user); String normalizedRoleCode = normalizeRoleCode(roleCode); if ("SUPER_ADMIN".equals(normalizedRoleCode) @@ -102,6 +103,7 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserStatus(String userId, String status) { UserAccount user = loadUser(userId); + rejectSystemAccountMutation(user); UserStatus nextStatus = parseManageableStatus(status); user.setStatus(nextStatus); userAccountRepository.save(user); @@ -164,4 +166,10 @@ public class AdminUserAppService { return userAccountRepository.findById(userId) .orElseThrow(() -> new DomainNotFoundException("error.admin.user.notFound", userId)); } + + private void rejectSystemAccountMutation(UserAccount user) { + if (user.isSystemAccount()) { + throw new DomainForbiddenException("error.admin.user.systemAccount.immutable"); + } + } } diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql index c525bfd2..bc3d5caa 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -16,4 +16,9 @@ WHERE id = 'builtin-skill-publisher' SELECT 1 FROM identity_binding WHERE identity_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM api_token + WHERE api_token.user_id = user_account.id ); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe..2301e0d5 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -134,6 +134,7 @@ error.deviceAuth.deviceCode.used=Device code has already been used error.admin.user.notFound=User not found: {0} error.admin.user.role.invalid=Invalid role: {0} error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role +error.admin.user.systemAccount.immutable=System accounts cannot be modified from user management error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef09563..a968cc77 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -134,6 +134,7 @@ error.deviceAuth.deviceCode.used=设备验证码已被使用 error.admin.user.notFound=用户不存在:{0} error.admin.user.role.invalid=无效的角色:{0} error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.systemAccount.immutable=系统账号不能在用户管理中修改 error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 7afa9cd9..1f8d938a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -35,18 +35,24 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.lang.reflect.Method; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Optional; import java.util.Set; -@ExtendWith(MockitoExtension.class) +@ExtendWith({MockitoExtension.class, OutputCaptureExtension.class}) class BuiltinSkillInitializerTest { private static final String GLOBAL = "global"; @@ -97,7 +103,7 @@ class BuiltinSkillInitializerTest { void skipsWhenDisabled() { properties.setEnabled(false); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(manifestLoader, never()).load(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); @@ -107,12 +113,26 @@ class BuiltinSkillInitializerTest { void skipsAllItemsWhenGlobalNamespaceDoesNotExist() { when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.empty()); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(manifestLoader, never()).load(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } + @Test + void synchronizesAfterApplicationReadyWithoutBlockingApplicationRunner() throws Exception { + assertThat(ApplicationRunner.class.isAssignableFrom(BuiltinSkillInitializer.class)).isFalse(); + + Method method = BuiltinSkillInitializer.class.getDeclaredMethod("synchronizeAfterApplicationReady"); + EventListener eventListener = method.getAnnotation(EventListener.class); + Async async = method.getAnnotation(Async.class); + + assertThat(eventListener).isNotNull(); + assertThat(eventListener.value()).containsExactly(ApplicationReadyEvent.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("skillhubEventExecutor"); + } + @Test void skipsSynchronizationWhenPublisherIdIsOccupiedByNonSystemAccount() { when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); @@ -120,7 +140,7 @@ class BuiltinSkillInitializerTest { when(userAccountRepository.findById(PUBLISHER)) .thenReturn(Optional.of(new UserAccount(PUBLISHER, "Human User", "human@example.com", null))); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(namespaceMemberRepository, never()).save(any()); verify(downloader, never()).download(any()); @@ -139,7 +159,7 @@ class BuiltinSkillInitializerTest { when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); @@ -157,7 +177,7 @@ class BuiltinSkillInitializerTest { when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); @@ -169,17 +189,34 @@ class BuiltinSkillInitializerTest { givenManifestAndSystemPublisher(); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(downloader, never()).download(any()); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } + @Test + void skipsMalformedUrlWithoutSynchronizationFailureLog(CapturedOutput output) { + ManifestItem malformed = new ManifestItem( + "skillhub-hello", + "1.0.0", + "https://bjcdn.openstorage.cn/skills/%zz.zip" + ); + givenManifestAndSystemPublisher(List.of(malformed)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + + runInitializer(); + + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + assertThat(output).doesNotContain("Failed to synchronize built-in skill slug=skillhub-hello"); + } + @Test void skipsWhenManifestSlugDoesNotMatchPackageMetadata() throws Exception { givenExtractedPackage(packageEntries("other-skill", "1.0.0", "same")); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @@ -188,7 +225,7 @@ class BuiltinSkillInitializerTest { void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { givenExtractedPackage(packageEntries("skillhub-hello", "1.0.1", "same")); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @@ -199,7 +236,7 @@ class BuiltinSkillInitializerTest { givenExtractedPackage(entries); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); ArgumentCaptor> entriesCaptor = ArgumentCaptor.captor(); verify(skillPublishService).publishFromEntries( @@ -221,7 +258,7 @@ class BuiltinSkillInitializerTest { when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); verify(userAccountRepository).save(userCaptor.capture()); @@ -251,14 +288,14 @@ class BuiltinSkillInitializerTest { when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillPublishService).publishFromEntries( eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); } @Test - void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted() throws Exception { + void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted(CapturedOutput output) throws Exception { Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "new-content")); @@ -274,11 +311,13 @@ class BuiltinSkillInitializerTest { new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("old-content"), "storage-key") )); - initializer.run(new DefaultApplicationArguments(new String[0])); + runInitializer(); verify(skillFileRepository).findByVersionId(200L); verify(skillPublishService).publishFromEntries( eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + assertThat(output).contains("Failed to publish built-in skill slug=skillhub-hello version=1.0.0"); + assertThat(output).doesNotContain("was published concurrently, skipping"); } private void givenExtractedPackage() throws Exception { @@ -297,13 +336,21 @@ class BuiltinSkillInitializerTest { } private void givenManifestAndSystemPublisher() { + givenManifestAndSystemPublisher(List.of(ITEM)); + } + + private void givenManifestAndSystemPublisher(List items) { when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); - when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(manifestLoader.load()).thenReturn(items); when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); } + private void runInitializer() { + initializer.synchronize(); + } + private static UserAccount systemPublisher() { return UserAccount.systemAccount(PUBLISHER, "Built-in Skill Publisher", null, null); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java index 39ea4b38..66bc128f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java @@ -17,8 +17,8 @@ class BuiltinSkillManifestLoaderTest { BuiltinSkillManifestLoader loader = loaderWith(""" { "skills": [ - {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/agentguard.zip"}, - {"slug": "agentguard", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/agentguard.zip"} + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/skillhub-hello.zip"}, + {"slug": "skillhub-hello", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/skillhub-hello.zip"} ] } """); @@ -69,8 +69,8 @@ class BuiltinSkillManifestLoaderTest { BuiltinSkillManifestLoader loader = loaderWith(""" { "skills": [ - {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, - {"slug": "agentguard", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, {"slug": "InvalidUppercase", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/invalid.zip"}, {"slug": "", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/blank.zip"}, {"slug": "missing-version", "url": "https://bjcdn.openstorage.cn/missing-version.zip"}, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java index 99f710b9..1df4203d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -24,10 +24,10 @@ class BuiltinSkillPackageExtractorTest { byte[] zip = zip( entry("SKILL.md", """ --- - name: agentguard + name: skillhub-hello version: 1.0.0 --- - # AgentGuard + # SkillHub Hello """), entry("README.md", "# Readme") ); @@ -50,12 +50,12 @@ class BuiltinSkillPackageExtractorTest { @Test void rejectsZipWithOnlyNestedSkillMd() throws Exception { - byte[] zip = zip(entry("agentguard/SKILL.md", """ + byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ --- - name: agentguard + name: skillhub-hello version: 1.0.0 --- - # AgentGuard + # SkillHub Hello """)); assertThatThrownBy(() -> extractor.extract(zip)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index ee23aee6..f2b87da5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -72,6 +72,14 @@ class FlywayMigrationGuardrailTest { assertThat(invalidFiles).isEmpty(); } + @Test + void systemAccountMigration_mustNotPromoteUsersWithApiTokens() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("FROM api_token"); + assertThat(migration).contains("api_token.user_id = user_account.id"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") @@ -92,4 +100,12 @@ class FlywayMigrationGuardrailTest { private String relativeToRepo(Path file) { return repoRoot().relativize(file).toString(); } + + private Path migrationPath(String fileName) { + return repoRoot() + .resolve("server") + .resolve("skillhub-app") + .resolve("src/main/resources/db/migration") + .resolve(fileName); + } } 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 4dd22e9c..7f2cfc22 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,18 @@ class AdminUserAppServiceTest { () -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN"))); } + @Test + void updateUserRole_rejectsSystemAccount() { + when(userAccountRepository.findById("builtin-skill-publisher")) + .thenReturn(Optional.of(systemUser())); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserRole("builtin-skill-publisher", "AUDITOR", Set.of("SUPER_ADMIN"))); + + verify(userRoleBindingRepository, never()).deleteByUserId(any()); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + } + @Test void updateUserRole_replacesExistingBindings() { when(userAccountRepository.findById("user-1")) @@ -137,6 +149,17 @@ class AdminUserAppServiceTest { assertThat(response.status()).isEqualTo("DISABLED"); } + @Test + void updateUserStatus_rejectsSystemAccount() { + when(userAccountRepository.findById("builtin-skill-publisher")) + .thenReturn(Optional.of(systemUser())); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserStatus("builtin-skill-publisher", "DISABLED")); + + verify(userAccountRepository, never()).save(any(UserAccount.class)); + } + @Test void updateUserStatus_withUnknownUser_throwsNotFound() { when(userAccountRepository.findById("missing")).thenReturn(Optional.empty()); @@ -152,6 +175,18 @@ class AdminUserAppServiceTest { return user; } + private UserAccount systemUser() { + UserAccount user = UserAccount.systemAccount( + "builtin-skill-publisher", + "Built-in Skill Publisher", + null, + null + ); + ReflectionTestUtils.setField(user, "createdAt", Instant.parse("2026-03-13T09:00:00Z")); + ReflectionTestUtils.setField(user, "updatedAt", Instant.parse("2026-03-13T09:00:00Z")); + return user; + } + private Role role(String code) { Role role = new Role(); ReflectionTestUtils.setField(role, "code", code); From 973c336c82ae66c507acc3019264bcd38392d608 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 8 Jun 2026 18:01:20 +0800 Subject: [PATCH 05/26] fix(auth): protect builtin system account boundaries Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 2 +- .../V43__user_account_system_account.sql | 10 +++++++ .../db/FlywayMigrationGuardrailTest.java | 10 +++++++ .../auth/local/PasswordResetService.java | 3 ++ .../auth/local/PasswordResetServiceTest.java | 28 +++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 7e5c5eb2..097a26d0 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -142,7 +142,7 @@ skillhub-hello-1.0.0.zip 3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。 5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。 -6. 确保系统发布者是 `@global` 的 `OWNER`。 +6. 如果系统发布者还不是 `@global` 成员,则创建 `OWNER` 成员记录;已有成员记录不会自动改角色。 7. 按 manifest 顺序处理每一个 item。 8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql index bc3d5caa..c96bb9fa 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -21,4 +21,14 @@ WHERE id = 'builtin-skill-publisher' SELECT 1 FROM api_token WHERE api_token.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM user_role_binding + WHERE user_role_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM namespace_member + WHERE namespace_member.user_id = user_account.id ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index f2b87da5..aad2480f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -80,6 +80,16 @@ class FlywayMigrationGuardrailTest { assertThat(migration).contains("api_token.user_id = user_account.id"); } + @Test + void systemAccountMigration_mustNotPromoteUsersWithRolesOrNamespaceMemberships() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("FROM user_role_binding"); + assertThat(migration).contains("user_role_binding.user_id = user_account.id"); + assertThat(migration).contains("FROM namespace_member"); + assertThat(migration).contains("namespace_member.user_id = user_account.id"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java index 60b52acc..74653ead 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -178,6 +178,9 @@ public class PasswordResetService { } private boolean isEligibleForReset(UserAccount user) { + if (user.isSystemAccount()) { + return false; + } if (user.getStatus() != UserStatus.ACTIVE) { return false; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java index aa78c1fb..251b7d54 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -4,6 +4,7 @@ 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.anyString; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.atLeastOnce; @@ -215,4 +216,31 @@ class PasswordResetServiceTest { .extracting("status") .isEqualTo(HttpStatus.BAD_REQUEST); } + + @Test + void adminTriggerPasswordReset_forSystemAccount_throwsBadRequest() { + UserAccount user = UserAccount.systemAccount( + "builtin-skill-publisher", + "Built-in Skill Publisher", + "builtin@example.com", + null + ); + given(userAccountRepository.findById("builtin-skill-publisher")).willReturn(Optional.of(user)); + lenient().when(credentialRepository.findByUserId("builtin-skill-publisher")).thenReturn( + Optional.of(new LocalCredential("builtin-skill-publisher", "builtin", "encoded")) + ); + lenient().when(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).thenReturn(List.of()); + lenient().when(passwordEncoder.encode(anyString())).thenReturn("encoded-value"); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("builtin-skill-publisher", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(credentialRepository, never()).findByUserId("builtin-skill-publisher"); + verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class)); + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } } From 973c37613ee8429a27ce8bd23c2fb394d3c8b203 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 9 Jun 2026 14:19:25 +0800 Subject: [PATCH 06/26] fix(bootstrap): harden builtin skill sync Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 6 +- .../bootstrap/BuiltinSkillInitializer.java | 20 ++-- .../BuiltinSkillRemotePackageDownloader.java | 74 ++++++++++-- .../V43__user_account_system_account.sql | 49 ++++++++ .../BuiltinSkillInitializerTest.java | 29 ++++- ...iltinSkillRemotePackageDownloaderTest.java | 106 +++++++++++++++++- .../db/FlywayMigrationGuardrailTest.java | 12 ++ 7 files changed, 273 insertions(+), 23 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 097a26d0..24bac647 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -172,9 +172,9 @@ skillhub-hello-1.0.0.zip | `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | | 同版本已存在且已发布 | 下载前跳过 | | 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | -| `@global/{slug}` 已存在,但 owner 不是 `builtin-skill-publisher` | 下载前跳过并记录 warning | +| `@global/{slug}` 已被其他 owner 发布 | 下载前跳过并记录 warning | -这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill。 +这意味着内置同步不会接管用户或管理员已经发布的同 slug Skill;仅有待审或未发布版本不会阻断内置同步。 同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 @@ -248,7 +248,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | | package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | -| slug is already owned by another user | 说明 `@global/{slug}` 已被非内置发布者占用,内置同步不会覆盖 | +| slug is already published by another user | 说明 `@global/{slug}` 已被非内置发布者发布,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | 如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index d6626087..5145c404 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -258,10 +258,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - boolean hasNonBuiltinOwner = existingSkills.stream() - .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); - if (hasNonBuiltinOwner) { - log.warn("Skipping built-in skill slug={} before download because the slug is already owned by another user", + if (hasPublishedOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} before download because the slug is already published by another user", item.slug()); return true; } @@ -292,10 +290,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - boolean hasNonBuiltinOwner = existingSkills.stream() - .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); - if (hasNonBuiltinOwner) { - log.warn("Skipping built-in skill slug={} because the slug is already owned by another user", + if (hasPublishedOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} because the slug is already published by another user", item.slug()); return true; } @@ -364,6 +360,14 @@ public class BuiltinSkillInitializer { return false; } + private boolean hasPublishedOtherOwnerConflict(List existingSkills) { + return existingSkills.stream() + .filter(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .anyMatch(skill -> !skillVersionRepository + .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty()); + } + private String computeFingerprint(SkillVersion version) { List files = skillFileRepository.findByVersionId(version.getId()).stream() .sorted(Comparator.comparing(SkillFile::getFilePath)) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java index c2819d3f..2e507f73 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java @@ -16,6 +16,12 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.Locale; import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; @Component @@ -30,6 +36,7 @@ public class BuiltinSkillRemotePackageDownloader { private final long maxPackageSize; private final HttpClient httpClient; + private final Duration requestTimeout; @Autowired public BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties) { @@ -38,13 +45,22 @@ public class BuiltinSkillRemotePackageDownloader { HttpClient.newBuilder() .connectTimeout(CONNECT_TIMEOUT) .followRedirects(HttpClient.Redirect.NEVER) - .build() + .build(), + REQUEST_TIMEOUT ); } BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties, HttpClient httpClient) { + this(properties, httpClient, REQUEST_TIMEOUT); + } + + BuiltinSkillRemotePackageDownloader( + SkillPublishProperties properties, + HttpClient httpClient, + Duration requestTimeout) { this.maxPackageSize = properties.getMaxPackageSize(); this.httpClient = httpClient; + this.requestTimeout = requestTimeout; } public Optional download(URI uri) { @@ -54,19 +70,19 @@ public class BuiltinSkillRemotePackageDownloader { } HttpRequest request = HttpRequest.newBuilder(uri) - .timeout(REQUEST_TIMEOUT) + .timeout(requestTimeout) .GET() .build(); try { HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - if (response.statusCode() != 200) { - log.warn("Failed to download built-in skill package from {}: HTTP {}", - safeUrl(uri), - response.statusCode()); - return Optional.empty(); - } try (InputStream body = response.body()) { - return readBounded(body); + if (response.statusCode() != 200) { + log.warn("Failed to download built-in skill package from {}: HTTP {}", + safeUrl(uri), + response.statusCode()); + return Optional.empty(); + } + return readBoundedWithTimeout(body, uri); } } catch (IOException ex) { log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); @@ -125,6 +141,46 @@ public class BuiltinSkillRemotePackageDownloader { return Optional.of(outputStream.toByteArray()); } + private Optional readBoundedWithTimeout(InputStream inputStream, URI uri) throws IOException { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Future> future = executor.submit(() -> readBounded(inputStream)); + try { + return future.get(Math.max(1, requestTimeout.toMillis()), TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + closeQuietly(inputStream); + future.cancel(true); + log.warn("Timed out while downloading built-in skill package body from {} after {}", + safeUrl(uri), + requestTimeout); + return Optional.empty(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + closeQuietly(inputStream); + future.cancel(true); + log.warn("Interrupted while reading built-in skill package body from {}", safeUrl(uri)); + return Optional.empty(); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof IOException ioException) { + throw ioException; + } + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Failed to read built-in skill package body", cause); + } finally { + executor.shutdownNow(); + } + } + + private static void closeQuietly(InputStream inputStream) { + try { + inputStream.close(); + } catch (IOException ignored) { + // Best-effort cleanup after timeout/interruption. + } + } + private static boolean isDisallowedHostLiteral(String host) { return "localhost".equals(host) || IPV4_LITERAL.matcher(host).matches() diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql index c96bb9fa..af7456ef 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -32,3 +32,52 @@ WHERE id = 'builtin-skill-publisher' FROM namespace_member WHERE namespace_member.user_id = user_account.id ); + +UPDATE user_account +SET system_account = TRUE, + display_name = 'Built-in Skill Publisher', + email = NULL, + avatar_url = NULL +WHERE id = 'builtin-skill-publisher' + AND display_name = 'SkillHub Built-in Publisher' + AND email = 'builtin-skill-publisher@example.invalid' + AND avatar_url IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM local_credential + WHERE local_credential.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_binding + WHERE identity_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM api_token + WHERE api_token.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM user_role_binding + WHERE user_role_binding.user_id = user_account.id + ) + AND EXISTS ( + SELECT 1 + FROM namespace_member legacy_member + JOIN namespace legacy_namespace ON legacy_namespace.id = legacy_member.namespace_id + WHERE legacy_member.user_id = user_account.id + AND legacy_namespace.slug = 'global' + AND legacy_member.role = 'OWNER' + ) + AND NOT EXISTS ( + SELECT 1 + FROM namespace_member bad_member + LEFT JOIN namespace bad_namespace ON bad_namespace.id = bad_member.namespace_id + WHERE bad_member.user_id = user_account.id + AND ( + bad_namespace.slug IS NULL + OR bad_namespace.slug <> 'global' + OR bad_member.role <> 'OWNER' + ) + ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 1f8d938a..01310147 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -184,10 +184,13 @@ class BuiltinSkillInitializerTest { } @Test - void skipsExistingSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { + void skipsPublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); givenManifestAndSystemPublisher(); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(published)); runInitializer(); @@ -195,6 +198,30 @@ class BuiltinSkillInitializerTest { verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } + @Test + void publishesWhenOnlyUnpublishedSkillOwnedByAnotherUserExists() throws Exception { + Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of(otherSkill)) + .thenReturn(List.of(otherSkill)); + lenient().when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of()); + + runInitializer(); + + verify(downloader).download(URI.create(ITEM.url())); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), + eq(entries), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false) + ); + } + @Test void skipsMalformedUrlWithoutSynchronizationFailureLog(CapturedOutput output) { ManifestItem malformed = new ManifestItem( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java index 9dbf2cdf..dc935f5d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java @@ -10,6 +10,7 @@ import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSession; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.net.Authenticator; import java.net.CookieHandler; import java.net.ProxySelector; @@ -22,6 +23,11 @@ import java.time.Duration; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; class BuiltinSkillRemotePackageDownloaderTest { @@ -81,6 +87,21 @@ class BuiltinSkillRemotePackageDownloaderTest { assertThat(client.sendCalls).isEqualTo(1); } + @Test + void closesNonSuccessResponseBody() { + CloseAwareInputStream body = new CloseAwareInputStream(new byte[] {1}); + FakeHttpClient client = new FakeHttpClient(500, body); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(body.closed()).isTrue(); + } + @Test void rejectedUrlDoesNotSendHttpRequest() { FakeHttpClient client = new FakeHttpClient(200, new byte[] {1}); @@ -105,14 +126,40 @@ class BuiltinSkillRemotePackageDownloaderTest { assertThat(downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))).isEmpty(); } + @Test + void returnsEmptyWhenResponseBodyStopsBeforeCompletion() throws Exception { + BlockingInputStream body = new BlockingInputStream(); + FakeHttpClient client = new FakeHttpClient(200, body); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client, + Duration.ofMillis(50) + ); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future> result = executor.submit( + () -> downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))); + + try { + assertThat(result.get(1, TimeUnit.SECONDS)).isEmpty(); + assertThat(body.closed()).isTrue(); + } finally { + body.close(); + executor.shutdownNow(); + } + } + static class FakeHttpClient extends HttpClient { private final int statusCode; - private final byte[] body; + private final InputStream body; private HttpRequest lastRequest; private int sendCalls; FakeHttpClient(int statusCode, byte[] body) { + this(statusCode, new ByteArrayInputStream(body)); + } + + FakeHttpClient(int statusCode, InputStream body) { this.statusCode = statusCode; this.body = body; } @@ -168,7 +215,7 @@ class BuiltinSkillRemotePackageDownloaderTest { lastRequest = request; sendCalls++; @SuppressWarnings("unchecked") - T responseBody = (T) new ByteArrayInputStream(body); + T responseBody = (T) body; return new FakeResponse<>(request, statusCode, responseBody); } @@ -190,6 +237,61 @@ class BuiltinSkillRemotePackageDownloaderTest { } } + static final class CloseAwareInputStream extends ByteArrayInputStream { + + private boolean closed; + + private CloseAwareInputStream(byte[] bytes) { + super(bytes); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + + boolean closed() { + return closed; + } + } + + static final class BlockingInputStream extends InputStream { + + private final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public int read() { + waitUntilClosed(); + return -1; + } + + @Override + public int read(byte[] bytes, int offset, int length) { + waitUntilClosed(); + return -1; + } + + @Override + public void close() { + closed.set(true); + } + + boolean closed() { + return closed.get(); + } + + private void waitUntilClosed() { + while (!closed.get()) { + try { + Thread.sleep(10); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + } + record FakeResponse(HttpRequest request, int statusCode, T body) implements HttpResponse { @Override public Optional> previousResponse() { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index aad2480f..21369016 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -90,6 +90,18 @@ class FlywayMigrationGuardrailTest { assertThat(migration).contains("namespace_member.user_id = user_account.id"); } + @Test + void systemAccountMigration_mustPromoteLegacyBuiltinPublisherSafely() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("SkillHub Built-in Publisher"); + assertThat(migration).contains("builtin-skill-publisher@example.invalid"); + assertThat(migration).contains("legacy_namespace.slug = 'global'"); + assertThat(migration).contains("legacy_member.role = 'OWNER'"); + assertThat(migration).contains("bad_member.user_id = user_account.id"); + assertThat(migration).contains("bad_namespace.slug <> 'global'"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") From dd3e511a91cf26573cbf39757808578143e1a12e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 10:31:40 +0800 Subject: [PATCH 07/26] fix(bootstrap): support skill directory archives Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 17 +++++++------- .../BuiltinSkillPackageExtractor.java | 22 +++---------------- .../BuiltinSkillPackageExtractorTest.java | 12 +++++----- 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 24bac647..316faa8d 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -83,7 +83,7 @@ manifest 文件格式如下: manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议: -- zip 根目录必须包含 `SKILL.md`。 +- zip 可以在根目录直接包含 `SKILL.md`,也可以包含一个单独的顶层 Skill 目录,并在该目录下包含 `SKILL.md`。 - `SKILL.md` frontmatter 中必须包含合法的 `name`、`description`、`version` 等元数据。 - `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`。 - `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`。 @@ -99,15 +99,16 @@ skillhub-hello-1.0.0.zip └── check.js ``` -不推荐的结构: +同样支持标准单目录 Skill 包: ```text skillhub-hello-1.0.0.zip └── skillhub-hello/ - └── SKILL.md + ├── SKILL.md + └── README.md ``` -原因是内置 Skill 同步要求根目录存在 `SKILL.md`,不会把嵌套目录中的 `SKILL.md` 当作入口。 +如果 zip 中存在多个顶层目录,或在多个目录中同时出现 `SKILL.md`,同步器会跳过该项并记录错误,避免误选入口。 ## 4. URL 安全限制 @@ -146,7 +147,7 @@ skillhub-hello-1.0.0.zip 7. 按 manifest 顺序处理每一个 item。 8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 -10. 解包并校验根目录 `SKILL.md`。 +10. 解包并校验 Skill 入口 `SKILL.md`。 11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。 13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 @@ -209,7 +210,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false 新增一个内置 Skill 的推荐步骤: -1. 准备 Skill 包,并确认 zip 根目录包含 `SKILL.md`。 +1. 准备 Skill 包,并确认 zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。 2. 检查 `SKILL.md` 中的 `name` 和 `version`。 3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。 4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。 @@ -246,7 +247,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | | URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | | package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | -| package must contain SKILL.md | 检查 zip 根目录是否存在 `SKILL.md` | +| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | | slug is already published by another user | 说明 `@global/{slug}` 已被非内置发布者发布,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | @@ -260,7 +261,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false - manifest JSON 格式合法。 - 每个 item 都包含 `slug`、`version`、`url`。 - URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。 -- zip 根目录包含 `SKILL.md`。 +- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。 - `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。 - `SKILL.md version` 与 manifest `version` 一致。 - 启动日志没有该 item 的 warning 或 error。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java index 9cd44cd6..d023190d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -8,8 +8,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @Component public class BuiltinSkillPackageExtractor { @@ -21,30 +19,16 @@ public class BuiltinSkillPackageExtractor { } public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { - assertRootSkillMd(zipBytes); SkillPackageArchiveExtractor.ExtractionResult result = archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); - boolean hasRootSkillMd = result.entries().stream() + boolean hasSkillMd = result.entries().stream() .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); - if (!hasRootSkillMd) { - throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); + if (!hasSkillMd) { + throw new IllegalArgumentException("Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH); } return result; } - private void assertRootSkillMd(byte[] zipBytes) throws IOException { - try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { - ZipEntry entry; - while ((entry = zipInputStream.getNextEntry()) != null) { - if (!entry.isDirectory() && SkillPackagePolicy.SKILL_MD_PATH.equals(entry.getName())) { - return; - } - zipInputStream.closeEntry(); - } - } - throw new IllegalArgumentException("Built-in skill package must contain root " + SkillPackagePolicy.SKILL_MD_PATH); - } - private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile { @Override diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java index 1df4203d..8836546f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -49,18 +49,20 @@ class BuiltinSkillPackageExtractorTest { } @Test - void rejectsZipWithOnlyNestedSkillMd() throws Exception { + void acceptsZipWithSingleTopLevelSkillDirectory() throws Exception { byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ --- name: skillhub-hello version: 1.0.0 --- # SkillHub Hello - """)); + """), entry("skillhub-hello/README.md", "# Readme")); - assertThatThrownBy(() -> extractor.extract(zip)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extract(zip); + + assertThat(result.entries()) + .extracting(entry -> entry.path()) + .containsExactly("SKILL.md", "README.md"); } private static ZipSource entry(String path, String content) { From 1b09ab88a2c03497bd451b66c00f341060d7b163 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 14:19:41 +0800 Subject: [PATCH 08/26] fix(bootstrap): enforce strict builtin skill skips Signed-off-by: dongmucat <1127093059@qq.com> --- docs/20-cloud-url-builtin-skills-setup.md | 6 ++--- .../bootstrap/BuiltinSkillInitializer.java | 15 +++++------ .../BuiltinSkillPackageExtractor.java | 4 +++ .../BuiltinSkillInitializerTest.java | 27 +++++-------------- .../BuiltinSkillPackageExtractorTest.java | 15 +++++++++++ 5 files changed, 34 insertions(+), 33 deletions(-) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md index 316faa8d..7de92d63 100644 --- a/docs/20-cloud-url-builtin-skills-setup.md +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -173,9 +173,9 @@ skillhub-hello-1.0.0.zip | `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | | 同版本已存在且已发布 | 下载前跳过 | | 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | -| `@global/{slug}` 已被其他 owner 发布 | 下载前跳过并记录 warning | +| `@global/{slug}` 已被其他 owner 创建或发布 | 下载前跳过并记录 warning | -这意味着内置同步不会接管用户或管理员已经发布的同 slug Skill;仅有待审或未发布版本不会阻断内置同步。 +这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill;即使该 Skill 仍处于待审、未发布或已拒绝状态,也会跳过对应 manifest item。 同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 @@ -249,7 +249,7 @@ SKILLHUB_BUILTIN_SKILLS_ENABLED=false | package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | | package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 | | manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | -| slug is already published by another user | 说明 `@global/{slug}` 已被非内置发布者发布,内置同步不会覆盖 | +| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 | | published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | 如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 5145c404..34379db7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -258,8 +258,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - if (hasPublishedOtherOwnerConflict(existingSkills)) { - log.warn("Skipping built-in skill slug={} before download because the slug is already published by another user", + if (hasOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user", item.slug()); return true; } @@ -290,8 +290,8 @@ public class BuiltinSkillInitializer { private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); - if (hasPublishedOtherOwnerConflict(existingSkills)) { - log.warn("Skipping built-in skill slug={} because the slug is already published by another user", + if (hasOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} because the slug already belongs to another user", item.slug()); return true; } @@ -360,12 +360,9 @@ public class BuiltinSkillInitializer { return false; } - private boolean hasPublishedOtherOwnerConflict(List existingSkills) { + private boolean hasOtherOwnerConflict(List existingSkills) { return existingSkills.stream() - .filter(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) - .anyMatch(skill -> !skillVersionRepository - .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED) - .isEmpty()); + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); } private String computeFingerprint(SkillVersion version) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java index d023190d..973dd5b2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -21,6 +21,10 @@ public class BuiltinSkillPackageExtractor { public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { SkillPackageArchiveExtractor.ExtractionResult result = archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); + if (!result.warnings().isEmpty()) { + throw new IllegalArgumentException("Built-in skill package has warnings: " + + String.join("; ", result.warnings())); + } boolean hasSkillMd = result.entries().stream() .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); if (!hasSkillMd) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 01310147..c2b5a836 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -184,13 +184,10 @@ class BuiltinSkillInitializerTest { } @Test - void skipsPublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() throws Exception { + void skipsSkillOwnedByAnotherUserBeforeDownloadingPackage() { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); - SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); givenManifestAndSystemPublisher(); when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); - when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(published)); runInitializer(); @@ -199,27 +196,15 @@ class BuiltinSkillInitializerTest { } @Test - void publishesWhenOnlyUnpublishedSkillOwnedByAnotherUserExists() throws Exception { + void skipsUnpublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); - List entries = packageEntries("skillhub-hello", "1.0.0", "same"); - givenExtractedPackage(entries); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) - .thenReturn(List.of(otherSkill)) - .thenReturn(List.of(otherSkill)); - lenient().when(skillVersionRepository.findBySkillIdAndStatus(100L, SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); + givenManifestAndSystemPublisher(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); runInitializer(); - verify(downloader).download(URI.create(ITEM.url())); - verify(skillPublishService).publishFromEntries( - eq(GLOBAL), - eq(entries), - eq(PUBLISHER), - eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")), - eq(false) - ); + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java index 8836546f..8f1a5397 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -65,6 +65,21 @@ class BuiltinSkillPackageExtractorTest { .containsExactly("SKILL.md", "README.md"); } + @Test + void rejectsZipWhenSkillDirectoryPromotionWouldIgnoreOutsideFiles() throws Exception { + byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ + --- + name: skillhub-hello + version: 1.0.0 + --- + # SkillHub Hello + """), entry("LICENSE", "Apache-2.0")); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Ignored file outside skill directory: LICENSE"); + } + private static ZipSource entry(String path, String content) { return new ZipSource(path, content.getBytes(StandardCharsets.UTF_8)); } From f35f91616a4e4f75de3aa14c0b56f980860423ff Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 16:51:35 +0800 Subject: [PATCH 09/26] fix(publish): preserve latest version reference cleanup order Signed-off-by: dongmucat <1127093059@qq.com> --- .../bootstrap/BuiltinSkillInitializer.java | 75 +++++++++++++------ .../BuiltinSkillInitializerTest.java | 10 ++- .../skill/service/SkillPublishService.java | 11 ++- .../service/SkillPublishServiceTest.java | 7 +- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 34379db7..20b9a363 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -126,10 +126,21 @@ public class BuiltinSkillInitializer { return; } + int published = 0; + int idempotentSkipped = 0; + int conflictSkipped = 0; + int failed = 0; for (ManifestItem item : items) { try { - syncItem(namespace.get(), item); + SyncOutcome outcome = syncItem(namespace.get(), item); + switch (outcome) { + case PUBLISHED -> published++; + case IDEMPOTENT_SKIPPED -> idempotentSkipped++; + case CONFLICT_SKIPPED -> conflictSkipped++; + case FAILED -> failed++; + } } catch (Exception exception) { + failed++; log.error( "Failed to synchronize built-in skill slug={} version={}: {}", item.slug(), @@ -139,6 +150,14 @@ public class BuiltinSkillInitializer { ); } } + log.info( + "Built-in skill synchronization finished: total={}, published={}, idempotentSkipped={}, conflictSkipped={}, failed={}", + items.size(), + published, + idempotentSkipped, + conflictSkipped, + failed + ); } private boolean ensureSystemPublisher(Namespace namespace) { @@ -171,21 +190,22 @@ public class BuiltinSkillInitializer { return true; } - private void syncItem(Namespace namespace, ManifestItem item) throws Exception { - if (shouldSkipBeforeDownload(namespace.getId(), item)) { - return; + private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exception { + Optional skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item); + if (skipBeforeDownload.isPresent()) { + return skipBeforeDownload.get(); } Optional packageUri = parsePackageUri(item); if (packageUri.isEmpty()) { - return; + return SyncOutcome.FAILED; } Optional packageBytes = downloader.download(packageUri.get()); if (packageBytes.isEmpty()) { log.warn("Skipping built-in skill slug={} version={} because package download failed", item.slug(), item.version()); - return; + return SyncOutcome.FAILED; } SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get()); @@ -199,7 +219,7 @@ public class BuiltinSkillInitializer { item.version(), packageSlug ); - return; + return SyncOutcome.FAILED; } if (!item.version().equals(metadata.version())) { log.warn( @@ -208,11 +228,12 @@ public class BuiltinSkillInitializer { item.version(), metadata.version() ); - return; + return SyncOutcome.FAILED; } - if (shouldSkipExisting(namespace.getId(), item, entries)) { - return; + Optional skipExisting = shouldSkipExisting(namespace.getId(), item, entries); + if (skipExisting.isPresent()) { + return skipExisting.get(); } try { @@ -226,14 +247,16 @@ public class BuiltinSkillInitializer { ); log.info("Published built-in skill slug={} version={} to @{}", item.slug(), item.version(), GLOBAL_NAMESPACE); + return SyncOutcome.PUBLISHED; } catch (RuntimeException exception) { if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) { log.info("Built-in skill slug={} version={} was published concurrently, skipping", item.slug(), item.version()); - return; + return SyncOutcome.IDEMPOTENT_SKIPPED; } log.error("Failed to publish built-in skill slug={} version={}: {}", item.slug(), item.version(), exception.getMessage(), exception); + return SyncOutcome.FAILED; } } @@ -256,25 +279,25 @@ public class BuiltinSkillInitializer { return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); } - private boolean shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { + private Optional shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); if (hasOtherOwnerConflict(existingSkills)) { log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user", item.slug()); - return true; + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); } Optional builtinSkill = existingSkills.stream() .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) .findFirst(); if (builtinSkill.isEmpty()) { - return false; + return Optional.empty(); } Optional existingVersion = skillVersionRepository .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); if (existingVersion.isEmpty()) { - return false; + return Optional.empty(); } SkillVersion version = existingVersion.get(); @@ -285,35 +308,35 @@ public class BuiltinSkillInitializer { log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}", item.slug(), item.version(), version.getStatus()); } - return true; + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); } - private boolean shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { + private Optional shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); if (hasOtherOwnerConflict(existingSkills)) { log.warn("Skipping built-in skill slug={} because the slug already belongs to another user", item.slug()); - return true; + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); } Optional builtinSkill = existingSkills.stream() .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) .findFirst(); if (builtinSkill.isEmpty()) { - return false; + return Optional.empty(); } Optional existingVersion = skillVersionRepository .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); if (existingVersion.isEmpty()) { - return false; + return Optional.empty(); } SkillVersion version = existingVersion.get(); if (version.getStatus() != SkillVersionStatus.PUBLISHED) { log.info("Skipping built-in skill slug={} version={} because existing version status is {}", item.slug(), item.version(), version.getStatus()); - return true; + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); } String packageFingerprint = computeFingerprint(entries); @@ -321,6 +344,7 @@ public class BuiltinSkillInitializer { if (packageFingerprint.equals(existingFingerprint)) { log.info("Skipping built-in skill slug={} version={} because it is already published", item.slug(), item.version()); + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); } else { log.warn( "Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}", @@ -329,8 +353,8 @@ public class BuiltinSkillInitializer { existingFingerprint, packageFingerprint ); + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); } - return true; } private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List entries) { @@ -404,4 +428,11 @@ public class BuiltinSkillInitializer { private record FileDigest(String path, String sha256) { } + + private enum SyncOutcome { + PUBLISHED, + IDEMPOTENT_SKIPPED, + CONFLICT_SKIPPED, + FAILED + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index c2b5a836..296f547a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -196,14 +196,16 @@ class BuiltinSkillInitializerTest { } @Test - void skipsUnpublishedSkillOwnedByAnotherUserBeforeDownloadingPackage() { + void skipsSkillOwnedByAnotherUserAfterDownloadingPackage() throws Exception { Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); - givenManifestAndSystemPublisher(); - when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "same")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) + .thenReturn(List.of(otherSkill)); runInitializer(); - verify(downloader, never()).download(any()); + verify(downloader).download(URI.create(ITEM.url())); verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); } 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 fed90cfc..698a0fdd 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 @@ -564,6 +564,13 @@ public class SkillPublishService { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } + // PostgreSQL prevents deleting a skill_version while skill.latest_version_id still references it. + if (version.getId().equals(skill.getLatestVersionId())) { + skill.setLatestVersionId(null); + skillRepository.save(skill); + skillRepository.flush(); + } + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); @@ -579,10 +586,6 @@ public class SkillPublishService { securityScanService.softDeleteByVersionId(version.getId()); skillVersionRepository.delete(version); skillVersionRepository.flush(); - - if (version.getId().equals(skill.getLatestVersionId())) { - skill.setLatestVersionId(null); - } } private String resolveNamespaceSlug(Long namespaceId) { 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 77264b23..e40fa545 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 @@ -455,6 +455,7 @@ class SkillPublishServiceTest { Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setLatestVersionId(8L); SkillVersion draftVersion = new SkillVersion(1L, "1.0.0-beta", publisherId); draftVersion.setStatus(SkillVersionStatus.DRAFT); setId(draftVersion, 8L); @@ -485,10 +486,14 @@ class SkillPublishServiceTest { Set.of() ); - InOrder inOrder = inOrder(skillVersionRepository); + assertNull(skill.getLatestVersionId()); + InOrder inOrder = inOrder(skillRepository, skillVersionRepository); + inOrder.verify(skillRepository).save(skill); + inOrder.verify(skillRepository).flush(); inOrder.verify(skillVersionRepository).delete(draftVersion); inOrder.verify(skillVersionRepository).flush(); inOrder.verify(skillVersionRepository, times(2)).save(any(SkillVersion.class)); + inOrder.verify(skillRepository).save(skill); } @Test From 738e8f8ceeeda61988541339738d538f47e6cbf9 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 10 Jun 2026 18:00:10 +0800 Subject: [PATCH 10/26] fix(web): stabilize frontend validation Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/pages/dashboard/my-skills.tsx | 8 ++++---- web/vite.config.ts | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 0d90f928..4dbe4765 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -64,20 +64,20 @@ export function MySkillsPage() { const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ to: '/dashboard/skills', search: (prev) => ({ ...prev, ...next }), replace: options?.replace, }) - } + }, [navigate]) // Push the debounced keyword to the URL (reset page to 0 when search changes) useEffect(() => { if (debouncedKeyword !== keyword) { updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) } - }, [debouncedKeyword]) + }, [debouncedKeyword, keyword, updateSearch]) // Sync keywordInput when navigating back via returnTo useEffect(() => { diff --git a/web/vite.config.ts b/web/vite.config.ts index 631fd1c0..f49b7759 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -22,6 +22,8 @@ export default defineConfig({ }, test: { exclude: ['**/node_modules/**', '**/e2e/**'], + testTimeout: 30000, + hookTimeout: 30000, }, server: { port: 3000, From 8045e52f5ed03896f9ea4f33bea54a316c32e73f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 11 Jun 2026 11:20:27 +0800 Subject: [PATCH 11/26] feat(bootstrap): add skillhub hello builtin skill Signed-off-by: dongmucat <1127093059@qq.com> --- .../src/main/resources/builtin-skills/manifest.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json index 2b485e41..8dba7bac 100644 --- a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -1,3 +1,9 @@ { - "skills": [] + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip" + } + ] } From a88b09e51b52059b86e3bf5c19f9369e82efcaa0 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:34:13 +0800 Subject: [PATCH 12/26] fix(publish): accept case-insensitive SKILL.md uploads --- docs/07-skill-protocol.md | 2 +- .../support/MultipartPackageExtractor.java | 3 +- .../support/ZipPackageExtractor.java | 3 +- .../MultipartPackageExtractorTest.java | 35 ++++++++++++++ .../SkillPackageArchiveExtractorTest.java | 32 +++++++++++++ .../support/ZipPackageExtractorTest.java | 47 +++++++++++++++++++ .../skill/validation/SkillPackagePolicy.java | 14 +++++- .../validation/SkillPackageValidatorTest.java | 29 ++++++++++++ web/src/docs/skill.md | 3 +- 9 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 6e9db3cc..4ee36a08 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -66,7 +66,7 @@ my-skill/ ``` 校验规则: -- 根目录必须包含 `SKILL.md` +- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md`、`Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md` - 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` - 单文件大小限制:1MB(可配置) - 总包大小限制:10MB(可配置) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 0a9fc793..e1a0a57c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -110,7 +111,7 @@ public class MultipartPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return path; + return SkillPackagePolicy.canonicalizeSkillMdPath(path); } private String determineContentType(String filename) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec70..a23e8cc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -112,7 +113,7 @@ public class ZipPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return normalizedPath; + return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); } catch (InvalidPathException ex) { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Invalid package path: " + path); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java new file mode 100644 index 00000000..f3be9120 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MultipartPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + MultipartPackageExtractor extractor = new MultipartPackageExtractor( + new SkillPublishProperties(), + new ObjectMapper() + ); + MockMultipartFile skillMd = new MockMultipartFile( + "files", + "skill.md", + "text/markdown", + "---\nname: test\n---\n".getBytes() + ); + + MultipartPackageExtractor.ExtractedPackage extracted = extractor.extract( + new MockMultipartFile[] {skillMd}, + "{\"namespace\":\"global\",\"slug\":\"test\"}" + ); + + assertEquals(1, extracted.entries().size()); + assertTrue(extracted.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(extracted.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index ba5cc2f7..c849fc4c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -85,6 +85,21 @@ class SkillPackageArchiveExtractorTest { assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); } + @Test + void canonicalizesCaseInsensitiveSkillMdAtRoot() throws Exception { + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + assertTrue(result.warnings().isEmpty()); + } + @Test void doesNotStripWhenMultipleRootEntries() throws Exception { byte[] zipBytes = createZip(Map.of( @@ -144,6 +159,23 @@ class SkillPackageArchiveExtractorTest { assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); } + @Test + void promotesCaseInsensitiveSkillMdFromSubdirectory() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/skill.md", "---\nname: test\n---\n".getBytes(), + "my-skill/README.md", "# readme".getBytes(), + "other.txt", "stray file".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("README.md"))); + assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); + } + @Test void rejectsAmbiguousMultipleSkillMdInSubdirectories() throws Exception { byte[] zipBytes = createZip(Map.of( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java new file mode 100644 index 00000000..02f26214 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZipPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties()); + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + List entries = extractor.extract(file); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md"))); + } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index eab8d2e2..ee3880ae 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -64,7 +64,19 @@ public final class SkillPackagePolicy { throw new IllegalArgumentException("Package entry path must be normalized: " + rawPath); } - return canonical; + return canonicalizeSkillMdPath(canonical); + } + + public static String canonicalizeSkillMdPath(String normalizedPath) { + int slashIndex = normalizedPath.lastIndexOf('/'); + String fileName = slashIndex >= 0 ? normalizedPath.substring(slashIndex + 1) : normalizedPath; + if (!SKILL_MD_PATH.equalsIgnoreCase(fileName)) { + return normalizedPath; + } + if (slashIndex < 0) { + return SKILL_MD_PATH; + } + return normalizedPath.substring(0, slashIndex + 1) + SKILL_MD_PATH; } public static boolean hasAllowedExtension(String path) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index d473a361..c2b6b63c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -40,6 +40,35 @@ class SkillPackageValidatorTest { assertTrue(result.errors().isEmpty()); } + @Test + void normalizesSkillMdFilenameCase() { + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("skill.md")); + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("Skill.MD")); + assertEquals("nested/SKILL.md", SkillPackagePolicy.normalizeEntryPath("nested/skill.md")); + } + + @Test + void acceptsSkillMdFilenameWithDifferentCase() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + """; + + List entries = List.of( + new PackageEntry("skill.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("README.md", "readme".getBytes(), 6, "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertTrue(result.passed()); + assertTrue(result.errors().isEmpty()); + } + @Test void testMissingSkillMd() { List entries = List.of( diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88..b8ed8bcf 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -138,7 +138,8 @@ If a request fails with `403`, check: ## Skill Package Contract -SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. +SkillHub expects OpenSkills-style packages with canonical `SKILL.md` as the entry point. Uploads +accept filename case variants such as `skill.md` and normalize them to `SKILL.md`. ## Publishing Guidance From 40d7de846289b61a31c305914760329402a04b1d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 11 Jun 2026 18:42:31 +0800 Subject: [PATCH 13/26] fix(security): harden review findings Signed-off-by: dongmucat <1127093059@qq.com> --- .env.release.draft | 3 + .env.release.example | 3 + .github/workflows/pr-scripts.yml | 8 +- .github/workflows/security.yml | 83 +++++++++ Makefile | 7 +- cli/src/platform/archive.ts | 84 ++++++++- cli/src/platform/download.ts | 56 ++++++ cli/src/services/install-service.ts | 3 +- cli/test/unit/platform/archive.test.ts | 28 +++ .../unit/services/install-service.test.ts | 26 ++- compose.release.yml | 3 + docker-compose.staging.yml | 6 + scanner/docs/configuration.md | 22 +-- scripts/smoke-test.sh | 13 +- scripts/tests/dev-web-host-test.sh | 25 +++ scripts/tests/validate-release-config-test.sh | 83 +++++++++ scripts/tests/workflow-security-test.sh | 33 ++++ scripts/validate-release-config.sh | 16 ++ .../config/DownloadRateLimitProperties.java | 2 +- .../config/SkillScannerProperties.java | 2 +- .../portal/NamespaceController.java | 9 +- .../AnonymousDownloadIdentityService.java | 23 +++ .../NamespacePortalQueryAppService.java | 13 +- .../src/main/resources/application-local.yml | 7 +- .../src/main/resources/application.yml | 4 +- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../DownloadRateLimitPropertiesTest.java | 16 ++ .../SkillScannerPropertiesBindingTest.java | 18 +- .../controller/DeviceAuthControllerTest.java | 58 ++++++ .../controller/LocalAuthControllerTest.java | 56 ++++++ .../NamespacePortalControllerTest.java | 35 ++++ .../NamespaceWorkflowContractTest.java | 2 +- .../controller/SkillStarControllerTest.java | 5 +- .../controller/cli/CliDryRunValidateTest.java | 4 + .../cli/CliSkillControllerTest.java | 1 + .../SkillSubscriptionControllerTest.java | 6 +- .../AnonymousDownloadIdentityServiceTest.java | 20 +++ .../NamespacePortalQueryAppServiceTest.java | 18 +- .../src/test/resources/application-test.yml | 3 + .../skillhub/auth/config/SecurityConfig.java | 20 ++- .../policy/RouteSecurityPolicyRegistry.java | 14 +- .../auth/config/SecurityConfigTest.java | 43 +++++ .../RouteSecurityPolicyRegistryTest.java | 14 +- .../skill/service/SkillPublishService.java | 10 ++ .../service/SkillPublishServiceTest.java | 166 +++++++++++++++++- web/e2e/helpers/csrf.ts | 27 +++ web/e2e/helpers/session.ts | 20 ++- web/e2e/helpers/test-data-builder.ts | 22 ++- web/e2e/public-skill-detail-anonymous.spec.ts | 2 + web/e2e/skill-subscription.spec.ts | 2 + web/e2e/skill-version-compare.spec.ts | 4 + web/src/api/generated/schema.d.ts | 4 + 53 files changed, 1094 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 cli/src/platform/download.ts create mode 100755 scripts/tests/dev-web-host-test.sh create mode 100755 scripts/tests/validate-release-config-test.sh create mode 100755 scripts/tests/workflow-security-test.sh create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java create mode 100644 web/e2e/helpers/csrf.ts diff --git a/.env.release.draft b/.env.release.draft index 40058266..c8f0872b 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub + +# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes diff --git a/.env.release.example b/.env.release.example index cdbddf06..40e51ac1 100644 --- a/.env.release.example +++ b/.env.release.example @@ -104,6 +104,9 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub # Security scanner is enabled by default. Set to false to disable scanning. SKILLHUB_SECURITY_SCANNER_ENABLED=true +# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes + # Scanner LLM configuration (optional, for AI-powered scanning features) SKILL_SCANNER_LLM_API_KEY= SKILL_SCANNER_LLM_BASE_URL= diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index eb7809d1..2f8cc80d 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -4,10 +4,13 @@ on: pull_request: paths: - 'scripts/**' + - 'Makefile' + - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' jobs: - publish-cli-test: + scripts-tests: + name: Script Regression Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,3 +20,6 @@ jobs: with: node-version: '21' - run: bash scripts/tests/publish-cli-test.sh + - run: bash scripts/tests/validate-release-config-test.sh + - run: bash scripts/tests/dev-web-host-test.sh + - run: bash scripts/tests/workflow-security-test.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..9b476ca9 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,83 @@ +name: Security + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency Review + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + + codeql: + name: CodeQL (${{ matrix.language }}) + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: manual + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Ensure Maven wrapper is executable + if: matrix.language == 'java-kotlin' + run: chmod +x server/mvnw + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Build Java for CodeQL + if: matrix.language == 'java-kotlin' + run: cd server && ./mvnw -q -DskipTests package + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/Makefile b/Makefile index 039f213d..bf3e7439 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid DEV_SERVER_LOG := $(DEV_DIR)/server.log DEV_WEB_LOG := $(DEV_DIR)/web.log DEV_WEB_URL := http://localhost:3000 +DEV_WEB_HOST ?= 127.0.0.1 DEV_API_URL := http://localhost:8080 DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 @@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \ fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ @@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' + cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' dev-server-restart: ## 重启后端开发服务器 @mkdir -p $(DEV_DIR) @@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖 cd web && CI=true pnpm install --frozen-lockfile dev-web: ## 启动前端开发服务器 - cd web && pnpm run dev + cd web && pnpm exec vite --host $(DEV_WEB_HOST) build-frontend: web-deps ## 构建前端 cd web && pnpm run build diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index 1e420567..c1306711 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -1,6 +1,14 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { zipSync, unzipSync } from 'fflate' +import { MAX_PACKAGE_BYTES } from './download' + +const MAX_ZIP_ENTRIES = 500 +const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024 +const EOCD_SIGNATURE = 0x06054b50 +const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50 +const ZIP64_MARKER_16 = 0xffff +const ZIP64_MARKER_32 = 0xffffffff /** * Extract a zip archive buffer into the target directory. @@ -8,7 +16,9 @@ import { zipSync, unzipSync } from 'fflate' */ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise { await mkdir(targetDir, { recursive: true }) - const files = unzipSync(new Uint8Array(buffer)) + const archive = new Uint8Array(buffer) + validateZipCentralDirectory(archive) + const files = unzipSync(archive) for (const [name, data] of Object.entries(files)) { const filePath = safeJoin(targetDir, name) if (name.endsWith('/')) { @@ -20,6 +30,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis } } +function validateZipCentralDirectory(archive: Uint8Array): void { + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + const eocdOffset = findEndOfCentralDirectory(view) + if (eocdOffset < 0) { + throw new Error('invalid zip central directory') + } + + const diskNumber = view.getUint16(eocdOffset + 4, true) + const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true) + const entriesOnDisk = view.getUint16(eocdOffset + 8, true) + const totalEntries = view.getUint16(eocdOffset + 10, true) + const centralDirectorySize = view.getUint32(eocdOffset + 12, true) + const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true) + + if ( + entriesOnDisk === ZIP64_MARKER_16 || + totalEntries === ZIP64_MARKER_16 || + centralDirectorySize === ZIP64_MARKER_32 || + centralDirectoryOffset === ZIP64_MARKER_32 + ) { + throw new Error('zip64 archives are not supported') + } + if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) { + throw new Error('multi-disk zip archives are not supported') + } + if (totalEntries > MAX_ZIP_ENTRIES) { + throw new Error('zip entry count exceeds limit') + } + if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) { + throw new Error('invalid zip central directory') + } + + let offset = centralDirectoryOffset + let totalUncompressedSize = 0 + const decoder = new TextDecoder() + for (let i = 0; i < totalEntries; i++) { + if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) { + throw new Error('invalid zip central directory') + } + const uncompressedSize = view.getUint32(offset + 24, true) + const nameLength = view.getUint16(offset + 28, true) + const extraLength = view.getUint16(offset + 30, true) + const commentLength = view.getUint16(offset + 32, true) + const nameStart = offset + 46 + const nameEnd = nameStart + nameLength + const nextOffset = nameEnd + extraLength + commentLength + if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) { + throw new Error('invalid zip central directory') + } + + const entryName = decoder.decode(archive.subarray(nameStart, nameEnd)) + if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) { + throw new Error('zip entry size exceeds limit') + } + totalUncompressedSize += uncompressedSize + if (totalUncompressedSize > MAX_PACKAGE_BYTES) { + throw new Error('zip total uncompressed size exceeds limit') + } + offset = nextOffset + } +} + +function findEndOfCentralDirectory(view: DataView): number { + const minOffset = Math.max(0, view.byteLength - 0xffff - 22) + for (let offset = view.byteLength - 22; offset >= minOffset; offset--) { + if (view.getUint32(offset, true) === EOCD_SIGNATURE) { + return offset + } + } + return -1 +} + /** * Create a zip archive from a directory. * Returns the archive as a Blob. diff --git a/cli/src/platform/download.ts b/cli/src/platform/download.ts new file mode 100644 index 00000000..30490731 --- /dev/null +++ b/cli/src/platform/download.ts @@ -0,0 +1,56 @@ +import { EXIT } from '../shared/constants' +import { CliError } from '../shared/errors' + +export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024 + +export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise { + const contentLength = response.headers.get('content-length') + if (contentLength !== null) { + const declaredLength = Number(contentLength) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new CliError('download exceeds maximum package size', EXIT.network, { + contentLength: declaredLength, + maxBytes + }) + } + } + + if (!response.body) { + const buffer = await response.arrayBuffer() + if (buffer.byteLength > maxBytes) { + throw new CliError('download exceeds maximum package size', EXIT.network, { + receivedBytes: buffer.byteLength, + maxBytes + }) + } + return buffer + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let receivedBytes = 0 + + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + receivedBytes += value.byteLength + if (receivedBytes > maxBytes) { + await reader.cancel() + throw new CliError('download exceeds maximum package size', EXIT.network, { + receivedBytes, + maxBytes + }) + } + chunks.push(value) + } + + const result = new Uint8Array(receivedBytes) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result.buffer +} diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index b989ce58..ed09ffd6 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -5,6 +5,7 @@ import { InventoryStore } from '../stores/inventory-store' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { extractZip } from '../platform/archive' +import { readBoundedResponseBody } from '../platform/download' import { pathExists } from '../platform/paths' import type { AgentCandidate } from '../agents/types' @@ -23,7 +24,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed const client = new SkillHubClient(options.registry, options.token) const resolved = await client.resolve(options.namespace, options.slug, options.version) const response = await client.download(options.namespace, options.slug, resolved.version) - const buffer = await response.arrayBuffer() + const buffer = await readBoundedResponseBody(response) const installed: Array<{ agent: string; dir: string }> = [] const store = new InventoryStore(options.home) diff --git a/cli/test/unit/platform/archive.test.ts b/cli/test/unit/platform/archive.test.ts index a39c3c03..98972134 100644 --- a/cli/test/unit/platform/archive.test.ts +++ b/cli/test/unit/platform/archive.test.ts @@ -56,4 +56,32 @@ describe('archive helpers', () => { const entries = await readdir(target) expect(entries).toEqual([]) }) + + test('rejects zip archives with more than 500 entries before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-')) + const manyEntries = Object.fromEntries( + Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)]) + ) + const archive = zipSync(manyEntries) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit') + }) + + test('rejects zip entries larger than 10 MiB before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-')) + const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) }) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit') + }) + + test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-')) + const oneMiB = new Uint8Array(1024 * 1024) + const entries = Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB]) + ) + const archive = zipSync(entries) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit') + }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 2ea2735d..784f4394 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -21,6 +21,13 @@ function installFetch(zipEntries: Record): typeof fetch { Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)]) )) + return installFetchWithDownloadResponse(new Response( + archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, + { status: 200 } + )) +} + +function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch { const fakeFetch = async (input: URL | RequestInfo) => { const path = new URL(String(input)).pathname if (path.endsWith('/resolve')) { @@ -37,8 +44,7 @@ function installFetch(zipEntries: Record): typeof fetch { }) } if (path.endsWith('/download')) { - const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer - return new Response(body, { status: 200 }) + return downloadResponse.clone() } return Response.json({ code: 404 }, { status: 404 }) } @@ -125,4 +131,20 @@ describe('installSkill', () => { expect(inventory.items[0].targets).toHaveLength(1) expect(inventory.items[0].targets[0].installDir).toBe(skillDir) }) + + test('rejects downloads whose content-length exceeds the package limit', async () => { + globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), { + status: 200, + headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) } + })) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false + })).rejects.toThrow('download exceeds maximum package size') + }) }) diff --git a/compose.release.yml b/compose.release.yml index 1b878731..5ed7086e 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -58,6 +58,7 @@ services: SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required} SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3} STORAGE_BASE_PATH: /var/lib/skillhub/storage SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-} @@ -99,6 +100,8 @@ services: condition: service_healthy redis: condition: service_healthy + skill-scanner: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 10s diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index a6c5e2ff..48d1fd1a 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -42,11 +42,17 @@ services: BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local OAUTH2_GITHUB_CLIENT_ID: local-placeholder OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes + SKILLHUB_SECURITY_SCANNER_ENABLED: "true" + SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000 + SKILLHUB_SECURITY_SCANNER_MODE: upload depends_on: postgres: condition: service_healthy redis: condition: service_healthy + skill-scanner: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 10s diff --git a/scanner/docs/configuration.md b/scanner/docs/configuration.md index 1de8606a..2544aed2 100644 --- a/scanner/docs/configuration.md +++ b/scanner/docs/configuration.md @@ -18,7 +18,7 @@ skillhub: security: scanner: # 基础配置 - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local} @@ -48,20 +48,20 @@ skillhub: #### `enabled` - **类型**:Boolean -- **默认值**:`false` +- **默认值**:`true` - **环境变量**:`SKILLHUB_SECURITY_SCANNER_ENABLED` - **说明**:是否启用安全扫描功能 - **影响**: - `true`:技能包发布时会触发安全扫描 - - `false`:跳过安全扫描,直接进入审核流程 + - `false`:仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner **示例**: ```yaml -# 开发环境:禁用扫描 +# 仅本地私有技能调试:禁用扫描 enabled: false -# 生产环境:启用扫描 +# 公共或命名空间可见发布:启用扫描 enabled: true ``` @@ -103,8 +103,8 @@ base-url: https://scanner.example.com **示例**: ```yaml -# Docker Compose 环境(共享卷) -mode: local +# Docker Compose 环境(Scanner 独立容器,无共享发布目录) +mode: upload # Kubernetes 环境(独立 Pod) mode: upload @@ -298,7 +298,7 @@ services: environment: - SKILLHUB_SECURITY_SCANNER_ENABLED=true - SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000 - - SKILLHUB_SECURITY_SCANNER_MODE=local + - SKILLHUB_SECURITY_SCANNER_MODE=upload - SKILLHUB_SCANNER_USE_BEHAVIORAL=false - SKILLHUB_SCANNER_USE_LLM=false - SKILLHUB_SCANNER_USE_META=true @@ -348,9 +348,9 @@ stringData: skillhub: security: scanner: - enabled: false # 开发时禁用扫描,加快迭代速度 + enabled: true # 默认启用;PUBLIC/NAMESPACE_ONLY 发布依赖扫描 base-url: http://localhost:8000 - mode: local + mode: upload analyzers: meta: true # 只启用元数据分析 policy: @@ -366,7 +366,7 @@ skillhub: scanner: enabled: true # 测试环境启用扫描 base-url: http://skill-scanner:8000 - mode: local + mode: upload analyzers: behavioral: true meta: true diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 35389ece..e1e46289 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -36,8 +36,8 @@ echo "Target: $BASE_URL" echo check "Health endpoint" "$BASE_URL/actuator/health" "200" -check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" -check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" +check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401" +check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null @@ -67,6 +67,15 @@ else FAIL=$((FAIL + 1)) fi +NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)" +if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then + echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)" + FAIL=$((FAIL + 1)) +fi + CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ -X POST "$BASE_URL/api/v1/auth/local/change-password" \ -b "$COOKIE_JAR" \ diff --git a/scripts/tests/dev-web-host-test.sh b/scripts/tests/dev-web-host-test.sh new file mode 100755 index 00000000..10c22a07 --- /dev/null +++ b/scripts/tests/dev-web-host-test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MAKEFILE="$REPO_ROOT/Makefile" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \ + || fail "Makefile must default DEV_WEB_HOST to 127.0.0.1" + +grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \ + || fail "dev web startup must pass DEV_WEB_HOST to vite" + +grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \ + || fail "dev-server must inject scanner upload environment" + +if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then + fail "dev web startup must not bind Vite to 0.0.0.0 by default" +fi + +echo "dev-web-host-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh new file mode 100755 index 00000000..3a52d7a3 --- /dev/null +++ b/scripts/tests/validate-release-config-test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh" + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_env() { + local file="$1" + local secret="${2:-}" + local include_secret="${3:-yes}" + cat >"$file" <>"$file" + fi +} + +expect_fail() { + local file="$1" + local expected="$2" + local output + if output="$("$SCRIPT" "$file" 2>&1)"; then + fail "expected validation to fail for $file" + fi + if [[ "$output" != *"$expected"* ]]; then + fail "expected output to contain '$expected', got: $output" + fi +} + +tmp="$(new_tmp)" + +valid_env="$tmp/valid.env" +write_env "$valid_env" "release-download-secret-32-bytes-minimum" +"$SCRIPT" "$valid_env" >/dev/null + +missing_env="$tmp/missing.env" +write_env "$missing_env" "" no +expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required" + +placeholder_env="$tmp/placeholder.env" +write_env "$placeholder_env" "change-me-in-production" +expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value" + +short_env="$tmp/short.env" +write_env "$short_env" "too-short" +expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" + +echo "validate-release-config-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh new file mode 100755 index 00000000..8219af51 --- /dev/null +++ b/scripts/tests/workflow-security-test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml" +PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" + +grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ + || fail "security workflow must run dependency review" +grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \ + || fail "security workflow must initialize CodeQL" +grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \ + || fail "security workflow must build Java with the server Maven wrapper" +grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ + || fail "security workflow must grant SARIF upload permission" + +grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when security workflow changes" +grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run dev-web-host-test" +grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run workflow-security-test" + +echo "workflow-security-test passed" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 1d4f9285..758b168f 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -97,10 +97,26 @@ validate_port() { esac } +validate_min_length() { + var_name="$1" + min_length="$2" + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + if [ "${#var_value}" -lt "$min_length" ]; then + error "$var_name must be at least $min_length characters" + fi +} + require_non_empty SKILLHUB_PUBLIC_BASE_URL validate_url SKILLHUB_PUBLIC_BASE_URL validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL +require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET +reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes" +validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32 + reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java index 3d922e90..2b1930fa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java @@ -10,7 +10,7 @@ public class DownloadRateLimitProperties { private String anonymousCookieName = "skillhub_anon_dl"; private Duration anonymousCookieMaxAge = Duration.ofDays(30); - private String anonymousCookieSecret = "change-me-in-production"; + private String anonymousCookieSecret; public String getAnonymousCookieName() { return anonymousCookieName; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java index 7ab3b55a..bb1f0f87 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "skillhub.security.scanner") public class SkillScannerProperties { - private boolean enabled = false; + private boolean enabled = true; private String baseUrl = "http://localhost:8000"; private String healthPath = "/health"; private String scanPath = "/scan-upload"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 69be5fa3..c0c34567 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import java.util.Set; /** * Namespace portal endpoints for discovery, membership management, and @@ -155,9 +156,13 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces/{slug}/members") public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @AuthenticationPrincipal PlatformPrincipal principal) { + Set platformRoles = principal != null && principal.platformRoles() != null + ? principal.platformRoles() + : Set.of(); return ok("response.success.read", - namespacePortalQueryAppService.listMembers(slug, pageable, userId)); + namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } @GetMapping("/namespaces/{slug}/member-candidates") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java index f6ff6dff..a166c666 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.ratelimit; import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -11,6 +12,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.Arrays; import java.util.Base64; +import java.util.Set; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.springframework.http.ResponseCookie; @@ -24,6 +26,12 @@ import org.springframework.stereotype.Component; public class AnonymousDownloadIdentityService { private static final String COOKIE_VERSION = "v1"; + private static final int MIN_SECRET_LENGTH = 32; + private static final Set DISALLOWED_SECRET_VALUES = Set.of( + "change-me-in-production", + "replace-me", + "replace-with-random-download-secret-32-bytes" + ); private static final SecureRandom RANDOM = new SecureRandom(); private final DownloadRateLimitProperties properties; @@ -35,6 +43,21 @@ public class AnonymousDownloadIdentityService { this.clientIpResolver = clientIpResolver; } + @PostConstruct + void validateAnonymousCookieSecret() { + String secret = properties.getAnonymousCookieSecret(); + if (secret == null || secret.isBlank()) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"); + } + String trimmedSecret = secret.trim(); + if (DISALLOWED_SECRET_VALUES.contains(trimmedSecret)) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder"); + } + if (trimmedSecret.length() < MIN_SECRET_LENGTH) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"); + } + } + public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) { String ip = clientIpResolver.resolve(request); String cookieId = extractValidCookieId(request); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 82b38610..e8df0ad9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -18,6 +19,7 @@ import com.iflytek.skillhub.dto.PageResponse; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -111,9 +113,16 @@ public class NamespacePortalQueryAppService { } @Transactional(readOnly = true) - public PageResponse listMembers(String slug, Pageable pageable, String userId) { + public PageResponse listMembers(String slug, Pageable pageable, String userId, Set platformRoles) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.assertMember(namespace.getId(), userId); + if (namespace.getType() == NamespaceType.GLOBAL) { + Set roles = platformRoles != null ? platformRoles : Set.of(); + if (!roles.contains("SUPER_ADMIN") && !roles.contains("USER_ADMIN")) { + throw new DomainForbiddenException("error.namespace.global.members.platformAdmin.required"); + } + } else { + namespaceService.assertMember(namespace.getId(), userId); + } Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); List memberUserIds = members.getContent().stream() diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 87dd2419..0e390aa5 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -27,13 +27,18 @@ skillhub: auth: mock: enabled: true + ratelimit: + download: + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:local-dev-download-secret-32-bytes} notification: cleanup: read-retention-days: 30 unread-retention-days: 90 security: scanner: - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} + base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} + mode: ${SKILLHUB_SECURITY_SCANNER_MODE:upload} stream: reclaim-enabled: ${SKILLHUB_SCAN_STREAM_RECLAIM_ENABLED:true} reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT2M} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7d9a1cbc..286ff05f 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -144,7 +144,7 @@ skillhub: download: anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl} anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D} - anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production} + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:} publish: max-file-count: 100 max-single-file-size: 10485760 # 10MB @@ -166,7 +166,7 @@ skillhub: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} security: scanner: - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} health-path: /health scan-path: /scan-upload diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 2301e0d5..8f7cf1df 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -68,6 +68,7 @@ error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} error.namespace.membership.required=Namespace membership required +error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required error.namespace.owner.required=Namespace owner role required error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces @@ -93,6 +94,7 @@ error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills error.skill.publish.archived=Archived skill must be restored before publishing: {0} review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} review.withdraw.not_submitter=Only the submitter can withdraw this review diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index a968cc77..e608d247 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -68,6 +68,7 @@ error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} error.namespace.membership.required=需要先加入该命名空间 +error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 error.namespace.owner.required=需要命名空间所有者权限 error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 @@ -93,6 +94,7 @@ error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0} error.skill.publish.precheck.failed=预发布校验失败:{0} +error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器 error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java new file mode 100644 index 00000000..56f8994d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DownloadRateLimitPropertiesTest { + + @Test + void anonymousCookieSecretDoesNotDefaultToProductionPlaceholder() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + + assertThat(properties.getAnonymousCookieSecret()) + .isNull(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java index 63efeb8b..42e25294 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java @@ -14,22 +14,34 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class SkillScannerPropertiesBindingTest { @Test - void defaultConfig_disablesScannerByDefault() throws IOException { + void defaultConfig_enablesScannerByDefault() throws IOException { SkillScannerProperties properties = bindProperties( List.of("application.yml"), Map.of() ); - assertFalse(properties.isEnabled()); + assertTrue(properties.isEnabled()); assertEquals("local", properties.getMode()); assertEquals("http://localhost:8000", properties.getBaseUrl()); } + @Test + void localConfig_usesUploadScannerModeByDefault() throws IOException { + SkillScannerProperties properties = bindProperties( + List.of("application-local.yml", "application.yml"), + Map.of() + ); + + assertTrue(properties.isEnabled()); + assertEquals("upload", properties.getMode()); + assertEquals("http://localhost:8000", properties.getBaseUrl()); + } + @Test void environmentVariables_overrideScannerDefaults() throws IOException { SkillScannerProperties properties = bindProperties( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java new file mode 100644 index 00000000..b2c8a460 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.device.DeviceCodeResponse; +import com.iflytek.skillhub.auth.device.DeviceTokenResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DeviceAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void requestDeviceCode_withoutCsrfIsAllowedForCliFlow() throws Exception { + given(deviceAuthService.generateDeviceCode()) + .willReturn(new DeviceCodeResponse("device-1", "USER-CODE", "http://localhost/device", 600, 5)); + + mockMvc.perform(post("/api/v1/auth/device/code")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.deviceCode").value("device-1")); + } + + @Test + void pollToken_withoutCsrfIsAllowedForCliFlow() throws Exception { + given(deviceAuthService.pollToken("device-1")) + .willReturn(DeviceTokenResponse.success("token-1")); + + mockMvc.perform(post("/api/v1/auth/device/token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"deviceCode\":\"device-1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.accessToken").value("token-1")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 7158cdfd..440fe4bf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import jakarta.servlet.http.Cookie; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -197,6 +198,61 @@ class LocalAuthControllerTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + void changePassword_withAuthentication_withInvalidCsrf_returnsForbidden() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .with(csrf().useInvalidToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isForbidden()); + } + + @Test + void changePassword_withSessionCookieAndBearerHeaderWithoutCsrf_isRejected() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .header("Authorization", "Bearer invalid-token") + .cookie(new Cookie("SESSION", "browser-session")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isUnauthorized()); + + verify(localAuthService, never()).changePassword("usr_3", "old", "Newpass123!"); + } + @Test void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception { mockMvc.perform(post("/api/v1/auth/local/password-reset/request") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 699cb740..566d3ef0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -174,6 +174,41 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void listMembers_globalNamespaceRejectsRegularUsers() throws Exception { + Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace); + given(namespaceMemberService.listMembers(eq(1L), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(), org.springframework.data.domain.PageRequest.of(0, 20), 0)); + + mockMvc.perform(get("/api/v1/namespaces/global/members") + .with(auth("regular-1")) + .requestAttr("userId", "regular-1")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void listMembers_globalNamespaceAllowsUserAdminWithoutMembership() throws Exception { + Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.MEMBER); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace); + doThrow(new DomainForbiddenException("error.namespace.membership.required")) + .when(namespaceService).assertMember(1L, "user-admin-1"); + given(namespaceMemberService.listMembers(eq(1L), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(member), org.springframework.data.domain.PageRequest.of(0, 20), 1)); + given(userAccountRepository.findByIdIn(List.of("user-2"))).willReturn(List.of(user)); + + mockMvc.perform(get("/api/v1/namespaces/global/members") + .with(auth("user-admin-1", Set.of("USER_ADMIN"))) + .requestAttr("userId", "user-admin-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].userId").value("user-2")) + .andExpect(jsonPath("$.data.items[0].email").value("alice@example.com")); + } + @Test void searchMemberCandidates_returnsCandidates() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index 72b5d26d..b72551ed 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -95,7 +95,7 @@ class NamespaceWorkflowContractTest { .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1")) .willReturn(adminMemberResponse); - given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"))) + given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"), eq(Set.of()))) .willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20)); given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1"))) .willReturn(adminMemberResponse); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 111d1173..a605b7cc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -136,7 +136,7 @@ class SkillStarControllerTest { } @Test - void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception { + void apiWebStarSkillWithCsrfShouldAllowSessionAuth() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -152,7 +152,8 @@ class SkillStarControllerTest { ); mockMvc.perform(put("/api/web/skills/10/star") - .with(authentication(auth))) + .with(authentication(auth)) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java index f2224de0..d82aa32d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java @@ -55,6 +55,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)) @@ -75,6 +76,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(false)) @@ -95,6 +97,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes())) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)); @@ -108,6 +111,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes())) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isBadRequest()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index d7edf81d..b2421c89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -118,6 +118,7 @@ class CliSkillControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .delete("/api/cli/v1/skills/global/demo") + .header("Authorization", "Bearer test-token") .with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.ok").value(true)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java index 809c22a9..4ebf9424 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java @@ -59,7 +59,8 @@ class SkillSubscriptionControllerTest { @Test void subscribe_skill_returns_envelope() throws Exception { mockMvc.perform(put("/api/web/skills/10/subscription") - .with(authentication(authenticatedUser()))) + .with(authentication(authenticatedUser())) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.timestamp").isNotEmpty()) @@ -80,7 +81,8 @@ class SkillSubscriptionControllerTest { @Test void unsubscribe_skill_returns_envelope() throws Exception { mockMvc.perform(delete("/api/web/skills/10/subscription") - .with(authentication(authenticatedUser()))) + .with(authentication(authenticatedUser())) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.timestamp").isNotEmpty()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java new file mode 100644 index 00000000..39603ebd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.ratelimit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import org.junit.jupiter.api.Test; + +class AnonymousDownloadIdentityServiceTest { + + @Test + void validateAnonymousCookieSecretRejectsReleaseExamplePlaceholder() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + properties.setAnonymousCookieSecret("replace-with-random-download-secret-32-bytes"); + AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver()); + + assertThatThrownBy(service::validateAnonymousCookieSecret) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must not use the default placeholder"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 10e58718..05a9fd3c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -29,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import java.util.Map; +import java.util.Set; class NamespacePortalQueryAppServiceTest { @@ -131,7 +132,7 @@ class NamespacePortalQueryAppServiceTest { when(userAccountRepository.findByIdIn(List.of("user-2"))) .thenReturn(List.of(user)); - PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of()); assertThat(result.items()).hasSize(1); MemberResponse mr = result.items().get(0); @@ -153,7 +154,7 @@ class NamespacePortalQueryAppServiceTest { when(userAccountRepository.findByIdIn(List.of("ghost-user"))) .thenReturn(List.of()); - PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of()); assertThat(result.items()).hasSize(1); MemberResponse mr = result.items().get(0); @@ -161,4 +162,17 @@ class NamespacePortalQueryAppServiceTest { assertThat(mr.displayName()).isNull(); assertThat(mr.email()).isNull(); } + + @Test + void listMembers_globalNamespaceRejectsRegularUsersEvenWhenTheyAreGlobalMembers() { + Namespace ns = namespace(1L, "global"); + ns.setType(NamespaceType.GLOBAL); + when(namespaceService.getNamespaceBySlug("global")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + assertThatThrownBy(() -> service.listMembers("global", PageRequest.of(0, 20), "user-1", Set.of())) + .isInstanceOf(DomainForbiddenException.class) + .hasMessageContaining("error.namespace.global.members.platformAdmin.required"); + } } diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index e7a6ea69..8749a355 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -41,6 +41,9 @@ skillhub: enforce-active-user-check: false access-policy: mode: OPEN + ratelimit: + download: + anonymous-cookie-secret: test-download-secret-32-bytes-long security: scanner: enabled: false diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 05b87025..8c2ff2dc 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; @@ -103,7 +105,7 @@ public class SecurityConfig { RequestMatcher csrfIgnoreMatcher = request -> { String path = request.getRequestURI(); String authorization = request.getHeader("Authorization"); - return routeSecurityPolicyRegistry.shouldIgnoreCsrf(path, authorization); + return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request)); }; http @@ -183,4 +185,20 @@ public class SecurityConfig { } } } + + static boolean hasSessionCookie(HttpServletRequest request) { + if (request.getRequestedSessionId() != null) { + return true; + } + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return false; + } + for (Cookie cookie : cookies) { + if ("SESSION".equals(cookie.getName()) || "JSESSIONID".equals(cookie.getName())) { + return true; + } + } + return false; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 5ac6c1d1..c4c52fff 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -150,14 +150,22 @@ public class RouteSecurityPolicyRegistry { return ApiTokenAuthorizationDecision.unsupported(path); } - public boolean shouldIgnoreCsrf(String path, String authorizationHeader) { - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader) { + return shouldIgnoreCsrf(method, path, authorizationHeader, false); + } + + public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader, boolean hasSessionCookie) { + if (!hasSessionCookie && authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { return true; } if (path == null) { return false; } - return path.startsWith("/api/"); + if (!"POST".equalsIgnoreCase(method)) { + return false; + } + return "/api/v1/auth/device/code".equals(path) + || "/api/v1/auth/device/token".equals(path); } public boolean shouldProjectRequestContext(String path) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java new file mode 100644 index 00000000..2865f3fb --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +class SecurityConfigTest { + + @Test + void hasSessionCookieDetectsSpringSessionCookie() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("SESSION", "session-id")); + + assertTrue(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieDetectsRequestedSessionIdFromServletContainer() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestedSessionId("custom-session-id"); + + assertTrue(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieIgnoresServerSideSessionWithoutClientSessionId() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); + + assertFalse(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieIgnoresNonSessionCookies() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("XSRF-TOKEN", "csrf-token")); + + assertFalse(SecurityConfig.hasSessionCookie(request)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8499ee4d..efb5b0bd 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -113,10 +113,16 @@ class RouteSecurityPolicyRegistryTest { } @Test - void shouldIgnoreCsrf_forBearerAndApiPaths() { - assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); - assertTrue(registry.shouldIgnoreCsrf("/not-api", "Bearer token")); - assertFalse(registry.shouldIgnoreCsrf("/ui/settings", null)); + void shouldIgnoreCsrf_onlyForBearerTokensAndDeviceTokenFlow() { + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/admin/users", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/local/change-password", null, false)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", true)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/code", null, false)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/token", null, false)); + assertFalse(registry.shouldIgnoreCsrf("GET", "/api/v1/auth/device/code", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/authorize", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/ui/settings", null, false)); } @Test 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 698a0fdd..c204306a 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 @@ -170,6 +170,9 @@ public class SkillPublishService { errors.add("Publisher is not a member of namespace: " + namespaceSlug); } } + if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { + errors.add("error.security.scanner.required"); + } // 3. Package validation ValidationResult packageValidation = skillPackageValidator.validate(entries); @@ -380,6 +383,9 @@ public class SkillPublishService { "error.skill.publish.precheck.confirmRequired", formatValidationMessages(publishWarnings)); } + if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { + throw new DomainBadRequestException("error.security.scanner.required"); + } // 6. Find or create Skill record (with owner isolation) List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); @@ -588,6 +594,10 @@ public class SkillPublishService { skillVersionRepository.flush(); } + private boolean requiresSecurityScanner(SkillVisibility visibility) { + return visibility == SkillVisibility.PUBLIC || visibility == SkillVisibility.NAMESPACE_ONLY; + } + private String resolveNamespaceSlug(Long namespaceId) { return namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId)) 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 e40fa545..73f118fd 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 @@ -101,7 +101,7 @@ class SkillPublishServiceTest { eventPublisher, CLOCK ); - lenient().when(securityScanService.isEnabled()).thenReturn(false); + lenient().when(securityScanService.isEnabled()).thenReturn(true); lenient().when(skillVersionRepository.findBySkillIdAndStatus(anyLong(), eq(SkillVersionStatus.PENDING_REVIEW))) .thenReturn(List.of()); lenient().when(reviewTaskRepository.save(any(ReviewTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -1284,6 +1284,120 @@ class SkillPublishServiceTest { assertEquals(SkillVersionStatus.SCAN_FAILED, SkillVersionStatus.valueOf("SCAN_FAILED")); } + @Test + void testPublishFromEntries_PublicWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "test-skill", "test-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + verify(skillRepository, never()).save(any(Skill.class)); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_NamespaceOnlyWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "team-skill", "team-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.NAMESPACE_ONLY, + Set.of() + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + verify(skillRepository, never()).save(any(Skill.class)); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_PrivateWhenScannerDisabled_ShouldAllowUploadWithoutScan() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "private-skill", "private-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PRIVATE, + Set.of() + ); + + assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus()); + verify(securityScanService, never()).triggerScan(anyLong(), anyList(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_SuperAdminPublicWhenScannerDisabled_ShouldStillRejectBeforeAutoPublish() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "admin-user"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "admin-skill", "admin-skill", "1.0.0", false); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN") + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); + } + + @Test + void testValidateOnly_PublicWhenScannerDisabled_ShouldReturnScannerRequiredError() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + List entries = skillEntries("test-skill", "1.0.0"); + 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()); + when(securityScanService.isEnabled()).thenReturn(false); + 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(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + + SkillPublishService.DryRunResult result = service.validateOnly( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); + + assertFalse(result.valid()); + assertTrue(result.errors().contains("error.security.scanner.required")); + } + @Test void testPublishFromEntries_WhenScannerEnabled_ShouldCreateReviewTaskAndTriggerScan() throws Exception { String namespaceSlug = "test-ns"; @@ -1377,6 +1491,56 @@ class SkillPublishServiceTest { verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); } + private record PublishFixture(List entries) { + } + + private PublishFixture stubValidPublishInputs( + String namespaceSlug, + String publisherId, + String skillName, + String skillSlug, + String version, + boolean stubMembership) throws Exception { + List entries = skillEntries(skillName, version); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + SkillMetadata metadata = new SkillMetadata(skillName, "Test", version, "Body", Map.of()); + Skill skill = new Skill(namespace.getId(), skillSlug, publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + if (stubMembership) { + NamespaceMember member = mock(NamespaceMember.class); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + } + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + lenient().when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)).thenReturn(List.of(skill)); + lenient().when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)).thenReturn(Optional.of(skill)); + lenient().when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)).thenReturn(Optional.empty()); + lenient().when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + lenient().when(skillRepository.save(any(Skill.class))).thenReturn(skill); + return new PublishFixture(entries); + } + + private List skillEntries(String skillName, String version) { + String skillMdContent = "---\nname: " + skillName + "\ndescription: Test\nversion: " + version + "\n---\nBody"; + PackageEntry skillMd = new PackageEntry( + "SKILL.md", + skillMdContent.getBytes(StandardCharsets.UTF_8), + skillMdContent.getBytes(StandardCharsets.UTF_8).length, + "text/markdown"); + PackageEntry readme = new PackageEntry("README.md", "content".getBytes(StandardCharsets.UTF_8), 7, "text/markdown"); + return List.of(skillMd, readme); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/web/e2e/helpers/csrf.ts b/web/e2e/helpers/csrf.ts new file mode 100644 index 00000000..410b0837 --- /dev/null +++ b/web/e2e/helpers/csrf.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test' + +const csrfCookieName = 'XSRF-TOKEN' +const csrfHeaderName = 'X-XSRF-TOKEN' +const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 + +async function readCsrfToken(page: Page): Promise { + const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName) + return cookie?.value?.trim() || null +} + +export async function csrfHeaders(page: Page, headers?: Record): Promise> { + let token = await readCsrfToken(page) + if (!token) { + await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + token = await readCsrfToken(page) + } + + if (!token) { + throw new Error('Missing XSRF-TOKEN cookie after auth provider warm-up') + } + + return { + ...headers, + [csrfHeaderName]: token, + } +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 763ef1c3..91554c62 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -1,4 +1,5 @@ import { expect, type Page, type TestInfo } from '@playwright/test' +import { csrfHeaders } from './csrf' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() @@ -50,15 +51,17 @@ function isRetryableStatus(status: number): boolean { } async function loginWithRetry( - request: Page['request'], + page: Page, username: string, currentPassword = password, retries = process.env.CI ? 10 : 6, ): Promise { + const request = page.context().request for (let i = 0; i < retries; i += 1) { try { const login = await request.post('/api/v1/auth/local/login', { data: { username, password: currentPassword }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -187,13 +190,13 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. - if (cached && await loginWithRetry(request, cached)) { + if (cached && await loginWithRetry(page, cached)) { await cacheSession(page, worker, cached) return { username: cached, password } } // Support environments where a deterministic worker account already exists. - if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) { + if (!cached && await loginWithRetry(page, username, password, process.env.CI ? 4 : 3)) { cachedUserByWorker.set(worker, username) await cacheSession(page, worker, username) return { username, password } @@ -206,6 +209,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${username}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -215,7 +219,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re return { username, password } } - if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) { + if (register.status() === 409 && await loginWithRetry(page, username, password, process.env.CI ? 8 : 6)) { cachedUserByWorker.set(worker, username) await cacheSession(page, worker, username) return { username, password } @@ -236,6 +240,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${uniqueUsername}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -269,7 +274,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re // Final fallback for environments where registration is temporarily unavailable. const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) for (const candidate of fallbackCandidates) { - if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) { + if (await loginWithRetry(page, candidate, password, process.env.CI ? 12 : 8)) { cachedUserByWorker.set(worker, candidate) await cacheSession(page, worker, candidate) return { username: candidate, password } @@ -295,6 +300,7 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -358,8 +364,6 @@ export async function createFreshSession(page: Page, testInfo?: TestInfo) { } export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) { - const request = page.context().request - await primeAuthProviders(page) const restored = await restoreCachedSessionForAccount(page, credentials.username) @@ -368,7 +372,7 @@ export async function loginWithCredentials(page: Page, credentials: TestCredenti } const loggedIn = await loginWithRetry( - request, + page, credentials.username, credentials.password, process.env.CI ? 12 : 8, diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index a6b77adc..f255ea4a 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 { csrfHeaders } from './csrf' type CleanupTask = () => Promise @@ -237,12 +238,14 @@ export class E2eTestDataBuilder { displayName, description: `E2E namespace ${slug}`, }, + headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, { data: { reason: 'e2e cleanup' }, + headers: await csrfHeaders(this.page), }) }) @@ -270,13 +273,17 @@ export class E2eTestDataBuilder { if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { return parseEnvelope( - await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`), + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`, { + headers: await csrfHeaders(this.page), + }), ) } if (namespace.status === 'ARCHIVED' && namespace.canRestore) { return parseEnvelope( - await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`), + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`, { + headers: await csrfHeaders(this.page), + }), ) } @@ -483,11 +490,12 @@ export class E2eTestDataBuilder { async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { let lastError: unknown - for (let attempt = 0; attempt < 30; attempt += 1) { + for (let attempt = 0; attempt < 60; attempt += 1) { try { await parseEnvelope( await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { data: { comment }, + headers: await csrfHeaders(this.page), }), ) return @@ -498,7 +506,7 @@ export class E2eTestDataBuilder { if (!isScanInProgress) { throw error } - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 1_000)) } } throw lastError instanceof Error ? lastError : new Error('approveReview timed out') @@ -515,6 +523,7 @@ export class E2eTestDataBuilder { await parseEnvelope<{ userId: string; role: string }>( await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, { data: { userId, role }, + headers: await csrfHeaders(this.page), }), ) } @@ -533,11 +542,14 @@ export class E2eTestDataBuilder { }, visibility: 'PUBLIC', }, + headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { - await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) + await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`, { + headers: await csrfHeaders(this.page), + }) }) return result diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts index 720c2796..f5834812 100644 --- a/web/e2e/public-skill-detail-anonymous.spec.ts +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -18,6 +18,8 @@ function escapeRegExp(value: string) { let seeded: PreparedSearchSeed | undefined test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeAll(async ({ browser }, testInfo) => { seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) }) diff --git a/web/e2e/skill-subscription.spec.ts b/web/e2e/skill-subscription.spec.ts index c6d74d24..22003201 100644 --- a/web/e2e/skill-subscription.spec.ts +++ b/web/e2e/skill-subscription.spec.ts @@ -16,6 +16,8 @@ function adminCredentials() { } test.describe('Skill Subscription (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) await registerSession(page, testInfo) diff --git a/web/e2e/skill-version-compare.spec.ts b/web/e2e/skill-version-compare.spec.ts index c24da0a7..274197ac 100644 --- a/web/e2e/skill-version-compare.spec.ts +++ b/web/e2e/skill-version-compare.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' import { loginWithCredentials, registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' @@ -16,6 +17,8 @@ function adminCredentials() { } test.describe('Skill Version Compare (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) await registerSession(page, testInfo) @@ -50,6 +53,7 @@ test.describe('Skill Version Compare (Real API)', () => { targetVersion: '1.1.0', confirmWarnings: true, }, + headers: await csrfHeaders(page), } ) expect(rereleaseResponse.ok()).toBe(true) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..07d8c65b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; From abdad25df3898aa63a3fa7133fcf1840314dede7 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 15 Jun 2026 14:45:09 +0800 Subject: [PATCH 14/26] feat(bootstrap): add agentguard builtin skill Signed-off-by: dongmucat <1127093059@qq.com> --- .../resources/builtin-skills/manifest.json | 5 +++ .../skill/metadata/SkillMetadataParser.java | 12 ++++++ .../metadata/SkillMetadataParserTest.java | 40 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json index 8dba7bac..a9fc8cac 100644 --- a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -4,6 +4,11 @@ "slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip" + }, + { + "slug": "agentguard", + "version": "1.1", + "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-12/9d063bc7-223a-4762-adeb-305c268aa29e.zip" } ] } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index 36fc1ffc..c44a4516 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -50,6 +50,9 @@ public class SkillMetadataParser { String name = extractRequiredField(frontmatter, "name"); String description = extractRequiredField(frontmatter, "description"); String version = extractOptionalField(frontmatter, "version"); + if (version == null) { + version = extractNestedOptionalField(frontmatter, "metadata", "version"); + } return new SkillMetadata(name, description, version, body, frontmatter); } @@ -122,4 +125,13 @@ public class SkillMetadataParser { Object value = frontmatter.get(fieldName); return value == null ? null : value.toString(); } + + private String extractNestedOptionalField(Map frontmatter, String objectFieldName, String fieldName) { + Object nestedValue = frontmatter.get(objectFieldName); + if (!(nestedValue instanceof Map nestedMap)) { + return null; + } + Object value = nestedMap.get(fieldName); + return value == null ? null : value.toString(); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index 15969023..aececcd2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -123,6 +123,46 @@ class SkillMetadataParserTest { assertNull(metadata.version()); } + @Test + void testUsesMetadataVersionWhenTopLevelVersionIsMissing() { + String content = """ + --- + name: agentguard + description: Agent security guard + metadata: + author: GoPlusSecurity + version: "1.1" + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("agentguard", metadata.name()); + assertEquals("Agent security guard", metadata.description()); + assertEquals("1.1", metadata.version()); + } + + @Test + void testTopLevelVersionTakesPrecedenceOverMetadataVersion() { + String content = """ + --- + name: versioned-skill + description: Prefer top-level version + version: 2.0.0 + metadata: + version: "1.1" + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("versioned-skill", metadata.name()); + assertEquals("Prefer top-level version", metadata.description()); + assertEquals("2.0.0", metadata.version()); + } + @Test void testFallsBackToLooseFrontmatterParsingWhenYamlSyntaxIsNotStrict() { String content = """ From 6d6577faa27f9eae586aa4bb45ff6ef5e491c4b9 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 10:23:02 +0800 Subject: [PATCH 15/26] fix(bootstrap): confirm builtin publish warnings Signed-off-by: dongmucat <1127093059@qq.com> --- .../bootstrap/BuiltinSkillInitializer.java | 3 +- .../BuiltinSkillInitializerTest.java | 31 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java index 20b9a363..7ed8c00d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -50,6 +50,7 @@ public class BuiltinSkillInitializer { private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); private static final Set SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN"); + private static final boolean CONFIRM_BUILTIN_PUBLISH_WARNINGS = true; private final BuiltinSkillProperties properties; private final BuiltinSkillManifestLoader manifestLoader; @@ -243,7 +244,7 @@ public class BuiltinSkillInitializer { SYSTEM_PUBLISHER_ID, SkillVisibility.PUBLIC, SYSTEM_PUBLISHER_ROLES, - false + CONFIRM_BUILTIN_PUBLISH_WARNINGS ); log.info("Published built-in skill slug={} version={} to @{}", item.slug(), item.version(), GLOBAL_NAMESPACE); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java index 296f547a..bc5602a0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.bootstrap; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; @@ -106,7 +107,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(manifestLoader, never()).load(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -116,7 +117,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(manifestLoader, never()).load(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -144,7 +145,7 @@ class BuiltinSkillInitializerTest { verify(namespaceMemberRepository, never()).save(any()); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -162,7 +163,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -180,7 +181,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -192,7 +193,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -206,7 +207,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader).download(URI.create(ITEM.url())); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -222,7 +223,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(downloader, never()).download(any()); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); assertThat(output).doesNotContain("Failed to synchronize built-in skill slug=skillhub-hello"); } @@ -232,7 +233,7 @@ class BuiltinSkillInitializerTest { runInitializer(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -241,7 +242,7 @@ class BuiltinSkillInitializerTest { runInitializer(); - verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), eq(false)); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); } @Test @@ -259,7 +260,7 @@ class BuiltinSkillInitializerTest { eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), - eq(false) + eq(true) ); assertThat(entriesCaptor.getValue()).isEqualTo(entries); } @@ -297,7 +298,7 @@ class BuiltinSkillInitializerTest { .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true))) .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); @@ -305,7 +306,7 @@ class BuiltinSkillInitializerTest { runInitializer(); verify(skillPublishService).publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true)); } @Test @@ -318,7 +319,7 @@ class BuiltinSkillInitializerTest { .thenReturn(List.of()) .thenReturn(List.of(builtinSkill)); when(skillPublishService.publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false))) + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true))) .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( @@ -329,7 +330,7 @@ class BuiltinSkillInitializerTest { verify(skillFileRepository).findByVersionId(200L); verify(skillPublishService).publishFromEntries( - eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(false)); + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true)); assertThat(output).contains("Failed to publish built-in skill slug=skillhub-hello version=1.0.0"); assertThat(output).doesNotContain("was published concurrently, skipping"); } From a865960cf3ae5b873f51e63229b31f054341acbc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 09:54:32 +0800 Subject: [PATCH 16/26] fix(notification): ISSUE-51 add profile review notifications Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 247 ++++++++++++++++++ .../portal/NotificationController.java | 3 + .../listener/NotificationEventListener.java | 16 ++ .../skillhub/listener/RecipientResolver.java | 10 + .../portal/NotificationControllerTest.java | 22 ++ .../NotificationEventListenerTest.java | 15 ++ .../listener/RecipientResolverTest.java | 16 ++ .../event/ProfileReviewSubmittedEvent.java | 10 + .../domain/user/UserProfileService.java | 22 +- .../domain/user/UserProfileServiceTest.java | 56 ++++ web/e2e/reviews-pagination.spec.ts | 8 + web/src/api/generated/schema.d.ts | 4 + web/src/app/router.tsx | 3 + web/src/pages/dashboard/reviews.test.ts | 14 + web/src/pages/dashboard/reviews.tsx | 22 +- 15 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 docs/api/notification.openapi.yaml create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml new file mode 100644 index 00000000..af5ce4fd --- /dev/null +++ b/docs/api/notification.openapi.yaml @@ -0,0 +1,247 @@ +openapi: 3.0.3 +info: + title: SkillHub Notification API + version: 1.0.0 + description: In-app notification list, unread count, and SSE event contract. +servers: + - url: /api/web + - url: /api/v1 +paths: + /notifications: + get: + summary: List notifications for the current user + operationId: listNotifications + security: + - sessionAuth: [] + parameters: + - name: category + in: query + required: false + description: Optional category filter. Profile review notifications reuse REVIEW. + schema: + type: string + enum: [PUBLISH, REVIEW, PROMOTION, REPORT] + example: REVIEW + - name: page + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + example: 0 + - name: size + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: 20 + responses: + "200": + description: Notification page. + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationPageResponse" + "400": + description: Invalid category. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /notifications/unread-count: + get: + summary: Get unread notification count for the current user + operationId: getNotificationUnreadCount + security: + - sessionAuth: [] + responses: + "200": + description: Unread count. + content: + application/json: + schema: + $ref: "#/components/schemas/UnreadCountResponse" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /notifications/sse: + get: + summary: Open notification SSE stream for the current user + operationId: streamNotifications + security: + - sessionAuth: [] + responses: + "200": + description: SSE stream. Each `notification` event carries NotificationSsePayload. + content: + text/event-stream: + schema: + type: string + "401": + description: Authentication required. +components: + securitySchemes: + sessionAuth: + type: apiKey + in: cookie + name: SESSION + schemas: + NotificationPageResponse: + type: object + properties: + code: + type: integer + example: 0 + msg: + type: string + example: ok + data: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Notification" + total: + type: integer + format: int64 + example: 1 + page: + type: integer + example: 0 + size: + type: integer + example: 20 + timestamp: + type: string + format: date-time + requestId: + type: string + Notification: + type: object + properties: + id: + type: integer + format: int64 + example: 1001 + category: + type: string + enum: [PUBLISH, REVIEW, PROMOTION, REPORT] + example: REVIEW + eventType: + type: string + description: Known values include REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, PROFILE_REVIEW_SUBMITTED, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, REPORT_SUBMITTED, REPORT_RESOLVED, SKILL_PUBLISHED, SUBSCRIPTION_NEW_VERSION, and SUBSCRIPTION_VERSION_YANKED. + example: PROFILE_REVIEW_SUBMITTED + title: + type: string + example: Profile review submitted + bodyJson: + type: string + description: JSON string. For PROFILE_REVIEW_SUBMITTED it contains profileReviewId, submitterId, and fields. + example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' + entityType: + type: string + example: PROFILE_REVIEW + entityId: + type: integer + format: int64 + example: 77 + status: + type: string + enum: [UNREAD, READ] + example: UNREAD + createdAt: + type: string + format: date-time + readAt: + type: string + format: date-time + nullable: true + targetType: + type: string + example: PROFILE_REVIEW + targetId: + type: integer + format: int64 + example: 77 + targetRoute: + type: string + description: Profile review notifications route to the admin profile review queue. + example: /dashboard/reviews?type=profile + NotificationSsePayload: + type: object + properties: + id: + type: integer + format: int64 + example: 1001 + category: + type: string + example: REVIEW + eventType: + type: string + example: PROFILE_REVIEW_SUBMITTED + title: + type: string + example: Profile review submitted + bodyJson: + type: string + example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' + entityType: + type: string + example: PROFILE_REVIEW + entityId: + type: integer + format: int64 + example: 77 + createdAt: + type: string + format: date-time + UnreadCountResponse: + type: object + properties: + code: + type: integer + example: 0 + msg: + type: string + example: ok + data: + type: object + properties: + count: + type: integer + format: int64 + example: 1 + timestamp: + type: string + format: date-time + requestId: + type: string + ErrorResponse: + type: object + properties: + code: + type: integer + example: 400 + msg: + type: string + example: Invalid category + timestamp: + type: string + format: date-time + requestId: + type: string diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index 6a1f1c68..7ab2f759 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -113,6 +113,9 @@ public class NotificationController extends BaseApiController { if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } + if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile"); + } if ("PROMOTION_SUBMITTED".equals(eventType)) { return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 74ad10d8..6d38c9f7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -127,6 +127,22 @@ public class NotificationEventListener { }); } + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) { + String title = "Profile review submitted"; + Map body = new LinkedHashMap<>(); + body.put("profileReviewId", event.profileReviewId()); + body.put("submitterId", event.submitterId()); + body.put("fields", event.fields()); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformUserAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId()); + } + } + @Async("skillhubEventExecutor") @TransactionalEventListener public void onReviewApproved(ReviewApprovedEvent event) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java index 90e076d7..0ea087d3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java @@ -39,4 +39,14 @@ public class RecipientResolver { List::copyOf )); } + + public List resolvePlatformUserAdmins() { + return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")) + .stream() + .map(binding -> binding.getUserId()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 2536180e..2f344f2c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -70,6 +70,28 @@ class NotificationControllerTest { verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)); } + @Test + void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { + Notification notification = notification( + 15L, + NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", + "{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}", + "PROFILE_REVIEW", + 77L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("admin-1", "REVIEW", 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("PROFILE_REVIEW"); + assertThat(item.targetId()).isEqualTo(77L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile"); + }); + } + @Test void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..8ffd251f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -99,6 +99,21 @@ class NotificationEventListenerTest { verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); } + @Test + void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception { + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformUserAdmins()) + .thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1")); + + listener.onProfileReviewSubmitted( + new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName"))); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L)); + verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + } + @Test void onReviewApproved_shouldDispatchToSubmitter() throws Exception { Skill skill = mockSkill(1L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java index 20325b9d..c056a1ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java @@ -80,4 +80,20 @@ class RecipientResolverTest { assertThat(result).containsExactly("skill-admin", "super-admin"); } + + @Test + void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() { + UserRoleBinding userAdmin = mock(UserRoleBinding.class); + UserRoleBinding superAdmin = mock(UserRoleBinding.class); + UserRoleBinding duplicate = mock(UserRoleBinding.class); + when(userAdmin.getUserId()).thenReturn("user-admin"); + when(superAdmin.getUserId()).thenReturn("super-admin"); + when(duplicate.getUserId()).thenReturn("user-admin"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(userAdmin, superAdmin, duplicate)); + + List result = resolver.resolvePlatformUserAdmins(); + + assertThat(result).containsExactly("user-admin", "super-admin"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java new file mode 100644 index 00000000..f0c250d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.event; + +import java.util.List; + +public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List fields) { + + public ProfileReviewSubmittedEvent { + fields = List.copyOf(fields); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java index 68b3951b..c6f037c3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java @@ -3,10 +3,13 @@ package com.iflytek.skillhub.domain.user; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,19 +32,22 @@ public class UserProfileService { private final ProfileModerationConfig moderationConfig; private final ProfileFieldPolicyConfig fieldPolicyConfig; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; public UserProfileService(UserAccountRepository userAccountRepository, ProfileChangeRequestRepository changeRequestRepository, ProfileModerationService moderationService, ProfileModerationConfig moderationConfig, ProfileFieldPolicyConfig fieldPolicyConfig, - AuditLogService auditLogService) { + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher) { this.userAccountRepository = userAccountRepository; this.changeRequestRepository = changeRequestRepository; this.moderationService = moderationService; this.moderationConfig = moderationConfig; this.fieldPolicyConfig = fieldPolicyConfig; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; } /** @@ -112,8 +118,10 @@ public class UserProfileService { // 5. Queue review changes if (!reviewChanges.isEmpty()) { cancelPendingRequests(userId); - saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING, - machineTag, null); + ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues, + ProfileChangeStatus.PENDING, machineTag, null); + eventPublisher.publishEvent(new ProfileReviewSubmittedEvent( + pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet()))); } // 6. Return appropriate result @@ -166,9 +174,9 @@ public class UserProfileService { /** * Persist a change request record for audit and review purposes. */ - private void saveChangeRequest(String userId, Map changes, - Map oldValues, ProfileChangeStatus status, - String machineResult, String machineReason) { + private ProfileChangeRequest saveChangeRequest(String userId, Map changes, + Map oldValues, ProfileChangeStatus status, + String machineResult, String machineReason) { ProfileChangeRequest request = new ProfileChangeRequest( userId, toJson(changes), @@ -177,7 +185,7 @@ public class UserProfileService { machineResult, machineReason ); - changeRequestRepository.save(request); + return changeRequestRepository.save(request); } private String toJson(Object obj) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java index d6af56f6..1346e6a2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java @@ -1,16 +1,21 @@ package com.iflytek.skillhub.domain.user; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -41,9 +46,24 @@ class UserProfileServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UserProfileService userProfileService; + @BeforeEach + void setUp() { + AtomicLong ids = new AtomicLong(1L); + lenient().when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + if (request.getId() == null) { + setField(request, "id", ids.getAndIncrement()); + } + return request; + }); + } + // -- Helper -- private UserAccount testUser() { @@ -136,6 +156,32 @@ class UserProfileServiceTest { verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() { + var user = testUser(); + when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(moderationConfig.machineReview()).thenReturn(false); + when(moderationConfig.humanReview()).thenReturn(true); + stubFieldPolicies(true); + when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING)) + .thenReturn(List.of()); + when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + setField(request, "id", 77L); + return request; + }); + + userProfileService.updateProfile( + "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent"); + + var eventCaptor = ArgumentCaptor.forClass(ProfileReviewSubmittedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ProfileReviewSubmittedEvent event = eventCaptor.getValue(); + assertEquals(77L, event.profileReviewId()); + assertEquals("user-1", event.submitterId()); + assertEquals(List.of("displayName"), event.fields()); + } + // ===== AC-P-005: Overwrite existing PENDING request ===== @Test @@ -221,4 +267,14 @@ class UserProfileServiceTest { userProfileService.updateProfile( "nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent")); } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } } diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts index f9906a35..83626e4d 100644 --- a/web/e2e/reviews-pagination.spec.ts +++ b/web/e2e/reviews-pagination.spec.ts @@ -81,4 +81,12 @@ test.describe('Review Management Pagination (Real API)', () => { } } }) + + test('opens the profile review queue from the review type search param', async ({ page }) => { + await page.goto('/dashboard/reviews?type=profile') + + await expect(page).toHaveURL(/\/dashboard\/reviews\?type=profile$/) + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Profile Review Queue' })).toBeVisible() + }) }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..07d8c65b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d654e026..c9b3bac8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', beforeLoad: requireAuth, + validateSearch: (search: Record): { type?: 'skill' | 'profile' } => ({ + type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined, + }), component: ReviewsPage, }) diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 694a0dce..186c15eb 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' +const useSearchMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), + useSearch: () => useSearchMock(), })) vi.mock('lucide-react', () => ({ @@ -119,6 +121,7 @@ describe('ReviewsPage', () => { data: [], isLoading: false, }) + useSearchMock.mockReturnValue({}) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } @@ -180,4 +183,15 @@ describe('ReviewsPage', () => { expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(1) }) + + it('uses the profile review tab when the review type search param requests it', () => { + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN') + userMock.platformRoles = ['SUPER_ADMIN'] + useSearchMock.mockReturnValue({ type: 'profile' }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 4ead6c84..94c0ded8 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' @@ -38,6 +38,7 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() + const search = useSearch({ from: '/dashboard/reviews' }) const { hasRole, user } = useAuth() const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ @@ -56,6 +57,9 @@ export function ReviewsPage() { // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' + const requestedType = search.type === 'skill' || search.type === 'profile' ? search.type : undefined + const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType + const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { if (hasGlobalReviewAccess || isLoadingNamespaces) { @@ -70,9 +74,9 @@ export function ReviewsPage() { void navigate({ to: '/dashboard', replace: true }) }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -93,6 +97,14 @@ export function ReviewsPage() { }) } + function handleTypeChange(value: string) { + void navigate({ + to: '/dashboard/reviews', + search: { type: value === 'profile' ? 'profile' : 'skill' }, + replace: true, + }) + } + function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { const currentPage = pages[status] return ( @@ -249,7 +261,7 @@ export function ReviewsPage() { {showTypeTabs ? ( - + Date: Fri, 12 Jun 2026 14:40:41 +0800 Subject: [PATCH 17/26] fix(review): allow super admin self promotion review Signed-off-by: dongmucat <1127093059@qq.com> --- docs/05-business-flows.md | 2 +- .../security/authorization.md | 2 +- .../security/authorization.md | 2 +- .../service/PromotionPortalAppService.java | 28 ++- .../PromotionApprovalFlowIntegrationTest.java | 92 ++++++++-- .../NotificationEventListenerTest.java | 26 +++ .../PromotionPortalAppServiceTest.java | 170 ++++++++++++++++++ .../review/ReviewPermissionChecker.java | 2 +- .../domain/review/PromotionServiceTest.java | 45 +++++ .../review/ReviewPermissionCheckerTest.java | 18 +- 10 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index e0719939..3cd6a7cb 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -112,7 +112,7 @@ | skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 | | namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 | | SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 | -| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;review 场景下还能审自己的提交 | 可 | 可 | +| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 | ### 对象存储写入策略 diff --git a/document/docs/02-administration/security/authorization.md b/document/docs/02-administration/security/authorization.md index 36d7fef3..75ff506e 100644 --- a/document/docs/02-administration/security/authorization.md +++ b/document/docs/02-administration/security/authorization.md @@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制(RBAC)系统。 | 角色 | 代码 | 实际能力 | |------|------|----------| -| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion,且普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | +| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion;普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | | 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本(yank)、处理技能举报;可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report;不能分配平台角色、不能看审计日志、不能管理用户。 | | 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 | | 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs` 和 `/actuator/prometheus`;治理工作台中只能看 activity,不能处理 review/promotion/report,也不能管理用户或技能。 | diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md index 4c0359be..ee411ca4 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md @@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles: | Role | Code | Effective behavior | |------|------|--------------------| -| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. | +| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. | | Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. | | User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. | | Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index 16a1e95b..ef4b1888 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -75,7 +75,8 @@ public class PromotionPortalAppService { comment, platformRoles(userId) ); - recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, detailWithComment(comment)); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, + detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -89,7 +90,8 @@ public class PromotionPortalAppService { comment, platformRoles(userId) ); - recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, detailWithComment(comment)); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, + detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -159,10 +161,26 @@ public class PromotionPortalAppService { ); } - private String detailWithComment(String comment) { - if (comment == null || comment.isBlank()) { + private String detailWithComment(String comment, boolean selfReview) { + boolean hasComment = comment != null && !comment.isBlank(); + if (!hasComment && !selfReview) { return null; } - return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + StringBuilder detail = new StringBuilder("{"); + if (hasComment) { + detail.append("\"comment\":\"").append(escapeJson(comment)).append("\""); + } + if (selfReview) { + if (hasComment) { + detail.append(","); + } + detail.append("\"selfReview\":true"); + } + detail.append("}"); + return detail.toString(); + } + + private String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); } } 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 44d39712..a2268a67 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 @@ -38,6 +38,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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; @@ -53,6 +56,8 @@ class PromotionApprovalFlowIntegrationTest { private static final String SUBMITTER_ID = "promotion-owner"; private static final String REVIEWER_ID = "docker-admin"; + private static final String SELF_SUPER_ADMIN_ID = "self-super-admin"; + private static final String SELF_SKILL_ADMIN_ID = "self-skill-admin"; @Autowired private MockMvc mockMvc; @@ -127,31 +132,86 @@ class PromotionApprovalFlowIntegrationTest { assertThat(targetVersions.get(0).getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); } + @Test + void approvePromotion_allowsSuperAdminToApproveOwnPromotionThroughV1Route() throws Exception { + when(rbacService.getUserRoleCodes(SELF_SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + PromotionGraph graph = createPromotionGraph(SELF_SUPER_ADMIN_ID); + + mockMvc.perform(post("/api/v1/promotions/" + graph.request().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"self approve\"}") + .with(authentication(portalAuth(SELF_SUPER_ADMIN_ID, "SUPER_ADMIN"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(graph.request().getId())) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.reviewedBy").value(SELF_SUPER_ADMIN_ID)) + .andExpect(jsonPath("$.data.submittedBy").value(SELF_SUPER_ADMIN_ID)); + + PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId())) + .stream() + .findFirst() + .orElseThrow(); + assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); + assertThat(savedRequest.getReviewedBy()).isEqualTo(SELF_SUPER_ADMIN_ID); + assertThat(savedRequest.getTargetSkillId()).isNotNull(); + verify(governanceNotificationService).notifyUser( + eq(SELF_SUPER_ADMIN_ID), + eq("PROMOTION"), + eq("PROMOTION_REQUEST"), + eq(graph.request().getId()), + eq("Promotion approved"), + any() + ); + } + + @Test + void approvePromotion_rejectsSkillAdminSelfApprovalThroughWebRoute() throws Exception { + when(rbacService.getUserRoleCodes(SELF_SKILL_ADMIN_ID)).thenReturn(Set.of("SKILL_ADMIN")); + PromotionGraph graph = createPromotionGraph(SELF_SKILL_ADMIN_ID); + + mockMvc.perform(post("/api/web/promotions/" + graph.request().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"self approve\"}") + .with(authentication(portalAuth(SELF_SKILL_ADMIN_ID, "SKILL_ADMIN"))) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId())) + .stream() + .findFirst() + .orElseThrow(); + assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + assertThat(savedRequest.getTargetSkillId()).isNull(); + } + private PromotionGraph createPromotionGraph() { + return createPromotionGraph(SUBMITTER_ID); + } + + private PromotionGraph createPromotionGraph(String submitterId) { String suffix = UUID.randomUUID().toString().substring(0, 8); - userAccountRepository.saveAndFlush( - new UserAccount(SUBMITTER_ID, "Promotion Owner", "owner-" + suffix + "@example.com", null) - ); - userAccountRepository.saveAndFlush( - new UserAccount(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com", null) - ); + saveUserIfAbsent(submitterId, "Promotion Owner", "owner-" + suffix + "@example.com"); + saveUserIfAbsent(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com"); Namespace globalNamespace = new Namespace("global-" + suffix, "Global " + suffix, REVIEWER_ID); globalNamespace.setType(NamespaceType.GLOBAL); globalNamespace = namespaceRepository.saveAndFlush(globalNamespace); - Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, SUBMITTER_ID); + Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, submitterId); teamNamespace = namespaceRepository.saveAndFlush(teamNamespace); - Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, SUBMITTER_ID, SkillVisibility.PUBLIC); + Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, submitterId, SkillVisibility.PUBLIC); sourceSkill.setDisplayName("Promote Skill " + suffix); sourceSkill.setSummary("Used to verify promotion approval flow."); - sourceSkill.setCreatedBy(SUBMITTER_ID); - sourceSkill.setUpdatedBy(SUBMITTER_ID); + sourceSkill.setCreatedBy(submitterId); + sourceSkill.setUpdatedBy(submitterId); sourceSkill = skillRepository.saveAndFlush(sourceSkill); - SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", SUBMITTER_ID); + SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", submitterId); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); sourceVersion.setPublishedAt(Instant.now()); sourceVersion.setRequestedVisibility(SkillVisibility.PUBLIC); @@ -160,16 +220,22 @@ class PromotionApprovalFlowIntegrationTest { sourceVersion = skillVersionRepository.saveAndFlush(sourceVersion); sourceSkill.setLatestVersionId(sourceVersion.getId()); - sourceSkill.setUpdatedBy(SUBMITTER_ID); + sourceSkill.setUpdatedBy(submitterId); sourceSkill = skillRepository.saveAndFlush(sourceSkill); PromotionRequest request = promotionRequestRepository.saveAndFlush( - new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), SUBMITTER_ID) + new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), submitterId) ); return new PromotionGraph(globalNamespace, sourceSkill, sourceVersion, request); } + private void saveUserIfAbsent(String userId, String displayName, String email) { + if (!userAccountRepository.existsById(userId)) { + userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null)); + } + } + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..ee943c4f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -144,6 +144,32 @@ class NotificationEventListenerTest { eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); } + @Test + void onPromotionApproved_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onPromotionApproved(new PromotionApprovedEvent(200L, 1L, "self-admin", "self-admin")); + + verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + + @Test + void onPromotionRejected_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onPromotionRejected(new PromotionRejectedEvent(200L, 1L, "self-admin", "self-admin", "not ready")); + + verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_REJECTED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + @Test void onReportResolved_shouldDispatchToReporter() throws Exception { Skill skill = mockSkill(1L); 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 new file mode 100644 index 00000000..abede8fc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -0,0 +1,170 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.PromotionService; +import com.iflytek.skillhub.dto.PromotionResponseDto; +import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.lang.reflect.Field; +import java.util.Set; +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 static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PromotionPortalAppServiceTest { + + private static final Long PROMOTION_ID = 1L; + private static final String SUPER_ADMIN_ID = "super-admin"; + private static final String REVIEWER_ID = "reviewer"; + private static final String SUBMITTER_ID = "submitter"; + + @Mock + private PromotionService promotionService; + @Mock + private PromotionRequestRepository promotionRequestRepository; + @Mock + private GovernanceQueryRepository governanceQueryRepository; + @Mock + private RbacService rbacService; + @Mock + private AuditLogService auditLogService; + + private PromotionPortalAppService service; + + @BeforeEach + void setUp() { + service = new PromotionPortalAppService( + promotionService, + promotionRequestRepository, + governanceQueryRepository, + rbacService, + auditLogService + ); + } + + @Test + void approvePromotion_recordsSelfReviewAuditDetailForSuperAdminSelfApproval() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + when(promotionService.approvePromotion(PROMOTION_ID, SUPER_ADMIN_ID, "ship", Set.of("SUPER_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.approvePromotion( + PROMOTION_ID, + "ship", + SUPER_ADMIN_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(SUPER_ADMIN_ID), + eq("PROMOTION_APPROVE"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"comment\":\"ship\",\"selfReview\":true}") + ); + } + + @Test + void rejectPromotion_recordsSelfReviewAuditDetailWithoutComment() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + when(promotionService.rejectPromotion(PROMOTION_ID, SUPER_ADMIN_ID, null, Set.of("SUPER_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.rejectPromotion( + PROMOTION_ID, + null, + SUPER_ADMIN_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(SUPER_ADMIN_ID), + eq("PROMOTION_REJECT"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"selfReview\":true}") + ); + } + + @Test + void approvePromotion_keepsExistingAuditDetailForReviewerApprovingOthersPromotion() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUBMITTER_ID); + when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SKILL_ADMIN")); + when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ship", Set.of("SKILL_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.approvePromotion( + PROMOTION_ID, + "ship", + REVIEWER_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(REVIEWER_ID), + eq("PROMOTION_APPROVE"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"comment\":\"ship\"}") + ); + } + + private PromotionResponseDto response(PromotionRequest request) { + return new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + "team-a", + "skill-a", + "1.0.0", + "global", + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + "Submitter", + request.getReviewedBy(), + null, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + ); + } + + private PromotionRequest promotionRequest(Long id, String submittedBy) { + PromotionRequest request = new PromotionRequest(10L, 20L, 30L, submittedBy); + setId(request, id); + return request; + } + + private void setId(Object entity, Long id) { + try { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index c3c125b1..df86ec75 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -108,7 +108,7 @@ public class ReviewPermissionChecker { String userId, Set platformRoles) { if (request.getSubmittedBy().equals(userId)) { - return false; + return platformRoles.contains("SUPER_ADMIN"); } return hasPlatformReviewRole(platformRoles); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 19b1887f..1b10f394 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -401,6 +401,51 @@ class PromotionServiceTest { verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); } + + @Test + void shouldNotifySubmitterWhenSuperAdminApprovesOwnPromotion() { + PromotionRequest request = createPendingPromotion(); + PromotionRequest approvedRequest = approvedPromotion(request, "self approve"); + approvedRequest.setReviewedBy(USER_ID); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", USER_ID, SkillVisibility.PUBLIC); + setField(newSkill, "id", NEW_SKILL_ID); + SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), USER_ID); + setField(newVersion, "id", NEW_VERSION_ID); + + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(request), Optional.of(approvedRequest)); + when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, USER_ID, "self approve", null, request.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest); + + promotionService.approvePromotion(PROMOTION_ID, USER_ID, "self approve", Set.of("SUPER_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); + } + + @Test + void shouldNotifySubmitterWhenSuperAdminRejectsOwnPromotion() { + PromotionRequest request = createPendingPromotion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.REJECTED, USER_ID, "self reject", null, request.getVersion())) + .thenReturn(1); + + promotionService.rejectPromotion(PROMOTION_ID, USER_ID, "self reject", Set.of("SUPER_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); + } } @Nested diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 25ae2f70..faf94008 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -234,10 +234,26 @@ class ReviewPermissionCheckerTest { } @Test - void cannotReviewOwnPromotion() { + void skillAdminCannotReviewOwnPromotion() { String userId = "user-2"; PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); assertFalse(checker.canReviewPromotion(req, userId, Set.of("SKILL_ADMIN"))); } + + @Test + void regularUserCannotReviewOwnPromotion() { + String userId = "user-2"; + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); + assertFalse(checker.canReviewPromotion(req, userId, + Set.of())); + } + + @Test + void superAdminCanReviewOwnPromotion() { + String userId = "user-2"; + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); + assertTrue(checker.canReviewPromotion(req, userId, + Set.of("SUPER_ADMIN"))); + } } From 85274097426c9a859092849a00e4e2e7e03d6598 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 11:57:49 +0800 Subject: [PATCH 18/26] fix(notification): keep SSE live push streams open (#524) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 3 +- .../portal/NotificationController.java | 3 +- .../skillhub/filter/RequestLoggingFilter.java | 8 +++ .../filter/RequestLoggingFilterTest.java | 23 +++++++++ .../sse/SseEmitterManagerTest.java | 51 ++++++++++++++++++- web/src/api/generated/schema.d.ts | 4 +- 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml index af5ce4fd..86fe6c4c 100644 --- a/docs/api/notification.openapi.yaml +++ b/docs/api/notification.openapi.yaml @@ -80,12 +80,13 @@ paths: /notifications/sse: get: summary: Open notification SSE stream for the current user + description: Keeps a long-lived `text/event-stream` connection open. The server first sends a `connected` event, later sends `notification` events with NotificationSsePayload bodies, and may send heartbeat comments to keep the connection alive. operationId: streamNotifications security: - sessionAuth: [] responses: "200": - description: SSE stream. Each `notification` event carries NotificationSsePayload. + description: Long-lived SSE stream. Each `notification` event carries NotificationSsePayload. content: text/event-stream: schema: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index 7ab2f759..ec62ebb9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController { return ok("response.success.deleted", null); } - @GetMapping("/sse") + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sse(@RequestAttribute("userId") String userId) { return sseEmitterManager.register(userId); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index cda766ff..0f83faee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_PREFIXES = Set.of( "/actuator", "/favicon.ico", "/assets/" ); + private static final Set SKIP_SUFFIXES = Set.of( + "/sse" + ); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -89,6 +92,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return true; } } + for (String suffix : SKIP_SUFFIXES) { + if (uri.endsWith(suffix)) { + return true; + } + } return false; } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index c79708a3..495eeb63 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -78,6 +78,29 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health")); } + @Test + void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse() + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + attachAppender(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> { + assertThat(res).isSameAs(response); + res.setContentType("text/event-stream"); + res.getWriter().write("event:connected\n"); + res.getWriter().flush(); + }; + + filter.doFilter(request, response, filterChain); + + assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse")); + } + @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java index 82c502c5..43646817 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.IOException; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; class SseEmitterManagerTest { @@ -30,13 +34,19 @@ class SseEmitterManagerTest { @Test void register_shouldReturnEmitter() { - emitters.add(new TestEmitter()); + TestEmitter testEmitter = new TestEmitter(); + emitters.add(testEmitter); SseEmitter emitter = manager.register("user-1"); assertNotNull(emitter); assertEquals(1, manager.totalEmitters()); assertEquals(1, manager.emittersForUser("user-1")); + assertEquals(1, testEmitter.sentEventCount()); + assertTrue(testEmitter.sentEventData(0).stream() + .anyMatch(value -> value.toString().contains("event:connected"))); + assertTrue(testEmitter.sentEventData(0).contains("ok")); + assertTrue(testEmitter.isOpen()); } @Test @@ -93,6 +103,27 @@ class SseEmitterManagerTest { assertEquals(1, manager.emittersForUser("user-1")); } + @Test + void push_shouldSendNotificationEventToRegisteredOpenEmitter() { + TestEmitter emitter = new TestEmitter(); + emitters.add(emitter); + manager.register("user-1"); + + Map payload = Map.of( + "id", 42L, + "eventType", "PROFILE_REVIEW_SUBMITTED" + ); + manager.push("user-1", payload); + + assertEquals(2, emitter.sentEventCount()); + assertTrue(emitter.sentEventData(1).stream() + .anyMatch(value -> value.toString().contains("event:notification"))); + assertTrue(emitter.sentEventData(1).contains(payload)); + assertTrue(emitter.isOpen()); + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + @Test void heartbeat_shouldRemoveEmitterWhenSendFails() { TestEmitter healthy = new TestEmitter(); @@ -152,6 +183,8 @@ class SseEmitterManagerTest { private boolean failAfterConnected; private boolean throwOnComplete; private int sendCount; + private boolean completed; + private final List> sentEvents = new ArrayList<>(); private TestEmitter() { super(60_000L); @@ -173,6 +206,18 @@ class SseEmitterManagerTest { errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); } + boolean isOpen() { + return !completed; + } + + int sentEventCount() { + return sentEvents.size(); + } + + List sentEventData(int index) { + return sentEvents.get(index); + } + @Override public synchronized void onCompletion(Runnable callback) { this.completionCallback = callback; @@ -193,6 +238,7 @@ class SseEmitterManagerTest { if (throwOnComplete) { throw new IllegalStateException("already complete"); } + completed = true; completionCallback.run(); } @@ -202,6 +248,9 @@ class SseEmitterManagerTest { if (failAfterConnected && sendCount > 1) { throw new IOException("send failed"); } + sentEvents.add(builder.build().stream() + .map(ResponseBodyEmitter.DataWithMediaType::getData) + .toList()); } } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 07d8c65b..a141fb20 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9717,7 +9717,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9737,7 +9737,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; From 73767a9022ce73b235520ebf2a83b582afb7cfda Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 12:18:02 +0800 Subject: [PATCH 19/26] fix(notification): avoid publish notice for promotion reviewers Signed-off-by: dongmucat <1127093059@qq.com> --- .../listener/NotificationEventListener.java | 3 +- .../NotificationEventListenerTest.java | 38 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 74ad10d8..c0d280fa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -19,6 +19,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; @Component public class NotificationEventListener { @@ -53,7 +54,7 @@ public class NotificationEventListener { @TransactionalEventListener public void onSkillPublished(SkillPublishedEvent event) { skillRepository.findById(event.skillId()).ifPresent(skill -> { - if (!event.publisherId().equals(skill.getCreatedBy())) { + if (!Objects.equals(event.publisherId(), skill.getOwnerId())) { return; } String title = "Skill published: " + skillDisplayName(skill); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..bcdc8953 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.notification.domain.NotificationCategory; import com.iflytek.skillhub.notification.service.NotificationDispatcher; @@ -43,6 +44,24 @@ class NotificationEventListenerTest { return skill; } + private Skill skill(Long id, String ownerId, String createdBy) { + Skill skill = new Skill(5L, "test-skill", ownerId, SkillVisibility.PUBLIC); + skill.setCreatedBy(createdBy); + skill.setDisplayName("Test Skill"); + setId(skill, id); + return skill; + } + + private void setId(Skill skill, Long id) { + try { + java.lang.reflect.Field field = Skill.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(skill, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + private void mockNamespace() { Namespace namespace = mock(Namespace.class); when(namespace.getSlug()).thenReturn("demo"); @@ -51,8 +70,7 @@ class NotificationEventListenerTest { @Test void onSkillPublished_shouldDispatchToPublisher() throws Exception { - Skill skill = mockSkill(1L); - when(skill.getCreatedBy()).thenReturn("publisher-1"); + Skill skill = skill(1L, "publisher-1", "publisher-1"); when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); mockNamespace(); when(objectMapper.writeValueAsString(any())).thenReturn("{}"); @@ -64,9 +82,19 @@ class NotificationEventListenerTest { } @Test - void onSkillPublished_shouldSkipWhenPublisherIsNotSkillCreator() throws Exception { - Skill skill = mock(Skill.class); - when(skill.getCreatedBy()).thenReturn("submitter-1"); + void onSkillPublished_shouldSkipWhenPublisherIsNotSkillOwner() throws Exception { + Skill skill = skill(1L, "submitter-1", "submitter-1"); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + + listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); + + verifyNoInteractions(dispatcher); + } + + @Test + void onSkillPublished_shouldSkipPromotedSkillCopyCreatedByReviewer() throws Exception { + Skill skill = skill(1L, "submitter-1", "reviewer-1"); + skill.setSourceSkillId(99L); when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); From 7d0402e93783dc9ccd2efb7dba726dda981296ee Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 10:17:50 +0800 Subject: [PATCH 20/26] fix(security): address review blockers Signed-off-by: dongmucat <1127093059@qq.com> --- .env.release.example | 1 + .github/workflows/pr-scripts.yml | 5 + cli/src/platform/archive.ts | 8 +- cli/test/unit/platform/archive.test.ts | 11 +++ scripts/runtime.sh | 38 +++++++ scripts/tests/runtime-secret-test.sh | 99 +++++++++++++++++++ scripts/tests/validate-release-config-test.sh | 13 +++ scripts/tests/workflow-security-test.sh | 9 ++ scripts/validate-release-config.sh | 24 +++++ 9 files changed, 206 insertions(+), 2 deletions(-) create mode 100755 scripts/tests/runtime-secret-test.sh diff --git a/.env.release.example b/.env.release.example index 40e51ac1..2d30c7bc 100644 --- a/.env.release.example +++ b/.env.release.example @@ -105,6 +105,7 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub SKILLHUB_SECURITY_SCANNER_ENABLED=true # Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +# runtime.sh generates and persists one automatically when this placeholder is still present. SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes # Scanner LLM configuration (optional, for AI-powered scanning features) diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 2f8cc80d..d15ee4bd 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -8,6 +8,9 @@ on: - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' +permissions: + contents: read + jobs: scripts-tests: name: Script Regression Tests @@ -16,10 +19,12 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '21' - run: bash scripts/tests/publish-cli-test.sh + - run: bash scripts/tests/runtime-secret-test.sh - run: bash scripts/tests/validate-release-config-test.sh - run: bash scripts/tests/dev-web-host-test.sh - run: bash scripts/tests/workflow-security-test.sh diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index c1306711..f549185d 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -19,8 +19,12 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis const archive = new Uint8Array(buffer) validateZipCentralDirectory(archive) const files = unzipSync(archive) - for (const [name, data] of Object.entries(files)) { - const filePath = safeJoin(targetDir, name) + const entries = Object.entries(files).map(([name, data]) => ({ + name, + data, + filePath: safeJoin(targetDir, name), + })) + for (const { name, data, filePath } of entries) { if (name.endsWith('/')) { await mkdir(filePath, { recursive: true }) continue diff --git a/cli/test/unit/platform/archive.test.ts b/cli/test/unit/platform/archive.test.ts index 98972134..b175e279 100644 --- a/cli/test/unit/platform/archive.test.ts +++ b/cli/test/unit/platform/archive.test.ts @@ -32,6 +32,17 @@ describe('archive helpers', () => { await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path') }) + test('rejects unsafe zip before writing earlier safe entries', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-partial-')) + const unsafe = zipSync({ + 'SKILL.md': new TextEncoder().encode('# Partial'), + '../escape.txt': new TextEncoder().encode('bad'), + }) + + await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path') + await expect(readFile(join(target, 'SKILL.md'), 'utf-8')).rejects.toThrow() + }) + test('rejects zip entries with absolute paths', async () => { const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-abs-')) const unsafe = zipSync({ '/etc/passwd': new TextEncoder().encode('bad') }) diff --git a/scripts/runtime.sh b/scripts/runtime.sh index f5b89550..f46e7c3e 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -180,6 +180,42 @@ get_env_value() { fi } +generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + + if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then + dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' + return 0 + fi + + echo "Unable to generate SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET. Install openssl or configure it manually." >&2 + exit 1 +} + +is_placeholder_secret() { + case "$1" in + ""|change-me-in-production|replace-me|replace-with-random-download-secret-32-bytes|TODO*|todo*|replace*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_anonymous_download_secret() { + secret="$(get_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "")" + if ! is_placeholder_secret "$secret" && [ "${#secret}" -ge 32 ]; then + return 0 + fi + + set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)" + echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE" +} + wait_for_postgres_ready() { postgres_user="$1" postgres_db="$2" @@ -280,6 +316,8 @@ prepare_runtime_files() { if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE" fi + + ensure_anonymous_download_secret } run_compose() { diff --git a/scripts/tests/runtime-secret-test.sh b/scripts/tests/runtime-secret-test.sh new file mode 100755 index 00000000..34dd4fab --- /dev/null +++ b/scripts/tests/runtime-secret-test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/runtime.sh" + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +install_fake_tools() { + local bin_dir="$1" + mkdir -p "$bin_dir" + cat >"$bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${DOCKER_LOG:?DOCKER_LOG is required}" +if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then + exit 0 +fi +exit 0 +EOF + chmod +x "$bin_dir/docker" + + cat >"$bin_dir/openssl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "rand" && "${2:-}" == "-hex" && "${3:-}" == "32" ]]; then + printf '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' + exit 0 +fi +echo "unsupported openssl args: $*" >&2 +exit 1 +EOF + chmod +x "$bin_dir/openssl" +} + +run_runtime() { + local home="$1" + local bin_dir="$2" + local stdout="$3" + DOCKER_LOG="$home/docker.log" \ + SKILLHUB_HOME="$home" \ + SKILLHUB_RAW_BASE="file://$REPO_ROOT" \ + PATH="$bin_dir:$PATH" \ + sh "$SCRIPT" up --version sha-test --public-url http://localhost >"$stdout" +} + +tmp="$(new_tmp)" +bin_dir="$tmp/bin" +install_fake_tools "$bin_dir" + +home_generated="$tmp/generated" +stdout_generated="$tmp/generated.out" +mkdir -p "$home_generated" +run_runtime "$home_generated" "$bin_dir" "$stdout_generated" + +generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)" +[[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \ + || fail "runtime should generate a persisted anonymous download secret" +grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \ + || fail "runtime should explain that it generated the secret" +if grep -Fq "$generated_secret" "$stdout_generated"; then + fail "runtime must not print the generated secret value" +fi + +home_preserved="$tmp/preserved" +stdout_preserved="$tmp/preserved.out" +mkdir -p "$home_preserved" +cat >"$home_preserved/.env.release" <<'EOF' +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=already-valid-runtime-secret-32-bytes +EOF +run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved" + +preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)" +[[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \ + || fail "runtime must preserve an existing valid anonymous download secret" +if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then + fail "runtime must not regenerate an existing valid secret" +fi + +echo "runtime-secret-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 3a52d7a3..fd76ddfc 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -80,4 +80,17 @@ short_env="$tmp/short.env" write_env "$short_env" "too-short" expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" +draft_env="$tmp/draft.env" +while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=*) + printf '%s\n' "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum" + ;; + *) + printf '%s\n' "$line" + ;; + esac +done <"$REPO_ROOT/.env.release.draft" >"$draft_env" +expect_fail "$draft_env" "POSTGRES_PASSWORD" + echo "validate-release-config-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 8219af51..94cb2556 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -12,6 +12,13 @@ fail() { [[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" +grep -Eq '^permissions:[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must declare top-level permissions" +grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts GITHUB_TOKEN permissions must be read-only" +grep -Fq 'persist-credentials: false' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts checkout must not persist credentials" + grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ || fail "security workflow must run dependency review" grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \ @@ -25,6 +32,8 @@ grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when security workflow changes" grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run runtime-secret-test" grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run dev-web-host-test" grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 758b168f..03e42bd8 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -52,6 +52,23 @@ reject_values() { done } +reject_patterns() { + var_name="$1" + shift + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + for pattern in "$@"; do + case "$var_value" in + $pattern) + error "$var_name still uses placeholder/default pattern: $var_value" + return 0 + ;; + esac + done +} + validate_url() { var_name="$1" eval "var_value=\${$var_name:-}" @@ -115,15 +132,22 @@ validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes" +reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*" validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32 reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" +reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*" reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" +reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*" if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then require_non_empty BOOTSTRAP_ADMIN_PASSWORD fi reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me" reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me" +reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*" +reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*" +reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*" +reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*" validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED From b44c65443d761bff454dcec2ab29a6cf4c8563e6 Mon Sep 17 00:00:00 2001 From: SenLinLeo <1664761477@qq.com> Date: Tue, 16 Jun 2026 16:25:45 +0800 Subject: [PATCH 21/26] fix(cli): treat highlighted install target as selected Signed-off-by: SenLinLeo <1664761477@qq.com> --- cli/src/agents/resolver.ts | 9 ++++- .../unit/agents/resolver-interactive.test.ts | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 cli/test/unit/agents/resolver-interactive.test.ts diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 3d983727..2290ee09 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -160,6 +160,7 @@ function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] { async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise { const prompts = await import('prompts') + let highlightedIndex = 0 const { selected } = await prompts.default({ type: 'multiselect', name: 'selected', @@ -167,7 +168,13 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise choices: candidates.map(c => ({ title: `${c.agent} (${c.rootDir})`, value: c - })) + })), + onRender: function (this: { cursor?: number }) { + highlightedIndex = this.cursor ?? highlightedIndex + }, + format: (selectedTargets: AgentCandidate[]) => ( + selectedTargets.length > 0 ? selectedTargets : [candidates[highlightedIndex] ?? candidates[0]!] + ) }) if (!selected || selected.length === 0) { throw new CliError('installation cancelled', EXIT.usage) diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts new file mode 100644 index 00000000..55bc99ac --- /dev/null +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { AgentCandidate } from '../../../src/agents/types' + +interface PromptOptions { + onRender?: (this: { cursor?: number }) => void + format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[] +} + +mock.module('prompts', () => ({ + default: (options: PromptOptions) => { + options.onRender?.call({ cursor: 1 }) + return { selected: options.format?.([]) ?? [] } + } +})) + +const { resolveInstallTargets } = await import('../../../src/agents/resolver') + +describe('resolveInstallTargets interactive prompt', () => { + test('uses the highlighted target when Enter submits an empty multiselect', async () => { + const detected: AgentCandidate[] = [ + { agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }, + { agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' } + ] + const highlighted = detected[1]! + + const targets = await resolveInstallTargets({ + cwd: '/repo', + agents: [], + json: false, + interactive: true, + detected + }) + + expect(targets).toEqual([highlighted]) + }) +}) From e50140272b6f18fca41865f8f80672c2ab3b502d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 17:48:15 +0800 Subject: [PATCH 22/26] fix(security): close review hardening gaps Signed-off-by: dongmucat <1127093059@qq.com> --- .github/workflows/pr-cli.yml | 5 ++ .github/workflows/pr-e2e.yml | 2 + .github/workflows/pr-scripts.yml | 6 ++ .github/workflows/pr-tests.yml | 6 ++ .github/workflows/security.yml | 4 + cli/src/services/install-service.ts | 83 ++++++++++++------- .../unit/services/install-service.test.ts | 40 +++++++++ scripts/runtime.sh | 62 ++++++++++++-- scripts/tests/runtime-secret-test.sh | 26 +++++- scripts/tests/workflow-security-test.sh | 33 ++++++-- .../skill/service/SkillDownloadService.java | 36 ++++++-- .../service/SkillDownloadServiceTest.java | 28 +++++++ 12 files changed, 280 insertions(+), 51 deletions(-) diff --git a/.github/workflows/pr-cli.yml b/.github/workflows/pr-cli.yml index 7de2a31e..65c0ca6a 100644 --- a/.github/workflows/pr-cli.yml +++ b/.github/workflows/pr-cli.yml @@ -7,6 +7,9 @@ on: - 'Makefile' - '.github/workflows/pr-cli.yml' +permissions: + contents: read + jobs: cli: strategy: @@ -16,6 +19,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.13 diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml index f25d8072..f94ffdc2 100644 --- a/.github/workflows/pr-e2e.yml +++ b/.github/workflows/pr-e2e.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index d15ee4bd..1e31dccf 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -4,7 +4,13 @@ on: pull_request: paths: - 'scripts/**' + - '.env.release.example' + - '.env.release.draft' + - 'compose.release.yml' - 'Makefile' + - '.github/workflows/pr-cli.yml' + - '.github/workflows/pr-e2e.yml' + - '.github/workflows/pr-tests.yml' - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index da4d7622..4e957ec3 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up pnpm uses: pnpm/action-setup@v4 @@ -52,6 +54,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Java uses: actions/setup-java@v4 @@ -74,6 +78,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Detect docs changes id: changed diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9b476ca9..3329a267 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Review dependency changes uses: actions/dependency-review-action@v4 @@ -54,6 +56,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Java if: matrix.language == 'java-kotlin' diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index ed09ffd6..4293ff6e 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' import { InventoryStore } from '../stores/inventory-store' @@ -39,35 +39,62 @@ export async function installSkill(options: InstallOptions): Promise<{ installed }) } - if (await pathExists(skillDir) && options.force) { - await store.removeTargetsByInstallDir(skillDir) - await rm(skillDir, { recursive: true, force: true }) + await mkdir(target.rootDir, { recursive: true }) + const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) + let movedIntoPlace = false + + try { + await extractZip(buffer, tempDir) + + const installedAt = new Date().toISOString() + const metaDir = join(tempDir, '.skillhub') + await mkdir(metaDir, { recursive: true }) + await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ + registry: options.registry, + namespace: options.namespace, + slug: options.slug, + version: resolved.version, + agent: target.agent, + installedAt + }, null, 2)) + + if (await pathExists(skillDir) && !options.force) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + + if (await pathExists(skillDir) && options.force) { + await store.removeTargetsByInstallDir(skillDir) + await rm(skillDir, { recursive: true, force: true }) + } + + try { + await rename(tempDir, skillDir) + } catch (error) { + if (!options.force && await pathExists(skillDir)) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + throw error + } + movedIntoPlace = true + + await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, { + agent: target.agent, + rootDir: target.rootDir, + installDir: skillDir, + installedAt + }) + } finally { + if (!movedIntoPlace) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } } - // Create skill directory and extract into a clean skill-specific directory. - await mkdir(skillDir, { recursive: true }) - await extractZip(buffer, skillDir) - - // Write .skillhub/metadata.json - const metaDir = join(skillDir, '.skillhub') - await mkdir(metaDir, { recursive: true }) - await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ - registry: options.registry, - namespace: options.namespace, - slug: options.slug, - version: resolved.version, - agent: target.agent, - installedAt: new Date().toISOString() - }, null, 2)) - - // Update inventory - await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, { - agent: target.agent, - rootDir: target.rootDir, - installDir: skillDir, - installedAt: new Date().toISOString() - }) - installed.push({ agent: target.agent, dir: skillDir }) } diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 784f4394..fd08e1b6 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -132,6 +132,46 @@ describe('installSkill', () => { expect(inventory.items[0].targets[0].installDir).toBe(skillDir) }) + test('force keeps old installation and inventory when replacement extraction fails', async () => { + globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 })) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Old') + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(inventoryPath, JSON.stringify({ + items: [{ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + version: '0.1.0', + targets: [{ + agent: 'codex', + rootDir, + installDir: skillDir, + installedAt: '2026-04-20T00:00:00.000Z' + }] + }] + }, null, 2)) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home + })).rejects.toThrow('invalid zip central directory') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old') + const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(inventory.items).toHaveLength(1) + expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' }) + expect(inventory.items[0].targets[0].installDir).toBe(skillDir) + }) + test('rejects downloads whose content-length exceeds the package limit', async () => { globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), { status: 200, diff --git a/scripts/runtime.sh b/scripts/runtime.sh index f46e7c3e..fec6375e 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -159,13 +159,34 @@ set_env_value() { fi tmp="$ENV_FILE.tmp" - if grep -q "^$key=" "$ENV_FILE"; then - sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp" - else - cat "$ENV_FILE" >"$tmp" - printf '%s=%s\n' "$key" "$value" >>"$tmp" - fi + found=false + old_umask="$(umask)" + umask 077 + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "$key="*) + printf '%s=%s\n' "$key" "$value" + found=true + ;; + *) + printf '%s\n' "$line" + ;; + esac + done <"$ENV_FILE" + if [ "$found" = "false" ]; then + printf '%s=%s\n' "$key" "$value" + fi + } >"$tmp" + umask "$old_umask" mv "$tmp" "$ENV_FILE" + secure_env_file +} + +secure_env_file() { + if [ -f "$ENV_FILE" ]; then + chmod 600 "$ENV_FILE" + fi } get_env_value() { @@ -235,6 +256,23 @@ wait_for_postgres_ready() { exit 1 } +wait_for_redis_ready() { + attempt=1 + + while [ "$attempt" -le 60 ]; do + if run_compose exec -T redis redis-cli ping >/dev/null 2>&1; then + return 0 + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + echo "Redis did not become ready in time." >&2 + run_compose logs redis >&2 || true + exit 1 +} + ensure_postgres_password_matches_env() { postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")" postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")" @@ -267,8 +305,12 @@ prepare_runtime_files() { download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE" if [ ! -f "$ENV_FILE" ]; then + old_umask="$(umask)" + umask 077 cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" + umask "$old_umask" fi + secure_env_file if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}" @@ -317,6 +359,10 @@ prepare_runtime_files() { set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE" fi + if [ "$DISABLE_SCANNER" = "true" ]; then + set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false" + fi + ensure_anonymous_download_secret } @@ -333,7 +379,9 @@ case "$COMMAND" in run_compose up -d postgres ensure_postgres_password_matches_env if [ "$DISABLE_SCANNER" = "true" ]; then - SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0 + run_compose up -d redis + wait_for_redis_ready + SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --no-deps --scale skill-scanner=0 server web else run_compose up -d fi diff --git a/scripts/tests/runtime-secret-test.sh b/scripts/tests/runtime-secret-test.sh index 34dd4fab..506b58fd 100755 --- a/scripts/tests/runtime-secret-test.sh +++ b/scripts/tests/runtime-secret-test.sh @@ -56,11 +56,21 @@ run_runtime() { local home="$1" local bin_dir="$2" local stdout="$3" + shift 3 DOCKER_LOG="$home/docker.log" \ SKILLHUB_HOME="$home" \ SKILLHUB_RAW_BASE="file://$REPO_ROOT" \ PATH="$bin_dir:$PATH" \ - sh "$SCRIPT" up --version sha-test --public-url http://localhost >"$stdout" + sh "$SCRIPT" up --version sha-test --public-url http://localhost "$@" >"$stdout" +} + +file_mode() { + local file="$1" + if stat -c %a "$file" >/dev/null 2>&1; then + stat -c %a "$file" + else + stat -f %Lp "$file" + fi } tmp="$(new_tmp)" @@ -75,6 +85,8 @@ run_runtime "$home_generated" "$bin_dir" "$stdout_generated" generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)" [[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \ || fail "runtime should generate a persisted anonymous download secret" +[[ "$(file_mode "$home_generated/.env.release")" == "600" ]] \ + || fail "runtime env file must be readable only by the owner" grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \ || fail "runtime should explain that it generated the secret" if grep -Fq "$generated_secret" "$stdout_generated"; then @@ -92,8 +104,20 @@ run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved" preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)" [[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \ || fail "runtime must preserve an existing valid anonymous download secret" +[[ "$(file_mode "$home_preserved/.env.release")" == "600" ]] \ + || fail "runtime env file must remain owner-readable only when an existing secret is preserved" if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then fail "runtime must not regenerate an existing valid secret" fi +home_no_scanner="$tmp/no-scanner" +stdout_no_scanner="$tmp/no-scanner.out" +mkdir -p "$home_no_scanner" +run_runtime "$home_no_scanner" "$bin_dir" "$stdout_no_scanner" --no-scanner + +grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.release" \ + || fail "runtime should persist scanner disabled state for --no-scanner" +grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \ + || fail "runtime --no-scanner should start server/web without waiting on scanner dependencies" + echo "runtime-secret-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 94cb2556..2d6deb43 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -10,14 +10,23 @@ fail() { exit 1 } +assert_pr_workflow_hardened() { + local workflow="$1" + grep -Eq '^permissions:[[:space:]]*$' "$workflow" \ + || fail "$workflow must declare top-level permissions" + grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$workflow" \ + || fail "$workflow GITHUB_TOKEN permissions must include contents: read" + grep -Fq 'persist-credentials: false' "$workflow" \ + || fail "$workflow checkout steps must not persist credentials" +} + [[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" -grep -Eq '^permissions:[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ - || fail "pr-scripts must declare top-level permissions" -grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$PR_SCRIPTS_WORKFLOW" \ - || fail "pr-scripts GITHUB_TOKEN permissions must be read-only" -grep -Fq 'persist-credentials: false' "$PR_SCRIPTS_WORKFLOW" \ - || fail "pr-scripts checkout must not persist credentials" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml" +assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW" +assert_pr_workflow_hardened "$SECURITY_WORKFLOW" grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ || fail "security workflow must run dependency review" @@ -30,6 +39,18 @@ grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when security workflow changes" +grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR CLI workflow changes" +grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR E2E workflow changes" +grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release env example changes" +grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release env draft changes" +grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release compose changes" grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run validate-release-config-test" grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index b53a6c63..56b69ddd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -102,7 +102,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -123,7 +123,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -150,7 +150,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(tag.getVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tagName)); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -161,9 +161,12 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } - private DownloadResult downloadVersion(Skill skill, SkillVersion version) { + private DownloadResult downloadVersion(Skill skill, + SkillVersion version, + String currentUserId, + Map userNsRoles) { assertPublishedAccessible(skill); - assertDownloadableVersion(skill, version); + assertDownloadableVersion(skill, version, currentUserId, userNsRoles); DownloadResult result = buildDownloadResult(skill, version); // Only increment download count for PUBLISHED versions @@ -304,18 +307,33 @@ public class SkillDownloadService { /** * Asserts that the version can be downloaded. * - PUBLISHED: anyone with skill access can download - * - UPLOADED/PENDING_REVIEW: only skill owner can download + * - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download */ - private void assertDownloadableVersion(Skill skill, SkillVersion version) { + private void assertDownloadableVersion(Skill skill, + SkillVersion version, + String currentUserId, + Map userNsRoles) { switch (version.getStatus()) { case PUBLISHED -> { // Anyone with skill access can download published versions } case UPLOADED, PENDING_REVIEW -> { - // Only owner can download UPLOADED/PENDING_REVIEW versions - // Note: This check is already done in assertCanDownload via visibilityChecker + if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } } default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } + + private boolean canManageSkillDraft(Skill skill, String currentUserId, Map userNsRoles) { + if (currentUserId == null) { + return false; + } + if (skill.getOwnerId().equals(currentUserId)) { + return true; + } + NamespaceRole role = userNsRoles == null ? null : userNsRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 4bc20d34..ce84509d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -6,6 +6,7 @@ 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.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -351,6 +352,33 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_RejectsAnonymousPendingReviewPublicSkill() throws Exception { + Namespace namespace = new Namespace("global", "Global", "system"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + Skill skill = new Skill(1L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.1.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.1.0")).thenReturn(Optional.of(version)); + + assertThrows(DomainForbiddenException.class, () -> + service.downloadVersion("global", "demo-skill", "1.1.0", null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); From 52251fcd0edd76f9372e14481458e1e61e0c20dd Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 10:25:34 +0800 Subject: [PATCH 23/26] chore(docs): remove handwritten notification OpenAPI (#524) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 248 ----------------------------- 1 file changed, 248 deletions(-) delete mode 100644 docs/api/notification.openapi.yaml diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml deleted file mode 100644 index 86fe6c4c..00000000 --- a/docs/api/notification.openapi.yaml +++ /dev/null @@ -1,248 +0,0 @@ -openapi: 3.0.3 -info: - title: SkillHub Notification API - version: 1.0.0 - description: In-app notification list, unread count, and SSE event contract. -servers: - - url: /api/web - - url: /api/v1 -paths: - /notifications: - get: - summary: List notifications for the current user - operationId: listNotifications - security: - - sessionAuth: [] - parameters: - - name: category - in: query - required: false - description: Optional category filter. Profile review notifications reuse REVIEW. - schema: - type: string - enum: [PUBLISH, REVIEW, PROMOTION, REPORT] - example: REVIEW - - name: page - in: query - required: false - schema: - type: integer - minimum: 0 - default: 0 - example: 0 - - name: size - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - example: 20 - responses: - "200": - description: Notification page. - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationPageResponse" - "400": - description: Invalid category. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /notifications/unread-count: - get: - summary: Get unread notification count for the current user - operationId: getNotificationUnreadCount - security: - - sessionAuth: [] - responses: - "200": - description: Unread count. - content: - application/json: - schema: - $ref: "#/components/schemas/UnreadCountResponse" - "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /notifications/sse: - get: - summary: Open notification SSE stream for the current user - description: Keeps a long-lived `text/event-stream` connection open. The server first sends a `connected` event, later sends `notification` events with NotificationSsePayload bodies, and may send heartbeat comments to keep the connection alive. - operationId: streamNotifications - security: - - sessionAuth: [] - responses: - "200": - description: Long-lived SSE stream. Each `notification` event carries NotificationSsePayload. - content: - text/event-stream: - schema: - type: string - "401": - description: Authentication required. -components: - securitySchemes: - sessionAuth: - type: apiKey - in: cookie - name: SESSION - schemas: - NotificationPageResponse: - type: object - properties: - code: - type: integer - example: 0 - msg: - type: string - example: ok - data: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/Notification" - total: - type: integer - format: int64 - example: 1 - page: - type: integer - example: 0 - size: - type: integer - example: 20 - timestamp: - type: string - format: date-time - requestId: - type: string - Notification: - type: object - properties: - id: - type: integer - format: int64 - example: 1001 - category: - type: string - enum: [PUBLISH, REVIEW, PROMOTION, REPORT] - example: REVIEW - eventType: - type: string - description: Known values include REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, PROFILE_REVIEW_SUBMITTED, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, REPORT_SUBMITTED, REPORT_RESOLVED, SKILL_PUBLISHED, SUBSCRIPTION_NEW_VERSION, and SUBSCRIPTION_VERSION_YANKED. - example: PROFILE_REVIEW_SUBMITTED - title: - type: string - example: Profile review submitted - bodyJson: - type: string - description: JSON string. For PROFILE_REVIEW_SUBMITTED it contains profileReviewId, submitterId, and fields. - example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' - entityType: - type: string - example: PROFILE_REVIEW - entityId: - type: integer - format: int64 - example: 77 - status: - type: string - enum: [UNREAD, READ] - example: UNREAD - createdAt: - type: string - format: date-time - readAt: - type: string - format: date-time - nullable: true - targetType: - type: string - example: PROFILE_REVIEW - targetId: - type: integer - format: int64 - example: 77 - targetRoute: - type: string - description: Profile review notifications route to the admin profile review queue. - example: /dashboard/reviews?type=profile - NotificationSsePayload: - type: object - properties: - id: - type: integer - format: int64 - example: 1001 - category: - type: string - example: REVIEW - eventType: - type: string - example: PROFILE_REVIEW_SUBMITTED - title: - type: string - example: Profile review submitted - bodyJson: - type: string - example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' - entityType: - type: string - example: PROFILE_REVIEW - entityId: - type: integer - format: int64 - example: 77 - createdAt: - type: string - format: date-time - UnreadCountResponse: - type: object - properties: - code: - type: integer - example: 0 - msg: - type: string - example: ok - data: - type: object - properties: - count: - type: integer - format: int64 - example: 1 - timestamp: - type: string - format: date-time - requestId: - type: string - ErrorResponse: - type: object - properties: - code: - type: integer - example: 400 - msg: - type: string - example: Invalid category - timestamp: - type: string - format: date-time - requestId: - type: string From 47a0ef1f7f29fcf187bfa078d2fb50c2d2cc281a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:48:04 +0800 Subject: [PATCH 24/26] chore(deps): patch dependabot security alerts Signed-off-by: dongmucat <1127093059@qq.com> --- docs/skillhub/.vitepress/config.ts | 7 +- docs/skillhub/package-lock.json | 220 +++++++------- docs/skillhub/package.json | 4 +- web/package.json | 7 +- web/pnpm-lock.yaml | 456 ++++++++++++++++++----------- web/vite.config.ts | 5 +- 6 files changed, 403 insertions(+), 296 deletions(-) diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index c99092f4..cdacab91 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -7,6 +7,11 @@ export default defineConfig({ ignoreDeadLinks: [/^http:\/\/localhost/], head: [], + vite: { + build: { + target: 'es2020', + }, + }, // Define root locale for redirect locales: { @@ -124,4 +129,4 @@ export default defineConfig({ }, }, }, -}) \ No newline at end of file +}) diff --git a/docs/skillhub/package-lock.json b/docs/skillhub/package-lock.json index e9bf813d..4bdf62a4 100644 --- a/docs/skillhub/package-lock.json +++ b/docs/skillhub/package-lock.json @@ -369,9 +369,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -386,9 +386,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -403,9 +403,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -420,9 +420,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -437,9 +437,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -454,9 +454,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -505,9 +505,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -522,9 +522,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -539,9 +539,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -556,9 +556,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -573,9 +573,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -590,9 +590,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -607,9 +607,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -624,9 +624,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -641,9 +641,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -658,9 +658,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -692,9 +692,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -726,9 +726,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -794,9 +794,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1757,9 +1757,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1770,32 +1770,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -2475,9 +2475,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/skillhub/package.json b/docs/skillhub/package.json index 4b119b04..a044c51f 100644 --- a/docs/skillhub/package.json +++ b/docs/skillhub/package.json @@ -11,8 +11,8 @@ "vitepress": "^1.6.3" }, "overrides": { - "vite": "^6.4.2", + "vite": "^6.4.3", "postcss": "^8.5.10", - "esbuild": "^0.25.0" + "esbuild": "^0.28.1" } } diff --git a/web/package.json b/web/package.json index 8dc05dfb..4b969147 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,10 @@ "esbuild" ], "overrides": { - "vite@<6.4.2": "^6.4.2", + "vite@<6.4.3": "^6.4.3", + "esbuild@<0.28.1": "^0.28.1", + "js-yaml@<4.2.0": "^4.2.0", + "@babel/core@<7.29.6": "^7.29.6", "postcss@<8.5.10": "^8.5.10", "picomatch@<2.3.2": "^2.3.2", "picomatch@>=4.0.0 <4.0.4": "^4.0.4", @@ -76,7 +79,7 @@ "postcss": "^8.5.10", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.4.2", + "vite": "^6.4.3", "vitest": "^4.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b12a2c88..389b1abb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -5,7 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - vite@<6.4.2: ^6.4.2 + vite@<6.4.3: ^6.4.3 + esbuild@<0.28.1: ^0.28.1 + js-yaml@<4.2.0: ^4.2.0 + '@babel/core@<7.29.6': ^7.29.6 postcss@<8.5.10: ^8.5.10 picomatch@<2.3.2: ^2.3.2 picomatch@>=4.0.0 <4.0.4: ^4.0.4 @@ -116,7 +119,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@6.4.2(jiti@1.21.7)) + version: 4.7.0(vite@6.4.3(jiti@1.21.7)) autoprefixer: specifier: ^10.4.0 version: 10.4.27(postcss@8.5.15) @@ -145,11 +148,11 @@ importers: specifier: ^5.7.0 version: 5.9.3 vite: - specifier: ^6.4.2 - version: 6.4.2(jiti@1.21.7) + specifier: ^6.4.3 + version: 6.4.3(jiti@1.21.7) vitest: specifier: ^4.1.0 - version: 4.1.8(jsdom@29.1.1)(vite@6.4.2(jiti@1.21.7)) + version: 4.1.8(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) packages: @@ -176,35 +179,51 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.29.6 '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} @@ -214,16 +233,24 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.0': @@ -231,17 +258,22 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^7.29.6 '@babel/plugin-transform-react-jsx-source@7.27.1': resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^7.29.6 '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} @@ -251,14 +283,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -343,158 +387,158 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1217,7 +1261,7 @@ packages: resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^6.4.2 + vite: ^6.4.3 '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -1226,7 +1270,7 @@ packages: resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: msw: optional: true @@ -1523,8 +1567,8 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1851,8 +1895,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsdom@29.1.1: @@ -2712,8 +2756,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -2768,7 +2812,7 @@ packages: '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -2906,19 +2950,25 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -2936,16 +2986,26 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/generator@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -2953,12 +3013,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -2966,27 +3033,35 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helpers@7.28.6': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/parser@7.29.7': dependencies: - '@babel/core': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.28.6': {} @@ -2997,6 +3072,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3009,11 +3090,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -3116,82 +3214,82 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': @@ -3209,7 +3307,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3585,7 +3683,7 @@ snapshots: colorette: 1.4.0 https-proxy-agent: 7.0.6(supports-color@10.2.2) js-levenshtein: 1.1.6 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 5.1.9 pluralize: 8.0.0 yaml-ast-parser: 0.0.43 @@ -3878,15 +3976,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) transitivePeerDependencies: - supports-color @@ -3899,13 +3997,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@6.4.2(jiti@1.21.7))': + '@vitest/mocker@4.1.8(vite@6.4.3(jiti@1.21.7))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) '@vitest/pretty-format@4.1.8': dependencies: @@ -4172,34 +4270,34 @@ snapshots: es-module-lexer@2.1.0: {} - esbuild@0.25.12: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -4253,7 +4351,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.1 + js-yaml: 4.2.0 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -4543,7 +4641,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -5206,7 +5304,7 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) classnames: 2.5.1 diff: 8.0.4 - js-yaml: 4.1.1 + js-yaml: 4.2.0 memoize-one: 6.0.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -5665,9 +5763,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.2(jiti@1.21.7): + vite@6.4.3(jiti@1.21.7): dependencies: - esbuild: 0.25.12 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 @@ -5677,10 +5775,10 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.8(jsdom@29.1.1)(vite@6.4.2(jiti@1.21.7)): + vitest@4.1.8(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@6.4.2(jiti@1.21.7)) + '@vitest/mocker': 4.1.8(vite@6.4.3(jiti@1.21.7)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -5697,7 +5795,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: jsdom: 29.1.1 diff --git a/web/vite.config.ts b/web/vite.config.ts index f49b7759..c5abfe3b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +const JS_BUILD_TARGET = 'es2020' const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14'] export default defineConfig({ @@ -12,12 +13,12 @@ export default defineConfig({ }, }, build: { - target: LEGACY_BROWSER_TARGETS, + target: JS_BUILD_TARGET, cssTarget: LEGACY_BROWSER_TARGETS, }, optimizeDeps: { esbuildOptions: { - target: LEGACY_BROWSER_TARGETS, + target: JS_BUILD_TARGET, }, }, test: { From 32f34fbf4c6c56489a734a317ccf09a660275adc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:27:19 +0800 Subject: [PATCH 25/26] fix(ci): remove unsupported Python CodeQL scan Signed-off-by: dongmucat <1127093059@qq.com> --- .github/workflows/pr-scripts.yml | 1 + .github/workflows/security.yml | 2 -- scripts/tests/workflow-security-test.sh | 13 +++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 1e31dccf..e521eb9e 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -13,6 +13,7 @@ on: - '.github/workflows/pr-tests.yml' - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' + - '**/*.py' permissions: contents: read diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3329a267..9382d933 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -50,8 +50,6 @@ jobs: build-mode: manual - language: javascript-typescript build-mode: none - - language: python - build-mode: none steps: - name: Check out repository diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 2d6deb43..9a1688ef 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -37,6 +37,17 @@ grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \ grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ || fail "security workflow must grant SARIF upload permission" +python_source="$(find "$REPO_ROOT" \ + \( -path "$REPO_ROOT/.git" -o -path '*/node_modules' -o -path '*/.venv' \) -prune -o \ + -type f -name '*.py' -print -quit)" +if [[ -n "$python_source" ]]; then + grep -Fq 'language: python' "$SECURITY_WORKFLOW" \ + || fail "security workflow must run Python CodeQL when Python source exists" +else + ! grep -Fq 'language: python' "$SECURITY_WORKFLOW" \ + || fail "security workflow must not run Python CodeQL without Python source" +fi + grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when security workflow changes" grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ @@ -45,6 +56,8 @@ grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR E2E workflow changes" grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when Python source changes" grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when release env example changes" grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ From 48a1de9640d4b298ccc5d0c20ac0827c766f234f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 15:19:07 +0800 Subject: [PATCH 26/26] fix(notification): preserve SSE headers for notification stream Signed-off-by: dongmucat <1127093059@qq.com> --- .../skillhub/filter/RequestLoggingFilter.java | 17 +++++ .../filter/RequestLoggingFilterTest.java | 66 +++++++++++++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index 0f83faee..d46a1e49 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -8,6 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; @@ -39,6 +41,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { throws ServletException, IOException { String uri = request.getRequestURI(); + if (isNotificationSse(uri)) { + prepareSseResponse(response); + filterChain.doFilter(request, response); + return; + } if (shouldSkip(uri)) { filterChain.doFilter(request, response); return; @@ -100,6 +107,16 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return false; } + private boolean isNotificationSse(String uri) { + return uri != null && uri.endsWith("/notifications/sse"); + } + + private void prepareSseResponse(HttpServletResponse response) { + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform"); + response.setHeader("X-Accel-Buffering", "no"); + } + private String getRequestBody(ContentCachingRequestWrapper request) { byte[] buf = request.getContentAsByteArray(); if (buf.length > 0) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index 495eeb63..11ec0aec 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -1,22 +1,26 @@ package com.iflytek.skillhub.filter; +import static org.assertj.core.api.Assertions.assertThat; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - +import jakarta.servlet.ServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.util.ContentCachingResponseWrapper; class RequestLoggingFilterTest { @@ -79,12 +83,12 @@ class RequestLoggingFilterTest { } @Test - void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse() + void doFilterInternal_skipsOtherSseEndpointsWithoutWrappingResponse() throws ServletException, IOException { RequestLoggingFilter filter = new RequestLoggingFilter(); attachAppender(); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/scan/sse"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = (req, res) -> { @@ -97,8 +101,10 @@ class RequestLoggingFilterTest { filter.doFilter(request, response, filterChain); assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getHeader("X-Accel-Buffering")).isNull(); + assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull(); assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); - assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse")); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/scan/sse")); } @Test @@ -124,6 +130,44 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {")); } + @Test + void doFilterInternal_shouldBypassCachingWrapperForNotificationSse() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference responseSeenByChain = new AtomicReference<>(); + FilterChain chain = (servletRequest, servletResponse) -> { + responseSeenByChain.set(servletResponse); + servletResponse.getWriter().write("event: connected\n"); + servletResponse.flushBuffer(); + }; + + filter.doFilter(request, response, chain); + + assertThat(responseSeenByChain.get()).isSameAs(response); + assertThat(response.getHeader("X-Accel-Buffering")).isEqualTo("no"); + assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache, no-transform"); + assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getContentAsString()).contains("event: connected"); + } + + @Test + void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/unread-count"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference responseSeenByChain = new AtomicReference<>(); + FilterChain chain = (servletRequest, servletResponse) -> { + responseSeenByChain.set(servletResponse); + servletResponse.getWriter().write("{\"count\":1}"); + }; + + filter.doFilter(request, response, chain); + + assertThat(responseSeenByChain.get()).isInstanceOf(ContentCachingResponseWrapper.class); + assertThat(response.getContentAsString()).isEqualTo("{\"count\":1}"); + } + private void attachAppender() { logger.setLevel(Level.INFO); appender = new ListAppender<>();