feat(bootstrap): sync built-in skills from cloud manifest

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-05 16:16:55 +08:00
parent 04caf21e76
commit 9bb6b31db7
13 changed files with 1555 additions and 1 deletions

View file

@ -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);

View file

@ -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<String> 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> namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE);
if (namespace.isEmpty()) {
log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization",
GLOBAL_NAMESPACE);
return;
}
List<ManifestItem> 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<byte[]> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> entries) {
List<Skill> 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<Skill> builtinSkill = existingSkills.stream()
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
.findFirst();
if (builtinSkill.isEmpty()) {
return false;
}
Optional<SkillVersion> 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<PackageEntry> entries) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
for (Skill skill : existingSkills) {
if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) {
continue;
}
Optional<SkillVersion> 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<SkillFile> 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<PackageEntry> entries) {
return computeFingerprintFromFileDigests(entries.stream()
.map(entry -> new FileDigest(entry.path(), sha256(entry.content())))
.toList());
}
private String computeFingerprintFromFileDigests(List<FileDigest> 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) {
}
}

View file

@ -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<ManifestItem> 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<ManifestItem> items = new ArrayList<>();
Set<String> 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) {
}
}

View file

@ -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");
}
}
}

View file

@ -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;
}
}

View file

@ -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<byte[]> 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<InputStream> 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<byte[]> 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 "<null>";
}
String host = uri.getHost();
String path = uri.getRawPath();
return (host == null ? "<unknown-host>" : host) + (path == null ? "" : path);
}
}

View file

@ -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}

View file

@ -0,0 +1,3 @@
{
"skills": []
}

View file

@ -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<PackageEntry> 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<PackageEntry> 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<List<PackageEntry>> 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<PackageEntry> 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<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userAccountRepository).save(userCaptor.capture());
assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER);
ArgumentCaptor<NamespaceMember> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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<SkillFile> skillFilesFor(List<PackageEntry> 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);
}
}
}

View file

@ -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<BuiltinSkillManifestLoader.ManifestItem> 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<BuiltinSkillManifestLoader.ManifestItem> 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";
}
}
}

View file

@ -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) {
}
}

View file

@ -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 {
}
}

View file

@ -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<byte[]> 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<byte[]> 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<byte[]> 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> cookieHandler() {
return Optional.empty();
}
@Override
public Optional<Duration> connectTimeout() {
return Optional.of(Duration.ofSeconds(5));
}
@Override
public Redirect followRedirects() {
return Redirect.NEVER;
}
@Override
public Optional<ProxySelector> proxy() {
return Optional.empty();
}
@Override
public SSLContext sslContext() {
return null;
}
@Override
public SSLParameters sslParameters() {
return null;
}
@Override
public Optional<Authenticator> authenticator() {
return Optional.empty();
}
@Override
public Version version() {
return Version.HTTP_1_1;
}
@Override
public Optional<Executor> executor() {
return Optional.empty();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
throws IOException {
lastRequest = request;
sendCalls++;
@SuppressWarnings("unchecked")
T responseBody = (T) new ByteArrayInputStream(body);
return new FakeResponse<>(request, statusCode, responseBody);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler
) {
throw new UnsupportedOperationException();
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler,
HttpResponse.PushPromiseHandler<T> pushPromiseHandler
) {
throw new UnsupportedOperationException();
}
}
record FakeResponse<T>(HttpRequest request, int statusCode, T body) implements HttpResponse<T> {
@Override
public Optional<HttpResponse<T>> 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> sslSession() {
return Optional.empty();
}
}
}