versions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|| version.getStatus() == SkillVersionStatus.PENDING_REVIEW
+ || version.getStatus() == SkillVersionStatus.UPLOADED
|| version.getStatus() == SkillVersionStatus.DRAFT
|| version.getStatus() == SkillVersionStatus.REJECTED
|| version.getStatus() == SkillVersionStatus.YANKED
@@ -689,15 +704,18 @@ public class SkillQueryService {
if (status == SkillVersionStatus.SCAN_FAILED) {
return 1;
}
- if (status == SkillVersionStatus.REJECTED) {
+ if (status == SkillVersionStatus.UPLOADED) {
return 2;
}
- if (status == SkillVersionStatus.PENDING_REVIEW) {
+ if (status == SkillVersionStatus.REJECTED) {
return 3;
}
- if (status == SkillVersionStatus.DRAFT) {
+ if (status == SkillVersionStatus.PENDING_REVIEW) {
return 4;
}
+ if (status == SkillVersionStatus.DRAFT) {
+ return 5;
+ }
if (status == SkillVersionStatus.YANKED) {
return 5;
}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java
new file mode 100644
index 000000000..df8e9fd10
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java
@@ -0,0 +1,159 @@
+package com.iflytek.skillhub.domain.skill.service;
+
+import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
+import com.iflytek.skillhub.domain.namespace.NamespaceRole;
+import com.iflytek.skillhub.domain.review.ReviewTask;
+import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
+import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
+import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
+import com.iflytek.skillhub.domain.skill.*;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Service for submitting skill versions for review and confirming private publishes.
+ *
+ * This service handles two key workflows for UPLOADED skill versions:
+ *
+ * - submitForReview: Transitions an UPLOADED version to PENDING_REVIEW status,
+ * creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.
+ * - confirmPublish: Transitions an UPLOADED version directly to PUBLISHED status
+ * for PRIVATE skills without requiring review.
+ *
+ *
+ * @see SkillVersionStatus#UPLOADED
+ * @see SkillVisibility#PRIVATE
+ */
+@Service
+public class SkillReviewSubmitService {
+
+ private final SkillRepository skillRepository;
+ private final SkillVersionRepository skillVersionRepository;
+ private final ReviewTaskRepository reviewTaskRepository;
+ private final NamespaceMemberRepository namespaceMemberRepository;
+ private final ApplicationEventPublisher eventPublisher;
+ private final Clock clock;
+
+ public SkillReviewSubmitService(
+ SkillRepository skillRepository,
+ SkillVersionRepository skillVersionRepository,
+ ReviewTaskRepository reviewTaskRepository,
+ NamespaceMemberRepository namespaceMemberRepository,
+ ApplicationEventPublisher eventPublisher,
+ Clock clock) {
+ this.skillRepository = skillRepository;
+ this.skillVersionRepository = skillVersionRepository;
+ this.reviewTaskRepository = reviewTaskRepository;
+ this.namespaceMemberRepository = namespaceMemberRepository;
+ this.eventPublisher = eventPublisher;
+ this.clock = clock;
+ }
+
+ /**
+ * Submit an UPLOADED or DRAFT version for review.
+ * Transitions version status from UPLOADED/DRAFT to PENDING_REVIEW.
+ *
+ * Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status.
+ *
+ * @param skillId the skill ID
+ * @param versionId the version ID
+ * @param targetVisibility the target visibility after approval
+ * @param actorUserId the user performing the action
+ * @param userNamespaceRoles user's namespace roles
+ */
+ @Transactional
+ public void submitForReview(Long skillId, Long versionId, SkillVisibility targetVisibility,
+ String actorUserId, Map userNamespaceRoles) {
+ Skill skill = skillRepository.findById(skillId)
+ .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
+ SkillVersion version = skillVersionRepository.findById(versionId)
+ .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
+
+ // Validate ownership
+ assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
+
+ // Validate version status - support both UPLOADED (new) and DRAFT (legacy)
+ if (version.getStatus() != SkillVersionStatus.UPLOADED
+ && version.getStatus() != SkillVersionStatus.DRAFT) {
+ throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion());
+ }
+
+ // Validate version belongs to skill
+ if (!version.getSkillId().equals(skillId)) {
+ throw new DomainBadRequestException("error.skill.version.mismatch");
+ }
+
+ // Update version
+ version.setStatus(SkillVersionStatus.PENDING_REVIEW);
+ version.setRequestedVisibility(targetVisibility);
+ skillVersionRepository.save(version);
+
+ // Create review task
+ ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId);
+ reviewTaskRepository.save(reviewTask);
+ }
+
+ /**
+ * Confirm publish for a PRIVATE skill version.
+ * Transitions version status from UPLOADED/DRAFT to PUBLISHED without review.
+ *
+ * Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status.
+ *
+ * @param skillId the skill ID
+ * @param versionId the version ID
+ * @param actorUserId the user performing the action
+ * @param userNamespaceRoles user's namespace roles
+ */
+ @Transactional
+ public void confirmPublish(Long skillId, Long versionId, String actorUserId,
+ Map userNamespaceRoles) {
+ Skill skill = skillRepository.findById(skillId)
+ .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
+ SkillVersion version = skillVersionRepository.findById(versionId)
+ .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
+
+ // Validate ownership
+ assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
+
+ // Validate skill visibility is PRIVATE
+ if (skill.getVisibility() != SkillVisibility.PRIVATE) {
+ throw new DomainBadRequestException("error.skill.confirm.notPrivate");
+ }
+
+ // Validate version status - support both UPLOADED (new) and DRAFT (legacy)
+ if (version.getStatus() != SkillVersionStatus.UPLOADED
+ && version.getStatus() != SkillVersionStatus.DRAFT) {
+ throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion());
+ }
+
+ // Validate version belongs to skill
+ if (!version.getSkillId().equals(skillId)) {
+ throw new DomainBadRequestException("error.skill.version.mismatch");
+ }
+
+ // Update version to PUBLISHED
+ version.setStatus(SkillVersionStatus.PUBLISHED);
+ version.setPublishedAt(Instant.now(clock));
+ skillVersionRepository.save(version);
+
+ // Update skill's latest version
+ skill.setLatestVersionId(versionId);
+ skill.setUpdatedBy(actorUserId);
+ skillRepository.save(skill);
+ }
+
+ private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) {
+ NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId());
+ boolean canManage = skill.getOwnerId().equals(actorUserId)
+ || namespaceRole == NamespaceRole.ADMIN
+ || namespaceRole == NamespaceRole.OWNER;
+ if (!canManage) {
+ throw new DomainForbiddenException("error.skill.lifecycle.noPermission");
+ }
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java
index 3709bece4..19473d617 100644
--- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java
@@ -16,12 +16,6 @@ import java.util.regex.Pattern;
@Component
public class BasicPrePublishValidator implements PrePublishValidator {
- private static final Pattern ASSIGNMENT_WITH_SENSITIVE_KEY = Pattern.compile(
- "(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*(.+)$"
- );
- private static final Pattern QUOTED_LITERAL = Pattern.compile("^(['\"])(.*)\\1$");
- private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
- private static final Pattern BARE_LITERAL = Pattern.compile("[A-Za-z0-9_\\-]{12,}");
private static final Pattern PLACEHOLDER_VALUE = Pattern.compile(
"(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*"
);
@@ -29,12 +23,15 @@ public class BasicPrePublishValidator implements PrePublishValidator {
new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"),
new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"),
new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"),
- new SecretRule(ASSIGNMENT_WITH_SENSITIVE_KEY, 0, "secret or token")
+ new SecretRule(
+ Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"),
+ 2,
+ "secret or token")
);
@Override
public ValidationResult validate(SkillPackageContext context) {
- List errors = new ArrayList<>();
+ List warnings = new ArrayList<>();
for (PackageEntry entry : context.entries()) {
if (!isTextLike(entry.path())) {
@@ -49,14 +46,11 @@ public class BasicPrePublishValidator implements PrePublishValidator {
if (!matcher.find()) {
continue;
}
- String matchedValue = extractMatchedValue(line, matcher, rule);
- if (matchedValue == null) {
- continue;
- }
+ String matchedValue = matcher.group(rule.valueGroup());
if (isPlaceholderValue(matchedValue)) {
continue;
}
- errors.add(entry.path()
+ warnings.add(entry.path()
+ " line " + (i + 1)
+ " contains a value that looks like a "
+ rule.label()
@@ -66,7 +60,7 @@ public class BasicPrePublishValidator implements PrePublishValidator {
}
}
- return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
+ return warnings.isEmpty() ? ValidationResult.pass() : ValidationResult.warn(warnings);
}
private boolean isTextLike(String path) {
@@ -93,64 +87,5 @@ public class BasicPrePublishValidator implements PrePublishValidator {
|| value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-');
}
- private String extractMatchedValue(String line, Matcher matcher, SecretRule rule) {
- if (rule.valueGroup() > 0) {
- return matcher.group(rule.valueGroup());
- }
-
- Matcher assignmentMatcher = ASSIGNMENT_WITH_SENSITIVE_KEY.matcher(line);
- if (!assignmentMatcher.find()) {
- return null;
- }
-
- String rawValue = assignmentMatcher.group(2).trim();
- if (rawValue.isBlank()) {
- return null;
- }
-
- Matcher quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
- if (quotedLiteralMatcher.matches()) {
- return quotedLiteralMatcher.group(2);
- }
-
- rawValue = stripInlineComment(rawValue);
- if (rawValue.isBlank()) {
- return null;
- }
-
- quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
- if (quotedLiteralMatcher.matches()) {
- return quotedLiteralMatcher.group(2);
- }
-
- if (looksLikeExpression(rawValue) || IDENTIFIER.matcher(rawValue).matches()) {
- return null;
- }
-
- return BARE_LITERAL.matcher(rawValue).matches() ? rawValue : null;
- }
-
- private String stripInlineComment(String rawValue) {
- int hashIndex = rawValue.indexOf('#');
- if (hashIndex >= 0) {
- return rawValue.substring(0, hashIndex).trim();
- }
- return rawValue;
- }
-
- private boolean looksLikeExpression(String rawValue) {
- return rawValue.contains("(")
- || rawValue.contains(")")
- || rawValue.contains(".")
- || rawValue.contains("[")
- || rawValue.contains("]")
- || rawValue.contains("{")
- || rawValue.contains("}")
- || rawValue.contains(",")
- || rawValue.contains(" ")
- || rawValue.contains("+")
- || rawValue.contains("/");
- }
-
private record SecretRule(Pattern pattern, int valueGroup, String label) {}
}
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 67d055b95..7cd86dc58 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
@@ -25,7 +25,7 @@ public final class SkillPackagePolicy {
// Configuration and schemas
".toml", ".xml", ".xsd", ".xsl", ".dtd", ".ini", ".cfg", ".env",
// Scripts and source code
- ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt",
+ ".js", ".cjs", ".mjs", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt",
".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash",
// Images
".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico",
@@ -126,7 +126,8 @@ public final class SkillPackagePolicy {
private static boolean isTextExtension(String path) {
return path.endsWith(".md") || path.endsWith(".txt")
|| path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml")
- || path.endsWith(".js") || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh")
+ || path.endsWith(".js") || path.endsWith(".cjs") || path.endsWith(".mjs")
+ || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh")
|| path.endsWith(".html") || path.endsWith(".css") || path.endsWith(".csv")
|| path.endsWith(".toml") || path.endsWith(".xml") || path.endsWith(".xsd")
|| path.endsWith(".xsl") || path.endsWith(".dtd") || path.endsWith(".ini")
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java
index 836a9eb5c..fb2b7a41f 100644
--- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java
@@ -49,6 +49,7 @@ public class SkillPackageValidator {
public ValidationResult validate(List entries) {
List errors = new ArrayList<>();
+ List warnings = new ArrayList<>();
Set normalizedPaths = new HashSet<>();
PackageEntry skillMd = null;
@@ -66,12 +67,12 @@ public class SkillPackageValidator {
}
if (!hasAllowedExtension(normalizedPath)) {
- errors.add("Disallowed file extension: " + normalizedPath);
+ warnings.add("Disallowed file extension: " + normalizedPath);
}
String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content());
if (contentMismatch != null) {
- errors.add(contentMismatch);
+ warnings.add(contentMismatch);
}
if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) {
@@ -82,7 +83,7 @@ public class SkillPackageValidator {
// 1. Check SKILL.md exists at root
if (skillMd == null) {
errors.add("Missing required file: SKILL.md at root");
- return ValidationResult.fail(errors);
+ return ValidationResult.of(errors, warnings);
}
// 2. Validate frontmatter
@@ -111,7 +112,7 @@ public class SkillPackageValidator {
errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")");
}
- return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
+ return ValidationResult.of(errors, warnings);
}
private boolean hasAllowedExtension(String normalizedPath) {
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java
index 5ec259c32..367f9812c 100644
--- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java
@@ -4,17 +4,32 @@ import java.util.List;
public record ValidationResult(
boolean passed,
- List errors
+ List errors,
+ List warnings
) {
public static ValidationResult pass() {
- return new ValidationResult(true, List.of());
+ return new ValidationResult(true, List.of(), List.of());
}
public static ValidationResult fail(List errors) {
- return new ValidationResult(false, errors);
+ return new ValidationResult(false, List.copyOf(errors), List.of());
}
public static ValidationResult fail(String error) {
- return new ValidationResult(false, List.of(error));
+ return new ValidationResult(false, List.of(error), List.of());
+ }
+
+ public static ValidationResult warn(List warnings) {
+ return new ValidationResult(true, List.of(), List.copyOf(warnings));
+ }
+
+ public static ValidationResult of(List errors, List warnings) {
+ List safeErrors = errors == null ? List.of() : List.copyOf(errors);
+ List safeWarnings = warnings == null ? List.of() : List.copyOf(warnings);
+ return new ValidationResult(safeErrors.isEmpty(), safeErrors, safeWarnings);
+ }
+
+ public boolean hasWarnings() {
+ return !warnings.isEmpty();
}
}
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 21fc18dc1..25ae2f704 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
@@ -26,13 +26,37 @@ class ReviewPermissionCheckerTest {
}
@Test
- void skillAdminCannotReviewOwnSubmission() {
+ void skillAdminCannotReviewOwnSubmissionWithoutNamespaceRole() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN")));
}
+ @Test
+ void skillAdminNamespaceAdminCanReviewOwnSubmission() {
+ String userId = "user-1";
+ ReviewTask task = new ReviewTask(1L, 10L, userId);
+ assertTrue(checker.canReview(task, userId,
+ NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of("SKILL_ADMIN")));
+ }
+
+ @Test
+ void skillAdminNamespaceOwnerCanReviewOwnSubmission() {
+ String userId = "user-1";
+ ReviewTask task = new ReviewTask(1L, 10L, userId);
+ assertTrue(checker.canReview(task, userId,
+ NamespaceType.TEAM, Map.of(10L, NamespaceRole.OWNER), Set.of("SKILL_ADMIN")));
+ }
+
+ @Test
+ void skillAdminNamespaceMemberCannotReviewOwnSubmission() {
+ String userId = "user-1";
+ ReviewTask task = new ReviewTask(1L, 10L, userId);
+ assertFalse(checker.canReview(task, userId,
+ NamespaceType.TEAM, Map.of(10L, NamespaceRole.MEMBER), Set.of("SKILL_ADMIN")));
+ }
+
@Test
void superAdminCannotReviewOwnSubmission() {
String userId = "user-1";
@@ -57,6 +81,24 @@ class ReviewPermissionCheckerTest {
NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN")));
}
+ @Test
+ void teamAdminCanReviewOwnTeamSubmission() {
+ String userId = "user-1";
+ ReviewTask task = new ReviewTask(1L, 10L, userId);
+ assertTrue(checker.canReview(task, userId,
+ NamespaceType.TEAM,
+ Map.of(10L, NamespaceRole.ADMIN), Set.of()));
+ }
+
+ @Test
+ void teamOwnerCanReviewOwnTeamSubmission() {
+ String userId = "user-1";
+ ReviewTask task = new ReviewTask(1L, 10L, userId);
+ assertTrue(checker.canReview(task, userId,
+ NamespaceType.TEAM,
+ Map.of(10L, NamespaceRole.OWNER), Set.of()));
+ }
+
@Test
void teamAdminCanReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java
index 90246ed1b..f4fde3396 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java
@@ -483,6 +483,46 @@ class ReviewServiceTest {
assertEquals(USER_ID, skill.getUpdatedBy());
}
+ @Test
+ void namespaceAdminCanApproveOwnSubmission() {
+ ReviewTask task = createPendingReviewTask();
+ Namespace ns = createTeamNamespace();
+ SkillVersion sv = createPendingReviewSkillVersion();
+ Skill skill = createSkill();
+
+ when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
+ when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
+ when(permissionChecker.canReview(
+ eq(task),
+ eq(USER_ID),
+ eq(ns.getType()),
+ eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
+ eq(Set.of())))
+ .thenReturn(true);
+ when(reviewTaskRepository.updateStatusWithVersion(
+ REVIEW_TASK_ID,
+ ReviewTaskStatus.APPROVED,
+ USER_ID,
+ "self approved as namespace admin",
+ task.getVersion()))
+ .thenReturn(1);
+ when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
+ when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
+ when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
+ when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
+
+ ReviewTask result = reviewService.approveReview(
+ REVIEW_TASK_ID,
+ USER_ID,
+ "self approved as namespace admin",
+ Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
+ Set.of());
+
+ assertNotNull(result);
+ assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
+ assertEquals(USER_ID, skill.getUpdatedBy());
+ }
+
@Test
void shouldThrowOnConcurrentModification() {
ReviewTask task = createPendingReviewTask();
@@ -602,6 +642,43 @@ class ReviewServiceTest {
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
}
+ @Test
+ void namespaceAdminCanRejectOwnSubmission() {
+ ReviewTask task = createPendingReviewTask();
+ Namespace ns = createTeamNamespace();
+ SkillVersion sv = createPendingReviewSkillVersion();
+
+ when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
+ when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
+ when(permissionChecker.canReview(
+ eq(task),
+ eq(USER_ID),
+ eq(ns.getType()),
+ eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
+ eq(Set.of())))
+ .thenReturn(true);
+ when(reviewTaskRepository.updateStatusWithVersion(
+ REVIEW_TASK_ID,
+ ReviewTaskStatus.REJECTED,
+ USER_ID,
+ "self rejected as namespace admin",
+ task.getVersion()))
+ .thenReturn(1);
+ when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
+ when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill()));
+ when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
+
+ ReviewTask result = reviewService.rejectReview(
+ REVIEW_TASK_ID,
+ USER_ID,
+ "self rejected as namespace admin",
+ Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
+ Set.of());
+
+ assertNotNull(result);
+ assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
+ }
+
@Test
void shouldThrowOnConcurrentModification() {
ReviewTask task = createPendingReviewTask();
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java
index 1ffe5c8f3..af26901a0 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
+import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@@ -158,4 +159,34 @@ class VisibilityCheckerTest {
boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles);
assertTrue(canAccess);
}
+
+ @Test
+ void testSuperAdminCanAccessPrivateSkill() {
+ boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
+ assertTrue(canAccess);
+ }
+
+ @Test
+ void testSuperAdminCanAccessHiddenSkill() {
+ boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
+ assertTrue(canAccess);
+ }
+
+ @Test
+ void testSuperAdminCanAccessUnpublishedSkill() {
+ boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
+ assertTrue(canAccess);
+ }
+
+ @Test
+ void testNonSuperAdminPlatformRolesDoNotGrantAccess() {
+ boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER"));
+ assertFalse(canAccess);
+ }
+
+ @Test
+ void testEmptyPlatformRolesDoNotGrantAccess() {
+ boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of());
+ assertFalse(canAccess);
+ }
}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java
index f2b254fa6..b6f3faff4 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java
@@ -156,7 +156,7 @@ class SkillGovernanceServiceTest {
}
@Test
- void withdrawPendingVersion_demotesVersionToDraft() {
+ void withdrawPendingVersion_demotesVersionToUploaded() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 1L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
@@ -167,7 +167,7 @@ class SkillGovernanceServiceTest {
SkillVersion result = service.withdrawPendingVersion(skill, version, "owner");
- assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT);
+ assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.UPLOADED);
verify(skillVersionRepository).save(version);
verify(skillRepository).save(skill);
verify(objectStorageService, never()).deleteObject(any());
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 8bc06d70f..caf6fe0cd 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
@@ -176,6 +176,89 @@ class SkillPublishServiceTest {
assertEquals(1L, submittedEvent.namespaceId());
}
+ @Test
+ void testPublishFromEntries_ShouldRequireConfirmationWhenWarningsExist() throws Exception {
+ String namespaceSlug = "test-ns";
+ String publisherId = "user-100";
+ String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
+
+ PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
+ List entries = List.of(skillMd);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
+ setId(namespace, 1L);
+ NamespaceMember member = mock(NamespaceMember.class);
+ SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
+ when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe")));
+ when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
+ "SKILL.md line 5 contains a value that looks like a secret or token.")));
+
+ DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
+ namespaceSlug,
+ entries,
+ publisherId,
+ SkillVisibility.PUBLIC,
+ Set.of()
+ ));
+
+ assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode());
+ assertTrue(String.valueOf(exception.messageArgs()[0]).contains("Disallowed file extension: malware.exe"));
+ assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token"));
+ verify(skillVersionRepository, never()).save(any(SkillVersion.class));
+ }
+
+ @Test
+ void testPublishFromEntries_ShouldAllowPublishAfterWarningConfirmation() throws Exception {
+ String namespaceSlug = "test-ns";
+ String publisherId = "user-100";
+ String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
+
+ PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
+ List entries = List.of(skillMd);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
+ setId(namespace, 1L);
+ NamespaceMember member = mock(NamespaceMember.class);
+ SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
+ Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
+ when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe")));
+ when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
+ "SKILL.md line 5 contains a value that looks like a secret or token.")));
+ when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
+ when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
+ when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
+ SkillVersion saved = invocation.getArgument(0);
+ if (saved.getId() == null) {
+ setId(saved, 10L);
+ }
+ return saved;
+ });
+ when(skillRepository.save(any())).thenReturn(skill);
+
+ SkillPublishService.PublishResult result = service.publishFromEntries(
+ namespaceSlug,
+ entries,
+ publisherId,
+ SkillVisibility.PUBLIC,
+ Set.of(),
+ true
+ );
+
+ assertEquals("1.0.0", result.version().getVersion());
+ assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
+ verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class));
+ }
+
@Test
void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception {
String namespaceSlug = "test-ns";
@@ -713,7 +796,7 @@ class SkillPublishServiceTest {
}
@Test
- void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception {
+ void testRereleasePublishedVersion_ShouldCloneFilesAndSubmitForReview() throws Exception {
String publisherId = "user-100";
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 11L);
@@ -772,15 +855,16 @@ class SkillPublishServiceTest {
"1.2.3",
"1.2.4",
publisherId,
- Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
+ Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
+ false
);
assertEquals("1.2.4", result.version().getVersion());
- assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus());
- assertEquals(Instant.now(CLOCK), result.version().getPublishedAt());
- assertEquals(30L, skill.getLatestVersionId());
- verify(reviewTaskRepository, never()).save(any());
- verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
+ // Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules)
+ assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
+ // Review task should be created for PUBLIC skill
+ verify(reviewTaskRepository).save(any());
+ verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
verify(skillPackageValidator).validate(argThat(entries ->
entries.size() == 2
&& entries.stream().anyMatch(entry ->
@@ -809,10 +893,176 @@ class SkillPublishServiceTest {
"1.2.3",
"1.2.4",
publisherId,
- Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
+ Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
+ false
));
}
+ @Test
+ void testRereleasePublishedVersion_PrivateSkill_ShouldGoToUploaded() throws Exception {
+ String publisherId = "user-100";
+ Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PRIVATE);
+ setId(skill, 11L);
+ skill.setDisplayName("Demo Skill");
+ skill.setSummary("Original summary");
+ Namespace namespace = new Namespace("global", "Global", "owner");
+ setId(namespace, 1L);
+
+ SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
+ setId(sourceVersion, 21L);
+ sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
+ sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
+
+ String sourceSkillMd = """
+ ---
+ name: Demo Skill
+ description: Original summary
+ version: 1.2.3
+ ---
+ Hello world
+ """;
+
+ SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
+
+ SkillMetadata rereleaseMetadata = new SkillMetadata(
+ "Demo Skill",
+ "Original summary",
+ "1.2.4",
+ "Hello world",
+ Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
+
+ when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
+ when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
+ when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
+ when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
+ when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
+ when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
+ when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
+ when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
+ when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
+ when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
+ SkillVersion saved = invocation.getArgument(0);
+ if (saved.getId() == null) {
+ setId(saved, 30L);
+ }
+ return saved;
+ });
+ when(skillRepository.save(any())).thenReturn(skill);
+
+ SkillPublishService.PublishResult result = service.rereleasePublishedVersion(
+ skill.getId(),
+ "1.2.3",
+ "1.2.4",
+ publisherId,
+ Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
+ false
+ );
+
+ assertEquals("1.2.4", result.version().getVersion());
+ // Rerelease for PRIVATE skill should go to UPLOADED status
+ assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus());
+ // No review task for PRIVATE skill
+ verify(reviewTaskRepository, never()).save(any());
+ verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
+ // latestVersionId should be updated for PRIVATE skill
+ assertEquals(30L, skill.getLatestVersionId());
+ }
+
+ @Test
+ void testRereleasePublishedVersion_ShouldRequireConfirmationWhenWarningsExist() throws Exception {
+ String publisherId = "user-100";
+ Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
+ setId(skill, 11L);
+ skill.setDisplayName("Demo Skill");
+ skill.setSummary("Original summary");
+ Namespace namespace = new Namespace("global", "Global", "owner");
+ setId(namespace, 1L);
+
+ SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
+ setId(sourceVersion, 21L);
+ sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
+ sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
+
+ String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world";
+ SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
+ SkillMetadata rereleaseMetadata = new SkillMetadata(
+ "Demo Skill", "Original summary", "1.2.4", "Hello world",
+ Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
+
+ when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
+ when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
+ when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
+ when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
+ when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
+ when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
+ when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
+ when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
+ when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
+ "SKILL.md line 5 contains a value that looks like a secret or token.")));
+
+ DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion(
+ skill.getId(), "1.2.3", "1.2.4", publisherId,
+ Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
+ false
+ ));
+
+ assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode());
+ assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token"));
+ verify(skillVersionRepository, never()).save(any(SkillVersion.class));
+ }
+
+ @Test
+ void testRereleasePublishedVersion_ShouldSucceedWhenWarningsConfirmed() throws Exception {
+ String publisherId = "user-100";
+ Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
+ setId(skill, 11L);
+ skill.setDisplayName("Demo Skill");
+ skill.setSummary("Original summary");
+ Namespace namespace = new Namespace("global", "Global", "owner");
+ setId(namespace, 1L);
+
+ SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
+ setId(sourceVersion, 21L);
+ sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
+ sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
+
+ String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world";
+ SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
+ SkillMetadata rereleaseMetadata = new SkillMetadata(
+ "Demo Skill", "Original summary", "1.2.4", "Hello world",
+ Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
+
+ when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
+ when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
+ when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
+ when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
+ when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
+ when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
+ when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
+ when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
+ when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
+ "SKILL.md line 5 contains a value that looks like a secret or token.")));
+ when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
+ SkillVersion saved = invocation.getArgument(0);
+ if (saved.getId() == null) { setId(saved, 30L); }
+ return saved;
+ });
+ when(skillRepository.save(any())).thenReturn(skill);
+
+ SkillPublishService.PublishResult result = service.rereleasePublishedVersion(
+ skill.getId(), "1.2.3", "1.2.4", publisherId,
+ Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
+ true // confirmWarnings = true → should bypass warning and succeed
+ );
+
+ assertEquals("1.2.4", result.version().getVersion());
+ assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
+ verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class));
+ }
+
@Test
void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
@@ -846,6 +1096,39 @@ class SkillPublishServiceTest {
));
}
+ @Test
+ void testPublishFromEntries_ShouldRejectWithPrivateConflictWhenOtherOwnerHasPrivatePublishedSkill() throws Exception {
+ String namespaceSlug = "test-ns";
+ String publisherId = "user-200";
+ String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
+
+ PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
+ List entries = List.of(skillMd);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
+ setId(namespace, 1L);
+ NamespaceMember member = mock(NamespaceMember.class);
+ SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
+
+ Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE);
+ setId(existingSkill, 1L);
+ SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
+ publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
+ when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
+ when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
+ when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
+ when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
+ when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
+
+ DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
+ namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of()
+ ));
+ assertEquals("error.skill.publish.nameConflict.private", ex.messageCode());
+ }
+
@Test
void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
@@ -934,8 +1217,8 @@ class SkillPublishServiceTest {
service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of());
- // Verify pending version was withdrawn to DRAFT
- assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus());
+ // Verify pending version was withdrawn to UPLOADED (not DRAFT, so it remains visible)
+ assertEquals(SkillVersionStatus.UPLOADED, pendingV1.getStatus());
verify(reviewTaskRepository).delete(pendingTask);
verify(skillVersionRepository).save(pendingV1);
}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java
index a8c8042f8..7ee683ac7 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java
@@ -30,6 +30,7 @@ import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -50,7 +51,6 @@ class SkillQueryServiceTest {
private SkillTagRepository skillTagRepository;
@Mock
private ObjectStorageService objectStorageService;
- @Mock
private VisibilityChecker visibilityChecker;
@Mock
private PromotionRequestRepository promotionRequestRepository;
@@ -65,6 +65,7 @@ class SkillQueryServiceTest {
@BeforeEach
void setUp() {
+ visibilityChecker = new VisibilityChecker();
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
service = new SkillQueryService(
@@ -105,7 +106,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null)));
@@ -148,7 +148,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
- when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@@ -176,7 +175,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
// Act & Assert
assertThrows(DomainForbiddenException.class, () ->
@@ -215,13 +213,11 @@ class SkillQueryServiceTest {
setId(namespace, 1L);
Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC);
setId(skill1, 1L);
- Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE);
+ Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE);
setId(skill2, 2L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2));
- when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true);
- when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false);
// Act
Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@@ -267,8 +263,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill));
- when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true);
- when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false);
Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@@ -296,8 +290,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(visibleSkill, hiddenSkill));
- when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true);
- when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false);
Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@@ -325,7 +317,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
when(objectStorageService.exists("key1")).thenReturn(true);
@@ -358,7 +349,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
assertThrows(DomainBadRequestException.class, () ->
@@ -385,7 +375,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
when(objectStorageService.exists(file.getStorageKey())).thenReturn(true);
@@ -419,7 +408,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile));
when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true);
@@ -482,7 +470,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
@@ -516,7 +503,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
when(objectStorageService.exists("storage-key")).thenReturn(true);
@@ -555,7 +541,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
Page result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20));
@@ -589,7 +574,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version100, version110));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110));
@@ -631,7 +615,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@@ -664,7 +647,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@@ -691,7 +673,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty());
@@ -723,7 +704,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class)));
@@ -753,7 +733,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED))
@@ -784,7 +763,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@@ -793,6 +771,56 @@ class SkillQueryServiceTest {
assertFalse(result.canSubmitPromotion());
}
+ @Test
+ void testGetSkillDetail_ShouldNotGrantLifecyclePermissionToSuperAdminInPortal() throws Exception {
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String userId = "super-1";
+ Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
+ setId(skill, 1L);
+ skill.setStatus(SkillStatus.ACTIVE);
+ skill.setLatestVersionId(11L);
+
+ SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
+ setId(published, 11L);
+ published.setStatus(SkillVersionStatus.PUBLISHED);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
+ when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
+
+ SkillQueryService.SkillDetailDTO result = service.getSkillDetail(
+ namespaceSlug, skillSlug, userId, userNsRoles, Set.of("SUPER_ADMIN"));
+
+ assertFalse(result.canManageLifecycle());
+ assertFalse(result.canSubmitPromotion());
+ assertEquals("PUBLISHED", result.resolutionMode());
+ }
+
+ @Test
+ void testGetSkillDetail_ShouldNotGrantPrivateVisibilityToSuperAdminInPortal() throws Exception {
+ String namespaceSlug = "test-ns";
+ String skillSlug = "test-skill";
+ String userId = "super-1";
+
+ Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
+ setId(namespace, 1L);
+ Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PRIVATE);
+ setId(skill, 1L);
+ skill.setStatus(SkillStatus.ACTIVE);
+ skill.setLatestVersionId(11L);
+
+ when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
+ when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
+
+ assertThrows(DomainForbiddenException.class, () ->
+ service.getSkillDetail(namespaceSlug, skillSlug, userId, Map.of(), Set.of("SUPER_ADMIN")));
+ }
+
@Test
void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception {
String namespaceSlug = "test-ns";
@@ -813,7 +841,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
@@ -853,7 +880,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
@@ -889,7 +915,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected));
@@ -925,7 +950,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
@@ -960,7 +984,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
when(objectStorageService.exists("storage-key")).thenReturn(true);
@@ -992,7 +1015,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
assertThrows(DomainBadRequestException.class, () ->
@@ -1024,7 +1046,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
Page result = service.listVersions(
@@ -1059,7 +1080,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
- when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
Page result = service.listVersions(
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java
new file mode 100644
index 000000000..4f8f9565c
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java
@@ -0,0 +1,272 @@
+package com.iflytek.skillhub.domain.skill.service;
+
+import com.iflytek.skillhub.domain.namespace.NamespaceRole;
+import com.iflytek.skillhub.domain.review.ReviewTask;
+import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
+import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
+import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
+import com.iflytek.skillhub.domain.skill.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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.context.ApplicationEventPublisher;
+
+import java.time.Clock;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for {@link SkillReviewSubmitService}.
+ */
+@ExtendWith(MockitoExtension.class)
+class SkillReviewSubmitServiceTest {
+
+ @Mock
+ private SkillRepository skillRepository;
+
+ @Mock
+ private SkillVersionRepository skillVersionRepository;
+
+ @Mock
+ private ReviewTaskRepository reviewTaskRepository;
+
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ private SkillReviewSubmitService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SkillReviewSubmitService(
+ skillRepository,
+ skillVersionRepository,
+ reviewTaskRepository,
+ null, // namespaceMemberRepository not used in these tests
+ eventPublisher,
+ Clock.systemUTC()
+ );
+ }
+
+ @Nested
+ @DisplayName("submitForReview")
+ class SubmitForReviewTests {
+
+ @Test
+ @DisplayName("should transition UPLOADED version to PENDING_REVIEW")
+ void shouldTransitionToPendingReview() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+ Long namespaceId = 10L;
+
+ Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+ when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Map roles = Map.of();
+
+ // When
+ service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles);
+
+ // Then
+ assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus());
+ assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility());
+ verify(reviewTaskRepository).save(any(ReviewTask.class));
+ }
+
+ @Test
+ @DisplayName("should accept DRAFT version (legacy compatibility)")
+ void shouldAcceptDraftForLegacyCompatibility() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+ Long namespaceId = 10L;
+
+ Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+ when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Map roles = Map.of();
+
+ // When
+ service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles);
+
+ // Then
+ assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus());
+ assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility());
+ verify(reviewTaskRepository).save(any(ReviewTask.class));
+ }
+
+ @Test
+ @DisplayName("should reject when version is neither UPLOADED nor DRAFT")
+ void shouldRejectWhenNotUploadedOrDraft() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+
+ Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+
+ // When/Then
+ assertThrows(DomainBadRequestException.class,
+ () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, Map.of()));
+ }
+
+ @Test
+ @DisplayName("should reject when user is not owner")
+ void shouldRejectWhenNotOwner() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String ownerId = "owner-1";
+ String otherUserId = "other-user";
+
+ Skill skill = createSkill(skillId, ownerId, 10L, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+
+ // When/Then
+ assertThrows(DomainForbiddenException.class,
+ () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, otherUserId, Map.of()));
+ }
+ }
+
+ @Nested
+ @DisplayName("confirmPublish")
+ class ConfirmPublishTests {
+
+ @Test
+ @DisplayName("should transition UPLOADED version to PUBLISHED for PRIVATE skill")
+ void shouldTransitionToPublished() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+ Long namespaceId = 10L;
+
+ Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+
+ // When
+ service.confirmPublish(skillId, versionId, userId, Map.of());
+
+ // Then
+ assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus());
+ assertNotNull(version.getPublishedAt());
+ assertEquals(versionId, skill.getLatestVersionId());
+ verify(skillRepository).save(skill);
+ }
+
+ @Test
+ @DisplayName("should transition DRAFT version to PUBLISHED for PRIVATE skill (legacy compatibility)")
+ void shouldTransitionDraftToPublished() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+ Long namespaceId = 10L;
+
+ Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+
+ // When
+ service.confirmPublish(skillId, versionId, userId, Map.of());
+
+ // Then
+ assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus());
+ assertNotNull(version.getPublishedAt());
+ assertEquals(versionId, skill.getLatestVersionId());
+ verify(skillRepository).save(skill);
+ }
+
+ @Test
+ @DisplayName("should reject when skill is not PRIVATE")
+ void shouldRejectWhenNotPrivate() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+
+ Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PUBLIC);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+
+ // When/Then
+ assertThrows(DomainBadRequestException.class,
+ () -> service.confirmPublish(skillId, versionId, userId, Map.of()));
+ }
+
+ @Test
+ @DisplayName("should reject when version is neither UPLOADED nor DRAFT")
+ void shouldRejectWhenNotUploadedOrDraft() {
+ // Given
+ Long skillId = 1L;
+ Long versionId = 100L;
+ String userId = "user-1";
+
+ Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE);
+ SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED);
+
+ when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
+ when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
+
+ // When/Then
+ assertThrows(DomainBadRequestException.class,
+ () -> service.confirmPublish(skillId, versionId, userId, Map.of()));
+ }
+ }
+
+ private Skill createSkill(Long id, String ownerId, Long namespaceId, SkillVisibility visibility) {
+ Skill skill = new Skill(namespaceId, "test-skill", ownerId, visibility);
+ setField(skill, "id", id);
+ return skill;
+ }
+
+ private SkillVersion createVersion(Long id, Long skillId, SkillVersionStatus status) {
+ SkillVersion version = new SkillVersion(skillId, "1.0.0", "user-1");
+ setField(version, "id", id);
+ version.setStatus(status);
+ return version;
+ }
+
+ private void setField(Object target, String fieldName, Object value) {
+ try {
+ java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java
index f5f2ad00e..f428559cd 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java
@@ -15,7 +15,7 @@ class BasicPrePublishValidatorTest {
private final BasicPrePublishValidator validator = new BasicPrePublishValidator();
@Test
- void shouldRejectObviousCredentialLeakWithHelpfulLocation() {
+ void shouldWarnOnObviousCredentialLeakWithHelpfulLocation() {
PackageEntry skillMd = new PackageEntry(
"SKILL.md",
"""
@@ -36,8 +36,8 @@ class BasicPrePublishValidatorTest {
1L
));
- assertFalse(result.passed());
- assertTrue(result.errors().stream().anyMatch(error ->
+ assertTrue(result.passed());
+ assertTrue(result.warnings().stream().anyMatch(error ->
error.contains("SKILL.md")
&& error.contains("line 5")
&& error.contains("looks like a")));
@@ -98,71 +98,4 @@ class BasicPrePublishValidatorTest {
assertTrue(result.passed());
}
-
- @Test
- void shouldAllowFunctionCallAssignedToTokenVariable() {
- PackageEntry script = new PackageEntry(
- "scripts/f2e_mock.py",
- """
- token = extract_group_token_value(response, group_choice.group_id)
- if token:
- return token
- """.getBytes(StandardCharsets.UTF_8),
- 97,
- "text/x-python"
- );
-
- ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
- List.of(script),
- new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
- "user-1",
- 1L
- ));
-
- assertTrue(result.passed());
- }
-
- @Test
- void shouldAllowIdentifierAssignedToSecretNamedVariable() {
- PackageEntry envTemplate = new PackageEntry(
- "config.env",
- """
- token=generated_token_value
- api_key=current_api_key
- """.getBytes(StandardCharsets.UTF_8),
- 46,
- "text/plain"
- );
-
- ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
- List.of(envTemplate),
- new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
- "user-1",
- 1L
- ));
-
- assertTrue(result.passed());
- }
-
- @Test
- void shouldRejectQuotedSecretWithTrailingComment() {
- PackageEntry script = new PackageEntry(
- "scripts/publish.py",
- """
- token = "ghp_abcdefghijklmnopqrstuvwxyz1234" # do not commit real token
- """.getBytes(StandardCharsets.UTF_8),
- 76,
- "text/x-python"
- );
-
- ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
- List.of(script),
- new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()),
- "user-1",
- 1L
- ));
-
- assertFalse(result.passed());
- assertTrue(result.errors().stream().anyMatch(error -> error.contains("scripts/publish.py")));
- }
}
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 39de46407..4f74edaab 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
@@ -70,8 +70,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
- assertFalse(result.passed());
- assertTrue(result.errors().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe")));
+ assertTrue(result.passed());
+ assertTrue(result.warnings().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe")));
}
@Test
@@ -236,8 +236,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
- assertFalse(result.passed());
- assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
+ assertTrue(result.passed());
+ assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
@@ -258,8 +258,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
- assertFalse(result.passed());
- assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
+ assertTrue(result.passed());
+ assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
@@ -269,8 +269,8 @@ class SkillPackageValidatorTest {
new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg")
);
ValidationResult result = validator.validate(entries);
- assertFalse(result.passed());
- assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg")));
+ assertTrue(result.passed());
+ assertTrue(result.warnings().stream().anyMatch(e -> e.contains("photo.jpeg")));
}
@Test
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java
new file mode 100644
index 000000000..a69702b0e
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java
@@ -0,0 +1,19 @@
+package com.iflytek.skillhub.infra.jpa;
+
+import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
+import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
+import java.time.Instant;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * JPA-backed repository for password-reset verification-code requests.
+ */
+public interface PasswordResetRequestJpaRepository
+ extends JpaRepository, PasswordResetRequestRepository {
+
+ List findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
+ String userId,
+ Instant now
+ );
+}
diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java
index a0efbbd06..8e5452b52 100644
--- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java
+++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java
@@ -32,8 +32,10 @@ import java.util.List;
public class S3StorageService implements ObjectStorageService {
private static final Logger log = LoggerFactory.getLogger(S3StorageService.class);
private final S3StorageProperties properties;
+ private final Object bucketPreparationLock = new Object();
private S3Client s3Client;
private S3Presigner s3Presigner;
+ private volatile boolean bucketPrepared;
public S3StorageService(S3StorageProperties properties) { this.properties = properties; }
@@ -42,6 +44,13 @@ public class S3StorageService implements ObjectStorageService {
ApacheHttpClient.Builder httpClientBuilder = ApacheHttpClient.builder()
.maxConnections(properties.getMaxConnections())
.connectionAcquisitionTimeout(properties.getConnectionAcquisitionTimeout());
+ this.s3Client = buildS3Client(httpClientBuilder);
+ this.s3Presigner = buildPresigner();
+ log.info("Initialized S3 storage client for bucket '{}' (bucket verification is deferred until first storage access)",
+ properties.getBucket());
+ }
+
+ protected S3Client buildS3Client(ApacheHttpClient.Builder httpClientBuilder) {
var builder = S3Client.builder()
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
@@ -54,9 +63,7 @@ public class S3StorageService implements ObjectStorageService {
if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) {
builder.endpointOverride(URI.create(properties.getEndpoint()));
}
- this.s3Client = builder.build();
- this.s3Presigner = buildPresigner();
- ensureBucketExists();
+ return builder.build();
}
S3Presigner buildPresigner() {
@@ -75,20 +82,28 @@ public class S3StorageService implements ObjectStorageService {
return presignerBuilder.build();
}
- private void ensureBucketExists() {
- if (!properties.isAutoCreateBucket()) {
- s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build());
+ private void ensureBucketPrepared() {
+ if (!properties.isAutoCreateBucket() || bucketPrepared) {
return;
}
- try { s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); }
- catch (NoSuchBucketException e) {
- log.info("Bucket '{}' does not exist, creating...", properties.getBucket());
- s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build());
+
+ synchronized (bucketPreparationLock) {
+ if (bucketPrepared) {
+ return;
+ }
+ try {
+ s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build());
+ } catch (NoSuchBucketException e) {
+ log.info("Bucket '{}' does not exist, creating...", properties.getBucket());
+ s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build());
+ }
+ bucketPrepared = true;
}
}
@Override public void putObject(String key, InputStream data, long size, String contentType) {
try {
+ ensureBucketPrepared();
s3Client.putObject(PutObjectRequest.builder().bucket(properties.getBucket()).key(key).contentType(contentType).contentLength(size).build(), RequestBody.fromInputStream(data, size));
} catch (RuntimeException e) {
throw new StorageAccessException("putObject", key, e);
@@ -97,6 +112,7 @@ public class S3StorageService implements ObjectStorageService {
@Override public InputStream getObject(String key) {
try {
+ ensureBucketPrepared();
return s3Client.getObject(GetObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
} catch (RuntimeException e) {
throw new StorageAccessException("getObject", key, e);
@@ -105,6 +121,7 @@ public class S3StorageService implements ObjectStorageService {
@Override public void deleteObject(String key) {
try {
+ ensureBucketPrepared();
s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
} catch (RuntimeException e) {
throw new StorageAccessException("deleteObject", key, e);
@@ -114,6 +131,7 @@ public class S3StorageService implements ObjectStorageService {
@Override public void deleteObjects(List keys) {
if (keys.isEmpty()) return;
try {
+ ensureBucketPrepared();
List ids = keys.stream().map(k -> ObjectIdentifier.builder().key(k).build()).toList();
s3Client.deleteObjects(DeleteObjectsRequest.builder().bucket(properties.getBucket()).delete(Delete.builder().objects(ids).build()).build());
} catch (RuntimeException e) {
@@ -122,13 +140,18 @@ public class S3StorageService implements ObjectStorageService {
}
@Override public boolean exists(String key) {
- try { s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return true; }
+ try {
+ ensureBucketPrepared();
+ s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
+ return true;
+ }
catch (NoSuchKeyException e) { return false; }
catch (RuntimeException e) { throw new StorageAccessException("exists", key, e); }
}
@Override public ObjectMetadata getMetadata(String key) {
try {
+ ensureBucketPrepared();
HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified());
} catch (RuntimeException e) {
diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java
index 8ca8cae70..0fa1887ce 100644
--- a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java
+++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java
@@ -2,12 +2,32 @@ package com.iflytek.skillhub.storage;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.http.apache.ApacheHttpClient;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
+import software.amazon.awssdk.services.s3.model.CreateBucketResponse;
+import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import java.io.ByteArrayInputStream;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
class S3StorageServiceTest {
@@ -27,6 +47,63 @@ class S3StorageServiceTest {
assertThat(presignedUrl.getPath()).isEqualTo("/artifacts/package.tgz");
}
+ @Test
+ void initShouldNotProbeBucketWhenAutoCreateIsDisabled() {
+ S3Client client = mock(S3Client.class);
+ S3Presigner presigner = mock(S3Presigner.class);
+ TestableS3StorageService service = new TestableS3StorageService(properties(false), client, presigner);
+
+ service.init();
+
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void putObjectShouldSkipBucketProbeWhenAutoCreateIsDisabled() {
+ S3Client client = mock(S3Client.class);
+ S3Presigner presigner = mock(S3Presigner.class);
+ when(client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+ .thenReturn(PutObjectResponse.builder().eTag("etag").build());
+ TestableS3StorageService service = new TestableS3StorageService(properties(false), client, presigner);
+
+ service.init();
+ byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
+ service.putObject("packages/demo.zip", new ByteArrayInputStream(content), content.length, "application/zip");
+
+ verify(client, never()).headBucket(any(HeadBucketRequest.class));
+ verify(client, never()).createBucket(any(CreateBucketRequest.class));
+ verify(client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+ }
+
+ @Test
+ void putObjectShouldCreateBucketOnlyOnceWhenAutoCreateIsEnabled() {
+ S3Client client = mock(S3Client.class);
+ S3Presigner presigner = mock(S3Presigner.class);
+ doThrow(NoSuchBucketException.builder().message("missing").build())
+ .when(client).headBucket(any(HeadBucketRequest.class));
+ when(client.createBucket(any(CreateBucketRequest.class)))
+ .thenReturn(CreateBucketResponse.builder().build());
+ when(client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+ .thenReturn(PutObjectResponse.builder().eTag("etag").build());
+ TestableS3StorageService service = new TestableS3StorageService(properties(true), client, presigner);
+
+ service.init();
+ byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
+ service.putObject("packages/demo-1.zip", new ByteArrayInputStream(content), content.length, "application/zip");
+ service.putObject("packages/demo-2.zip", new ByteArrayInputStream(content), content.length, "application/zip");
+
+ verify(client, times(1)).headBucket(any(HeadBucketRequest.class));
+ verify(client, times(1)).createBucket(any(CreateBucketRequest.class));
+ verify(client, times(2)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+ }
+
+ private S3StorageProperties properties(boolean autoCreateBucket) {
+ S3StorageProperties properties = createProperties(true);
+ properties.setBucket("skillhub");
+ properties.setAutoCreateBucket(autoCreateBucket);
+ return properties;
+ }
+
private URI presignGetObjectUrl(boolean forcePathStyle) {
S3StorageService storageService = new S3StorageService(createProperties(forcePathStyle));
try (var presigner = storageService.buildPresigner()) {
@@ -53,4 +130,25 @@ class S3StorageServiceTest {
properties.setForcePathStyle(forcePathStyle);
return properties;
}
+
+ private static final class TestableS3StorageService extends S3StorageService {
+ private final S3Client client;
+ private final S3Presigner presigner;
+
+ private TestableS3StorageService(S3StorageProperties properties, S3Client client, S3Presigner presigner) {
+ super(properties);
+ this.client = client;
+ this.presigner = presigner;
+ }
+
+ @Override
+ protected S3Client buildS3Client(ApacheHttpClient.Builder httpClientBuilder) {
+ return client;
+ }
+
+ @Override
+ S3Presigner buildPresigner() {
+ return presigner;
+ }
+ }
}
diff --git a/web/e2e/helpers/auth-fixtures.ts b/web/e2e/helpers/auth-fixtures.ts
index 31a9fb4af..fc89c8a1c 100644
--- a/web/e2e/helpers/auth-fixtures.ts
+++ b/web/e2e/helpers/auth-fixtures.ts
@@ -5,3 +5,12 @@ export async function setEnglishLocale(page: Page) {
window.localStorage.setItem('i18nextLng', 'en')
})
}
+
+export async function setUniqueClientIp(page: Page, seed: string) {
+ const suffix = Date.now() + Math.floor(Math.random() * 1000)
+ const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250
+ const fourthOctet = suffix % 250
+ await page.context().setExtraHTTPHeaders({
+ 'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`,
+ })
+}
diff --git a/web/e2e/helpers/review-seed.ts b/web/e2e/helpers/review-seed.ts
new file mode 100644
index 000000000..683ad2b5d
--- /dev/null
+++ b/web/e2e/helpers/review-seed.ts
@@ -0,0 +1,64 @@
+import type { Browser, Page, TestInfo } from '@playwright/test'
+import { loginWithCredentials, registerSession } from './session'
+import { E2eTestDataBuilder, type SeededReviewData } from './test-data-builder'
+
+function getOptionalEnv(name: string): string | undefined {
+ const value = process.env[name]?.trim()
+ return value ? value : undefined
+}
+
+function adminCredentials() {
+ return {
+ username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
+ password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
+ }
+}
+
+function matchCandidateUsername(
+ candidate: { userId: string; displayName: string; email?: string },
+ username: string,
+) {
+ return candidate.userId === username
+ || candidate.displayName === username
+ || candidate.email === `${username}@example.test`
+}
+
+export async function createNamespaceReviewData(
+ browser: Browser,
+ page: Page,
+ testInfo: TestInfo,
+): Promise Promise }> {
+ const credentials = await registerSession(page, testInfo, { allowMockSession: false })
+ const builder = new E2eTestDataBuilder(page, testInfo)
+ await builder.init()
+
+ const adminContext = await browser.newContext()
+ const adminPage = await adminContext.newPage()
+ const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
+
+ await loginWithCredentials(adminPage, adminCredentials(), testInfo)
+ await adminBuilder.init()
+
+ const namespace = await adminBuilder.createNamespace('e2e-team')
+ const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username)
+ const matchedCandidate = candidates.find((candidate) => matchCandidateUsername(candidate, credentials.username)) ?? candidates[0]
+
+ if (!matchedCandidate) {
+ throw new Error(`No namespace member candidate found for review actor ${credentials.username}`)
+ }
+
+ await adminBuilder.addNamespaceMember(namespace.slug, matchedCandidate.userId, 'ADMIN')
+ const skill = await builder.publishSkill(namespace.slug)
+ const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version)
+
+ return {
+ namespace,
+ skill,
+ reviewTaskId,
+ cleanup: async () => {
+ await builder.cleanup()
+ await adminBuilder.cleanup()
+ await adminContext.close()
+ },
+ }
+}
diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts
index 6b6ee60ff..763ef1c37 100644
--- a/web/e2e/helpers/session.ts
+++ b/web/e2e/helpers/session.ts
@@ -10,6 +10,10 @@ export interface TestCredentials {
username: string
}
+interface RegisterSessionOptions {
+ allowMockSession?: boolean
+}
+
interface SessionSnapshot {
username: string
cookies: Array<{
@@ -160,7 +164,7 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us
return { username: 'local-user', password }
}
-async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
+async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
const worker = testInfo?.parallelIndex ?? 0
const cached = cachedUserByWorker.get(worker)
const username = usernameForWorker(testInfo)
@@ -175,9 +179,11 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
return { username: restored.username, password }
}
- const mockSession = await tryBootstrapMockSession(page, worker)
- if (mockSession) {
- return mockSession
+ if (options?.allowMockSession !== false) {
+ const mockSession = await tryBootstrapMockSession(page, worker)
+ if (mockSession) {
+ return mockSession
+ }
}
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
@@ -317,12 +323,12 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
throw new Error(`Failed to create fresh e2e session for worker ${worker}`)
}
-export async function registerSession(page: Page, testInfo?: TestInfo) {
+export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
let lastError: unknown
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
- return await registerSessionOnce(page, testInfo)
+ return await registerSessionOnce(page, testInfo, options)
} catch (error) {
lastError = error
if (attempt < 2) {
diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts
index 7d7d4e975..0123468ba 100644
--- a/web/e2e/helpers/test-data-builder.ts
+++ b/web/e2e/helpers/test-data-builder.ts
@@ -10,6 +10,11 @@ export interface SeededNamespace {
id: number
slug: string
displayName: string
+ status?: string
+ type?: string
+ currentUserRole?: string
+ canUnfreeze?: boolean
+ canRestore?: boolean
}
export interface SeededSkill {
@@ -34,6 +39,13 @@ interface ReviewTaskSummary {
version: string
}
+interface NamespaceCandidate {
+ userId: string
+ displayName: string
+ email?: string
+ status: string
+}
+
interface ApiEnvelope {
code: number
msg: string
@@ -45,6 +57,8 @@ interface ApiFailure extends Error {
code?: number
}
+const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000
+
export interface SeedSkillOptions {
name?: string
description?: string
@@ -65,6 +79,24 @@ function uniqueSuffix(testInfo?: TestInfo): string {
return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
+async function runCleanupTaskWithTimeout(task: CleanupTask): Promise {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`))
+ }, cleanupTimeoutMs)
+
+ void task()
+ .then(() => {
+ clearTimeout(timeout)
+ resolve()
+ })
+ .catch((error) => {
+ clearTimeout(timeout)
+ reject(error)
+ })
+ })
+}
+
function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48)
const description = options?.description || 'E2E generated skill for real-request tests'
@@ -166,7 +198,7 @@ export class E2eTestDataBuilder {
async cleanup(): Promise {
for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) {
try {
- await this.cleanupTasks[i]()
+ await runCleanupTaskWithTimeout(this.cleanupTasks[i])
} catch {
// Best-effort cleanup for E2E environments.
}
@@ -207,6 +239,34 @@ export class E2eTestDataBuilder {
)
}
+ private isTeamNamespace(namespace: SeededNamespace): boolean {
+ return namespace.type === 'TEAM' || namespace.slug !== 'global'
+ }
+
+ private isActiveNamespace(namespace: SeededNamespace): boolean {
+ return namespace.status === 'ACTIVE'
+ }
+
+ private async activateNamespace(namespace: SeededNamespace): Promise {
+ if (!this.isTeamNamespace(namespace)) {
+ return null
+ }
+
+ if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
+ return parseEnvelope(
+ await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`),
+ )
+ }
+
+ if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
+ return parseEnvelope(
+ await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`),
+ )
+ }
+
+ return null
+ }
+
async ensureWritableNamespace(): Promise {
if (this.ensuredNamespace) {
return this.ensuredNamespace
@@ -224,12 +284,75 @@ export class E2eTestDataBuilder {
}
const namespaces = await this.listMyNamespaces()
- const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0]
- if (!writable) {
- throw new Error('No namespace available for e2e data seeding')
+ const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
+ if (activeTeam) {
+ this.ensuredNamespace = activeTeam
+ return activeTeam
}
- this.ensuredNamespace = writable
- return writable
+
+ const activeFallback = namespaces.find((item) => this.isActiveNamespace(item))
+ if (activeFallback) {
+ this.ensuredNamespace = activeFallback
+ return activeFallback
+ }
+
+ const activatable = namespaces.find((item) =>
+ this.isTeamNamespace(item)
+ && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
+ )
+ if (activatable) {
+ const activated = await this.activateNamespace(activatable)
+ if (activated) {
+ this.ensuredNamespace = activated
+ return activated
+ }
+ }
+
+ const summary = namespaces
+ .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
+ .join(', ')
+ throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`)
+ }
+
+ async ensureReviewableNamespace(): Promise {
+ if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(this.ensuredNamespace)) {
+ return this.ensuredNamespace
+ }
+
+ try {
+ const created = await this.createNamespace('e2e-team')
+ this.ensuredNamespace = created
+ return created
+ } catch (error) {
+ const failure = error as ApiFailure
+ if (failure.status !== 403) {
+ throw error
+ }
+ }
+
+ const namespaces = await this.listMyNamespaces()
+ const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
+ if (activeTeam) {
+ this.ensuredNamespace = activeTeam
+ return activeTeam
+ }
+
+ const activatable = namespaces.find((item) =>
+ this.isTeamNamespace(item)
+ && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
+ )
+ if (activatable) {
+ const activated = await this.activateNamespace(activatable)
+ if (activated) {
+ this.ensuredNamespace = activated
+ return activated
+ }
+ }
+
+ const summary = namespaces
+ .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
+ .join(', ')
+ throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`)
}
private async getMySkillInNamespace(namespaceSlug: string): Promise {
@@ -350,6 +473,21 @@ export class E2eTestDataBuilder {
)
}
+ async searchNamespaceMemberCandidates(slug: string, search: string): Promise {
+ const query = new URLSearchParams({ search })
+ return parseEnvelope(
+ await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`),
+ )
+ }
+
+ async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise {
+ await parseEnvelope<{ userId: string; role: string }>(
+ await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, {
+ data: { userId, role },
+ }),
+ )
+ }
+
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise {
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
@@ -384,7 +522,7 @@ export class E2eTestDataBuilder {
}
async createReviewData(): Promise {
- const namespace = await this.ensureWritableNamespace()
+ const namespace = await this.ensureReviewableNamespace()
const skill = await this.publishSkill(namespace.slug)
return { namespace, skill }
}
diff --git a/web/e2e/namespace-review-detail-access.spec.ts b/web/e2e/namespace-review-detail-access.spec.ts
new file mode 100644
index 000000000..1daac06d9
--- /dev/null
+++ b/web/e2e/namespace-review-detail-access.spec.ts
@@ -0,0 +1,59 @@
+import { expect, test } from '@playwright/test'
+import { setEnglishLocale } from './helpers/auth-fixtures'
+import { createNamespaceReviewData } from './helpers/review-seed'
+
+test.describe('Namespace Review Detail Access (Real API)', () => {
+ test.describe.configure({ timeout: 120_000 })
+
+ test.beforeEach(async ({ page }) => {
+ await setEnglishLocale(page)
+ })
+
+ test('opens namespace review detail from the namespace review list', async ({ browser, page }, testInfo) => {
+ let seeded: Awaited> | undefined
+ try {
+ seeded = await createNamespaceReviewData(browser, page, testInfo)
+
+ await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`)
+
+ await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible()
+ await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`)).toBeVisible()
+
+ await page.getByRole('link', { name: 'Open review' }).first().click()
+
+ await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/\\d+$`))
+ await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible()
+ await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`).first()).toBeVisible()
+ } finally {
+ await seeded?.cleanup()
+ }
+ })
+
+ test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ browser, page }, testInfo) => {
+ let seeded: Awaited> | undefined
+ try {
+ seeded = await createNamespaceReviewData(browser, page, testInfo)
+
+ await page.goto('/dashboard/reviews')
+
+ await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/)
+ await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible()
+ } finally {
+ await seeded?.cleanup()
+ }
+ })
+
+ test('redirects namespace review detail opened through the global detail route', async ({ browser, page }, testInfo) => {
+ let seeded: Awaited> | undefined
+ try {
+ seeded = await createNamespaceReviewData(browser, page, testInfo)
+
+ await page.goto(`/dashboard/reviews/${seeded.reviewTaskId}`)
+
+ await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${seeded.reviewTaskId}$`))
+ await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible()
+ } finally {
+ await seeded?.cleanup()
+ }
+ })
+})
diff --git a/web/e2e/namespace-reviews-data.spec.ts b/web/e2e/namespace-reviews-data.spec.ts
index a173ecaae..b2cad5c6a 100644
--- a/web/e2e/namespace-reviews-data.spec.ts
+++ b/web/e2e/namespace-reviews-data.spec.ts
@@ -1,26 +1,24 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
-import { registerSession } from './helpers/session'
-import { E2eTestDataBuilder } from './helpers/test-data-builder'
+import { createNamespaceReviewData } from './helpers/review-seed'
test.describe('Namespace Reviews Data (Real API)', () => {
- test.beforeEach(async ({ page }, testInfo) => {
+ test.describe.configure({ timeout: 120_000 })
+
+ test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
- await registerSession(page, testInfo)
})
- test('opens namespace reviews page with seeded review data context', async ({ page }, testInfo) => {
- const builder = new E2eTestDataBuilder(page, testInfo)
- await builder.init()
-
+ test('opens namespace reviews page with seeded review data context', async ({ browser, page }, testInfo) => {
+ let seeded: Awaited> | undefined
try {
- const seeded = await builder.createReviewData()
+ seeded = await createNamespaceReviewData(browser, page, testInfo)
await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`)
await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible()
await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible()
} finally {
- await builder.cleanup()
+ await seeded?.cleanup()
}
})
})
diff --git a/web/e2e/password-reset.spec.ts b/web/e2e/password-reset.spec.ts
new file mode 100644
index 000000000..a37efcf5e
--- /dev/null
+++ b/web/e2e/password-reset.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from '@playwright/test'
+import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
+
+test.describe('Password Reset (Real API)', () => {
+ function uniqueResetEmail(seed: string) {
+ return `nonexistent_${seed}_${Date.now()}@example.com`
+ }
+
+ test.beforeEach(async ({ page }) => {
+ await setEnglishLocale(page)
+ })
+
+ test('sends verification code from reset-password page', async ({ page }) => {
+ const email = uniqueResetEmail('request')
+ await setUniqueClientIp(page, 'password-reset-request')
+
+ await page.goto('/reset-password')
+
+ await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
+ await page.getByLabel('Email').fill(email)
+ await page.getByRole('button', { name: 'Send Verification Code' }).click()
+
+ await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible()
+ })
+
+ test('shows backend validation error for an invalid reset code', async ({ page }) => {
+ const email = uniqueResetEmail('invalid-code')
+ await setUniqueClientIp(page, 'password-reset-invalid-code')
+
+ await page.goto('/reset-password')
+
+ await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
+ await page.getByLabel('Email').fill(email)
+ await page.getByRole('button', { name: 'Send Verification Code' }).click()
+ await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible()
+ await page.getByLabel('Verification Code').fill('123456')
+ await page.getByLabel('New Password').fill('Passw0rd!123')
+ await page.getByLabel('Confirm Password').fill('Passw0rd!123')
+ await page.getByRole('button', { name: 'Reset Password' }).click()
+
+ await expect(
+ page.getByText(/The verification code is invalid or has expired\.|验证码无效或已过期。/)
+ ).toBeVisible()
+ })
+})
diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts
new file mode 100644
index 000000000..56ba8885a
--- /dev/null
+++ b/web/e2e/public-skill-detail-anonymous.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from '@playwright/test'
+import { setEnglishLocale } from './helpers/auth-fixtures'
+import { getSearchCard, prepareSearchSeed, type PreparedSearchSeed } from './helpers/search-seed'
+
+const SEARCH_URL = (q: string) => `/search?q=${encodeURIComponent(q)}&sort=relevance&page=0&starredOnly=false`
+
+function latestSeed(seed: PreparedSearchSeed) {
+ return {
+ skill: seed.skills[seed.skills.length - 1],
+ skillName: seed.skillNames[seed.skillNames.length - 1],
+ }
+}
+
+let seeded: PreparedSearchSeed | undefined
+
+test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
+ test.beforeAll(async ({ browser }, testInfo) => {
+ seeded = await prepareSearchSeed(browser, testInfo, { count: 1 })
+ })
+
+ test.afterAll(async () => {
+ await seeded?.dispose()
+ seeded = undefined
+ })
+
+ test.beforeEach(async ({ page }) => {
+ await setEnglishLocale(page)
+ })
+
+ test('allows anonymous users to open a public skill detail and view install content', async ({ page }) => {
+ const current = latestSeed(seeded!)
+
+ await page.goto(SEARCH_URL(seeded!.keyword))
+ const card = getSearchCard(page, current.skillName)
+ await expect(card).toBeVisible({ timeout: 15_000 })
+
+ await card.click()
+
+ await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`))
+ await expect(page).not.toHaveURL(/\/login\?returnTo=/)
+ await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
+ await expect(page.getByText('Install', { exact: true })).toBeVisible()
+ await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible()
+ })
+})
diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts
index a1f1ef76a..867c8248b 100644
--- a/web/e2e/publish-flow-ui.spec.ts
+++ b/web/e2e/publish-flow-ui.spec.ts
@@ -1,8 +1,19 @@
import { expect, test } from '@playwright/test'
+import path from 'node:path'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
+interface PublishEnvelope {
+ code: number
+ msg?: string
+ data: {
+ namespace: string
+ slug: string
+ version: string
+ }
+}
+
test.describe('Publish Flow UI (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
@@ -15,19 +26,48 @@ test.describe('Publish Flow UI (Real API)', () => {
try {
const namespace = await builder.ensureWritableNamespace()
- const packagePath = builder.createSkillPackageFile()
+ const skillName = `publish-ui-${Date.now().toString(36)}`
+ const packagePath = builder.createSkillPackageFile({ name: skillName })
await page.goto('/dashboard/publish')
await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible()
- await page.locator('#namespace').click()
- await page.getByText(new RegExp(`\\(@${namespace.slug}\\)`)).first().click()
+ const namespaceTrigger = page.locator('#namespace')
+ await expect(namespaceTrigger).toBeVisible()
+ await namespaceTrigger.click()
+ const namespaceOption = page.getByRole('option', {
+ name: new RegExp(`\\(@${namespace.slug}\\)`),
+ }).first()
+ await expect(namespaceOption).toBeVisible()
+ await namespaceOption.evaluate((element: HTMLElement) => {
+ element.scrollIntoView({ block: 'center' })
+ element.click()
+ })
+ await expect(namespaceTrigger).toContainText(`@${namespace.slug}`)
await page.locator('input[type="file"]').setInputFiles(packagePath)
- await page.getByRole('button', { name: 'Confirm Publish' }).click()
+ await expect(page.getByText(path.basename(packagePath))).toBeVisible()
+ const confirmButton = page.getByRole('button', { name: 'Confirm Publish' })
+ await expect(confirmButton).toBeEnabled()
+ const publishResponsePromise = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST'
+ && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`),
+ { timeout: 90_000 },
+ )
+ await confirmButton.click()
+ const publishResponse = await publishResponsePromise
+ const publishBody = await publishResponse.json() as PublishEnvelope
- await expect(page).toHaveURL('/dashboard/skills')
- await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible()
+ expect(publishResponse.status(), `publish failed: ${publishBody.msg ?? 'unknown error'}`).toBe(200)
+ expect(publishBody.code).toBe(0)
+ expect(publishBody.data.namespace).toBe(namespace.slug)
+
+ await page.goto('/dashboard/skills')
+ await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 30_000 })
+ await expect(page.getByRole('heading', { name: skillName, exact: true })).toBeVisible({ timeout: 30_000 })
+ await expect(page.getByText(`@${publishBody.data.namespace}`).first()).toBeVisible()
+ await expect(page.getByText(`v${publishBody.data.version}`).first()).toBeVisible()
} finally {
await builder.cleanup()
}
diff --git a/web/e2e/register-email-required.spec.ts b/web/e2e/register-email-required.spec.ts
new file mode 100644
index 000000000..54d2476a1
--- /dev/null
+++ b/web/e2e/register-email-required.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from '@playwright/test'
+import { setEnglishLocale } from './helpers/auth-fixtures'
+
+function buildUniqueUser() {
+ const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
+ return {
+ username: `e2e_reg_${suffix}`,
+ email: `e2e_reg_${suffix}@example.test`,
+ password: 'Passw0rd!123',
+ }
+}
+
+test.describe('Register Email Required (Real API)', () => {
+ test.beforeEach(async ({ page }) => {
+ await setEnglishLocale(page)
+ })
+
+ test('registers successfully when email is provided', async ({ page }) => {
+ const user = buildUniqueUser()
+ await page.goto('/register')
+
+ await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible()
+ await page.getByLabel('Username').fill(user.username)
+ await page.getByLabel('Email').fill(user.email)
+ await page.getByLabel('Password').fill(user.password)
+ await page.getByRole('button', { name: 'Register & Login' }).click()
+
+ await expect(page).toHaveURL('/dashboard')
+ })
+
+ test('shows required validation when email is missing', async ({ page }) => {
+ await page.goto('/register')
+
+ await page.getByLabel('Username').fill(`e2e_no_email_${Date.now().toString(36)}`)
+ await page.getByLabel('Password').fill('Passw0rd!123')
+ await page.getByRole('button', { name: 'Register & Login' }).click()
+
+ const isEmailMissing = await page.getByLabel('Email').evaluate((element) => {
+ const input = element as HTMLInputElement
+ return input.validity.valueMissing
+ })
+
+ expect(isEmailMissing).toBeTruthy()
+ await expect(page).toHaveURL(/\/register/)
+ })
+})
diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts
index 2d0c85310..7ab8574ab 100644
--- a/web/e2e/register-login-validation.spec.ts
+++ b/web/e2e/register-login-validation.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test'
-import { setEnglishLocale } from './helpers/auth-fixtures'
+import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
import { createFreshSession } from './helpers/session'
// TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框
@@ -10,14 +10,16 @@ const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i
const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|请求过于频繁/
test.describe('Register - Username Validation (Real API)', () => {
- test.beforeEach(async ({ page }) => {
+ test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
+ await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
await page.goto('/register')
})
// TC_UN_008 P0
test('TC_UN_008: shows required error when username is empty', async ({ page }) => {
- await page.getByRole('button', { name: 'Register' }).click()
+ await page.getByLabel(/username/i).click()
+ await page.getByLabel(/email/i).click()
await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible()
})
@@ -51,18 +53,19 @@ test.describe('Register - Username Validation (Real API)', () => {
})
test.describe('Register - Email Validation (Real API)', () => {
- test.beforeEach(async ({ page }) => {
+ test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
+ await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
await page.goto('/register')
})
- // TC_EM_007 P0 - email is optional
- test('TC_EM_007: allows empty email (email is optional)', async ({ page }) => {
+ // TC_EM_007 P0 - email is required
+ test('TC_EM_007: shows required error when email is empty', async ({ page }) => {
const emailField = page.getByLabel(/email/i)
if (await emailField.isVisible()) {
await emailField.clear()
await emailField.blur()
- await expect(page.getByText(/email.*required/i)).not.toBeVisible()
+ await expect(page.getByText(/email.*required|required.*email/i)).toBeVisible()
}
})
@@ -88,14 +91,16 @@ test.describe('Register - Email Validation (Real API)', () => {
})
test.describe('Register - Password Validation (Real API)', () => {
- test.beforeEach(async ({ page }) => {
+ test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
+ await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
await page.goto('/register')
})
// TC_PW_013 P0 - empty password
test('TC_PW_013: shows required error when password is empty', async ({ page }) => {
- await page.getByRole('button', { name: 'Register' }).click()
+ await page.getByLabel(/^password/i).click()
+ await page.getByLabel(/username/i).click()
await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible()
})
@@ -123,8 +128,9 @@ test.describe('Register - Password Validation (Real API)', () => {
})
test.describe('Register Flow (Real API)', () => {
- test.beforeEach(async ({ page }) => {
+ test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
+ await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
})
// TC_REG_001 P0 - successful registration with all fields
@@ -138,7 +144,7 @@ test.describe('Register Flow (Real API)', () => {
await emailField.fill(`test_${suffix}@example.test`)
}
await page.getByLabel(/^password/i).fill('Test123!@')
- await page.getByRole('button', { name: 'Register' }).click()
+ await page.getByRole('button', { name: 'Register & Login' }).click()
// Should redirect away from /register on success
await expect(page).not.toHaveURL('/register')
existingRegisteredUsername = username
@@ -160,13 +166,14 @@ test.describe('Register Flow (Real API)', () => {
await page.goto('/register')
await setEnglishLocale(page)
await page.getByLabel(/username/i).fill(username)
+ await page.getByLabel(/email/i).fill(`duplicate_${Date.now()}@example.test`)
await page.getByLabel(/^password/i).fill('Test123!@')
const main = page.getByRole('main')
const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first()
const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first()
for (let attempt = 0; attempt < 3; attempt += 1) {
- await page.getByRole('button', { name: 'Register' }).click()
+ await page.getByRole('button', { name: 'Register & Login' }).click()
if (await duplicateUsernameError.isVisible().catch(() => false)) {
return
@@ -184,27 +191,35 @@ test.describe('Register Flow (Real API)', () => {
})
// TC_REG_002 P0 - registration without email
- test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => {
+ test('TC_REG_002: shows required validation when email is missing', async ({ page }) => {
await page.goto('/register')
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5)
- await page.getByLabel(/username/i).fill(`noemail_${suffix}`)
+ await page.getByLabel(/username/i).fill(`emailrequired_${suffix}`)
await page.getByLabel(/^password/i).fill('Test123!@')
- await page.getByRole('button', { name: 'Register' }).click()
- await expect(page).not.toHaveURL('/register')
+ await page.getByLabel(/email/i).click()
+ await page.getByLabel(/username/i).click()
+ await page.getByRole('button', { name: 'Register & Login' }).click()
+ const emailField = page.getByLabel(/email/i)
+ await expect(emailField).toBeFocused()
+ await expect
+ .poll(async () => emailField.evaluate((input) => (input as HTMLInputElement).validity.valueMissing))
+ .toBe(true)
})
// TC_REG_005 P0 - required fields empty on submit
test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => {
await page.goto('/register')
- await page.getByRole('button', { name: 'Register' }).click()
+ await page.getByLabel(/email/i).fill(`required_fields_${Date.now()}@example.test`)
+ await page.getByRole('button', { name: 'Register & Login' }).click()
await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible()
await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible()
})
})
test.describe('Login Flow (Real API)', () => {
- test.beforeEach(async ({ page }) => {
+ test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
+ await setUniqueClientIp(page, `register-validation-${testInfo.title}`)
})
// TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here)
@@ -223,8 +238,9 @@ test.describe('Login Flow (Real API)', () => {
await page.goto('/register')
await page.getByLabel(/username/i).fill(username)
+ await page.getByLabel(/email/i).fill(`login_${suffix}@example.test`)
await page.getByLabel(/^password/i).fill('Test123!@')
- await page.getByRole('button', { name: 'Register' }).click()
+ await page.getByRole('button', { name: 'Register & Login' }).click()
await expect(page).not.toHaveURL('/register')
await page.goto('/login')
diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts
index 3fca443a5..1deadc585 100644
--- a/web/e2e/search-card-interaction.spec.ts
+++ b/web/e2e/search-card-interaction.spec.ts
@@ -30,38 +30,61 @@ async function waitForCards(page: Page) {
const keyword = basicSeed?.keyword
const encodedKeyword = keyword ? encodeURIComponent(keyword) : null
+ let reloaded = false
- for (let attempt = 0; attempt < 4; attempt += 1) {
- await page.waitForLoadState('networkidle')
+ const waitForMatchingResponse = async () => {
+ if (!encodedKeyword) {
+ return
+ }
+
+ await page.waitForResponse(async (response) => {
+ if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) {
+ return false
+ }
+ if (response.status() !== 200) {
+ return false
+ }
+
+ try {
+ const payload = await response.json() as { data?: { items?: Array } }
+ return Array.isArray(payload.data?.items) && payload.data.items.length > 0
+ } catch {
+ return false
+ }
+ }, { timeout: 15_000 }).catch(() => null)
+ }
+
+ const waitForCardCount = async () => {
+ await expect.poll(
+ async () => cards.count(),
+ {
+ timeout: 20_000,
+ intervals: [250, 500, 1_000, 2_000],
+ },
+ ).toBeGreaterThan(0)
+ }
+
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
+
+ if (await cards.count() > 0) {
+ return cards
+ }
+
+ await waitForMatchingResponse()
+
+ try {
+ await waitForCardCount()
+ } catch {
+ if (reloaded) {
+ throw new Error('Timed out waiting for search cards after one reload fallback')
+ }
+
+ reloaded = true
+ await page.reload({ waitUntil: 'networkidle' })
await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
-
- if (await cards.count() > 0) {
- return cards
- }
-
- if (attempt < 3) {
- const responsePromise = encodedKeyword
- ? page.waitForResponse(async (response) => {
- if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) {
- return false
- }
- if (response.status() !== 200) {
- return false
- }
-
- try {
- const payload = await response.json() as { data?: { items?: Array } }
- return Array.isArray(payload.data?.items) && payload.data.items.length > 0
- } catch {
- return false
- }
- }, { timeout: 12_000 }).catch(() => null)
- : Promise.resolve(null)
-
- await page.waitForTimeout(750 * (attempt + 1))
- await page.reload({ waitUntil: 'networkidle' })
- await responsePromise
- }
+ await waitForMatchingResponse()
+ await waitForCardCount()
}
return cards
diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts
index f5775bfb9..be14db8fd 100644
--- a/web/e2e/search-page-full.spec.ts
+++ b/web/e2e/search-page-full.spec.ts
@@ -39,12 +39,11 @@ test.describe('Search Input (Real API)', () => {
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
})
- // TC_SEARCH_INPUT_003 P0 - empty search guidance
- test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => {
+ // TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list
+ test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => {
await page.goto(searchUrl(''))
await expect(page).toHaveURL(/\/search/)
- await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible()
- await expect(page.getByText('Please enter a search keyword')).toBeVisible()
+ await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
})
// TC_SEARCH_INPUT_004 P0 - Enter key triggers search
diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts
index 8c77919cd..de2abd6ec 100644
--- a/web/e2e/settings-pages.spec.ts
+++ b/web/e2e/settings-pages.spec.ts
@@ -13,6 +13,13 @@ test.describe('Settings Pages (Real API)', () => {
await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible()
})
+ test('navigates to reset-password page from profile settings', async ({ page }) => {
+ await page.goto('/settings/profile')
+ await page.getByRole('button', { name: 'Reset Password' }).click()
+ await expect(page).toHaveURL('/reset-password')
+ await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
+ })
+
test('shows validation when current password is missing', async ({ page }) => {
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 928036a0a..1e7351ff9 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -2,6 +2,8 @@ import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import type {
ChangePasswordRequest,
+ PasswordResetConfirmRequest,
+ PasswordResetRequest,
ApiToken,
CreateTokenRequest,
CreateTokenResponse,
@@ -354,6 +356,26 @@ export const authApi = {
})
},
+ async requestPasswordReset(request: PasswordResetRequest): Promise {
+ await fetchJson('/api/v1/auth/local/password-reset/request', {
+ method: 'POST',
+ headers: await ensureCsrfHeaders({
+ 'Content-Type': 'application/json',
+ }),
+ body: JSON.stringify(request),
+ })
+ },
+
+ async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise {
+ await fetchJson('/api/v1/auth/local/password-reset/confirm', {
+ method: 'POST',
+ headers: await ensureCsrfHeaders({
+ 'Content-Type': 'application/json',
+ }),
+ body: JSON.stringify(request),
+ })
+ },
+
async logout(): Promise {
const response = await fetch('/api/v1/auth/logout', {
method: 'POST',
@@ -466,14 +488,44 @@ export const skillLifecycleApi = {
})
},
- async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise {
+ async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
- body: JSON.stringify({ targetVersion }),
+ body: JSON.stringify({ targetVersion, confirmWarnings }),
+ })
+ },
+
+ /**
+ * Submit an UPLOADED version for review.
+ * Transitions version status from UPLOADED to PENDING_REVIEW.
+ */
+ async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise {
+ const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
+ await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, {
+ method: 'POST',
+ headers: await ensureCsrfHeaders({
+ 'Content-Type': 'application/json',
+ }),
+ body: JSON.stringify({ version, targetVisibility }),
+ })
+ },
+
+ /**
+ * Confirm publish for a PRIVATE skill version.
+ * Transitions version status from UPLOADED to PUBLISHED without review.
+ */
+ async confirmPublish(namespace: string, slug: string, version: string): Promise {
+ const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
+ await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, {
+ method: 'POST',
+ headers: await ensureCsrfHeaders({
+ 'Content-Type': 'application/json',
+ }),
+ body: JSON.stringify({ version }),
})
},
}
@@ -1063,6 +1115,13 @@ export const adminApi = {
})
},
+ async triggerPasswordReset(userId: string): Promise {
+ await fetchJson(`/api/v1/admin/users/${userId}/password-reset`, {
+ method: 'POST',
+ headers: getCsrfHeaders(),
+ })
+ },
+
async getAuditLogs(params: {
action?: string
userId?: string
diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts
index 5da2df9da..d87140256 100644
--- a/web/src/api/generated/schema.d.ts
+++ b/web/src/api/generated/schema.d.ts
@@ -1140,6 +1140,38 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/auth/local/password-reset/request": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["requestPasswordReset"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/local/password-reset/confirm": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["confirmPasswordReset"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/auth/local/login": {
parameters: {
query?: never;
@@ -1220,6 +1252,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/admin/users/{userId}/password-reset": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["triggerPasswordReset"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/admin/users/{userId}/enable": {
parameters: {
query?: never;
@@ -3189,6 +3237,7 @@ export interface components {
};
SkillVersionRereleaseRequest: {
targetVersion: string;
+ confirmWarnings?: boolean;
};
SkillReportSubmitRequest: {
reason?: string;
@@ -3413,7 +3462,15 @@ export interface components {
LocalRegisterRequest: {
username: string;
password: string;
- email?: string;
+ email: string;
+ };
+ PasswordResetRequestDto: {
+ email: string;
+ };
+ PasswordResetConfirmRequest: {
+ email: string;
+ code: string;
+ newPassword: string;
};
LocalLoginRequest: {
username: string;
@@ -5624,6 +5681,7 @@ export interface operations {
parameters: {
query: {
visibility: string;
+ confirmWarnings?: boolean;
};
header?: never;
path: {
@@ -5655,6 +5713,7 @@ export interface operations {
parameters: {
query: {
visibility: string;
+ confirmWarnings?: boolean;
};
header?: never;
path: {
@@ -6678,6 +6737,7 @@ export interface operations {
query: {
payload: string;
files: string[];
+ confirmWarnings?: boolean;
};
header?: never;
path?: never;
@@ -6722,6 +6782,7 @@ export interface operations {
parameters: {
query: {
namespace: string;
+ confirmWarnings?: boolean;
};
header?: never;
path?: never;
@@ -6819,6 +6880,54 @@ export interface operations {
};
};
};
+ requestPasswordReset: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["PasswordResetRequestDto"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseVoid"];
+ };
+ };
+ };
+ };
+ confirmPasswordReset: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["PasswordResetConfirmRequest"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseVoid"];
+ };
+ };
+ };
+ };
login: {
parameters: {
query?: never;
@@ -6935,6 +7044,28 @@ export interface operations {
};
};
};
+ triggerPasswordReset: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ userId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["ApiResponseVoid"];
+ };
+ };
+ };
+ };
enableUser: {
parameters: {
query?: never;
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 61c9fa46b..2b2f93823 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -53,7 +53,7 @@ export interface LocalLoginRequest {
}
export interface LocalRegisterRequest extends LocalLoginRequest {
- email?: string
+ email: string
}
export interface ChangePasswordRequest {
@@ -61,6 +61,16 @@ export interface ChangePasswordRequest {
newPassword: string
}
+export interface PasswordResetRequest {
+ email: string
+}
+
+export interface PasswordResetConfirmRequest {
+ email: string
+ code: string
+ newPassword: string
+}
+
export type CreateNamespaceRequest = Omit & {
slug: string
displayName: string
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index fd48292b6..4095904d3 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -65,6 +65,7 @@ const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'L
const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage')
const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
+const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage')
const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage')
const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage')
const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
@@ -85,22 +86,18 @@ const NamespaceReviewsPage = createLazyRouteComponent(
() => import('@/pages/dashboard/namespace-reviews'),
'NamespaceReviewsPage',
)
-const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
-const ReviewsPage = createRoleProtectedRouteComponent(
- () => import('@/pages/dashboard/reviews'),
- 'ReviewsPage',
- ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'],
+const NamespaceReviewDetailPage = createLazyRouteComponent(
+ () => import('@/pages/dashboard/review-detail'),
+ 'NamespaceReviewDetailPage',
)
+const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
+const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const ReportsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/reports'),
'ReportsPage',
['SKILL_ADMIN', 'SUPER_ADMIN'],
)
-const ReviewDetailPage = createRoleProtectedRouteComponent(
- () => import('@/pages/dashboard/review-detail'),
- 'ReviewDetailPage',
- ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'],
-)
+const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage')
const PromotionsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/promotions'),
'PromotionsPage',
@@ -184,6 +181,12 @@ const registerRoute = createRoute({
component: RegisterPage,
})
+const resetPasswordRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'reset-password',
+ component: ResetPasswordPage,
+})
+
const privacyRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'privacy',
@@ -221,7 +224,6 @@ const namespaceRoute = createRoute({
const skillDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/space/$namespace/$slug',
- beforeLoad: requireAuth,
validateSearch: (search: Record): { returnTo?: string } => ({
returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined,
}),
@@ -298,6 +300,13 @@ const dashboardReviewDetailRoute = createRoute({
component: ReviewDetailPage,
})
+const dashboardNamespaceReviewDetailRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'dashboard/namespaces/$slug/reviews/$id',
+ beforeLoad: requireAuth,
+ component: NamespaceReviewDetailPage,
+})
+
const dashboardPromotionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/promotions',
@@ -397,6 +406,7 @@ const routeTree = rootRoute.addChildren([
skillsRoute,
loginRoute,
registerRoute,
+ resetPasswordRoute,
privacyRoute,
searchRoute,
termsRoute,
@@ -408,6 +418,7 @@ const routeTree = rootRoute.addChildren([
dashboardNamespacesRoute,
dashboardNamespaceMembersRoute,
dashboardNamespaceReviewsRoute,
+ dashboardNamespaceReviewDetailRoute,
dashboardGovernanceRoute,
dashboardReviewsRoute,
dashboardReportsRoute,
diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts
index be9c77432..7c0abf73d 100644
--- a/web/src/features/admin/use-admin-users.ts
+++ b/web/src/features/admin/use-admin-users.ts
@@ -97,3 +97,13 @@ export function useEnableUser() {
},
})
}
+
+export function useTriggerUserPasswordReset() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (userId: string) => adminApi.triggerPasswordReset(userId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
+ },
+ })
+}
diff --git a/web/src/features/publish/publish-error-utils.test.ts b/web/src/features/publish/publish-error-utils.test.ts
new file mode 100644
index 000000000..119881e4c
--- /dev/null
+++ b/web/src/features/publish/publish-error-utils.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import {
+ extractPrecheckWarnings,
+ isFrontmatterFailureMessage,
+ isPrecheckConfirmationMessage,
+ isPrecheckFailureMessage,
+ isVersionExistsMessage,
+} from './publish-error-utils'
+
+describe('publish-error-utils', () => {
+ it('detects confirmation-required warnings in English', () => {
+ expect(isPrecheckConfirmationMessage('Pre-publish warnings require confirmation before publishing:\n- warning')).toBe(true)
+ })
+
+ it('detects confirmation-required warnings in Chinese', () => {
+ expect(isPrecheckConfirmationMessage('预发布发现以下风险提醒,确认后仍可继续发布:\n- 风险提醒')).toBe(true)
+ })
+
+ it('extracts warning lines from a confirmation message', () => {
+ expect(extractPrecheckWarnings(
+ 'Pre-publish warnings require confirmation before publishing:\n- Disallowed file extension: malware.exe\n- SKILL.md line 5 contains a value that looks like a secret or token.'
+ )).toEqual([
+ 'Disallowed file extension: malware.exe',
+ 'SKILL.md line 5 contains a value that looks like a secret or token.',
+ ])
+ })
+
+ it('keeps existing blocking precheck detection', () => {
+ expect(isPrecheckFailureMessage('Pre-publish validation failed: validator blocked publish')).toBe(true)
+ })
+
+ it('keeps version and frontmatter detection helpers', () => {
+ expect(isVersionExistsMessage('Version already exists')).toBe(true)
+ expect(isFrontmatterFailureMessage('Invalid SKILL.md frontmatter')).toBe(true)
+ })
+})
diff --git a/web/src/features/publish/publish-error-utils.ts b/web/src/features/publish/publish-error-utils.ts
new file mode 100644
index 000000000..dd115214a
--- /dev/null
+++ b/web/src/features/publish/publish-error-utils.ts
@@ -0,0 +1,74 @@
+const PRECHECK_CONFIRM_MARKERS = [
+ 'Pre-publish warnings require confirmation before publishing',
+ '预发布发现以下风险提醒,确认后仍可继续发布',
+]
+
+const PRECHECK_FAILURE_MARKERS = [
+ 'error.skill.publish.precheck.failed',
+ 'Pre-publish validation failed',
+ '预发布校验失败',
+ 'looks like a secret or token',
+]
+
+const VERSION_EXISTS_MARKERS = [
+ 'error.skill.version.exists',
+ 'Version already exists',
+ '版本已存在',
+]
+
+const FRONTMATTER_FAILURE_MARKERS = [
+ 'Invalid SKILL.md frontmatter',
+ '技能包校验失败:Invalid SKILL.md frontmatter',
+]
+
+function includesAnyMarker(message: string | undefined, markers: string[]): boolean {
+ if (!message) {
+ return false
+ }
+
+ return markers.some((marker) => message.includes(marker))
+}
+
+export function isVersionExistsMessage(message?: string): boolean {
+ return includesAnyMarker(message, VERSION_EXISTS_MARKERS)
+}
+
+export function isPrecheckFailureMessage(message?: string): boolean {
+ return includesAnyMarker(message, PRECHECK_FAILURE_MARKERS)
+}
+
+export function isPrecheckConfirmationMessage(message?: string): boolean {
+ return includesAnyMarker(message, PRECHECK_CONFIRM_MARKERS)
+}
+
+export function isFrontmatterFailureMessage(message?: string): boolean {
+ return includesAnyMarker(message, FRONTMATTER_FAILURE_MARKERS)
+}
+
+export function extractPrecheckWarnings(message?: string): string[] {
+ if (!message) {
+ return []
+ }
+
+ const normalized = message.replace(/\r/g, '').trim()
+ if (!normalized) {
+ return []
+ }
+
+ return normalized
+ .split('\n')
+ .map((line, index) => {
+ const trimmed = line.trim()
+ if (!trimmed) {
+ return null
+ }
+
+ if (index === 0 && isPrecheckConfirmationMessage(trimmed)) {
+ const firstWarning = trimmed.replace(/^.*?[::]\s*/, '').trim()
+ return firstWarning && !isPrecheckConfirmationMessage(firstWarning) ? firstWarning : null
+ }
+
+ return trimmed.replace(/^[-*•]\s*/, '')
+ })
+ .filter((line): line is string => Boolean(line))
+}
diff --git a/web/src/features/review/review-paths.test.ts b/web/src/features/review/review-paths.test.ts
new file mode 100644
index 000000000..bcd612005
--- /dev/null
+++ b/web/src/features/review/review-paths.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildGlobalReviewsPath,
+ buildNamespaceReviewDetailPath,
+ buildNamespaceReviewsPath,
+ canAccessGlobalReviewCenter,
+ canAccessReviewCenter,
+ canManageNamespaceReviews,
+ getPreferredNamespaceReviewEntry,
+} from './review-paths'
+
+describe('review-paths', () => {
+ it('builds the global reviews path', () => {
+ expect(buildGlobalReviewsPath()).toBe('/dashboard/reviews')
+ })
+
+ it('builds namespace review paths', () => {
+ expect(buildNamespaceReviewsPath('team alpha')).toBe('/dashboard/namespaces/team%20alpha/reviews')
+ expect(buildNamespaceReviewDetailPath('team alpha', 12)).toBe('/dashboard/namespaces/team%20alpha/reviews/12')
+ })
+
+ it('detects global review access from platform roles', () => {
+ expect(canAccessGlobalReviewCenter(['SKILL_ADMIN'])).toBe(true)
+ expect(canAccessGlobalReviewCenter(['USER_ADMIN'])).toBe(true)
+ expect(canAccessGlobalReviewCenter(['SUPER_ADMIN'])).toBe(true)
+ expect(canAccessGlobalReviewCenter(['USER'])).toBe(false)
+ })
+
+ it('recognizes namespace review managers', () => {
+ expect(canManageNamespaceReviews('OWNER')).toBe(true)
+ expect(canManageNamespaceReviews('ADMIN')).toBe(true)
+ expect(canManageNamespaceReviews('MEMBER')).toBe(false)
+ })
+
+ it('prefers active team namespaces for namespace review entry', () => {
+ expect(getPreferredNamespaceReviewEntry([
+ {
+ id: 1,
+ slug: 'archived-team',
+ displayName: 'Archived Team',
+ type: 'TEAM',
+ status: 'ARCHIVED',
+ immutable: false,
+ canFreeze: false,
+ canUnfreeze: false,
+ canArchive: false,
+ canRestore: false,
+ currentUserRole: 'ADMIN',
+ createdAt: '',
+ },
+ {
+ id: 2,
+ slug: 'active-team',
+ displayName: 'Active Team',
+ type: 'TEAM',
+ status: 'ACTIVE',
+ immutable: false,
+ canFreeze: false,
+ canUnfreeze: false,
+ canArchive: false,
+ canRestore: false,
+ currentUserRole: 'OWNER',
+ createdAt: '',
+ },
+ ])?.slug).toBe('active-team')
+ })
+
+ it('returns null when no manageable namespace exists', () => {
+ expect(getPreferredNamespaceReviewEntry([
+ {
+ id: 1,
+ slug: 'member-team',
+ displayName: 'Member Team',
+ type: 'TEAM',
+ status: 'ACTIVE',
+ immutable: false,
+ canFreeze: false,
+ canUnfreeze: false,
+ canArchive: false,
+ canRestore: false,
+ currentUserRole: 'MEMBER',
+ createdAt: '',
+ },
+ ])).toBeNull()
+ })
+
+ it('detects review center access from either platform roles or namespace roles', () => {
+ expect(canAccessReviewCenter(['SKILL_ADMIN'], [])).toBe(true)
+ expect(canAccessReviewCenter([], [
+ {
+ id: 2,
+ slug: 'team-admin',
+ displayName: 'Team Admin',
+ type: 'TEAM',
+ status: 'ACTIVE',
+ immutable: false,
+ canFreeze: false,
+ canUnfreeze: false,
+ canArchive: false,
+ canRestore: false,
+ currentUserRole: 'ADMIN',
+ createdAt: '',
+ },
+ ])).toBe(true)
+ expect(canAccessReviewCenter([], [])).toBe(false)
+ })
+})
diff --git a/web/src/features/review/review-paths.ts b/web/src/features/review/review-paths.ts
new file mode 100644
index 000000000..081f3c04a
--- /dev/null
+++ b/web/src/features/review/review-paths.ts
@@ -0,0 +1,48 @@
+import type { ManagedNamespace, NamespaceRole } from '@/api/types'
+
+const GLOBAL_REVIEW_PLATFORM_ROLES = ['SKILL_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'] as const
+
+export function buildGlobalReviewsPath() {
+ return '/dashboard/reviews'
+}
+
+export function buildNamespaceReviewsPath(slug: string) {
+ return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews`
+}
+
+export function buildNamespaceReviewDetailPath(slug: string, reviewId: number) {
+ return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews/${reviewId}`
+}
+
+export function canAccessGlobalReviewCenter(platformRoles?: readonly string[]) {
+ return GLOBAL_REVIEW_PLATFORM_ROLES.some((role) => platformRoles?.includes(role))
+}
+
+export function canManageNamespaceReviews(role?: NamespaceRole) {
+ return role === 'OWNER' || role === 'ADMIN'
+}
+
+export function getPreferredNamespaceReviewEntry(
+ namespaces?: readonly ManagedNamespace[],
+) {
+ if (!namespaces?.length) {
+ return null
+ }
+
+ const manageableNamespaces = namespaces.filter((namespace) =>
+ namespace.type === 'TEAM' && canManageNamespaceReviews(namespace.currentUserRole),
+ )
+ if (manageableNamespaces.length === 0) {
+ return null
+ }
+
+ const activeNamespace = manageableNamespaces.find((namespace) => namespace.status === 'ACTIVE')
+ return activeNamespace ?? manageableNamespaces[0]
+}
+
+export function canAccessReviewCenter(
+ platformRoles?: readonly string[],
+ namespaces?: readonly ManagedNamespace[],
+) {
+ return canAccessGlobalReviewCenter(platformRoles) || getPreferredNamespaceReviewEntry(namespaces) !== null
+}
diff --git a/web/src/features/skill/version-status-badge.tsx b/web/src/features/skill/version-status-badge.tsx
new file mode 100644
index 000000000..0a518b04a
--- /dev/null
+++ b/web/src/features/skill/version-status-badge.tsx
@@ -0,0 +1,92 @@
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/shared/lib/utils'
+
+type VersionStatus =
+ | 'DRAFT'
+ | 'SCANNING'
+ | 'SCAN_FAILED'
+ | 'UPLOADED'
+ | 'PENDING_REVIEW'
+ | 'PUBLISHED'
+ | 'REJECTED'
+ | 'YANKED'
+
+const statusStyles: Record = {
+ PUBLISHED:
+ 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
+ UPLOADED:
+ 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400',
+ PENDING_REVIEW:
+ 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
+ REJECTED:
+ 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
+ SCANNING:
+ 'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400',
+ SCAN_FAILED:
+ 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
+ YANKED:
+ 'border-border/60 bg-secondary/40 text-muted-foreground',
+ DRAFT:
+ 'border-border/60 bg-secondary/40 text-muted-foreground',
+}
+
+const i18nKeys: Record = {
+ DRAFT: 'skillDetail.versionStatusDraft',
+ SCANNING: 'skillDetail.versionStatusScanning',
+ SCAN_FAILED: 'skillDetail.versionStatusScanFailed',
+ UPLOADED: 'skillDetail.versionStatusUploaded',
+ PENDING_REVIEW: 'skillDetail.versionStatusPendingReview',
+ PUBLISHED: 'skillDetail.versionStatusPublished',
+ REJECTED: 'skillDetail.versionStatusRejected',
+ YANKED: 'skillDetail.versionStatusYanked',
+}
+
+/** Color-coded row styles (left-border + subtle background) for version cards. */
+export const versionRowStyles: Record = {
+ UPLOADED:
+ 'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]',
+ PENDING_REVIEW:
+ 'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]',
+ REJECTED:
+ 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]',
+ SCANNING:
+ 'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]',
+ SCAN_FAILED:
+ 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]',
+ PUBLISHED: '',
+ YANKED: '',
+ DRAFT: '',
+}
+
+export function getVersionRowStyle(status?: string): string {
+ if (!status) return ''
+ return versionRowStyles[status as VersionStatus] ?? ''
+}
+
+export function VersionStatusBadge({
+ status,
+ className,
+}: {
+ status?: string
+ className?: string
+}) {
+ const { t } = useTranslation()
+ if (!status) return null
+
+ const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT
+ const label = i18nKeys[status as VersionStatus]
+ ? t(i18nKeys[status as VersionStatus])
+ : status
+
+ return (
+
+ {label}
+
+ )
+}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 1f88708e2..b0cec8d43 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -209,6 +209,7 @@
"hidePassword": "Hide password",
"submitting": "Logging in...",
"submit": "Login",
+ "forgotPassword": "Forgot password?",
"noAccount": "Don't have an account?",
"register": "Sign up now",
"oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.",
@@ -232,21 +233,47 @@
"email": "Email",
"password": "Password",
"usernamePlaceholder": "3-64 characters: letters, numbers, or underscores",
- "emailPlaceholder": "Optional, for account identification",
+ "emailPlaceholder": "Enter your email",
+ "emailRequired": "Email is required",
"passwordPlaceholder": "At least 8 characters with 3 character types",
- "usernameRequired": "Username is required",
- "usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores",
- "emailInvalid": "Email format is invalid",
- "passwordRequired": "Password is required",
- "passwordTooShort": "Password must be at least 8 characters",
- "passwordTooWeak": "Password must include at least 3 character types",
- "usernameExists": "Username already exists",
- "emailExists": "Email already exists",
"submitting": "Registering...",
"submit": "Register & Login",
"hasAccount": "Already have an account?",
"login": "Back to login",
- "oauthHint": "Sign in directly with your existing OAuth account, no local password needed."
+ "oauthHint": "Sign in directly with your existing OAuth account, no local password needed.",
+ "usernameRequired": "Username is required",
+ "usernameInvalid": "Only letters, numbers, or underscores allowed (3-64 characters)",
+ "usernameExists": "Username already exists",
+ "passwordRequired": "Password is required",
+ "passwordTooShort": "Password must be at least 8 characters",
+ "passwordTooWeak": "Password must contain at least 3 character types (uppercase, lowercase, numbers, special)",
+ "emailInvalid": "Invalid email format",
+ "emailExists": "Email already exists"
+ },
+ "resetPassword": {
+ "title": "Reset Password",
+ "subtitle": "Enter your email, verification code, and new password.",
+ "email": "Email",
+ "emailPlaceholder": "Enter email",
+ "emailRequired": "Please enter your email",
+ "emailInvalid": "Please enter a valid email address",
+ "code": "Verification Code",
+ "codePlaceholder": "Enter 6-digit verification code",
+ "sendCode": "Send Verification Code",
+ "sendingCode": "Sending...",
+ "codeSentMessage": "If the account is eligible, a verification code has been sent.",
+ "codeRequired": "Please enter the verification code",
+ "newPassword": "New Password",
+ "newPasswordPlaceholder": "Enter new password",
+ "newPasswordRequired": "Please enter a new password",
+ "confirmPassword": "Confirm Password",
+ "confirmPasswordPlaceholder": "Re-enter new password",
+ "passwordMismatch": "The two passwords do not match",
+ "submit": "Reset Password",
+ "submitting": "Resetting...",
+ "successMessage": "Password reset successful. Please sign in with your new password.",
+ "genericError": "Failed to reset password",
+ "backToLogin": "Back to login"
},
"device": {
"title": "Device Authorization",
@@ -529,6 +556,7 @@
"approveUser": "Approve",
"disable": "Disable",
"enable": "Enable",
+ "resetPassword": "Reset Password",
"totalRecords": "Total {{total}} records, page {{page}}",
"prevPage": "Previous",
"nextPage": "Next",
@@ -543,7 +571,8 @@
"roleSuperAdmin": "Super Admin",
"confirmAction": "Confirm Action",
"confirmDisable": "Are you sure you want to disable user {{username}}?",
- "confirmEnable": "Are you sure you want to enable user {{username}}?"
+ "confirmEnable": "Are you sure you want to enable user {{username}}?",
+ "confirmResetPassword": "Send password reset verification code to user {{username}}?"
},
"adminLabels": {
"title": "Label Management",
@@ -650,6 +679,7 @@
"subtitle": "Manage your display name and personal information.",
"displayName": "Display Name",
"email": "Email",
+ "resetPassword": "Reset Password",
"edit": "Edit",
"save": "Save",
"saving": "Saving...",
@@ -777,6 +807,7 @@
"versionStatusDraft": "Draft",
"versionStatusScanning": "Scanning",
"versionStatusScanFailed": "Scan Failed",
+ "versionStatusUploaded": "Uploaded",
"versionStatusPendingReview": "Pending Review",
"versionStatusPublished": "Published",
"versionStatusRejected": "Rejected",
@@ -825,6 +856,18 @@
"withdrawReviewSuccessTitle": "Review withdrawn",
"withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.",
"withdrawReviewErrorTitle": "Failed to withdraw review",
+ "confirmPublish": "Confirm Publish",
+ "confirmPublishDialogTitle": "Confirm publish",
+ "confirmPublishDialogDescription": "Publish version {{version}} as a private skill? It will be available for you to download and install, but not visible on the marketplace.",
+ "confirmPublishSuccessTitle": "Version published",
+ "confirmPublishSuccessDescription": "Version {{version}} has been published as a private skill.",
+ "confirmPublishErrorTitle": "Failed to confirm publish",
+ "submitReview": "Submit for Review",
+ "submitReviewDialogTitle": "Submit for review",
+ "submitReviewDialogDescription": "Submit version {{version}} for public review? Once approved, it will be visible on the marketplace.",
+ "submitReviewSuccessTitle": "Submitted for review",
+ "submitReviewSuccessDescription": "Version {{version}} has been submitted for review.",
+ "submitReviewErrorTitle": "Failed to submit for review",
"deleteVersion": "Delete Version",
"deleteVersionConfirmTitle": "Delete version",
"deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?",
@@ -868,6 +911,9 @@
"rereleaseSuccessTitle": "Version re-released",
"rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.",
"rereleaseErrorTitle": "Failed to re-release version",
+ "rereleaseWarningTitle": "Pre-publish warning",
+ "rereleaseWarningDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue re-releasing.",
+ "rereleaseWarningConfirm": "Continue re-releasing",
"yankVersion": "Yank Current Version",
"promoteToGlobal": "Promote to Global",
"promotionSectionTitle": "Promote to Global",
@@ -1035,6 +1081,7 @@
"sortLabel": "Time Order",
"sortNewest": "Newest first",
"sortOldest": "Oldest first",
+ "openReview": "Open review",
"pageSummary": "Total {{total}} records, page {{page}}",
"prevPage": "Previous",
"nextPage": "Next",
@@ -1181,6 +1228,10 @@
"versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.",
"precheckFailedTitle": "Pre-publish check failed",
"precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.",
+ "warningConfirmTitle": "Pre-publish warning",
+ "warningConfirmDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue publishing.",
+ "warningConfirmContinue": "Continue publishing",
+ "warningConfirmCancel": "Go back and fix",
"frontmatterFailedTitle": "SKILL.md format is invalid",
"frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.",
"selectRequired": "Please select namespace and file"
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 92920e8fb..0e49a24f6 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -209,6 +209,7 @@
"hidePassword": "隐藏密码",
"submitting": "登录中...",
"submit": "登录",
+ "forgotPassword": "忘记密码?",
"noAccount": "还没有账号?",
"register": "立即注册",
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
@@ -232,21 +233,47 @@
"email": "邮箱",
"password": "密码",
"usernamePlaceholder": "3-64 位字母、数字或下划线",
- "emailPlaceholder": "可选,用于后续账号识别",
+ "emailPlaceholder": "请输入邮箱",
+ "emailRequired": "请输入邮箱",
"passwordPlaceholder": "至少 8 位,包含 3 种字符类型",
- "usernameRequired": "请输入用户名",
- "usernameInvalid": "用户名需为 3-64 位,且只能包含字母、数字或下划线",
- "emailInvalid": "邮箱格式不正确",
- "passwordRequired": "请输入密码",
- "passwordTooShort": "密码至少需要 8 位",
- "passwordTooWeak": "密码至少需要包含 3 种字符类型",
- "usernameExists": "用户名已存在",
- "emailExists": "邮箱已存在",
"submitting": "注册中...",
"submit": "注册并登录",
"hasAccount": "已有账号?",
"login": "返回登录",
- "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。"
+ "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。",
+ "usernameRequired": "请输入用户名",
+ "usernameInvalid": "仅支持字母、数字或下划线(3-64 位)",
+ "usernameExists": "用户名已存在",
+ "passwordRequired": "请输入密码",
+ "passwordTooShort": "密码至少需要 8 个字符",
+ "passwordTooWeak": "密码需包含至少 3 种字符类型(大写、小写、数字、特殊字符)",
+ "emailInvalid": "邮箱格式不正确",
+ "emailExists": "邮箱已存在"
+ },
+ "resetPassword": {
+ "title": "重置密码",
+ "subtitle": "输入邮箱、验证码和新密码以完成重置。",
+ "email": "邮箱",
+ "emailPlaceholder": "请输入邮箱",
+ "emailRequired": "请输入邮箱",
+ "emailInvalid": "请输入正确的邮箱格式",
+ "code": "验证码",
+ "codePlaceholder": "请输入 6 位验证码",
+ "sendCode": "发送验证码",
+ "sendingCode": "发送中...",
+ "codeSentMessage": "如果账号符合条件,验证码已发送。",
+ "codeRequired": "请输入验证码",
+ "newPassword": "新密码",
+ "newPasswordPlaceholder": "请输入新密码",
+ "newPasswordRequired": "请输入新密码",
+ "confirmPassword": "确认新密码",
+ "confirmPasswordPlaceholder": "请再次输入新密码",
+ "passwordMismatch": "两次输入的密码不一致",
+ "submit": "重置密码",
+ "submitting": "重置中...",
+ "successMessage": "密码重置成功,请使用新密码登录。",
+ "genericError": "重置密码失败",
+ "backToLogin": "返回登录"
},
"device": {
"title": "设备授权",
@@ -529,6 +556,7 @@
"approveUser": "审批通过",
"disable": "禁用",
"enable": "启用",
+ "resetPassword": "重置密码",
"totalRecords": "共 {{total}} 条记录,第 {{page}} 页",
"prevPage": "上一页",
"nextPage": "下一页",
@@ -543,7 +571,8 @@
"roleSuperAdmin": "超级管理员",
"confirmAction": "确认操作",
"confirmDisable": "确定要禁用用户 {{username}} 吗?",
- "confirmEnable": "确定要启用用户 {{username}} 吗?"
+ "confirmEnable": "确定要启用用户 {{username}} 吗?",
+ "confirmResetPassword": "确定给用户 {{username}} 发送密码重置验证码吗?"
},
"adminLabels": {
"title": "标签管理",
@@ -650,6 +679,7 @@
"subtitle": "管理你的昵称和个人信息。",
"displayName": "昵称",
"email": "邮箱",
+ "resetPassword": "重置密码",
"edit": "编辑",
"save": "保存",
"saving": "保存中...",
@@ -777,6 +807,7 @@
"versionStatusDraft": "草稿",
"versionStatusScanning": "安全扫描中",
"versionStatusScanFailed": "扫描失败",
+ "versionStatusUploaded": "已上传",
"versionStatusPendingReview": "审核中",
"versionStatusPublished": "已发布",
"versionStatusRejected": "已拒绝",
@@ -825,6 +856,19 @@
"withdrawReviewSuccessTitle": "已撤销审核",
"withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。",
"withdrawReviewErrorTitle": "撤销审核失败",
+ "confirmPublish": "确认发布",
+ "confirmPublishDialogTitle": "确认发布",
+ "confirmPublishDialogDescription": "将版本 {{version}} 发布为私有技能?发布后您可以下载和安装,但不会在市场展示。",
+ "confirmPublishSuccessTitle": "版本已发布",
+ "confirmPublishSuccessDescription": "版本 {{version}} 已发布为私有技能。",
+ "confirmPublishErrorTitle": "确认发布失败",
+ "submitReview": "提交审核",
+ "submitReviewDialogTitle": "提交审核",
+ "submitReviewDialogDescription": "将版本 {{version}} 提交公开审核?审核通过后将在市场展示。",
+ "submitReviewSuccessTitle": "已提交审核",
+ "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。",
+ "submitReviewErrorTitle": "提交审核失败",
+ "withdrawReviewErrorTitle": "撤销审核失败",
"deleteVersion": "删除版本",
"deleteVersionConfirmTitle": "确认删除版本",
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",
@@ -868,6 +912,9 @@
"rereleaseSuccessTitle": "版本已重新发布",
"rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。",
"rereleaseErrorTitle": "重新发布版本失败",
+ "rereleaseWarningTitle": "发布前风险提醒",
+ "rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。",
+ "rereleaseWarningConfirm": "继续重新发布",
"yankVersion": "撤回当前版本",
"promoteToGlobal": "申请提升到全局",
"promotionSectionTitle": "提升到全局",
@@ -1035,6 +1082,7 @@
"sortLabel": "时间排序",
"sortNewest": "最新优先",
"sortOldest": "最早优先",
+ "openReview": "进入审核详情",
"pageSummary": "共 {{total}} 条记录,第 {{page}} 页",
"prevPage": "上一页",
"nextPage": "下一页",
@@ -1181,6 +1229,10 @@
"versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。",
"precheckFailedTitle": "发布前校验未通过",
"precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。",
+ "warningConfirmTitle": "发布前风险提醒",
+ "warningConfirmDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续发布。",
+ "warningConfirmContinue": "继续发布",
+ "warningConfirmCancel": "返回修改",
"frontmatterFailedTitle": "SKILL.md 格式有误",
"frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。",
"selectRequired": "请选择命名空间和文件"
diff --git a/web/src/pages/admin/users.test.tsx b/web/src/pages/admin/users.test.tsx
index a47ade76e..1b27359c6 100644
--- a/web/src/pages/admin/users.test.tsx
+++ b/web/src/pages/admin/users.test.tsx
@@ -64,6 +64,7 @@ vi.mock('@/features/admin/use-admin-users', () => ({
useApproveUser: () => ({ mutate: vi.fn(), isPending: false }),
useDisableUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
useEnableUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useTriggerUserPasswordReset: () => ({ mutateAsync: vi.fn(), isPending: false }),
useUpdateUserRole: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))
diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx
index 29caf4958..1dc051f29 100644
--- a/web/src/pages/admin/users.tsx
+++ b/web/src/pages/admin/users.tsx
@@ -29,7 +29,14 @@ import {
DialogTitle,
} from '@/shared/ui/dialog'
import { Label } from '@/shared/ui/label'
-import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users'
+import {
+ useAdminUsers,
+ useApproveUser,
+ useDisableUser,
+ useEnableUser,
+ useTriggerUserPasswordReset,
+ useUpdateUserRole,
+} from '@/features/admin/use-admin-users'
import type { AdminUser } from '@/features/admin/use-admin-users'
/**
@@ -54,7 +61,7 @@ export function AdminUsersPage() {
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [newRole, setNewRole] = useState('')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
- const [actionType, setActionType] = useState<'ban' | 'unban'>('ban')
+ const [actionType, setActionType] = useState<'ban' | 'unban' | 'reset'>('ban')
const { data, isLoading } = useAdminUsers({
search,
@@ -67,6 +74,7 @@ export function AdminUsersPage() {
const approveUserMutation = useApproveUser()
const disableUserMutation = useDisableUser()
const enableUserMutation = useEnableUser()
+ const triggerPasswordResetMutation = useTriggerUserPasswordReset()
const formatDate = (dateString: string) => {
return formatLocalDateTime(dateString, i18n.language)
@@ -105,6 +113,12 @@ export function AdminUsersPage() {
setConfirmDialogOpen(true)
}
+ const handleTriggerPasswordReset = (user: AdminUser) => {
+ setSelectedUser(user)
+ setActionType('reset')
+ setConfirmDialogOpen(true)
+ }
+
const confirmRoleChange = async () => {
if (!selectedUser || !newRole || newRole === (selectedUser.platformRoles[0] || 'USER')) return
try {
@@ -116,18 +130,20 @@ export function AdminUsersPage() {
}
}
- const confirmStatusChange = async () => {
+ const confirmUserAction = async () => {
if (!selectedUser) return
try {
if (actionType === 'ban') {
await disableUserMutation.mutateAsync(selectedUser.userId)
- } else {
+ } else if (actionType === 'unban') {
await enableUserMutation.mutateAsync(selectedUser.userId)
+ } else {
+ await triggerPasswordResetMutation.mutateAsync(selectedUser.userId)
}
setConfirmDialogOpen(false)
setSelectedUser(null)
} catch (error) {
- console.error('Failed to update status:', error)
+ console.error('Failed to apply user action:', error)
}
}
@@ -259,6 +275,13 @@ export function AdminUsersPage() {
{t('adminUsers.enable')}
)}
+