Merge remote-tracking branch 'upstream/main' into feat/skillhub-cli-v2

This commit is contained in:
chenbaowang 2026-04-21 09:51:21 +08:00
commit b332d0ba97
95 changed files with 3178 additions and 263 deletions

View file

@ -56,6 +56,13 @@ DEVICE_AUTH_VERIFICATION_URI=
OAUTH2_GITHUB_CLIENT_ID=
OAUTH2_GITHUB_CLIENT_SECRET=
# Optional: configure real GitLab OAuth before exposing the stack to other users.
# Set OAUTH2_GITLAB_BASE_URI to your self-hosted GitLab URL when applicable.
OAUTH2_GITLAB_CLIENT_ID=
OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=
SPRING_MAIL_PORT=587

42
.github/release-template.md vendored Normal file
View file

@ -0,0 +1,42 @@
# SkillHub {{version}}
{{One-line summary of the key changes in this release}}
## 🌟 Highlights
- {{Highlight 1}}
- {{Highlight 2}}
- {{Highlight 3}}
## 🚨 Breaking Changes
⚠️ {{If any, describe impact and migration guide}}
## ✨ Features
- {{Feature description}} by @author in #PR
## 🐛 Bug Fixes
- {{Fix description}} by @author in #PR
## ⚡ Performance
- {{Performance improvement}} by @author in #PR
## 📚 Documentation
- {{Documentation changes}} by @author in #PR
## 🔧 Chore
- {{Maintenance work}} by @author in #PR
## 📖 Documentation
- Docs site: https://iflytek.github.io/skillhub/
## 👥 New Contributors
{{Keep as-is}}
**Full Changelog**: https://github.com/iflytek/skillhub/compare/{{prev_tag}}...{{tag}}

View file

@ -138,6 +138,47 @@ export class GitHubClient {
);
}
async listCommitPulls(sha: string): Promise<Array<{ number: number; title: string }>> {
return this.request(
"GET",
`/repos/${this.owner}/${this.repo}/commits/${sha}/pulls`,
);
}
async createDraftRelease(
tag: string,
name: string,
body: string,
): Promise<{ id: number; upload_url: string }> {
return this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, {
tag_name: tag,
name,
body,
draft: true,
prerelease: false,
});
}
async uploadReleaseAsset(
releaseId: number,
filename: string,
content: string,
): Promise<void> {
const uploadUrl = `https://uploads.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`;
const response = await fetch(uploadUrl, {
method: "POST",
headers: {
...this.headers(),
"Content-Type": "text/markdown",
},
body: content,
});
if (!response.ok) {
throw await GitHubApiError.fromResponse(response);
}
}
private async paginate<T>(path: string): Promise<T[]> {
const collected: T[] = [];
let nextPath: string | null = path;

View file

@ -37,6 +37,35 @@ export async function requestOpenAiCompatibleJson(
throw lastError ?? new Error("LLM request failed for an unknown reason.");
}
export async function requestOpenAiCompatibleMarkdown(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
try {
return await requestOnceMarkdown(config, systemPrompt, userPrompt);
} catch (error) {
const normalized = normalizeRequestError(
error,
attempt,
config.maxAttempts,
);
lastError = normalized;
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
throw normalized;
}
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
}
}
throw lastError ?? new Error("LLM request failed for an unknown reason.");
}
async function requestOnce(
config: IssueLlmConfig,
systemPrompt: string,
@ -88,6 +117,57 @@ async function requestOnce(
}
}
async function requestOnceMarkdown(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
signal: controller.signal,
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
temperature: config.temperature,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
});
if (!response.ok) {
const message =
`LLM request failed with status ${response.status}: ${await response
.text()}`;
throw new RetryableHttpError(
message,
response.status,
response.headers.get("retry-after"),
);
}
const payload = (await response.json()) as OpenAiCompatibleResponse;
const content = payload.choices?.[0]?.message?.content;
const text = normalizeMessageContent(content);
if (!text) {
throw new Error("LLM response did not include message content.");
}
return text;
} finally {
clearTimeout(timeout);
}
}
class RetryableHttpError extends Error {
status: number;
retryAfterSeconds: number | null;

53
.github/scripts/release-notes-config.ts vendored Normal file
View file

@ -0,0 +1,53 @@
import { IssueLlmConfig } from "./issue-llm-types.ts";
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_ATTEMPTS = 2;
const DEFAULT_RETRY_BACKOFF_MS = 1500;
const DEFAULT_TEMPERATURE = 0.2;
export function readReleaseNotesLlmConfig(): IssueLlmConfig | null {
const baseUrl = normalizeUrl(
Deno.env.get("RELEASE_NOTES_LLM_BASE_URL") ||
Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL"),
);
const apiKey = (
Deno.env.get("RELEASE_NOTES_LLM_API_KEY") ||
Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY")
)?.trim() ?? "";
const model = (
Deno.env.get("RELEASE_NOTES_LLM_MODEL") ||
Deno.env.get("ISSUE_TRIAGE_LLM_MODEL")
)?.trim() ?? "";
if (!baseUrl || !apiKey || !model) {
console.warn(
"LLM config missing, will use fallback mode (conventional commit grouping)",
);
return null;
}
return {
mode: "assist",
provider: "openai-compatible",
baseUrl,
apiKey,
model,
timeoutMs: DEFAULT_TIMEOUT_MS,
maxAttempts: DEFAULT_MAX_ATTEMPTS,
retryBackoffMs: DEFAULT_RETRY_BACKOFF_MS,
temperature: DEFAULT_TEMPERATURE,
maxComments: 0,
maxCommentChars: 0,
maxBodyChars: 0,
};
}
function normalizeUrl(value: string | undefined | null) {
const trimmed = value?.trim();
if (!trimmed) {
return "";
}
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
}

View file

@ -0,0 +1,271 @@
import { GitHubClient } from "./github.ts";
import { IssueLlmConfig } from "./issue-llm-types.ts";
import { requestOpenAiCompatibleMarkdown } from "./issue-llm-provider.ts";
interface RawCommit {
sha: string;
message: string;
author: string;
prNumber?: number;
prTitle?: string;
excluded: boolean;
}
interface ChangeEntry {
type: string;
scope: string;
description: string;
prNumber?: number;
authors: string[];
commits: string[];
}
export async function generateReleaseNotes(
owner: string,
repo: string,
tag: string,
prevTag: string,
llmConfig: IssueLlmConfig | null,
dryRun: boolean,
): Promise<string> {
const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo);
console.log(`Collecting changes from ${prevTag} to ${tag}...`);
const changes = await collectChanges(github, prevTag, tag);
console.log(`Found ${changes.length} changes after deduplication`);
if (llmConfig) {
console.log("Generating release notes with LLM...");
try {
const markdown = await generateMarkdownWithLLM(
llmConfig,
changes,
tag,
prevTag,
owner,
repo,
);
return markdown;
} catch (error) {
console.error("LLM generation failed:", error);
console.log("Falling back to conventional commit grouping...");
return generateFallback(changes, tag, prevTag, owner, repo);
}
} else {
console.log("Using fallback mode (conventional commit grouping)...");
return generateFallback(changes, tag, prevTag, owner, repo);
}
}
async function collectChanges(
github: GitHubClient,
prevTag: string,
tag: string,
): Promise<ChangeEntry[]> {
const gitLogCmd = new Deno.Command("git", {
args: ["log", `${prevTag}..${tag}`, "--format=%H|%s|%an"],
stdout: "piped",
});
const gitLogOutput = await gitLogCmd.output();
const gitLogText = new TextDecoder().decode(gitLogOutput.stdout);
const rawCommits: RawCommit[] = [];
for (const line of gitLogText.trim().split("\n")) {
if (!line) continue;
const [sha, message, author] = line.split("|");
rawCommits.push({
sha,
message,
author,
excluded: false,
});
}
for (const commit of rawCommits) {
if (/^Revert "(.+)"$/.test(commit.message)) {
commit.excluded = true;
const revertedMsg = commit.message.match(/^Revert "(.+)"$/)?.[1];
if (revertedMsg) {
const reverted = rawCommits.find((c) => c.message === revertedMsg);
if (reverted) reverted.excluded = true;
}
}
if (/^Merge (pull request|branch|remote-tracking)/.test(commit.message)) {
commit.excluded = true;
}
}
for (const commit of rawCommits.filter((c) => !c.excluded)) {
try {
const pulls = await github.listCommitPulls(commit.sha);
if (pulls.length > 0) {
commit.prNumber = pulls[0].number;
commit.prTitle = pulls[0].title;
}
} catch (error) {
console.warn(`Failed to fetch PR for commit ${commit.sha}:`, error);
}
}
const prMap = new Map<number, ChangeEntry>();
const standaloneCommits: ChangeEntry[] = [];
for (const commit of rawCommits.filter((c) => !c.excluded)) {
const parsed = parseConventionalCommit(commit.message);
const description = commit.prTitle || parsed.description;
if (commit.prNumber) {
if (!prMap.has(commit.prNumber)) {
prMap.set(commit.prNumber, {
type: parsed.type,
scope: parsed.scope,
description,
prNumber: commit.prNumber,
authors: [commit.author],
commits: [commit.sha],
});
} else {
const entry = prMap.get(commit.prNumber)!;
if (!entry.authors.includes(commit.author)) {
entry.authors.push(commit.author);
}
entry.commits.push(commit.sha);
}
} else {
standaloneCommits.push({
type: parsed.type,
scope: parsed.scope,
description,
authors: [commit.author],
commits: [commit.sha],
});
}
}
return [...prMap.values(), ...standaloneCommits];
}
function parseConventionalCommit(message: string): {
type: string;
scope: string;
description: string;
} {
const match = message.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
if (match) {
return {
type: match[1],
scope: match[2] || "",
description: match[3],
};
}
return {
type: "other",
scope: "",
description: message,
};
}
async function generateMarkdownWithLLM(
config: IssueLlmConfig,
changes: ChangeEntry[],
tag: string,
prevTag: string,
owner: string,
repo: string,
): Promise<string> {
const template = await Deno.readTextFile(".github/release-template.md");
const systemPrompt = `You are a senior product manager and technical documentation expert. Rewrite the following technical change list into user-friendly Release Notes.
Requirements:
1. Strictly follow the Markdown structure and heading levels of the template below
2. Remove any section entirely (including its heading) if there are no items for it
3. Rewrite technical jargon into language that end-users can understand
4. For Breaking Changes, add an upgrade / migration guide
5. Highlights must contain 2-4 items, distilled from the most important changes
6. PR number format: #123
7. Contributor format: @username
8. Replace {{version}}, {{prev_tag}}, {{tag}} with actual values
9. Output ONLY the Markdown content no extra commentary, no code fences
Template:
---
${template}
---`;
const changesList = changes.map((c) => {
const pr = c.prNumber ? ` (#${c.prNumber})` : "";
const authors = c.authors.map((a) => `@${a}`).join(", ");
return `- ${c.type}(${c.scope}): ${c.description}${pr} by ${authors}`;
}).join("\n");
const userPrompt = `Version: ${tag}
Previous version: ${prevTag}
Repository: ${owner}/${repo}
Change list:
${changesList}`;
const markdown = await requestOpenAiCompatibleMarkdown(
config,
systemPrompt,
userPrompt,
);
return markdown
.replace(/\{\{version\}\}/g, tag)
.replace(/\{\{prev_tag\}\}/g, prevTag)
.replace(/\{\{tag\}\}/g, tag);
}
function generateFallback(
changes: ChangeEntry[],
tag: string,
prevTag: string,
owner: string,
repo: string,
): string {
const grouped = new Map<string, ChangeEntry[]>();
for (const change of changes) {
const type = change.type;
if (!grouped.has(type)) {
grouped.set(type, []);
}
grouped.get(type)!.push(change);
}
const typeLabels: Record<string, string> = {
feat: "## ✨ Features",
fix: "## 🐛 Bug Fixes",
docs: "## 📚 Documentation",
perf: "## ⚡ Performance",
refactor: "## 🔧 Improvements",
test: "## 🧪 Tests",
chore: "## 🔧 Chore",
};
let md = `# SkillHub ${tag}\n\n`;
md += `> [Auto-generated - LLM unavailable]\n\n`;
for (const [type, items] of grouped.entries()) {
const label = typeLabels[type] || `## ${type}`;
md += `${label}\n\n`;
for (const item of items) {
const pr = item.prNumber ? ` in #${item.prNumber}` : "";
const authors = item.authors.map((a) => `@${a}`).join(", ");
md += `- ${item.description}${pr} by ${authors}\n`;
}
md += "\n";
}
const contributors = [
...new Set(changes.flatMap((c) => c.authors)),
];
md += `## 👥 Contributors\n\n`;
md += contributors.map((a) => `@${a}`).join(", ") + "\n\n";
md += `**Full Changelog**: https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}\n`;
return md;
}

