diff --git a/.env.release.example b/.env.release.example index 9b863c23..3f020fc7 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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 diff --git a/.github/release-template.md b/.github/release-template.md new file mode 100644 index 00000000..d70055d8 --- /dev/null +++ b/.github/release-template.md @@ -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}} + diff --git a/.github/scripts/github.ts b/.github/scripts/github.ts index 7b9778b5..70103211 100644 --- a/.github/scripts/github.ts +++ b/.github/scripts/github.ts @@ -138,6 +138,47 @@ export class GitHubClient { ); } + async listCommitPulls(sha: string): Promise> { + 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 { + 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(path: string): Promise { const collected: T[] = []; let nextPath: string | null = path; diff --git a/.github/scripts/issue-llm-provider.ts b/.github/scripts/issue-llm-provider.ts index 276193c3..f73dd8bf 100644 --- a/.github/scripts/issue-llm-provider.ts +++ b/.github/scripts/issue-llm-provider.ts @@ -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 { + 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 { + 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; diff --git a/.github/scripts/release-notes-config.ts b/.github/scripts/release-notes-config.ts new file mode 100644 index 00000000..d059cfc4 --- /dev/null +++ b/.github/scripts/release-notes-config.ts @@ -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; +} diff --git a/.github/scripts/release-notes-generator.ts b/.github/scripts/release-notes-generator.ts new file mode 100644 index 00000000..f1437177 --- /dev/null +++ b/.github/scripts/release-notes-generator.ts @@ -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 { + 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 { + 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(); + 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 { + 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(); + for (const change of changes) { + const type = change.type; + if (!grouped.has(type)) { + grouped.set(type, []); + } + grouped.get(type)!.push(change); + } + + const typeLabels: Record = { + 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; +} diff --git a/.github/scripts/release-notes.ts b/.github/scripts/release-notes.ts new file mode 100644 index 00000000..64691cec --- /dev/null +++ b/.github/scripts/release-notes.ts @@ -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 { + 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 --repo --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); +}); diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 00000000..ebba66bc --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -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 diff --git a/.gitignore b/.gitignore index dcf12884..2eae3701 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index e9f0c3fe..9a8f6c39 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README_zh.md b/README_zh.md index 4009e4cf..72c7eb73 100644 --- a/README_zh.md +++ b/README_zh.md @@ -7,6 +7,7 @@
[![文档](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) diff --git a/docker-compose.yml b/docker-compose.yml index 7bf18ffc..77dc264b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/oss-02-core-semantic-rules.md b/docs/oss-02-core-semantic-rules.md index cd256d05..484b17ef 100644 --- a/docs/oss-02-core-semantic-rules.md +++ b/docs/oss-02-core-semantic-rules.md @@ -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 职责) | --- diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 20c2dd58..f5b89550 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -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 diff --git a/scripts/skillhub-test-deploy-remote.sh b/scripts/skillhub-test-deploy-remote.sh index 39663624..08dde944 100644 --- a/scripts/skillhub-test-deploy-remote.sh +++ b/scripts/skillhub-test-deploy-remote.sh @@ -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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index 57609296..b6fc5b1f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -97,7 +97,8 @@ public class ClawHubCompatAppService { String hash, String userId, Map userNsRoles) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + Map 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 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 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 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 normalizeRoles(Map 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 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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index d75a82e1..2d499c31 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -82,8 +82,10 @@ public class ClawHubCompatController { @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download") public ResponseEntity 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 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 userNsRoles) { + return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 68dd32fc..8c02af39 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -83,7 +83,8 @@ public class ClawHubRegistryFacade { CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( coordinate.namespace(), coordinate.slug(), - userId + userId, + normalizeRoles(userNsRoles) ); Skill skill = context.skill(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java index a151ed8f..9da699a2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java @@ -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 userNsRoles) { + if (skill == null) { + return false; + } + Map 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 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)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index e18fa681..2f2c603e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController { } @GetMapping("/namespaces") - public ApiResponse> listNamespaces(Pageable pageable) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable)); + public ApiResponse> listNamespaces( + Pageable pageable, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 getNamespace(@PathVariable String slug, - @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { return ok("response.success.read", namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index cf233a74..f763424f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController { return true; } Map 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); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java index 6ca9e708..b3d61fc9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; public record SkillVersionRereleaseRequest( @NotBlank(message = "{validation.required}") - String targetVersion + String targetVersion, + boolean confirmWarnings ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 366038ee..d4df24c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -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 userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( NamespaceMember::getNamespaceId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 6a21714e..cff2eb59 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.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 listNamespaces(Pageable pageable) { - Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); - return PageResponse.from(namespaces.map(NamespaceResponse::from)); + public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + Page empty = new PageImpl<>( + List.of(), + PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()), + 0 + ); + return PageResponse.from(empty); + } + + List 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 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 userNamespaceRoles) { + Map 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); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index 5670ebef..e7f86d45 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -154,7 +154,8 @@ public class SkillLifecycleAppService { skillVersion.getVersion(), targetVersion, userId, - normalizeRoles(userNamespaceRoles) + normalizeRoles(userNamespaceRoles), + request.confirmWarnings() ); auditLogService.record( userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 57a1145a..820980bb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -122,10 +122,8 @@ public class SkillSearchAppService { } private boolean hasPlatformWideReadAccess(Set 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( diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index db4397f8..0cc32ec4 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1fea61ca..942d976a 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 05bd0905..bc94ca3b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java new file mode 100644 index 00000000..c5921f59 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -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"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java new file mode 100644 index 00000000..eb731e82 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java @@ -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()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 1a1e2a6c..a78c7d73 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java new file mode 100644 index 00000000..3500ce04 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java @@ -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); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 47e77a31..a25d3104 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -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()); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 76775b1f..404d6419 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index 4bdc7899..72b5d26d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 677336c7..e47da366 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -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; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java index ab4ddb2b..eb5f87d6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java @@ -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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index 91f6daee..1bd5f879 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -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 indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index acf5a24f..09751065 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -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) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index 53c8367a..ae5fd27b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -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.>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.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java index 13c0be27..38373f64 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -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") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 7108fc40..6091a905 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 57710c88..fdac46f8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -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) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java index 163694f4..6e28aa68 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -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; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java index c578baa3..20ca3fd6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java @@ -31,12 +31,14 @@ public class CustomOAuth2UserService implements OAuth2UserService(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(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"); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java new file mode 100644 index 00000000..d6c840a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java @@ -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. + * + *

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 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 emails = restClient.get() + .uri(baseUrl + "/user/emails") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue()) + .retrieve() + .body(new org.springframework.core.ParameterizedTypeReference>() {}); + + 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(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 498d2010..8235032c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -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/**") ); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java index 3908ab71..4b811d36 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -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, diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java new file mode 100644 index 00000000..643be583 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 6bf8c098..cb8ef84c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -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 diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8c227dd3..f6c8d742 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -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)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index bc5dae42..c3c125b1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -28,7 +28,8 @@ public class ReviewPermissionChecker { Map userNamespaceRoles, Set 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 userNamespaceRoles) { + if (namespaceType == NamespaceType.GLOBAL) { + return false; + } + + NamespaceRole role = userNamespaceRoles.get(namespaceId); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index f4632159..65c8e753 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -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 userNamespaceRoles) { + return canAccess(skill, currentUserId, userNamespaceRoles, Set.of()); + } + + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles, Set 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 platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 07ca9735..4cf72082 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -156,7 +156,8 @@ public class SkillPublishService { String sourceVersion, String targetVersion, String publisherId, - Map userNamespaceRoles) { + Map 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 ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0a64260c..8bf88767 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -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 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 userNsRoles, + Set platformRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles); + } + /** * Lists skills within a namespace after filtering out records the caller is * not allowed to discover. diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 21fc18dc..25ae2f70 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -26,13 +26,37 @@ class ReviewPermissionCheckerTest { } @Test - void skillAdminCannotReviewOwnSubmission() { + void skillAdminCannotReviewOwnSubmissionWithoutNamespaceRole() { String userId = "user-1"; ReviewTask task = new ReviewTask(1L, 10L, userId); assertFalse(checker.canReview(task, userId, NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } + @Test + void skillAdminNamespaceAdminCanReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of("SKILL_ADMIN"))); + } + + @Test + void skillAdminNamespaceOwnerCanReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(10L, NamespaceRole.OWNER), Set.of("SKILL_ADMIN"))); + } + + @Test + void skillAdminNamespaceMemberCannotReviewOwnSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertFalse(checker.canReview(task, userId, + NamespaceType.TEAM, Map.of(10L, NamespaceRole.MEMBER), Set.of("SKILL_ADMIN"))); + } + @Test void superAdminCannotReviewOwnSubmission() { String userId = "user-1"; @@ -57,6 +81,24 @@ class ReviewPermissionCheckerTest { NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN"))); } + @Test + void teamAdminCanReviewOwnTeamSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, + Map.of(10L, NamespaceRole.ADMIN), Set.of())); + } + + @Test + void teamOwnerCanReviewOwnTeamSubmission() { + String userId = "user-1"; + ReviewTask task = new ReviewTask(1L, 10L, userId); + assertTrue(checker.canReview(task, userId, + NamespaceType.TEAM, + Map.of(10L, NamespaceRole.OWNER), Set.of())); + } + @Test void teamAdminCanReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 90246ed1..f4fde339 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -483,6 +483,46 @@ class ReviewServiceTest { assertEquals(USER_ID, skill.getUpdatedBy()); } + @Test + void namespaceAdminCanApproveOwnSubmission() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview( + eq(task), + eq(USER_ID), + eq(ns.getType()), + eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)), + eq(Set.of()))) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, + ReviewTaskStatus.APPROVED, + USER_ID, + "self approved as namespace admin", + task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + ReviewTask result = reviewService.approveReview( + REVIEW_TASK_ID, + USER_ID, + "self approved as namespace admin", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), + Set.of()); + + assertNotNull(result); + assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); + assertEquals(USER_ID, skill.getUpdatedBy()); + } + @Test void shouldThrowOnConcurrentModification() { ReviewTask task = createPendingReviewTask(); @@ -602,6 +642,43 @@ class ReviewServiceTest { assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); } + @Test + void namespaceAdminCanRejectOwnSubmission() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview( + eq(task), + eq(USER_ID), + eq(ns.getType()), + eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)), + eq(Set.of()))) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, + ReviewTaskStatus.REJECTED, + USER_ID, + "self rejected as namespace admin", + task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + ReviewTask result = reviewService.rejectReview( + REVIEW_TASK_ID, + USER_ID, + "self rejected as namespace admin", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), + Set.of()); + + assertNotNull(result); + assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); + } + @Test void shouldThrowOnConcurrentModification() { ReviewTask task = createPendingReviewTask(); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 1ffe5c8f..af26901a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -158,4 +159,34 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles); assertTrue(canAccess); } + + @Test + void testSuperAdminCanAccessPrivateSkill() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testSuperAdminCanAccessHiddenSkill() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testSuperAdminCanAccessUnpublishedSkill() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testNonSuperAdminPlatformRolesDoNotGrantAccess() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER")); + assertFalse(canAccess); + } + + @Test + void testEmptyPlatformRolesDoNotGrantAccess() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of()); + assertFalse(canAccess); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index ae091889..caf6fe0c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -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 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"; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index a8c8042f..7ee683ac 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -30,6 +30,7 @@ import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -50,7 +51,6 @@ class SkillQueryServiceTest { private SkillTagRepository skillTagRepository; @Mock private ObjectStorageService objectStorageService; - @Mock private VisibilityChecker visibilityChecker; @Mock private PromotionRequestRepository promotionRequestRepository; @@ -65,6 +65,7 @@ class SkillQueryServiceTest { @BeforeEach void setUp() { + visibilityChecker = new VisibilityChecker(); skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository); service = new SkillQueryService( @@ -105,7 +106,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null))); @@ -148,7 +148,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill)); - when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -176,7 +175,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); // Act & Assert assertThrows(DomainForbiddenException.class, () -> @@ -215,13 +213,11 @@ class SkillQueryServiceTest { setId(namespace, 1L); Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC); setId(skill1, 1L); - Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE); + Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE); setId(skill2, 2L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2)); - when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false); // Act Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -267,8 +263,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) .thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill)); - when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false); Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -296,8 +290,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) .thenReturn(List.of(visibleSkill, hiddenSkill)); - when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false); Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -325,7 +317,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); when(objectStorageService.exists("key1")).thenReturn(true); @@ -358,7 +349,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); assertThrows(DomainBadRequestException.class, () -> @@ -385,7 +375,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); when(objectStorageService.exists(file.getStorageKey())).thenReturn(true); @@ -419,7 +408,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile)); when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true); @@ -482,7 +470,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( @@ -516,7 +503,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); when(objectStorageService.exists("storage-key")).thenReturn(true); @@ -555,7 +541,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected)); Page result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20)); @@ -589,7 +574,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of(version100, version110)); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110)); @@ -631,7 +615,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); @@ -664,7 +647,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -691,7 +673,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty()); @@ -723,7 +704,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); @@ -753,7 +733,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)) @@ -784,7 +763,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -793,6 +771,56 @@ class SkillQueryServiceTest { assertFalse(result.canSubmitPromotion()); } + @Test + void testGetSkillDetail_ShouldNotGrantLifecyclePermissionToSuperAdminInPortal() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "super-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail( + namespaceSlug, skillSlug, userId, userNsRoles, Set.of("SUPER_ADMIN")); + + assertFalse(result.canManageLifecycle()); + assertFalse(result.canSubmitPromotion()); + assertEquals("PUBLISHED", result.resolutionMode()); + } + + @Test + void testGetSkillDetail_ShouldNotGrantPrivateVisibilityToSuperAdminInPortal() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String userId = "super-1"; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PRIVATE); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, userId, Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception { String namespaceSlug = "test-ns"; @@ -813,7 +841,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of()); @@ -853,7 +880,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); @@ -889,7 +915,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of()); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected)); @@ -925,7 +950,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( @@ -960,7 +984,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); when(objectStorageService.exists("storage-key")).thenReturn(true); @@ -992,7 +1015,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); assertThrows(DomainBadRequestException.class, () -> @@ -1024,7 +1046,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published)); Page result = service.listVersions( @@ -1059,7 +1080,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); Page result = service.listVersions( diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index e6888827..ec0cfa37 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { Set 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) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index ea33092b..0025f2ec 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -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 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 diff --git a/web/e2e/helpers/review-seed.ts b/web/e2e/helpers/review-seed.ts new file mode 100644 index 00000000..683ad2b5 --- /dev/null +++ b/web/e2e/helpers/review-seed.ts @@ -0,0 +1,64 @@ +import type { Browser, Page, TestInfo } from '@playwright/test' +import { loginWithCredentials, registerSession } from './session' +import { E2eTestDataBuilder, type SeededReviewData } from './test-data-builder' + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +function matchCandidateUsername( + candidate: { userId: string; displayName: string; email?: string }, + username: string, +) { + return candidate.userId === username + || candidate.displayName === username + || candidate.email === `${username}@example.test` +} + +export async function createNamespaceReviewData( + browser: Browser, + page: Page, + testInfo: TestInfo, +): Promise Promise }> { + const credentials = await registerSession(page, testInfo, { allowMockSession: false }) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + const namespace = await adminBuilder.createNamespace('e2e-team') + const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username) + const matchedCandidate = candidates.find((candidate) => matchCandidateUsername(candidate, credentials.username)) ?? candidates[0] + + if (!matchedCandidate) { + throw new Error(`No namespace member candidate found for review actor ${credentials.username}`) + } + + await adminBuilder.addNamespaceMember(namespace.slug, matchedCandidate.userId, 'ADMIN') + const skill = await builder.publishSkill(namespace.slug) + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + + return { + namespace, + skill, + reviewTaskId, + cleanup: async () => { + await builder.cleanup() + await adminBuilder.cleanup() + await adminContext.close() + }, + } +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 6b6ee60f..763ef1c3 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -10,6 +10,10 @@ export interface TestCredentials { username: string } +interface RegisterSessionOptions { + allowMockSession?: boolean +} + interface SessionSnapshot { username: string cookies: Array<{ @@ -160,7 +164,7 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us return { username: 'local-user', password } } -async function registerSessionOnce(page: Page, testInfo?: TestInfo) { +async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) { const worker = testInfo?.parallelIndex ?? 0 const cached = cachedUserByWorker.get(worker) const username = usernameForWorker(testInfo) @@ -175,9 +179,11 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { return { username: restored.username, password } } - const mockSession = await tryBootstrapMockSession(page, worker) - if (mockSession) { - return mockSession + if (options?.allowMockSession !== false) { + const mockSession = await tryBootstrapMockSession(page, worker) + if (mockSession) { + return mockSession + } } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. @@ -317,12 +323,12 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { throw new Error(`Failed to create fresh e2e session for worker ${worker}`) } -export async function registerSession(page: Page, testInfo?: TestInfo) { +export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) { let lastError: unknown for (let attempt = 0; attempt < 3; attempt += 1) { try { - return await registerSessionOnce(page, testInfo) + return await registerSessionOnce(page, testInfo, options) } catch (error) { lastError = error if (attempt < 2) { diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 7d7d4e97..0123468b 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -10,6 +10,11 @@ export interface SeededNamespace { id: number slug: string displayName: string + status?: string + type?: string + currentUserRole?: string + canUnfreeze?: boolean + canRestore?: boolean } export interface SeededSkill { @@ -34,6 +39,13 @@ interface ReviewTaskSummary { version: string } +interface NamespaceCandidate { + userId: string + displayName: string + email?: string + status: string +} + interface ApiEnvelope { code: number msg: string @@ -45,6 +57,8 @@ interface ApiFailure extends Error { code?: number } +const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000 + export interface SeedSkillOptions { name?: string description?: string @@ -65,6 +79,24 @@ function uniqueSuffix(testInfo?: TestInfo): string { return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } +async function runCleanupTaskWithTimeout(task: CleanupTask): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`)) + }, cleanupTimeoutMs) + + void task() + .then(() => { + clearTimeout(timeout) + resolve() + }) + .catch((error) => { + clearTimeout(timeout) + reject(error) + }) + }) +} + function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) { const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48) const description = options?.description || 'E2E generated skill for real-request tests' @@ -166,7 +198,7 @@ export class E2eTestDataBuilder { async cleanup(): Promise { for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) { try { - await this.cleanupTasks[i]() + await runCleanupTaskWithTimeout(this.cleanupTasks[i]) } catch { // Best-effort cleanup for E2E environments. } @@ -207,6 +239,34 @@ export class E2eTestDataBuilder { ) } + private isTeamNamespace(namespace: SeededNamespace): boolean { + return namespace.type === 'TEAM' || namespace.slug !== 'global' + } + + private isActiveNamespace(namespace: SeededNamespace): boolean { + return namespace.status === 'ACTIVE' + } + + private async activateNamespace(namespace: SeededNamespace): Promise { + if (!this.isTeamNamespace(namespace)) { + return null + } + + if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { + return parseEnvelope( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`), + ) + } + + if (namespace.status === 'ARCHIVED' && namespace.canRestore) { + return parseEnvelope( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`), + ) + } + + return null + } + async ensureWritableNamespace(): Promise { if (this.ensuredNamespace) { return this.ensuredNamespace @@ -224,12 +284,75 @@ export class E2eTestDataBuilder { } const namespaces = await this.listMyNamespaces() - const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0] - if (!writable) { - throw new Error('No namespace available for e2e data seeding') + const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item)) + if (activeTeam) { + this.ensuredNamespace = activeTeam + return activeTeam } - this.ensuredNamespace = writable - return writable + + const activeFallback = namespaces.find((item) => this.isActiveNamespace(item)) + if (activeFallback) { + this.ensuredNamespace = activeFallback + return activeFallback + } + + const activatable = namespaces.find((item) => + this.isTeamNamespace(item) + && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)), + ) + if (activatable) { + const activated = await this.activateNamespace(activatable) + if (activated) { + this.ensuredNamespace = activated + return activated + } + } + + const summary = namespaces + .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`) + .join(', ') + throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`) + } + + async ensureReviewableNamespace(): Promise { + if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(this.ensuredNamespace)) { + return this.ensuredNamespace + } + + try { + const created = await this.createNamespace('e2e-team') + this.ensuredNamespace = created + return created + } catch (error) { + const failure = error as ApiFailure + if (failure.status !== 403) { + throw error + } + } + + const namespaces = await this.listMyNamespaces() + const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item)) + if (activeTeam) { + this.ensuredNamespace = activeTeam + return activeTeam + } + + const activatable = namespaces.find((item) => + this.isTeamNamespace(item) + && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)), + ) + if (activatable) { + const activated = await this.activateNamespace(activatable) + if (activated) { + this.ensuredNamespace = activated + return activated + } + } + + const summary = namespaces + .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`) + .join(', ') + throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`) } private async getMySkillInNamespace(namespaceSlug: string): Promise { @@ -350,6 +473,21 @@ export class E2eTestDataBuilder { ) } + async searchNamespaceMemberCandidates(slug: string, search: string): Promise { + const query = new URLSearchParams({ search }) + return parseEnvelope( + await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`), + ) + } + + async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise { + await parseEnvelope<{ userId: string; role: string }>( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, { + data: { userId, role }, + }), + ) + } + async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise { const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` const zipBuffer = buildSkillPackageZipBuffer(unique, options) @@ -384,7 +522,7 @@ export class E2eTestDataBuilder { } async createReviewData(): Promise { - const namespace = await this.ensureWritableNamespace() + const namespace = await this.ensureReviewableNamespace() const skill = await this.publishSkill(namespace.slug) return { namespace, skill } } diff --git a/web/e2e/namespace-review-detail-access.spec.ts b/web/e2e/namespace-review-detail-access.spec.ts new file mode 100644 index 00000000..1daac06d --- /dev/null +++ b/web/e2e/namespace-review-detail-access.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { createNamespaceReviewData } from './helpers/review-seed' + +test.describe('Namespace Review Detail Access (Real API)', () => { + test.describe.configure({ timeout: 120_000 }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('opens namespace review detail from the namespace review list', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined + try { + seeded = await createNamespaceReviewData(browser, page, testInfo) + + await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) + + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`)).toBeVisible() + + await page.getByRole('link', { name: 'Open review' }).first().click() + + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/\\d+$`)) + await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`).first()).toBeVisible() + } finally { + await seeded?.cleanup() + } + }) + + test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined + try { + seeded = await createNamespaceReviewData(browser, page, testInfo) + + await page.goto('/dashboard/reviews') + + await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/) + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + } finally { + await seeded?.cleanup() + } + }) + + test('redirects namespace review detail opened through the global detail route', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined + try { + seeded = await createNamespaceReviewData(browser, page, testInfo) + + await page.goto(`/dashboard/reviews/${seeded.reviewTaskId}`) + + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${seeded.reviewTaskId}$`)) + await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() + } finally { + await seeded?.cleanup() + } + }) +}) diff --git a/web/e2e/namespace-reviews-data.spec.ts b/web/e2e/namespace-reviews-data.spec.ts index a173ecaa..b2cad5c6 100644 --- a/web/e2e/namespace-reviews-data.spec.ts +++ b/web/e2e/namespace-reviews-data.spec.ts @@ -1,26 +1,24 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' -import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { createNamespaceReviewData } from './helpers/review-seed' test.describe('Namespace Reviews Data (Real API)', () => { - test.beforeEach(async ({ page }, testInfo) => { + test.describe.configure({ timeout: 120_000 }) + + test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - await registerSession(page, testInfo) }) - test('opens namespace reviews page with seeded review data context', async ({ page }, testInfo) => { - const builder = new E2eTestDataBuilder(page, testInfo) - await builder.init() - + test('opens namespace reviews page with seeded review data context', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined try { - const seeded = await builder.createReviewData() + seeded = await createNamespaceReviewData(browser, page, testInfo) await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible() } finally { - await builder.cleanup() + await seeded?.cleanup() } }) }) diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts new file mode 100644 index 00000000..56ba8885 --- /dev/null +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { getSearchCard, prepareSearchSeed, type PreparedSearchSeed } from './helpers/search-seed' + +const SEARCH_URL = (q: string) => `/search?q=${encodeURIComponent(q)}&sort=relevance&page=0&starredOnly=false` + +function latestSeed(seed: PreparedSearchSeed) { + return { + skill: seed.skills[seed.skills.length - 1], + skillName: seed.skillNames[seed.skillNames.length - 1], + } +} + +let seeded: PreparedSearchSeed | undefined + +test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.beforeAll(async ({ browser }, testInfo) => { + seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) + }) + + test.afterAll(async () => { + await seeded?.dispose() + seeded = undefined + }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('allows anonymous users to open a public skill detail and view install content', async ({ page }) => { + const current = latestSeed(seeded!) + + await page.goto(SEARCH_URL(seeded!.keyword)) + const card = getSearchCard(page, current.skillName) + await expect(card).toBeVisible({ timeout: 15_000 }) + + await card.click() + + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`)) + await expect(page).not.toHaveURL(/\/login\?returnTo=/) + await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + await expect(page.getByText('Install', { exact: true })).toBeVisible() + await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible() + await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible() + }) +}) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index a1f1ef76..867c8248 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -1,8 +1,19 @@ import { expect, test } from '@playwright/test' +import path from 'node:path' import { setEnglishLocale } from './helpers/auth-fixtures' import { registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' +interface PublishEnvelope { + code: number + msg?: string + data: { + namespace: string + slug: string + version: string + } +} + test.describe('Publish Flow UI (Real API)', () => { test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) @@ -15,19 +26,48 @@ test.describe('Publish Flow UI (Real API)', () => { try { const namespace = await builder.ensureWritableNamespace() - const packagePath = builder.createSkillPackageFile() + const skillName = `publish-ui-${Date.now().toString(36)}` + const packagePath = builder.createSkillPackageFile({ name: skillName }) await page.goto('/dashboard/publish') await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() - await page.locator('#namespace').click() - await page.getByText(new RegExp(`\\(@${namespace.slug}\\)`)).first().click() + const namespaceTrigger = page.locator('#namespace') + await expect(namespaceTrigger).toBeVisible() + await namespaceTrigger.click() + const namespaceOption = page.getByRole('option', { + name: new RegExp(`\\(@${namespace.slug}\\)`), + }).first() + await expect(namespaceOption).toBeVisible() + await namespaceOption.evaluate((element: HTMLElement) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) await page.locator('input[type="file"]').setInputFiles(packagePath) - await page.getByRole('button', { name: 'Confirm Publish' }).click() + await expect(page.getByText(path.basename(packagePath))).toBeVisible() + const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) + await expect(confirmButton).toBeEnabled() + const publishResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`), + { timeout: 90_000 }, + ) + await confirmButton.click() + const publishResponse = await publishResponsePromise + const publishBody = await publishResponse.json() as PublishEnvelope - await expect(page).toHaveURL('/dashboard/skills') - await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + expect(publishResponse.status(), `publish failed: ${publishBody.msg ?? 'unknown error'}`).toBe(200) + expect(publishBody.code).toBe(0) + expect(publishBody.data.namespace).toBe(namespace.slug) + + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('heading', { name: skillName, exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByText(`@${publishBody.data.namespace}`).first()).toBeVisible() + await expect(page.getByText(`v${publishBody.data.version}`).first()).toBeVisible() } finally { await builder.cleanup() } diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts index 3fca443a..1deadc58 100644 --- a/web/e2e/search-card-interaction.spec.ts +++ b/web/e2e/search-card-interaction.spec.ts @@ -30,38 +30,61 @@ async function waitForCards(page: Page) { const keyword = basicSeed?.keyword const encodedKeyword = keyword ? encodeURIComponent(keyword) : null + let reloaded = false - for (let attempt = 0; attempt < 4; attempt += 1) { - await page.waitForLoadState('networkidle') + const waitForMatchingResponse = async () => { + if (!encodedKeyword) { + return + } + + await page.waitForResponse(async (response) => { + if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { + return false + } + if (response.status() !== 200) { + return false + } + + try { + const payload = await response.json() as { data?: { items?: Array } } + return Array.isArray(payload.data?.items) && payload.data.items.length > 0 + } catch { + return false + } + }, { timeout: 15_000 }).catch(() => null) + } + + const waitForCardCount = async () => { + await expect.poll( + async () => cards.count(), + { + timeout: 20_000, + intervals: [250, 500, 1_000, 2_000], + }, + ).toBeGreaterThan(0) + } + + await page.waitForLoadState('networkidle') + await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) + + if (await cards.count() > 0) { + return cards + } + + await waitForMatchingResponse() + + try { + await waitForCardCount() + } catch { + if (reloaded) { + throw new Error('Timed out waiting for search cards after one reload fallback') + } + + reloaded = true + await page.reload({ waitUntil: 'networkidle' }) await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) - - if (await cards.count() > 0) { - return cards - } - - if (attempt < 3) { - const responsePromise = encodedKeyword - ? page.waitForResponse(async (response) => { - if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { - return false - } - if (response.status() !== 200) { - return false - } - - try { - const payload = await response.json() as { data?: { items?: Array } } - return Array.isArray(payload.data?.items) && payload.data.items.length > 0 - } catch { - return false - } - }, { timeout: 12_000 }).catch(() => null) - : Promise.resolve(null) - - await page.waitForTimeout(750 * (attempt + 1)) - await page.reload({ waitUntil: 'networkidle' }) - await responsePromise - } + await waitForMatchingResponse() + await waitForCardCount() } return cards diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index f5775bfb..be14db8f 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -39,12 +39,11 @@ test.describe('Search Input (Real API)', () => { await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) }) - // TC_SEARCH_INPUT_003 P0 - empty search guidance - test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => { + // TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list + test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => { await page.goto(searchUrl('')) await expect(page).toHaveURL(/\/search/) - await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() - await expect(page.getByText('Please enter a search keyword')).toBeVisible() + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) }) // TC_SEARCH_INPUT_004 P0 - Enter key triggers search diff --git a/web/public/github-logo.svg b/web/public/github-logo.svg new file mode 100644 index 00000000..a1133401 --- /dev/null +++ b/web/public/github-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/public/gitlab-logo.svg b/web/public/gitlab-logo.svg new file mode 100644 index 00000000..71a666d0 --- /dev/null +++ b/web/public/gitlab-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c110c064..1e7351ff 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -488,14 +488,14 @@ export const skillLifecycleApi = { }) }, - async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ targetVersion }), + body: JSON.stringify({ targetVersion, confirmWarnings }), }) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index bfc1e4c9..d8714025 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3237,6 +3237,7 @@ export interface components { }; SkillVersionRereleaseRequest: { targetVersion: string; + confirmWarnings?: boolean; }; SkillReportSubmitRequest: { reason?: string; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d096987b..4095904d 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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): { 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, diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index ba0bc040..cde4453f 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -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 ( + {provider} + ) +} + /** * 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 }} > - - - + {t('loginButton.loginWith', { name: provider.displayName })} ))}

) } + diff --git a/web/src/features/review/review-paths.test.ts b/web/src/features/review/review-paths.test.ts new file mode 100644 index 00000000..bcd61200 --- /dev/null +++ b/web/src/features/review/review-paths.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + buildGlobalReviewsPath, + buildNamespaceReviewDetailPath, + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, + canAccessReviewCenter, + canManageNamespaceReviews, + getPreferredNamespaceReviewEntry, +} from './review-paths' + +describe('review-paths', () => { + it('builds the global reviews path', () => { + expect(buildGlobalReviewsPath()).toBe('/dashboard/reviews') + }) + + it('builds namespace review paths', () => { + expect(buildNamespaceReviewsPath('team alpha')).toBe('/dashboard/namespaces/team%20alpha/reviews') + expect(buildNamespaceReviewDetailPath('team alpha', 12)).toBe('/dashboard/namespaces/team%20alpha/reviews/12') + }) + + it('detects global review access from platform roles', () => { + expect(canAccessGlobalReviewCenter(['SKILL_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['USER_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['SUPER_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['USER'])).toBe(false) + }) + + it('recognizes namespace review managers', () => { + expect(canManageNamespaceReviews('OWNER')).toBe(true) + expect(canManageNamespaceReviews('ADMIN')).toBe(true) + expect(canManageNamespaceReviews('MEMBER')).toBe(false) + }) + + it('prefers active team namespaces for namespace review entry', () => { + expect(getPreferredNamespaceReviewEntry([ + { + id: 1, + slug: 'archived-team', + displayName: 'Archived Team', + type: 'TEAM', + status: 'ARCHIVED', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + { + id: 2, + slug: 'active-team', + displayName: 'Active Team', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'OWNER', + createdAt: '', + }, + ])?.slug).toBe('active-team') + }) + + it('returns null when no manageable namespace exists', () => { + expect(getPreferredNamespaceReviewEntry([ + { + id: 1, + slug: 'member-team', + displayName: 'Member Team', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'MEMBER', + createdAt: '', + }, + ])).toBeNull() + }) + + it('detects review center access from either platform roles or namespace roles', () => { + expect(canAccessReviewCenter(['SKILL_ADMIN'], [])).toBe(true) + expect(canAccessReviewCenter([], [ + { + id: 2, + slug: 'team-admin', + displayName: 'Team Admin', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + ])).toBe(true) + expect(canAccessReviewCenter([], [])).toBe(false) + }) +}) diff --git a/web/src/features/review/review-paths.ts b/web/src/features/review/review-paths.ts new file mode 100644 index 00000000..081f3c04 --- /dev/null +++ b/web/src/features/review/review-paths.ts @@ -0,0 +1,48 @@ +import type { ManagedNamespace, NamespaceRole } from '@/api/types' + +const GLOBAL_REVIEW_PLATFORM_ROLES = ['SKILL_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'] as const + +export function buildGlobalReviewsPath() { + return '/dashboard/reviews' +} + +export function buildNamespaceReviewsPath(slug: string) { + return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` +} + +export function buildNamespaceReviewDetailPath(slug: string, reviewId: number) { + return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews/${reviewId}` +} + +export function canAccessGlobalReviewCenter(platformRoles?: readonly string[]) { + return GLOBAL_REVIEW_PLATFORM_ROLES.some((role) => platformRoles?.includes(role)) +} + +export function canManageNamespaceReviews(role?: NamespaceRole) { + return role === 'OWNER' || role === 'ADMIN' +} + +export function getPreferredNamespaceReviewEntry( + namespaces?: readonly ManagedNamespace[], +) { + if (!namespaces?.length) { + return null + } + + const manageableNamespaces = namespaces.filter((namespace) => + namespace.type === 'TEAM' && canManageNamespaceReviews(namespace.currentUserRole), + ) + if (manageableNamespaces.length === 0) { + return null + } + + const activeNamespace = manageableNamespaces.find((namespace) => namespace.status === 'ACTIVE') + return activeNamespace ?? manageableNamespaces[0] +} + +export function canAccessReviewCenter( + platformRoles?: readonly string[], + namespaces?: readonly ManagedNamespace[], +) { + return canAccessGlobalReviewCenter(platformRoles) || getPreferredNamespaceReviewEntry(namespaces) !== null +} diff --git a/web/src/features/skill/version-status-badge.tsx b/web/src/features/skill/version-status-badge.tsx new file mode 100644 index 00000000..0a518b04 --- /dev/null +++ b/web/src/features/skill/version-status-badge.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +type VersionStatus = + | 'DRAFT' + | 'SCANNING' + | 'SCAN_FAILED' + | 'UPLOADED' + | 'PENDING_REVIEW' + | 'PUBLISHED' + | 'REJECTED' + | 'YANKED' + +const statusStyles: Record = { + PUBLISHED: + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', + UPLOADED: + 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400', + PENDING_REVIEW: + 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', + REJECTED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + SCANNING: + 'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400', + SCAN_FAILED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + YANKED: + 'border-border/60 bg-secondary/40 text-muted-foreground', + DRAFT: + 'border-border/60 bg-secondary/40 text-muted-foreground', +} + +const i18nKeys: Record = { + DRAFT: 'skillDetail.versionStatusDraft', + SCANNING: 'skillDetail.versionStatusScanning', + SCAN_FAILED: 'skillDetail.versionStatusScanFailed', + UPLOADED: 'skillDetail.versionStatusUploaded', + PENDING_REVIEW: 'skillDetail.versionStatusPendingReview', + PUBLISHED: 'skillDetail.versionStatusPublished', + REJECTED: 'skillDetail.versionStatusRejected', + YANKED: 'skillDetail.versionStatusYanked', +} + +/** Color-coded row styles (left-border + subtle background) for version cards. */ +export const versionRowStyles: Record = { + UPLOADED: + 'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]', + PENDING_REVIEW: + 'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]', + REJECTED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + SCANNING: + 'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]', + SCAN_FAILED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + PUBLISHED: '', + YANKED: '', + DRAFT: '', +} + +export function getVersionRowStyle(status?: string): string { + if (!status) return '' + return versionRowStyles[status as VersionStatus] ?? '' +} + +export function VersionStatusBadge({ + status, + className, +}: { + status?: string + className?: string +}) { + const { t } = useTranslation() + if (!status) return null + + const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT + const label = i18nKeys[status as VersionStatus] + ? t(i18nKeys[status as VersionStatus]) + : status + + return ( + + {label} + + ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6f095b4f..ef1a3ef3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 14fd66fc..3d7f9fa0 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "下一页", diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 8e031088..735307ca 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -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) + } + }) }) diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 009d5f4f..fb995e8a 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -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>({ PENDING: 0, APPROVED: 0, @@ -24,9 +26,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { }) const [activeStatus, setActiveStatus] = useState('PENDING') const [sortDirection, setSortDirection] = useState('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 ? (

{review.reviewComment}

) : null} +
+ + {t('nsReviews.openReview')} + +
))} {query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null} @@ -151,7 +161,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} - + ) } diff --git a/web/src/pages/dashboard/review-detail.test.tsx b/web/src/pages/dashboard/review-detail.test.tsx index 84ba117b..6b8cde31 100644 --- a/web/src/pages/dashboard/review-detail.test.tsx +++ b/web/src/pages/dashboard/review-detail.test.tsx @@ -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() + + 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() + + 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() + + 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: { diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 72a2a8df..959d5af3 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -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(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 (
@@ -121,6 +156,23 @@ export function ReviewDetailPage() { ) } + const hasNamespaceMismatch = Boolean(namespaceSlug && review.namespace !== namespaceSlug) + + if (hasNamespaceMismatch) { + return ( +
+
+

{t('review.notFound')}

+
+
+ +
+
+ ) + } + const reviewFiles = reviewSkillDetail?.files const activeReviewVersion = reviewSkillDetail?.versions?.find( (version) => version.version === reviewSkillDetail.activeVersion @@ -136,7 +188,7 @@ export function ReviewDetailPage() {

{t('review.detail')}

{t('review.id')}: {review.id}

- @@ -363,3 +415,26 @@ export function ReviewDetailPage() { ) } + +export function ReviewDetailPage() { + const { id } = useParams({ from: '/dashboard/reviews/$id' }) + + return ( + + ) +} + +export function NamespaceReviewDetailPage() { + const { id, slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews/$id' }) + + return ( + + ) +} diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 992c102b..694a0dce 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -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 } diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 426da9b8..4ead6c84 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -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>({ 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 ( +
+ + + Loading... + +
+ ) + } + return (
diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index 726b5553..a921d5d3 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -46,7 +46,13 @@ vi.mock('@/shared/components/skeleton-loader', () => ({ })) vi.mock('@/shared/components/empty-state', () => ({ - EmptyState: () =>
empty-state
, + EmptyState: ({ title, description }: { title: string; description?: string }) => ( +
+ empty-state + {title} + {description ? {description} : null} +
+ ), })) 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() + + 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() + + expect(html).toContain('empty-state') + expect(html).toContain('search.noResults') + expect(html).not.toContain('search.enterKeyword') + }) }) diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 6258ca19..58c421db 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -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 (
@@ -313,11 +311,9 @@ export function SearchPage() { )} diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 067a171f..87bdcb48 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -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() + + 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({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index d28b4f75..19f64983 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -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(null) const [rereleaseTarget, setRereleaseTarget] = useState(null) const [targetVersionInput, setTargetVersionInput] = useState('') + const [rereleaseWarnings, setRereleaseWarnings] = useState([]) + const [rereleaseWarningDialogOpen, setRereleaseWarningDialogOpen] = useState(false) const [diffSourceVersion, setDiffSourceVersion] = useState(null) const [confirmPublishTarget, setConfirmPublishTarget] = useState(null) const [submitReviewTarget, setSubmitReviewTarget] = useState(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() {
{skill.status && ( - + {resolveSkillStatusLabel(skill.status)} )} + {skill.visibility && ( + + {skill.visibility === 'PUBLIC' && } + {skill.visibility === 'PRIVATE' && } + {skill.visibility === 'NAMESPACE_ONLY' && } + {skill.visibility === 'PUBLIC' && t('publish.visibilityOptions.public')} + {skill.visibility === 'PRIVATE' && t('publish.visibilityOptions.private')} + {skill.visibility === 'NAMESPACE_ONLY' && t('publish.visibilityOptions.namespaceOnly')} + + )} {isReviewFlowPending && ( {t('skillDetail.versionStatusPendingReview')} @@ -957,7 +990,7 @@ export function SkillDetailPage() { ))}
) : ( -
{t('skillDetail.noVersions')}
+ {t('skillDetail.noVersions')} )} @@ -1396,6 +1429,8 @@ export function SkillDetailPage() { if (!open) { setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } }} > @@ -1422,13 +1457,34 @@ export function SkillDetailPage() { - + +

{t('skillDetail.rereleaseWarningDescription')}

+
    + {rereleaseWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ } + confirmText={t('skillDetail.rereleaseWarningConfirm')} + onConfirm={() => { + setRereleaseWarningDialogOpen(false) + handleRereleaseVersion(true) + }} + /> + { diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index d1a9cec0..f0281a83 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -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(null) const closeTimerRef = useRef(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) { {t('user.menu.stars')} - {canAccessReviewCenter ? ( - + {reviewCenterVisible ? ( + {t('user.menu.reviews')} ) : null} diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 4784ada5..cf19d18c 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -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] })