79
.github/scripts/release-notes.ts vendored Normal file
View file

@ -0,0 +1,79 @@
import { GitHubClient } from "./github.ts";
import { readReleaseNotesLlmConfig } from "./release-notes-config.ts";
import { generateReleaseNotes } from "./release-notes-generator.ts";
function readFlag(name: string): string | null {
const index = Deno.args.indexOf(name);
if (index === -1 || index === Deno.args.length - 1) {
return null;
}
return Deno.args[index + 1];
}
function hasFlag(name: string): boolean {
return Deno.args.includes(name);
}
async function detectPrevTag(tag: string): Promise<string> {
const cmd = new Deno.Command("git", {
args: ["tag", "--sort=-v:refname"],
stdout: "piped",
});
const output = await cmd.output();
const tags = new TextDecoder().decode(output.stdout).trim().split("\n");
const currentIndex = tags.indexOf(tag);
if (currentIndex === -1 || currentIndex === tags.length - 1) {
throw new Error(`Cannot find previous tag for ${tag}`);
}
return tags[currentIndex + 1];
}
async function main() {
const owner = readFlag("--owner");
const repo = readFlag("--repo");
const tag = readFlag("--tag");
const prevTagArg = readFlag("--prev-tag");
const dryRun = hasFlag("--dry-run");
const skipLlm = hasFlag("--skip-llm");
if (!owner || !repo || !tag) {
console.error("Usage: release-notes.ts --owner <owner> --repo <repo> --tag <tag> [--prev-tag <prev-tag>] [--dry-run] [--skip-llm]");
Deno.exit(1);
}
const prevTag = prevTagArg || await detectPrevTag(tag);
console.log(`Generating release notes for ${tag} (previous: ${prevTag})`);
const llmConfig = skipLlm ? null : readReleaseNotesLlmConfig();
const markdown = await generateReleaseNotes(
owner,
repo,
tag,
prevTag,
llmConfig,
dryRun,
);
if (dryRun) {
console.log("\n=== DRY RUN MODE ===\n");
console.log(markdown);
console.log("\n=== END DRY RUN ===");
return;
}
console.log("Creating draft release...");
const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo);
const release = await github.createDraftRelease(tag, tag, markdown);
console.log(`Draft release created: ${release.id}`);
console.log(`\nDraft release created successfully!`);
console.log(`View at: https://github.com/${owner}/${repo}/releases/tag/${tag}`);
}
main().catch((error) => {
console.error("Error:", error);
Deno.exit(1);
});

46
.github/workflows/release-notes.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: AI Release Notes
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to generate release notes for (e.g. v0.3.0)"
required: true
prev_tag:
description: "Previous tag (auto-detect if empty)"
required: false
permissions:
contents: write
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: v2.x
- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
RELEASE_NOTES_LLM_BASE_URL: ${{ vars.RELEASE_NOTES_LLM_BASE_URL || 'https://models.inference.ai.azure.com' }}
RELEASE_NOTES_LLM_API_KEY: ${{ secrets.RELEASE_NOTES_LLM_API_KEY || github.token }}
RELEASE_NOTES_LLM_MODEL: ${{ vars.RELEASE_NOTES_LLM_MODEL || 'gpt-4o-mini' }}
ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }}
ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }}
ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }}
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
PREV="${{ github.event.inputs.prev_tag }}"
ARGS="--owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }} --tag $TAG"
[ -n "$PREV" ] && ARGS="$ARGS --prev-tag $PREV"
deno run --allow-env --allow-net --allow-run --allow-read \
.github/scripts/release-notes.ts $ARGS

4
.gitignore vendored
View file

@ -62,6 +62,7 @@ package-lock.json
**/.playwright/
**/playwright-report/
**/test-results/
.playwright-mcp/
# Python / temporary files
*.py[cod]
@ -82,3 +83,6 @@ docs/superpowers/
# Local workspace metadata
AGENTS.md
CLAUDE.md
# Local config file
.mcp.json

View file

@ -8,6 +8,7 @@
[![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iflytek/skillhub)
[![Docs](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub)
[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE)
[![Build](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub)
@ -95,7 +96,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan
**For users in China (Aliyun mirror):**
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
If deployment runs into problems, clear the existing runtime home and retry.
@ -195,7 +196,7 @@ Published images target both `linux/amd64` and `linux/arm64`.
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
# Aliyun mirror (recommended for users in China)
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
**Deployment parameters:**
@ -441,6 +442,7 @@ what you'd like to change.
- 💬 **Community Discussion**: [GitHub Discussions](https://github.com/iflytek/skillhub/discussions)
- 🐛 **Bug Reports**: [Issues](https://github.com/iflytek/skillhub/issues)
- 👾 **Discord**: [Join our Server](https://discord.gg/qHYvtDNPHS)
- 👥 **WeChat Work Group**:
![WeChat Work Group](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png)

View file

@ -7,6 +7,7 @@
<div align="center">
[![文档](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub)
[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS)
[![许可证](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE)
[![构建](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub)
@ -67,7 +68,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
**国内用户(阿里云镜像):**
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
如果部署遇到问题,请清除现有的运行时目录并重试。
@ -177,7 +178,7 @@ skillhub/
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
# 阿里云镜像(国内推荐)
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
### 配置参数说明
@ -373,6 +374,7 @@ namespace `my-space` 和 skill slug `my-skill`。
- 💬 **社区讨论**[GitHub Discussions](https://github.com/iflytek/skillhub/discussions)
- 🐛 **Bug 报告**[Issues](https://github.com/iflytek/skillhub/issues)
- 👾 **Discord**[加入我们的服务器](https://discord.gg/qHYvtDNPHS)
- 👥 **企业微信群**
![企业微信群](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png)

View file

@ -32,7 +32,7 @@ services:
redis:
image: ${REDIS_IMAGE:-redis:7-alpine}
ports:
- "6379:6379"
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s

View file

@ -589,13 +589,13 @@ private boolean canDownload(SkillVersion version, Skill skill, String currentUse
| 问题 | 严重程度 | 状态 |
|------|---------|------|
| 新增 UPLOADED 状态 | 高 | 待实现 |
| PRIVATE skill 发布逻辑改动 | 高 | 待实现 |
| 提交审核接口 | 高 | 待实现 |
| 撤回审核后进入 UPLOADED | 中 | 待实现 |
| 同名冲突检查补全 | 中 | 待实现 |
| 管理员可见 UPLOADED skill | 低 | 待实现 |
| package_name 唯一性检查 | 低 | 可选 |
| 新增 UPLOADED 状态 | 高 | 已完成 |
| PRIVATE skill 发布逻辑改动 | 高 | 已完成 |
| 提交审核接口 | 高 | 已完成 |
| 撤回审核后进入 UPLOADED | 中 | 已完成 |
| 同名冲突检查补全 | 中 | 已完成 |
| 管理员可见 UPLOADED skill | 低 | 已完成 |
| package_name 唯一性检查 | 低 | 可选SaaS Adapter 职责) |
---

View file

@ -168,6 +168,63 @@ set_env_value() {
mv "$tmp" "$ENV_FILE"
}
get_env_value() {
key="$1"
default_value="${2:-}"
value="$(grep "^$key=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true)"
if [ -n "$value" ]; then
printf '%s' "$value"
else
printf '%s' "$default_value"
fi
}
wait_for_postgres_ready() {
postgres_user="$1"
postgres_db="$2"
attempt=1
while [ "$attempt" -le 60 ]; do
if run_compose exec -T postgres pg_isready -U "$postgres_user" -d "$postgres_db" >/dev/null 2>&1; then
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
echo "PostgreSQL did not become ready in time." >&2
run_compose logs postgres >&2 || true
exit 1
}
ensure_postgres_password_matches_env() {
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")"
if [ -z "$postgres_password" ]; then
echo "POSTGRES_PASSWORD must not be empty." >&2
exit 1
fi
wait_for_postgres_ready "$postgres_user" "$postgres_db"
run_compose exec -T postgres \
psql -U "$postgres_user" -d "$postgres_db" \
-v ON_ERROR_STOP=1 \
-v password="$postgres_password" <<'SQL' >/dev/null
SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password');
\gexec
SQL
run_compose exec -T -e "PGPASSWORD=$postgres_password" postgres \
psql -h 127.0.0.1 -U "$postgres_user" -d "$postgres_db" \
-v ON_ERROR_STOP=1 \
-c 'select current_user;' >/dev/null
}
prepare_runtime_files() {
mkdir -p "$SKILLHUB_HOME"
download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE"
@ -235,6 +292,8 @@ prepare_runtime_files
case "$COMMAND" in
up)
run_compose up -d postgres
ensure_postgres_password_matches_env
if [ "$DISABLE_SCANNER" = "true" ]; then
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0
else

View file

@ -104,6 +104,63 @@ set_env_value() {
mv "${tmp}" .env.release
}
get_env_value() {
key="$1"
default_value="${2:-}"
value="$(grep -E "^${key}=" .env.release | tail -n 1 | cut -d= -f2- || true)"
if [[ -n "${value}" ]]; then
printf '%s' "${value}"
else
printf '%s' "${default_value}"
fi
}
wait_for_postgres_ready() {
postgres_user="$1"
postgres_db="$2"
for attempt in $(seq 1 60); do
if docker compose --env-file .env.release -f compose.release.yml exec -T postgres \
pg_isready -U "${postgres_user}" -d "${postgres_db}" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "PostgreSQL did not become ready in time" >&2
docker compose --env-file .env.release -f compose.release.yml logs postgres >&2 || true
exit 1
}
ensure_postgres_password_matches_env() {
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")"
if [[ -z "${postgres_password}" ]]; then
echo "POSTGRES_PASSWORD must not be empty" >&2
exit 1
fi
wait_for_postgres_ready "${postgres_user}" "${postgres_db}"
docker compose --env-file .env.release -f compose.release.yml exec -T postgres \
psql -U "${postgres_user}" -d "${postgres_db}" \
-v ON_ERROR_STOP=1 \
-v password="${postgres_password}" <<'SQL' >/dev/null
SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password');
\gexec
SQL
docker compose --env-file .env.release -f compose.release.yml exec -T \
-e PGPASSWORD="${postgres_password}" postgres \
psql -h 127.0.0.1 -U "${postgres_user}" -d "${postgres_db}" \
-v ON_ERROR_STOP=1 \
-c 'select current_user;' >/dev/null
}
cd "${runtime_dir}"
test -f .env.release
@ -123,6 +180,8 @@ run_url=${run_url}
METADATA
docker compose --env-file .env.release -f compose.release.yml pull
docker compose --env-file .env.release -f compose.release.yml up -d postgres
ensure_postgres_password_matches_env
docker compose --env-file .env.release -f compose.release.yml up -d
docker compose --env-file .env.release -f compose.release.yml ps

View file

@ -97,7 +97,8 @@ public class ClawHubCompatAppService {
String hash,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug);
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
coord.namespace(),
@ -106,7 +107,7 @@ public class ClawHubCompatAppService {
"latest".equals(version) ? "latest" : null,
hash,
userId,
userNsRoles != null ? userNsRoles : Map.of()
roles
);
return toResolveResponse(resolved);
}
@ -135,23 +136,37 @@ public class ClawHubCompatAppService {
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}
public String downloadLocationByQuery(String slug, String version) {
SkillCoordinate coord = resolveQueryCoordinate(slug);
public String downloadLocationByQuery(String slug,
String version,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
return "latest".equals(version)
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}
private SkillCoordinate resolveQueryCoordinate(String slug) {
private SkillCoordinate resolveQueryCoordinate(String slug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
if (slug != null && slug.contains("--")) {
return mapper.fromCanonical(slug);
}
CompatSkillLookupService.CompatSkillContext context;
try {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
context = compatSkillLookupService.findByLegacySlug(slug);
} catch (DomainNotFoundException ex) {
return mapper.fromCanonical(slug);
}
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) {
throw new DomainNotFoundException("error.skill.notFound", slug);
}
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
return userNsRoles != null ? userNsRoles : Map.of();
}
public ClawHubSkillListResponse listSkills(int page,
@ -185,11 +200,18 @@ public class ClawHubCompatAppService {
}
public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) {
return getSkill(canonicalSlug, userId, Map.of());
}
public ClawHubSkillResponse getSkill(String canonicalSlug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coord.namespace(),
coord.slug(),
userId
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
SkillVersion latestVersionEntity = context.latestVersion().orElse(null);

View file

@ -82,8 +82,10 @@ public class ClawHubCompatController {
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
@GetMapping("/download")
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
@RequestParam(defaultValue = "latest") String version) {
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version));
@RequestParam(defaultValue = "latest") String version,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles));
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@ -99,8 +101,9 @@ public class ClawHubCompatController {
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@GetMapping("/skills/{canonicalSlug}")
public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug,
@RequestAttribute(value = "userId", required = false) String userId) {
return clawHubCompatAppService.getSkill(canonicalSlug, userId);
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)

View file

@ -83,7 +83,8 @@ public class ClawHubRegistryFacade {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coordinate.namespace(),
coordinate.slug(),
userId
userId,
normalizeRoles(userNsRoles)
);
Skill skill = context.skill();

View file

@ -1,13 +1,16 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
@ -22,15 +25,18 @@ public class CompatSkillLookupService {
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillSlugResolutionService skillSlugResolutionService;
private final VisibilityChecker visibilityChecker;
public CompatSkillLookupService(SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
SkillSlugResolutionService skillSlugResolutionService) {
SkillSlugResolutionService skillSlugResolutionService,
VisibilityChecker visibilityChecker) {
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillSlugResolutionService = skillSlugResolutionService;
this.visibilityChecker = visibilityChecker;
}
public CompatSkillContext findByLegacySlug(String slug) {
@ -41,10 +47,28 @@ public class CompatSkillLookupService {
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (skill == null) {
return false;
}
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
return visibilityChecker.canAccess(skill, currentUserId, roles);
}
public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) {
return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of());
}
public CompatSkillContext resolveVisible(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
if (!canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainNotFoundException("error.skill.notFound", skillSlug);
}
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}

View file

@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController {
}
@GetMapping("/namespaces")
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
}
@GetMapping("/me/namespaces")
@ -68,7 +70,7 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));

View file

@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController {
return true;
}
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
NamespaceRole namespaceRole = namespaceRoles.get(skill.getNamespaceId());
if (namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER) {
return true;
}
return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles);
}

View file

@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
public record SkillVersionRereleaseRequest(
@NotBlank(message = "{validation.required}")
String targetVersion
String targetVersion,
boolean confirmWarnings
) {
}

View file

@ -73,6 +73,7 @@ public class AuthContextFilter extends OncePerRequestFilter {
return;
}
request.setAttribute("userId", principal.userId());
request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of());
Map<Long, NamespaceRole> userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream()
.collect(Collectors.toMap(
NamespaceMember::getNamespaceId,

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
@ -20,6 +21,8 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -50,9 +53,31 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable) {
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
return PageResponse.from(namespaces.map(NamespaceResponse::from));
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
List.of(),
PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()),
0
);
return PageResponse.from(empty);
}
List<Namespace> scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
.filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE)
.sorted(Comparator.comparing(Namespace::getSlug))
.toList();
int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size());
int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size());
Page<NamespaceResponse> page = new PageImpl<>(
scopedNamespaces.subList(fromIndex, toIndex).stream()
.map(NamespaceResponse::from)
.toList(),
pageable,
scopedNamespaces.size()
);
return PageResponse.from(page);
}
@Transactional(readOnly = true)
@ -73,10 +98,14 @@ public class NamespacePortalQueryAppService {
@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
slug,
userId,
userNamespaceRoles != null ? userNamespaceRoles : Map.of());
namespaceRoles);
if (!namespaceRoles.containsKey(namespace.getId())) {
throw new DomainForbiddenException("error.namespace.membership.required");
}
return NamespaceResponse.from(namespace);
}

View file

@ -154,7 +154,8 @@ public class SkillLifecycleAppService {
skillVersion.getVersion(),
targetVersion,
userId,
normalizeRoles(userNamespaceRoles)
normalizeRoles(userNamespaceRoles),
request.confirmWarnings()
);
auditLogService.record(
userId,

View file

@ -122,10 +122,8 @@ public class SkillSearchAppService {
}
private boolean hasPlatformWideReadAccess(Set<String> platformRoles) {
if (platformRoles == null || platformRoles.isEmpty()) {
return false;
}
return platformRoles.contains("SUPER_ADMIN");
// Super admins should use a dedicated admin interface, not the public portal
return false;
}
private SearchResponse searchVisibleSkills(

View file

@ -53,10 +53,29 @@ spring:
github:
client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder}
scope: read:user,user:email
scope:
- read:user
- user:email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: GitHub
authorization-grant-type: authorization_code
gitlab:
client-id: ${OAUTH2_GITLAB_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET:placeholder}
scope:
- read_user
- email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
provider:
github:
user-info-uri: https://api.github.com/user
gitlab:
authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
user-name-attribute: username
servlet:
multipart:
max-file-size: 100MB
@ -189,7 +208,7 @@ management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
include: health,info
endpoint:
health:
show-details: when-authorized
@ -198,4 +217,4 @@ management:
application: skillhub
export:
prometheus:
enabled: true
enabled: false

View file

@ -104,7 +104,7 @@ error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can m
error.skill.version.exists=Version already exists: {0}
error.skill.version.notFound=Version not found: {0}
error.skill.version.notPublished=Version is not published: {0}
error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0}
error.skill.version.delete.unsupported=Only DRAFT, UPLOADED, REJECTED, or SCAN_FAILED versions can be deleted: {0}
error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0}
error.skill.report.reason.required=Please provide a report reason
error.skill.report.unavailable=This skill cannot be reported right now: {0}

View file

@ -104,7 +104,7 @@ error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员
error.skill.version.exists=版本已存在:{0}
error.skill.version.notFound=未找到版本:{0}
error.skill.version.notPublished=版本未发布:{0}
error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0}
error.skill.version.delete.unsupported=只有 DRAFT、UPLOADED、REJECTED 或 SCAN_FAILED 版本可以删除:{0}
error.skill.version.delete.lastVersion=无法删除最后一个版本:{0}
error.skill.report.reason.required=请填写举报原因
error.skill.report.unavailable=当前无法举报该技能:{0}

View file

@ -0,0 +1,80 @@
package com.iflytek.skillhub.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class ClawHubCompatAppServiceTest {
private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class);
private final SkillQueryService skillQueryService = mock(SkillQueryService.class);
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class);
private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class);
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
private final SkillStarService skillStarService = mock(SkillStarService.class);
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
new CanonicalSlugMapper(),
skillSearchAppService,
skillQueryService,
skillPublishService,
zipPackageExtractor,
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
skillStarService
);
@Test
void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
privateSkill,
Optional.empty()
);
when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context);
when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false);
assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null))
.isInstanceOf(DomainNotFoundException.class);
}
@Test
void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
publicSkill,
Optional.empty()
);
when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context);
when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true);
String location = service.downloadLocationByQuery("my-skill", "latest", null, null);
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download");
}
}

View file

@ -0,0 +1,102 @@
package com.iflytek.skillhub.compat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ClawHubCompatControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private ClawHubCompatAppService clawHubCompatAppService;
@Test
void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception {
when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
mockMvc.perform(get("/api/v1/skills/priv"))
.andExpect(status().isNotFound());
}
@Test
void getSkill_returnsSkill_whenCallerHasNamespacePermission() throws Exception {
var roles = Map.of(1L, NamespaceRole.ADMIN);
var response = new ClawHubSkillResponse(
new ClawHubSkillResponse.SkillInfo(
"team-ai--priv",
"Private Skill",
"summary",
Map.of(),
Map.of(),
0L,
0L
),
null,
null,
new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null)
);
when(clawHubCompatAppService.getSkill("team-ai--priv", "admin-1", roles)).thenReturn(response);
mockMvc.perform(get("/api/v1/skills/team-ai--priv")
.requestAttr("userId", "admin-1")
.requestAttr("userNsRoles", roles))
.andExpect(status().isOk())
.andExpect(jsonPath("$.skill.slug").value("team-ai--priv"));
verify(clawHubCompatAppService).getSkill("team-ai--priv", "admin-1", roles);
}
@Test
void downloadQuery_returnsNotFound_whenAnonymousCannotAccessPrivateLegacySlug() throws Exception {
when(clawHubCompatAppService.downloadLocationByQuery(eq("priv"), eq("latest"), isNull(), isNull()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
mockMvc.perform(get("/api/v1/download")
.param("slug", "priv")
.param("version", "latest"))
.andExpect(status().isNotFound());
}
@Test
void downloadQuery_returnsNotFound_whenUserWithoutNamespaceRoleAccessesPrivateLegacySlug() throws Exception {
when(clawHubCompatAppService.downloadLocationByQuery("priv", "latest", "user-1", Map.of()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
mockMvc.perform(get("/api/v1/download")
.param("slug", "priv")
.param("version", "latest")
.requestAttr("userId", "user-1")
.requestAttr("userNsRoles", Map.of()))
.andExpect(status().isNotFound());
}
}

View file

@ -36,6 +36,9 @@ import java.util.Set;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
@ -150,6 +153,7 @@ class ClawHubCompatControllerTest {
void resolve_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
when(compatSkillLookupService.findByLegacySlug("my-skill"))
.thenReturn(legacyCompatContext("global", "my-skill"));
when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true);
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
@ -161,6 +165,7 @@ class ClawHubCompatControllerTest {
.andExpect(jsonPath("$.match.version").value("latest"))
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap());
verify(skillQueryService).resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of());
}
@ -177,11 +182,14 @@ class ClawHubCompatControllerTest {
void download_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
when(compatSkillLookupService.findByLegacySlug("my-skill"))
.thenReturn(legacyCompatContext("global", "my-skill"));
when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true);
mockMvc.perform(get("/api/v1/download")
.param("slug", "my-skill")
.param("version", "latest"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "/api/v1/skills/global/my-skill/download"));
verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap());
}
@Test

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class CompatSkillLookupServiceTest {
private final SkillRepository skillRepository = mock(SkillRepository.class);
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class);
private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class);
private final VisibilityChecker visibilityChecker = mock(VisibilityChecker.class);
private final CompatSkillLookupService service = new CompatSkillLookupService(
skillRepository,
namespaceRepository,
skillVersionRepository,
skillSlugResolutionService,
visibilityChecker
);
@Test
void resolveVisible_throwsNotFoundWhenCallerCannotAccessSkill() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
ReflectionTestUtils.setField(namespace, "id", 1L);
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
ReflectionTestUtils.setField(privateSkill, "id", 7L);
privateSkill.setLatestVersionId(70L);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(skillSlugResolutionService.resolve(1L, "priv", null, SkillSlugResolutionService.Preference.PUBLISHED))
.thenReturn(privateSkill);
when(visibilityChecker.canAccess(privateSkill, null, Map.of())).thenReturn(false);
assertThatThrownBy(() -> service.resolveVisible("team-a", "priv", null, Map.of()))
.isInstanceOf(DomainNotFoundException.class);
}
@Test
void resolveVisible_returnsSkillWhenCallerHasNamespaceAccess() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
ReflectionTestUtils.setField(namespace, "id", 1L);
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
ReflectionTestUtils.setField(privateSkill, "id", 7L);
privateSkill.setLatestVersionId(70L);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(skillSlugResolutionService.resolve(1L, "priv", "admin-1", SkillSlugResolutionService.Preference.PUBLISHED))
.thenReturn(privateSkill);
when(visibilityChecker.canAccess(privateSkill, "admin-1", Map.of(1L, NamespaceRole.ADMIN))).thenReturn(true);
CompatSkillLookupService.CompatSkillContext result = service.resolveVisible(
"team-a",
"priv",
"admin-1",
Map.of(1L, NamespaceRole.ADMIN)
);
assertThat(result.skill().getId()).isEqualTo(7L);
}
}

View file

@ -142,11 +142,12 @@ class AuthControllerTest {
mockMvc.perform(get("/api/v1/auth/providers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee")))
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab")))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github",
"/oauth2/authorization/gitee"
"/oauth2/authorization/gitee",
"/oauth2/authorization/gitlab"
)))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());

View file

@ -94,18 +94,9 @@ class NamespacePortalControllerTest {
}
@Test
void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow(
new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException(
"error.namespace.slug.notFound",
"team-a"
)
);
void getNamespace_requiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/namespaces/team-a"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
.andExpect(status().isUnauthorized());
}
@Test

View file

@ -75,6 +75,7 @@ class NamespaceWorkflowContractTest {
Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM);
Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN);
UserAccount adminUser = new UserAccount("user-admin", "Admin", "admin@example.com", null);
setMemberId(adminMember, 11L);
NamespaceResponse namespaceResponse = NamespaceResponse.from(namespace);
NamespaceResponse frozenResponse = NamespaceResponse.from(frozen);

View file

@ -22,6 +22,7 @@ import java.util.Map;
import java.util.TimeZone;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.security.ScannerType;
import com.iflytek.skillhub.domain.security.SecurityAudit;
@ -56,6 +58,9 @@ class SecurityAuditControllerTest {
@MockBean
private ScanTaskProducer scanTaskProducer;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void getSecurityAudit_returnsAuditPayload() throws Exception {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
@ -129,6 +134,29 @@ class SecurityAuditControllerTest {
.andExpect(jsonPath("$.code").value(403));
}
@Test
void getSecurityAudit_allowsNamespaceAdminForPendingUnpublishedSkill() throws Exception {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
setField(audit, "id", 9L);
audit.setScanId("scan-team-admin");
audit.setVerdict(SecurityVerdict.SAFE);
audit.setIsSafe(true);
audit.setMaxSeverity("LOW");
audit.setFindingsCount(0);
given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L)));
given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "owner-1")));
given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of(audit));
given(namespaceMemberRepository.findByUserId("team-admin"))
.willReturn(List.of(new NamespaceMember(5L, "team-admin", NamespaceRole.ADMIN)));
mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit")
.with(auth("team-admin")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(9L))
.andExpect(jsonPath("$.data[0].scanId").value("scan-team-admin"));
}
private RequestPostProcessor auth(String userId) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.review.ReviewTask;
@ -128,6 +129,37 @@ class SkillApprovalVisibilityFlowIntegrationTest {
assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName());
}
@Test
void namespaceAdminCanApproveOwnTeamReview() throws Exception {
PendingSkillGraph graph = createPendingTeamSkill("team-admin");
when(namespaceMemberRepository.findByUserId("team-admin"))
.thenReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(
graph.namespace().getId(),
"team-admin",
NamespaceRole.ADMIN
)));
when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of());
mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve")
.contentType("application/json")
.content("{\"comment\":\"approved by namespace admin\"}")
.with(authentication(apiAuth("team-admin")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId()))
.andExpect(jsonPath("$.data.status").value("APPROVED"))
.andExpect(jsonPath("$.data.reviewedBy").value("team-admin"))
.andExpect(jsonPath("$.data.reviewComment").value("approved by namespace admin"));
Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow();
SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow();
assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId());
assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
assertThat(savedVersion.getPublishedAt()).isNotNull();
}
private PendingSkillGraph createPendingGlobalSkill(String ownerId) {
String suffix = UUID.randomUUID().toString().substring(0, 8);
@ -154,6 +186,31 @@ class SkillApprovalVisibilityFlowIntegrationTest {
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}
private PendingSkillGraph createPendingTeamSkill(String ownerId) {
String suffix = UUID.randomUUID().toString().substring(0, 8);
Namespace namespace = new Namespace("team-approval-" + suffix, "Team Approval " + suffix, ownerId);
namespace = namespaceRepository.save(namespace);
Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
skill.setDisplayName("Approval Skill " + suffix);
skill.setSummary("Team namespace self-review should be allowed for namespace admins.");
skill.setCreatedBy(ownerId);
skill.setUpdatedBy(ownerId);
skill = skillRepository.save(skill);
skillRepository.flush();
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId);
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
version.setRequestedVisibility(SkillVisibility.PUBLIC);
version = skillVersionRepository.save(version);
skillVersionRepository.flush();
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}
private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException {
Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
Optional<SkillSearchDocumentEntity> indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);

View file

@ -212,7 +212,8 @@ class SkillLifecycleControllerTest {
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap()))
anyMap(),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
@ -279,7 +280,8 @@ class SkillLifecycleControllerTest {
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap()))
anyMap(),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
@ -299,7 +301,44 @@ class SkillLifecycleControllerTest {
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap());
anyMap(),
eq(false));
}
@Test
void rereleaseVersion_passesConfirmWarningsToService() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner");
setNamespaceId(namespace, 1L);
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
setSkillId(skill, 1L);
SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner");
setSkillVersionId(newVersion, 3L);
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
.willReturn(skill);
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
setSkillVersionId(sourceVersion, 2L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion));
given(skillPublishService.rereleasePublishedVersion(
eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true)))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
.requestAttr("userId", "usr_1")
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetVersion\":\"1.2.4\",\"confirmWarnings\":true}")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION"));
verify(skillPublishService).rereleasePublishedVersion(
eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true));
}
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {

View file

@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import org.mockito.ArgumentMatchers;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@ -69,7 +72,7 @@ class SkillPublishControllerTest {
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
ArgumentMatchers.<List<PackageEntry>>any(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
@ -120,7 +123,7 @@ class SkillPublishControllerTest {
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
ArgumentMatchers.<List<PackageEntry>>any(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),

View file

@ -35,13 +35,14 @@ class PrometheusEndpointTest {
private DeviceAuthService deviceAuthService;
@Test
void prometheusEndpoint_exposesCustomMetrics() {
void metricsRegistry_stillRecordsCustomMetrics_whenPrometheusEndpointIsDisabled() {
skillHubMetrics.incrementUserRegister();
skillHubMetrics.recordLocalLogin(true);
skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW");
assertThat(environment.getProperty("management.endpoints.web.exposure.include"))
.contains("prometheus");
.doesNotContain("prometheus")
.doesNotContain("metrics");
assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d);
assertThat(meterRegistry.get("skillhub.auth.login")
.tag("method", "local")

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
@ -16,6 +17,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
@ -71,6 +73,39 @@ class NamespacePortalQueryAppServiceTest {
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
}
@Test
void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() {
Namespace teamA = namespace(1L, "team-a");
Namespace teamB = namespace(2L, "team-b");
Namespace archived = namespace(3L, "archived");
archived.setStatus(NamespaceStatus.ARCHIVED);
when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA));
var response = service.listNamespaces(
PageRequest.of(0, 10),
Map.of(
1L, NamespaceRole.MEMBER,
2L, NamespaceRole.ADMIN,
3L, NamespaceRole.OWNER
)
);
assertThat(response.items()).hasSize(2);
assertThat(response.items().get(0).slug()).isEqualTo("team-a");
assertThat(response.items().get(1).slug()).isEqualTo("team-b");
}
@Test
void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() {
Namespace namespace = namespace(1L, "team-a");
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
.thenReturn(namespace);
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
.isInstanceOf(DomainForbiddenException.class);
}
private Namespace namespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, slug, "owner-1");
ReflectionTestUtils.setField(namespace, "id", id);

View file

@ -216,7 +216,7 @@ class SkillSearchAppServiceTest {
}
@Test
void search_shouldGrantPlatformWideAccessToSuperAdmin() {
void search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal() {
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER"));
@ -228,7 +228,7 @@ class SkillSearchAppServiceTest {
SearchVisibilityScope scope = captor.getValue().visibilityScope();
assertEquals("admin-1", scope.userId());
assertEquals(true, scope.platformWideAccess());
assertEquals(false, scope.platformWideAccess());
}
private void setField(Object target, String fieldName, Object value) {

View file

@ -1,9 +1,21 @@
package com.iflytek.skillhub.auth.entity;
import jakarta.persistence.*;
import java.time.Clock;
import java.time.Instant;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "identity_binding",
uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"}))
@ -24,6 +36,7 @@ public class IdentityBinding {
@Column(name = "login_name", length = 128)
private String loginName;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "extra_json", columnDefinition = "jsonb")
private String extraJson;

View file

@ -31,12 +31,14 @@ public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequ
PlatformPrincipal principal = context.principal();
var attrs = new HashMap<>(context.upstreamUser().getAttributes());
attrs.put("platformPrincipal", principal);
// Store providerLogin under a fixed key so DefaultOAuth2User can find it
attrs.put("providerLogin", principal.userId());
var authorities = new LinkedHashSet<GrantedAuthority>(context.upstreamUser().getAuthorities());
principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
return new DefaultOAuth2User(authorities, attrs, "login");
return new DefaultOAuth2User(authorities, attrs, "providerLogin");
}
}

View file

@ -0,0 +1,142 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
/**
* Provider-specific claims extractor that enriches GitLab OAuth users with their
* verified email information.
*
* <p>GitLab OAuth2 user info endpoint returns user profile data. This extractor
* fetches additional email information from GitLab API when needed.
*/
@Component
public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
private static final Logger log = LoggerFactory.getLogger(GitLabClaimsExtractor.class);
private final RestClient restClient;
public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
@Override
public String getProvider() {
return "gitlab";
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
log.debug("Extracting GitLab OAuth claims for user attributes: {}", attrs.keySet());
// GitLab returns email directly in user info
String email = (String) attrs.get("email");
boolean emailVerified = isConfirmed(attrs.get("confirmed_at"));
log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified);
// If email is not verified or not present, try to fetch from emails API
if (email == null || !emailVerified) {
log.debug("Email not verified or missing, attempting to fetch from GitLab emails API");
GitLabEmail primaryEmail = loadPrimaryEmail(request);
if (primaryEmail != null) {
email = primaryEmail.email();
emailVerified = true;
log.debug("Found verified email from GitLab API: {}", email);
} else {
log.debug("No verified email found from GitLab emails API");
}
}
// GitLab uses "username" for login name
String username = (String) attrs.get("username");
if (username == null) {
username = (String) attrs.get("login");
}
String subject = String.valueOf(attrs.get("id"));
log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}",
subject, username, email, emailVerified);
return new OAuthClaims(
"gitlab",
subject,
email,
emailVerified,
username,
attrs
);
}
private GitLabEmail loadPrimaryEmail(OAuth2UserRequest request) {
String baseUrl = getGitLabApiBaseUrl(request);
log.debug("Loading primary email from GitLab API base URL: {}", baseUrl);
try {
List<GitLabEmail> emails = restClient.get()
.uri(baseUrl + "/user/emails")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<List<GitLabEmail>>() {});
if (emails == null || emails.isEmpty()) {
log.debug("No emails returned from GitLab emails API");
return null;
}
log.debug("Retrieved {} emails from GitLab API", emails.size());
// Return the primary verified email
return emails.stream()
.filter(GitLabEmail::confirmed)
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage());
return null;
}
}
/**
* Determines the GitLab API base URL from the provider configuration.
* The user-info-uri is configured as ${OAUTH2_GITLAB_BASE_URI}/api/v4/user,
* so we simply remove the /user suffix to get the API base URL.
*/
private String getGitLabApiBaseUrl(OAuth2UserRequest request) {
String userInfoUri = request.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri();
log.debug("GitLab user info URI: {}", userInfoUri);
// user-info-uri format: ${OAUTH2_GITLAB_BASE_URI}/api/v4/user
// Remove /user suffix to get API base URL
String baseUrl = userInfoUri.substring(0, userInfoUri.length() - "/user".length());
log.debug("GitLab API base URL: {}", baseUrl);
return baseUrl;
}
/**
* GitLab marks a confirmed email by populating confirmed_at.
*/
private record GitLabEmail(String email, @JsonProperty("confirmed_at") String confirmedAt) {
boolean confirmed() {
return confirmedAt != null && !confirmedAt.isBlank();
}
}
private boolean isConfirmed(Object confirmedAt) {
return confirmedAt instanceof String value && !value.isBlank();
}
}

View file

@ -71,10 +71,10 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"),
RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**")
);

View file

@ -1,7 +1,5 @@
package com.iflytek.skillhub.auth.session;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -10,6 +8,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Service;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
/**
* Synchronizes {@link PlatformPrincipal} snapshots with Spring Security's
* session-backed authentication context.
@ -53,7 +55,13 @@ public class PlatformSessionService {
Authentication authentication,
HttpServletRequest request,
boolean rotateSessionId) {
persist(principal, authentication, request, rotateSessionId);
// Create a new authentication with PlatformPrincipal as the principal
// instead of using the OAuth2 authentication which has OAuth2User as principal
var authorities = principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
Authentication platformAuth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
persist(principal, platformAuth, request, rotateSessionId);
}
private void persist(PlatformPrincipal principal,

View file

@ -0,0 +1,103 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class GitLabClaimsExtractorTest {
@Test
void extract_marksProfileEmailVerifiedWhenConfirmedAtPresent() {
RestClient.Builder restClientBuilder = RestClient.builder();
GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
OAuthClaims claims = extractor.extract(
userRequest(),
new DefaultOAuth2User(
java.util.List.of(),
Map.of(
"id", 42,
"username", "alice",
"email", "alice@gitlab.example",
"confirmed_at", "2026-04-16T08:00:00Z"
),
"username"
)
);
assertThat(claims.email()).isEqualTo("alice@gitlab.example");
assertThat(claims.emailVerified()).isTrue();
assertThat(claims.providerLogin()).isEqualTo("alice");
}
@Test
void extract_loadsConfirmedEmailFromEmailListWhenProfileEmailIsUnconfirmed() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://gitlab.example.com/api/v4/user/emails"))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
.andRespond(withSuccess(
"""
[
{"email":"alice@gitlab.example","confirmed_at":"2026-04-16T08:00:00Z"},
{"email":"alice+pending@gitlab.example","confirmed_at":null}
]
""",
MediaType.APPLICATION_JSON
));
GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
OAuthClaims claims = extractor.extract(
userRequest(),
new DefaultOAuth2User(
java.util.List.of(),
Map.of(
"id", 42,
"username", "alice",
"email", "alice+pending@gitlab.example"
),
"username"
)
);
assertThat(claims.email()).isEqualTo("alice@gitlab.example");
assertThat(claims.emailVerified()).isTrue();
server.verify();
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("read_user", "email")
.authorizationUri("https://gitlab.example.com/oauth/authorize")
.tokenUri("https://gitlab.example.com/oauth/token")
.userInfoUri("https://gitlab.example.com/api/v4/user")
.userNameAttributeName("username")
.clientName("GitLab")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -61,7 +61,7 @@ class OAuth2LoginHandlersTest {
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal);
assertThat(securityContext).isNotNull();
assertThat(securityContext.getAuthentication()).isSameAs(authentication);
assertThat(securityContext.getAuthentication().getPrincipal()).isEqualTo(principal);
}
@Test

View file

@ -58,6 +58,21 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(matchedWeb);
}
@Test
void authorizationPolicies_shouldRequireAuthenticationForNamespaceDiscovery() {
boolean matchedV1 = registry.authorizationPolicies().stream()
.anyMatch(policy -> policy.method() == HttpMethod.GET
&& "/api/v1/namespaces".equals(policy.pattern())
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
boolean matchedWeb = registry.authorizationPolicies().stream()
.anyMatch(policy -> policy.method() == HttpMethod.GET
&& "/api/web/namespaces".equals(policy.pattern())
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
assertTrue(matchedV1);
assertTrue(matchedWeb);
}
@Test
void shouldIgnoreCsrf_forBearerAndApiPaths() {
assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null));

View file

@ -28,7 +28,8 @@ public class ReviewPermissionChecker {
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (task.getSubmittedBy().equals(userId)) {
return platformRoles.contains("SUPER_ADMIN");
return platformRoles.contains("SUPER_ADMIN")
|| canSelfReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles);
}
return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);
}
@ -135,4 +136,15 @@ public class ReviewPermissionChecker {
return platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN");
}
private boolean canSelfReviewNamespace(Long namespaceId,
NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles) {
if (namespaceType == NamespaceType.GLOBAL) {
return false;
}
NamespaceRole role = userNamespaceRoles.get(namespaceId);
return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN;
}
}

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import java.util.Set;
/**
* Evaluates whether a caller may read a skill based on publication state, visibility, ownership,
@ -11,6 +12,13 @@ import java.util.Map;
public class VisibilityChecker {
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
return canAccess(skill, currentUserId, userNamespaceRoles, Set.of());
}
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles, Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
return true;
}
if (skill.isHidden()) {
return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId()));
}
@ -31,4 +39,8 @@ public class VisibilityChecker {
private boolean isAdminOrAbove(NamespaceRole role) {
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
}
private boolean isSuperAdmin(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
}

View file

@ -156,7 +156,8 @@ public class SkillPublishService {
String sourceVersion,
String targetVersion,
String publisherId,
Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> userNamespaceRoles,
boolean confirmWarnings) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
assertCanManageLifecycle(skill, publisherId, userNamespaceRoles);
@ -181,7 +182,7 @@ public class SkillPublishService {
publisherId,
skill.getVisibility(),
Set.of(),
false, // confirmWarnings=false: no warnings to confirm for rerelease
confirmWarnings, // confirmWarnings: honour caller's choice for rerelease
false, // forceAutoPublish=false: respect visibility rules
true
);

View file

@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -149,11 +150,14 @@ public class SkillQueryService {
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED
&& !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.namespace.archived", namespaceSlug);
}
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
@ -198,6 +202,15 @@ public class SkillQueryService {
);
}
public SkillDetailDTO getSkillDetail(
String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> platformRoles) {
return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles);
}
/**
* Lists skills within a namespace after filtering out records the caller is
* not allowed to discover.

View file

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

View file

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

View file

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

View file

@ -855,7 +855,8 @@ 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());
@ -892,7 +893,8 @@ 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
));
}
@ -953,7 +955,8 @@ 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());
@ -966,6 +969,100 @@ class SkillPublishServiceTest {
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";
@ -999,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<PackageEntry> 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";

View file

@ -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<Skill> 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<Skill> 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<Skill> 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<SkillVersion> 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<Long, NamespaceRole> 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<SkillVersion> 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<SkillVersion> result = service.listVersions(

View file

@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
Set<Long> adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty()
? Set.of(-1L)
: query.visibilityScope().adminNamespaceIds();
boolean platformWideAccess = query.visibilityScope().platformWideAccess();
StringBuilder sql = new StringBuilder();
sql.append("SELECT d.skill_id ");
@ -118,9 +117,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND (d.visibility = 'PUBLIC' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) ");
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE) ");
sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) ");
sql.append("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE) ");
}
sql.append(") ");
@ -131,7 +127,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND (n.status <> 'ARCHIVED' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR d.namespace_id IN :memberNamespaceIds ");
sql.append("OR :platformWideAccess = TRUE ");
}
sql.append(") ");
@ -196,8 +191,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
if (query.visibilityScope().userId() != null) {
nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
nativeQuery.setParameter("platformWideAccess", platformWideAccess);
nativeQuery.setParameter("userId", query.visibilityScope().userId());
}
if (query.namespaceId() != null) {
@ -243,8 +236,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
if (query.visibilityScope().userId() != null) {
countQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
countQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
countQuery.setParameter("platformWideAccess", platformWideAccess);
countQuery.setParameter("userId", query.visibilityScope().userId());
}
if (query.namespaceId() != null) {

View file

@ -393,7 +393,7 @@ class PostgresFullTextQueryServiceTest {
}
@Test
void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() {
void platformWideAccessShouldNotBypassVisibilityInPortalSearch() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
@ -418,12 +418,12 @@ class PostgresFullTextQueryServiceTest {
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
// Portal search should not include platformWideAccess bypass logic
assertThat(sqlCaptor.getAllValues().getFirst())
.contains("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE)")
.contains("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE)")
.contains("OR :platformWideAccess = TRUE");
verify(nativeQuery).setParameter("platformWideAccess", true);
verify(countQuery).setParameter("platformWideAccess", true);
.doesNotContain("platformWideAccess")
.doesNotContain("PRIVATE");
verify(nativeQuery, never()).setParameter("platformWideAccess", true);
verify(countQuery, never()).setParameter("platformWideAccess", true);
}
@Test

View file

@ -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<SeededReviewData & { reviewTaskId: number; cleanup: () => Promise<void> }> {
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()
},
}
}

View file

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

View file

@ -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<T> {
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<void> {
await new Promise<void>((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<void> {
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<SeededNamespace | null> {
if (!this.isTeamNamespace(namespace)) {
return null
}
if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
return parseEnvelope<SeededNamespace>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`),
)
}
if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
return parseEnvelope<SeededNamespace>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`),
)
}
return null
}
async ensureWritableNamespace(): Promise<SeededNamespace> {
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<SeededNamespace> {
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<SeededSkill | null> {
@ -350,6 +473,21 @@ export class E2eTestDataBuilder {
)
}
async searchNamespaceMemberCandidates(slug: string, search: string): Promise<NamespaceCandidate[]> {
const query = new URLSearchParams({ search })
return parseEnvelope<NamespaceCandidate[]>(
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<void> {
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<SeededSkill> {
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<SeededReviewData> {
const namespace = await this.ensureWritableNamespace()
const namespace = await this.ensureReviewableNamespace()
const skill = await this.publishSkill(namespace.slug)
return { namespace, skill }
}

View file

@ -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<ReturnType<typeof createNamespaceReviewData>> | 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<ReturnType<typeof createNamespaceReviewData>> | 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<ReturnType<typeof createNamespaceReviewData>> | 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()
}
})
})

View file

@ -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<ReturnType<typeof createNamespaceReviewData>> | 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()
}
})
})

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 -0.5 25 25" xmlns="http://www.w3.org/2000/svg"><path d="m12.301 0h.093c2.242 0 4.34.613 6.137 1.68l-.055-.031c1.871 1.094 3.386 2.609 4.449 4.422l.031.058c1.04 1.769 1.654 3.896 1.654 6.166 0 5.406-3.483 10-8.327 11.658l-.087.026c-.063.02-.135.031-.209.031-.162 0-.312-.054-.433-.144l.002.001c-.128-.115-.208-.281-.208-.466 0-.005 0-.01 0-.014v.001q0-.048.008-1.226t.008-2.154c.007-.075.011-.161.011-.249 0-.792-.323-1.508-.844-2.025.618-.061 1.176-.163 1.718-.305l-.076.017c.573-.16 1.073-.373 1.537-.642l-.031.017c.508-.28.938-.636 1.292-1.058l.006-.007c.372-.476.663-1.036.84-1.645l.009-.035c.209-.683.329-1.468.329-2.281 0-.045 0-.091-.001-.136v.007c0-.022.001-.047.001-.072 0-1.248-.482-2.383-1.269-3.23l.003.003c.168-.44.265-.948.265-1.479 0-.649-.145-1.263-.404-1.814l.011.026c-.115-.022-.246-.035-.381-.035-.334 0-.649.078-.929.216l.012-.005c-.568.21-1.054.448-1.512.726l.038-.022-.609.384c-.922-.264-1.981-.416-3.075-.416s-2.153.152-3.157.436l.081-.02q-.256-.176-.681-.433c-.373-.214-.814-.421-1.272-.595l-.066-.022c-.293-.154-.64-.244-1.009-.244-.124 0-.246.01-.364.03l.013-.002c-.248.524-.393 1.139-.393 1.788 0 .531.097 1.04.275 1.509l-.01-.029c-.785.844-1.266 1.979-1.266 3.227 0 .025 0 .051.001.076v-.004c-.001.039-.001.084-.001.13 0 .809.12 1.591.344 2.327l-.015-.057c.189.643.476 1.202.85 1.693l-.009-.013c.354.435.782.793 1.267 1.062l.022.011c.432.252.933.465 1.46.614l.046.011c.466.125 1.024.227 1.595.284l.046.004c-.431.428-.718 1-.784 1.638l-.001.012c-.207.101-.448.183-.699.236l-.021.004c-.256.051-.549.08-.85.08-.022 0-.044 0-.066 0h.003c-.394-.008-.756-.136-1.055-.348l.006.004c-.371-.259-.671-.595-.881-.986l-.007-.015c-.198-.336-.459-.614-.768-.827l-.009-.006c-.225-.169-.49-.301-.776-.38l-.016-.004-.32-.048c-.023-.002-.05-.003-.077-.003-.14 0-.273.028-.394.077l.007-.003q-.128.072-.08.184c.039.086.087.16.145.225l-.001-.001c.061.072.13.135.205.19l.003.002.112.08c.283.148.516.354.693.603l.004.006c.191.237.359.505.494.792l.01.024.16.368c.135.402.38.738.7.981l.005.004c.3.234.662.402 1.057.478l.016.002c.33.064.714.104 1.106.112h.007c.045.002.097.002.15.002.261 0 .517-.021.767-.062l-.027.004.368-.064q0 .609.008 1.418t.008.873v.014c0 .185-.08.351-.208.466h-.001c-.119.089-.268.143-.431.143-.075 0-.147-.011-.214-.032l.005.001c-4.929-1.689-8.409-6.283-8.409-11.69 0-2.268.612-4.393 1.681-6.219l-.032.058c1.094-1.871 2.609-3.386 4.422-4.449l.058-.031c1.739-1.034 3.835-1.645 6.073-1.645h.098-.005zm-7.64 17.666q.048-.112-.112-.192-.16-.048-.208.032-.048.112.112.192.144.096.208-.032zm.497.545q.112-.08-.032-.256-.16-.144-.256-.048-.112.08.032.256.159.157.256.047zm.48.72q.144-.112 0-.304-.128-.208-.272-.096-.144.08 0 .288t.272.112zm.672.673q.128-.128-.064-.304-.192-.192-.32-.048-.144.128.064.304.192.192.32.044zm.913.4q.048-.176-.208-.256-.24-.064-.304.112t.208.24q.24.097.304-.096zm1.009.08q0-.208-.272-.176-.256 0-.256.176 0 .208.272.176.256.001.256-.175zm.929-.16q-.032-.176-.288-.144-.256.048-.224.24t.288.128.225-.224z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#FC6D26" d="M14.975 8.904L14.19 6.55l-1.552-4.67a.268.268 0 00-.255-.18.268.268 0 00-.254.18l-1.552 4.667H5.422L3.87 1.879a.267.267 0 00-.254-.179.267.267 0 00-.254.18l-1.55 4.667-.784 2.357a.515.515 0 00.193.583l6.78 4.812 6.778-4.812a.516.516 0 00.196-.583z"/><path fill="#E24329" d="M8 14.296l2.578-7.75H5.423L8 14.296z"/><path fill="#FC6D26" d="M8 14.296l-2.579-7.75H1.813L8 14.296z"/><path fill="#FCA326" d="M1.81 6.549l-.784 2.354a.515.515 0 00.193.583L8 14.3 1.81 6.55z"/><path fill="#E24329" d="M1.812 6.549h3.612L3.87 1.882a.268.268 0 00-.254-.18.268.268 0 00-.255.18L1.812 6.549z"/><path fill="#FC6D26" d="M8 14.296l2.578-7.75h3.614L8 14.296z"/><path fill="#FCA326" d="M14.19 6.549l.783 2.354a.514.514 0 01-.193.583L8 14.296l6.188-7.747h.001z"/><path fill="#E24329" d="M14.19 6.549H10.58l1.551-4.667a.267.267 0 01.255-.18c.115 0 .217.073.254.18l1.552 4.667z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -488,14 +488,14 @@ export const skillLifecycleApi = {
})
},
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise<void> {
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${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 }),
})
},

View file

@ -3237,6 +3237,7 @@ export interface components {
};
SkillVersionRereleaseRequest: {
targetVersion: string;
confirmWarnings?: boolean;
};
SkillReportSubmitRequest: {
reason?: string;

View file

@ -86,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',
@ -228,7 +224,6 @@ const namespaceRoute = createRoute({
const skillDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/space/$namespace/$slug',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): { returnTo?: string } => ({
returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined,
}),
@ -305,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',
@ -416,6 +418,7 @@ const routeTree = rootRoute.addChildren([
dashboardNamespacesRoute,
dashboardNamespaceMembersRoute,
dashboardNamespaceReviewsRoute,
dashboardNamespaceReviewDetailRoute,
dashboardGovernanceRoute,
dashboardReviewsRoute,
dashboardReportsRoute,

View file

@ -6,6 +6,20 @@ interface LoginButtonProps {
returnTo?: string
}
/**
* Returns the appropriate icon for a given OAuth provider.
*/
function OAuthIcon({ provider }: { provider: string }) {
const normalizedProvider = provider.toLowerCase()
return (
<img
src={`/${normalizedProvider}-logo.svg`}
alt={provider}
className="w-5 h-5 mr-3"
/>
)
}
/**
* Renders OAuth login buttons from the auth-method catalog returned by the backend.
*/
@ -37,12 +51,11 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
window.location.href = provider.actionUrl
}}
>
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<OAuthIcon provider={provider.provider} />
{t('loginButton.loginWith', { name: provider.displayName })}
</Button>
))}
</div>
)
}

View file

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

View file

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

View file

@ -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<VersionStatus, string> = {
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<VersionStatus, string> = {
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<VersionStatus, string> = {
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 (
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
style,
className,
)}
>
{label}
</span>
)
}

View file

@ -212,7 +212,7 @@
"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.",
"oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.",
"passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.",
"enterpriseSsoTitle": "Enterprise SSO",
"enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.",
@ -911,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",
@ -1078,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",

View file

@ -212,7 +212,7 @@
"forgotPassword": "忘记密码?",
"noAccount": "还没有账号?",
"register": "立即注册",
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
"enterpriseSsoTitle": "企业单点登录",
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",
@ -912,6 +912,9 @@
"rereleaseSuccessTitle": "版本已重新发布",
"rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。",
"rereleaseErrorTitle": "重新发布版本失败",
"rereleaseWarningTitle": "发布前风险提醒",
"rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。",
"rereleaseWarningConfirm": "继续重新发布",
"yankVersion": "撤回当前版本",
"promoteToGlobal": "申请提升到全局",
"promotionSectionTitle": "提升到全局",
@ -1079,6 +1082,7 @@
"sortLabel": "时间排序",
"sortNewest": "最新优先",
"sortOldest": "最早优先",
"openReview": "进入审核详情",
"pageSummary": "共 {{total}} 条记录,第 {{page}} 页",
"prevPage": "上一页",
"nextPage": "下一页",

View file

@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: { children: unknown; to: string }) => createElement('a', { href: to }, children as string),
useParams: () => ({ slug: 'test-ns' }),
}))
@ -127,6 +128,8 @@ describe('NamespaceReviewsPage', () => {
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(html).toContain('nsReviews.pageSummary')
expect(html).toContain('/dashboard/namespaces/test-ns/reviews/1')
expect(html).toContain('nsReviews.openReview')
expect(paginationProps).toHaveLength(1)
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(2)
@ -154,4 +157,18 @@ describe('NamespaceReviewsPage', () => {
expect(paginationProps).toHaveLength(0)
})
it('does not enable review queries before namespace detail resolves', () => {
useNamespaceDetailMock.mockReturnValue({
data: undefined,
isLoading: true,
})
renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(useReviewListMock).toHaveBeenCalled()
for (const call of useReviewListMock.mock.calls) {
expect(call[5]).toBe(false)
}
})
})

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useParams } from '@tanstack/react-router'
import { Link, useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Card } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
@ -15,8 +16,9 @@ type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
type TimeSortDirection = 'ASC' | 'DESC'
const PAGE_SIZE = 10
function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug: string }) {
const { t, i18n } = useTranslation()
const reviewsEnabled = typeof namespaceId === 'number' && namespaceId > 0
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
PENDING: 0,
APPROVED: 0,
@ -24,9 +26,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
})
const [activeStatus, setActiveStatus] = useState<ReviewStatus>('PENDING')
const [sortDirection, setSortDirection] = useState<TimeSortDirection>('DESC')
const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING')
const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED')
const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED')
const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'PENDING')
const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'APPROVED')
const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'REJECTED')
const changePage = (status: ReviewStatus, nextPage: number) => {
setPages((current) => ({ ...current, [status]: nextPage }))
@ -90,6 +92,14 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
{review.reviewComment ? (
<p className="mt-3 text-sm text-muted-foreground">{review.reviewComment}</p>
) : null}
<div className="mt-4 flex justify-end">
<Link
to={buildNamespaceReviewDetailPath(slug, review.id)}
className="inline-flex items-center rounded-md border border-border/60 px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{t('nsReviews.openReview')}
</Link>
</div>
</div>
))}
{query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null}
@ -151,7 +161,7 @@ export function NamespaceReviewsPage() {
{readOnlyMessage}
</Card>
) : null}
<ReviewListSection namespaceId={namespace?.id} />
<ReviewListSection namespaceId={namespace?.id} slug={slug} />
</div>
)
}

View file

@ -5,7 +5,11 @@ const navigateMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
useParams: () => ({ id: '13' }),
useParams: (options?: { from?: string }) => (
options?.from === '/dashboard/namespaces/$slug/reviews/$id'
? { id: '13', slug: 'team-alpha' }
: { id: '13' }
),
}))
vi.mock('react-i18next', async () => {
@ -112,6 +116,11 @@ vi.mock('@/features/review/use-review-detail', () => ({
}),
}))
const userMock = { platformRoles: ['SKILL_ADMIN'] as string[] }
vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({ user: userMock }),
}))
// Mock hooks used directly by the review-detail page for file browser sidebar
vi.mock('@/features/review/use-review-file', () => ({
useReviewFile: () => ({ data: null, isLoading: false, error: null }),
@ -122,11 +131,12 @@ vi.mock('@/api/client', () => ({
WEB_API_PREFIX: '/api/web',
}))
import { ReviewDetailPage } from './review-detail'
import { NamespaceReviewDetailPage, ReviewDetailPage } from './review-detail'
describe('ReviewDetailPage', () => {
beforeEach(() => {
navigateMock.mockReset()
userMock.platformRoles = ['SKILL_ADMIN']
useReviewDetailMock.mockReset()
useReviewSkillDetailMock.mockReset()
useReviewDetailMock.mockReturnValue({
@ -206,6 +216,81 @@ describe('ReviewDetailPage', () => {
expect(html).toContain('review.notFound')
})
it('renders namespace review detail through the namespace route wrapper', () => {
useReviewDetailMock.mockReturnValue({
data: {
id: 13,
namespace: 'team-alpha',
skillSlug: 'demo-skill',
version: '1.2.0',
status: 'PENDING',
submittedBy: 'local-admin',
submittedByName: 'Local Admin',
submittedAt: '2026-03-19T00:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
},
isLoading: false,
})
const html = renderToStaticMarkup(<NamespaceReviewDetailPage />)
expect(html).toContain('review.detail')
expect(html).toContain('demo-skill')
})
it('redirects namespace reviews opened through the global route for namespace operators', () => {
userMock.platformRoles = []
useReviewDetailMock.mockReturnValue({
data: {
id: 13,
namespace: 'team-alpha',
skillSlug: 'demo-skill',
version: '1.2.0',
status: 'PENDING',
submittedBy: 'local-admin',
submittedByName: 'Local Admin',
submittedAt: '2026-03-19T00:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
},
isLoading: false,
})
const html = renderToStaticMarkup(<ReviewDetailPage />)
expect(html).toBe('')
})
it('shows not-found state when the namespace route slug does not match the review namespace', () => {
useReviewDetailMock.mockReturnValue({
data: {
id: 13,
namespace: 'other-team',
skillSlug: 'demo-skill',
version: '1.2.0',
status: 'PENDING',
submittedBy: 'local-admin',
submittedByName: 'Local Admin',
submittedAt: '2026-03-19T00:00:00Z',
reviewedBy: null,
reviewedByName: null,
reviewedAt: null,
reviewComment: null,
},
isLoading: false,
})
const html = renderToStaticMarkup(<NamespaceReviewDetailPage />)
expect(html).toContain('review.notFound')
expect(html).toContain('review.backToList')
})
it('disables approval and shows a scanning hint while the active review version is scanning', () => {
useReviewSkillDetailMock.mockReturnValue({
data: {

View file

@ -1,7 +1,14 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { ChevronDown, Folder } from 'lucide-react'
import { useAuth } from '@/features/auth/use-auth'
import {
buildGlobalReviewsPath,
buildNamespaceReviewDetailPath,
buildNamespaceReviewsPath,
canAccessGlobalReviewCenter,
} from '@/features/review/review-paths'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
@ -25,11 +32,18 @@ import { useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectRevie
* interaction state because both actions depend on route-local confirmation
* dialogs, comment input, and redirect behavior after completion.
*/
export function ReviewDetailPage() {
const { id } = useParams({ from: '/dashboard/reviews/$id' })
function ReviewDetailScreen({
taskId,
backTo,
namespaceSlug,
}: {
taskId: number
backTo: string
namespaceSlug?: string
}) {
const navigate = useNavigate()
const { t, i18n } = useTranslation()
const taskId = Number(id)
const { user } = useAuth()
const { data: review, isLoading } = useReviewDetail(taskId)
const {
@ -40,7 +54,7 @@ export function ReviewDetailPage() {
const approveMutation = useApproveReview({
onSuccess: () => {
toast.success(t('review.approveSuccess'))
navigate({ to: '/dashboard/reviews' })
navigate({ to: backTo })
},
onError: (error) => {
toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error))
@ -49,7 +63,7 @@ export function ReviewDetailPage() {
const rejectMutation = useRejectReview({
onSuccess: () => {
toast.success(t('review.rejectSuccess'))
navigate({ to: '/dashboard/reviews' })
navigate({ to: backTo })
},
onError: (error) => {
toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error))
@ -64,6 +78,12 @@ export function ReviewDetailPage() {
const [fileBrowserOpen, setFileBrowserOpen] = useState(true)
const [previewNode, setPreviewNode] = useState<FileTreeNode | null>(null)
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
const shouldRedirectToNamespaceRoute =
!namespaceSlug &&
!!review &&
review.namespace !== 'global' &&
!hasGlobalReviewAccess
// File content for preview — uses the review-bound version via review file API
const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useReviewFile(
@ -92,6 +112,17 @@ export function ReviewDetailPage() {
return formatLocalDateTime(dateString, i18n.language)
}
useEffect(() => {
if (!shouldRedirectToNamespaceRoute || !review) {
return
}
void navigate({
to: buildNamespaceReviewDetailPath(review.namespace, review.id),
replace: true,
})
}, [navigate, review, shouldRedirectToNamespaceRoute])
const handleApprove = async () => {
approveMutation.mutate({ taskId, comment: comment || undefined })
}
@ -113,6 +144,10 @@ export function ReviewDetailPage() {
)
}
if (shouldRedirectToNamespaceRoute) {
return null
}
if (!review) {
return (
<div className="text-center py-20 animate-fade-up">
@ -121,6 +156,23 @@ export function ReviewDetailPage() {
)
}
const hasNamespaceMismatch = Boolean(namespaceSlug && review.namespace !== namespaceSlug)
if (hasNamespaceMismatch) {
return (
<div className="space-y-6 max-w-3xl animate-fade-up">
<div className="text-center py-20">
<h2 className="text-2xl font-bold font-heading mb-2">{t('review.notFound')}</h2>
</div>
<div className="flex justify-center">
<Button variant="outline" onClick={() => navigate({ to: backTo })}>
{t('review.backToList')}
</Button>
</div>
</div>
)
}
const reviewFiles = reviewSkillDetail?.files
const activeReviewVersion = reviewSkillDetail?.versions?.find(
(version) => version.version === reviewSkillDetail.activeVersion
@ -136,7 +188,7 @@ export function ReviewDetailPage() {
<h1 className="text-4xl font-bold font-heading mb-2">{t('review.detail')}</h1>
<p className="text-muted-foreground">{t('review.id')}: {review.id}</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: '/dashboard/reviews' })}>
<Button variant="outline" onClick={() => navigate({ to: backTo })}>
{t('review.backToList')}
</Button>
</div>
@ -363,3 +415,26 @@ export function ReviewDetailPage() {
</div>
)
}
export function ReviewDetailPage() {
const { id } = useParams({ from: '/dashboard/reviews/$id' })
return (
<ReviewDetailScreen
taskId={Number(id)}
backTo={buildGlobalReviewsPath()}
/>
)
}
export function NamespaceReviewDetailPage() {
const { id, slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews/$id' })
return (
<ReviewDetailScreen
taskId={Number(id)}
backTo={buildNamespaceReviewsPath(slug)}
namespaceSlug={slug}
/>
)
}

View file

@ -67,8 +67,14 @@ vi.mock('@/features/review/use-review-list', () => ({
}))
const hasRoleMock = vi.fn()
const userMock = { platformRoles: ['SKILL_ADMIN'] }
vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({ hasRole: hasRoleMock }),
useAuth: () => ({ hasRole: hasRoleMock, user: userMock }),
}))
const useMyNamespacesMock = vi.fn()
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => useMyNamespacesMock(),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -106,7 +112,13 @@ describe('ReviewsPage', () => {
paginationProps.length = 0
hasRoleMock.mockReset()
useReviewListMock.mockReset()
useMyNamespacesMock.mockReset()
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN')
userMock.platformRoles = ['SKILL_ADMIN']
useMyNamespacesMock.mockReturnValue({
data: [],
isLoading: false,
})
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled) {
return { data: null, isLoading: false }

View file

@ -1,10 +1,16 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { FileCheck2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import {
buildNamespaceReviewsPath,
canAccessGlobalReviewCenter,
getPreferredNamespaceReviewEntry,
} from '@/features/review/review-paths'
import {
Table,
TableBody,
@ -32,7 +38,8 @@ const PAGE_SIZE = 20
export function ReviewsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const { hasRole } = useAuth()
const { hasRole, user } = useAuth()
const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
PENDING: 0,
APPROVED: 0,
@ -43,14 +50,29 @@ export function ReviewsPage() {
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces)
const showTypeTabs = isSkillAdmin && isUserAdmin
// Determine default top-level tab
const defaultType = isSkillAdmin ? 'skill' : 'profile'
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING')
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED')
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED')
useEffect(() => {
if (hasGlobalReviewAccess || isLoadingNamespaces) {
return
}
if (namespaceReviewEntry) {
void navigate({ to: buildNamespaceReviewsPath(namespaceReviewEntry.slug), replace: true })
return
}
void navigate({ to: '/dashboard', replace: true })
}, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate])
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING')
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED')
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED')
const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language)
@ -211,6 +233,17 @@ export function ReviewsPage() {
)
}
if (!hasGlobalReviewAccess) {
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
<Card className="p-8 text-center text-muted-foreground">
Loading...
</Card>
</div>
)
}
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />

View file

@ -46,7 +46,13 @@ vi.mock('@/shared/components/skeleton-loader', () => ({
}))
vi.mock('@/shared/components/empty-state', () => ({
EmptyState: () => <div>empty-state</div>,
EmptyState: ({ title, description }: { title: string; description?: string }) => (
<div>
empty-state
<span>{title}</span>
{description ? <span>{description}</span> : null}
</div>
),
}))
vi.mock('@/shared/components/pagination', () => ({
@ -202,4 +208,55 @@ describe('SearchPage', () => {
},
})
})
it('renders the default skill list when the empty query still returns items', () => {
useSearchMock.mockReturnValue({
q: '',
label: '',
sort: 'newest',
page: 0,
starredOnly: false,
})
useSearchSkillsMock.mockReturnValue({
data: {
items: [{ id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', downloadCount: 1, starCount: 1, ratingCount: 0, updatedAt: '2026-03-20T00:00:00Z', canSubmitPromotion: false }],
total: 1,
page: 0,
size: 12,
},
isLoading: false,
isFetching: false,
})
const html = renderToStaticMarkup(<SearchPage />)
expect(html).toContain('skill-card')
expect(html).not.toContain('empty-state')
})
it('shows a generic empty state when the default discovery list is empty', () => {
useSearchMock.mockReturnValue({
q: '',
label: '',
sort: 'newest',
page: 0,
starredOnly: false,
})
useSearchSkillsMock.mockReturnValue({
data: {
items: [],
total: 0,
page: 0,
size: 12,
},
isLoading: false,
isFetching: false,
})
const html = renderToStaticMarkup(<SearchPage />)
expect(html).toContain('empty-state')
expect(html).toContain('search.noResults')
expect(html).not.toContain('search.enterKeyword')
})
})

View file

@ -125,8 +125,6 @@ export function SearchPage() {
isLoading: isLoadingStarred,
isFetching: isFetchingStarred,
} = useMyStars(starredOnly && isAuthenticated)
const shouldShowGuidance = !starredOnly && !q && !selectedLabel
useEffect(() => {
// Debounce URL updates while the user is typing so query state stays shareable without
// triggering a navigation on every keystroke.
@ -202,10 +200,10 @@ export function SearchPage() {
: data
? Math.ceil(data.total / data.size)
: 0
const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? []))
const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading)
const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading)
const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0))
const displayItems = starredOnly ? starredPageItems : (data?.items ?? [])
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading
const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)
return (
<div className={APP_SHELL_PAGE_CLASS_NAME}>
@ -313,11 +311,9 @@ export function SearchPage() {
<EmptyState
title={starredOnly ? t('search.noStarredResults') : t('search.noResults')}
description={
shouldShowGuidance
? t('search.enterKeyword')
: starredOnly
starredOnly
? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills'))
: (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword'))
: (q ? t('search.noResultsFor', { q }) : undefined)
}
/>
)}

View file

@ -2,10 +2,17 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const navigateMock = vi.fn()
const hasRoleMock = vi.fn((role: string) => role === 'USER')
const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER')
const useSkillDetailMock = vi.fn()
const useSkillLabelsMock = vi.fn()
const useSkillVersionsMock = vi.fn()
let authState: {
user: { userId: string; platformRoles: string[] } | null
hasRole: (role: string) => boolean
} = {
user: { userId: 'owner-1', platformRoles: ['USER'] },
hasRole: hasRoleMock,
}
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
@ -32,10 +39,7 @@ vi.mock('@tanstack/react-query', () => ({
}))
vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({
user: { userId: 'owner-1', platformRoles: ['USER'] },
hasRole: hasRoleMock,
}),
useAuth: () => authState,
}))
vi.mock('@/features/report/use-skill-reports', () => ({
@ -165,6 +169,10 @@ describe('SkillDetailPage', () => {
beforeEach(() => {
navigateMock.mockReset()
hasRoleMock.mockImplementation((role: string) => role === 'USER')
authState = {
user: { userId: 'owner-1', platformRoles: ['USER'] },
hasRole: hasRoleMock,
}
useSkillDetailMock.mockReturnValue({
data: createSkill(),
isLoading: false,
@ -208,6 +216,31 @@ describe('SkillDetailPage', () => {
expect(html).not.toContain('skillDetail.deleteSkill')
})
it('renders public skill details for an anonymous viewer', () => {
authState = {
user: null,
hasRole: vi.fn(() => false),
}
useSkillDetailMock.mockReturnValue({
data: createSkill({
canManageLifecycle: false,
canInteract: true,
visibility: 'PUBLIC',
}),
isLoading: false,
isFetching: false,
error: null,
})
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(html).toContain('Demo Skill')
expect(html).toContain('install')
expect(html).not.toContain('skillDetail.loginRequired')
expect(html).not.toContain('skillDetail.deleteSkill')
})
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, RefreshCw, ShieldCheck, Terminal, User } from 'lucide-react'
import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { FileTree } from '@/features/skill/file-tree'
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
@ -16,6 +16,7 @@ import {
shouldCollapseOverview,
} from '@/features/skill/overview-collapse'
import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error'
import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils'
import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow'
import { isSkillDetailQueriesEnabled } from './skill-detail-query'
import { RatingInput } from '@/features/social/rating-input'
@ -116,6 +117,8 @@ export function SkillDetailPage() {
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
const [rereleaseTarget, setRereleaseTarget] = useState<string | null>(null)
const [targetVersionInput, setTargetVersionInput] = useState('')
const [rereleaseWarnings, setRereleaseWarnings] = useState<string[]>([])
const [rereleaseWarningDialogOpen, setRereleaseWarningDialogOpen] = useState(false)
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
const [confirmPublishTarget, setConfirmPublishTarget] = useState<string | null>(null)
const [submitReviewTarget, setSubmitReviewTarget] = useState<string | null>(null)
@ -575,7 +578,7 @@ export function SkillDetailPage() {
setTargetVersionInput(suggestNextVersion(version))
}
const handleRereleaseVersion = async () => {
const handleRereleaseVersion = async (confirmWarnings = false) => {
if (!rereleaseTarget || !targetVersionInput.trim()) {
return
}
@ -585,6 +588,7 @@ export function SkillDetailPage() {
slug,
version: rereleaseTarget,
targetVersion: targetVersionInput.trim(),
confirmWarnings,
})
toast.success(
t('skillDetail.rereleaseSuccessTitle'),
@ -592,7 +596,15 @@ export function SkillDetailPage() {
)
setRereleaseTarget(null)
setTargetVersionInput('')
setRereleaseWarnings([])
setRereleaseWarningDialogOpen(false)
} catch (error) {
if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage)) {
const warnings = extractPrecheckWarnings(error.serverMessage)
setRereleaseWarnings(warnings)
setRereleaseWarningDialogOpen(true)
return
}
toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
@ -710,10 +722,31 @@ export function SkillDetailPage() {
<div className="flex items-center gap-3 mb-1">
<NamespaceBadge type="GLOBAL" name={namespace} />
{skill.status && (
<span className="badge-soft badge-soft-blue">
<span className={cn(
'badge-soft',
skill.status === 'ACTIVE' && 'badge-soft-green',
skill.status === 'ARCHIVED' && 'bg-secondary text-muted-foreground',
skill.status === 'HIDDEN' && 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
!['ACTIVE', 'ARCHIVED', 'HIDDEN'].includes(skill.status) && 'badge-soft-blue',
)}>
{resolveSkillStatusLabel(skill.status)}
</span>
)}
{skill.visibility && (
<span className={cn(
'badge-soft inline-flex items-center gap-1',
skill.visibility === 'PUBLIC' && 'badge-soft-green',
skill.visibility === 'PRIVATE' && 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
skill.visibility === 'NAMESPACE_ONLY' && 'badge-soft-blue',
)}>
{skill.visibility === 'PUBLIC' && <Globe className="h-3 w-3" />}
{skill.visibility === 'PRIVATE' && <Lock className="h-3 w-3" />}
{skill.visibility === 'NAMESPACE_ONLY' && <Users className="h-3 w-3" />}
{skill.visibility === 'PUBLIC' && t('publish.visibilityOptions.public')}
{skill.visibility === 'PRIVATE' && t('publish.visibilityOptions.private')}
{skill.visibility === 'NAMESPACE_ONLY' && t('publish.visibilityOptions.namespaceOnly')}
</span>
)}
{isReviewFlowPending && (
<span className="badge-soft" style={{ background: '#fef3c7', color: '#92400e' }}>
{t('skillDetail.versionStatusPendingReview')}
@ -957,7 +990,7 @@ export function SkillDetailPage() {
))}
</div>
) : (
<div className="text-muted-foreground text-center py-8">{t('skillDetail.noVersions')}</div>
<Card className="p-8 text-muted-foreground text-center">{t('skillDetail.noVersions')}</Card>
)}
</Card>
</TabsContent>
@ -1396,6 +1429,8 @@ export function SkillDetailPage() {
if (!open) {
setRereleaseTarget(null)
setTargetVersionInput('')
setRereleaseWarnings([])
setRereleaseWarningDialogOpen(false)
}
}}
>
@ -1422,13 +1457,34 @@ export function SkillDetailPage() {
<Button variant="outline" onClick={() => setRereleaseTarget(null)}>
{t('dialog.cancel')}
</Button>
<Button onClick={handleRereleaseVersion} disabled={rereleaseVersionMutation.isPending || !targetVersionInput.trim()}>
<Button onClick={() => handleRereleaseVersion()} disabled={rereleaseVersionMutation.isPending || !targetVersionInput.trim()}>
{rereleaseVersionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.rereleaseVersion')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={rereleaseWarningDialogOpen}
onOpenChange={setRereleaseWarningDialogOpen}
title={t('skillDetail.rereleaseWarningTitle')}
description={
<div className="space-y-2">
<p>{t('skillDetail.rereleaseWarningDescription')}</p>
<ul className="list-disc space-y-1 pl-5 text-sm">
{rereleaseWarnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
}
confirmText={t('skillDetail.rereleaseWarningConfirm')}
onConfirm={() => {
setRereleaseWarningDialogOpen(false)
handleRereleaseVersion(true)
}}
/>
<ConfirmDialog
open={!!confirmPublishTarget}
onOpenChange={(open) => {

View file

@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths'
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { cn } from '@/shared/lib/utils'
@ -22,19 +24,19 @@ interface UserMenuProps {
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: myNamespaces } = useMyNamespaces()
const rootRef = useRef<HTMLDivElement | null>(null)
const closeTimerRef = useRef<number | null>(null)
const [isHovered, setIsHovered] = useState(false)
const [isClickOpen, setIsClickOpen] = useState(false)
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN')
const canSeeGovernance = canViewGovernanceCenter(user.platformRoles)
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
const isSuperAdmin = hasRole('SUPER_ADMIN')
const canAccessReviewCenter = isReviewer || isUserAdmin
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
const isLocalAccount = !user.oauthProvider
const open = isHovered || isClickOpen
@ -157,8 +159,8 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
<Link to="/dashboard/stars" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.stars')}
</Link>
{canAccessReviewCenter ? (
<Link to="/dashboard/reviews" className={menuItemClassName} onClick={closeMenu}>
{reviewCenterVisible ? (
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviews')}
</Link>
) : null}

View file

@ -206,8 +206,11 @@ export function useRereleaseSkillVersion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ namespace, slug, version, targetVersion }: { namespace: string; slug: string; version: string; targetVersion: string }) =>
skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion),
mutationFn: ({ namespace, slug, version, targetVersion, confirmWarnings }: { namespace: string; slug: string; version: string; targetVersion: string; confirmWarnings?: boolean }) =>
skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion, confirmWarnings),
meta: {
skipGlobalErrorHandler: true,
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] })