diff --git a/.env.release.draft b/.env.release.draft index 8a0c28e2..40058266 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -80,3 +80,16 @@ DEVICE_AUTH_VERIFICATION_URI= # Leave both empty if you are not enabling GitHub login yet. OAUTH2_GITHUB_CLIENT_ID= OAUTH2_GITHUB_CLIENT_SECRET= + +# SMTP configuration for password reset verification emails. +SPRING_MAIL_HOST=smtp.example.com +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME=TODO_fill_smtp_username +SPRING_MAIL_PASSWORD=TODO_fill_smtp_password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub diff --git a/.env.release.example b/.env.release.example index 366409e5..9b863c23 100644 --- a/.env.release.example +++ b/.env.release.example @@ -56,6 +56,19 @@ DEVICE_AUTH_VERIFICATION_URI= OAUTH2_GITHUB_CLIENT_ID= OAUTH2_GITHUB_CLIENT_SECRET= +# SMTP configuration for password reset verification emails. +SPRING_MAIL_HOST= +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub + # Security scanner is enabled by default. Set to false to disable scanning. SKILLHUB_SECURITY_SCANNER_ENABLED=true diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml new file mode 100644 index 00000000..b9bc7c21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/reward-task.yml @@ -0,0 +1,48 @@ +name: 💰 Reward Task +description: Task issue with Reward +title: '[Reward] ' +labels: + - reward +body: + - type: textarea + id: description + attributes: + label: Task description + validations: + required: true + + - type: dropdown + id: currency + attributes: + label: Reward currency + options: + - 'USD $' + - 'CAD C$' + - 'AUD A$' + - 'GBP £' + - 'EUR €' + - 'CNY ¥' + - 'HKD HK$' + - 'TWD NT$' + - 'SGD S$' + - 'KRW ₩' + - 'JPY ¥' + - 'INR ₹' + - 'UAH ₴' + validations: + required: true + + - type: input + id: amount + attributes: + label: Reward amount + validations: + required: true + + - type: input + id: payer + attributes: + label: Reward payer + description: GitHub username of the payer (optional, defaults to issue creator) + validations: + required: false diff --git a/.github/scripts/count-reward.ts b/.github/scripts/count-reward.ts new file mode 100644 index 00000000..a6448cbc --- /dev/null +++ b/.github/scripts/count-reward.ts @@ -0,0 +1,66 @@ +import { $, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const rawTags = + await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`; + +const lastMonth = new Date(); +lastMonth.setMonth(lastMonth.getMonth() - 1); +const lastMonthStr = lastMonth.toJSON().slice(0, 7); + +const rewardTags = rawTags.stdout + .split("\n") + .filter((line) => line.split(/\s+/)[1] >= lastMonthStr) + .map((line) => line.split(/\s+/)[0]); + +let rawYAML = ""; + +for (const tag of rewardTags) + rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + "\n"; + +if (!rawYAML.trim()) { + console.warn("No reward data is found for the last month."); + + process.exit(0); +} + +const rewards = YAML.parse(rawYAML) as Reward[]; + +const groupedRewards = Object.groupBy(rewards, ({ payee }) => payee); + +const summaryList = Object.entries(groupedRewards).map(([payee, rewards]) => { + const reward = rewards!.reduce( + (acc, { currency, reward }) => { + acc[currency] ??= 0; + acc[currency] += reward; + return acc; + }, + {} as Record, + ); + + return { + payee, + reward, + accounts: rewards!.map(({ payee: _, ...account }) => account), + }; +}); + +const summaryText = YAML.stringify(summaryList); + +console.log(summaryText); + +const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`; + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + +await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`; +await $`git push origin --tags --no-verify`; + +await $`git config unset user.name`; +await $`git config unset user.email`; + +await $`gh release create ${tagName} --notes ${summaryText}`; diff --git a/.github/scripts/deno.json b/.github/scripts/deno.json new file mode 100644 index 00000000..6950ade9 --- /dev/null +++ b/.github/scripts/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} \ No newline at end of file diff --git a/.github/scripts/github.ts b/.github/scripts/github.ts new file mode 100644 index 00000000..7b9778b5 --- /dev/null +++ b/.github/scripts/github.ts @@ -0,0 +1,230 @@ +interface GitHubUser { + login: string; +} + +interface GitHubLabelRef { + name?: string; +} + +export interface GitHubIssue { + number: number; + title: string; + body: string | null; + state: string; + labels: GitHubLabelRef[]; + comments: number; + created_at: string; + updated_at: string; + user: GitHubUser; + html_url: string; + pull_request?: Record; +} + +export interface GitHubIssueComment { + id: number; + body: string; + user: GitHubUser; + created_at: string; + updated_at: string; + html_url: string; +} + +export interface GitHubLabelDefinition { + name: string; + color: string; + description: string; +} + +function buildApiUrl(path: string) { + return `https://api.github.com${path}`; +} + +export class GitHubClient { + constructor( + private readonly token: string, + private readonly owner: string, + private readonly repo: string, + ) {} + + async getIssue(issueNumber: number): Promise { + return this.request( + "GET", + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}`, + ); + } + + async listIssueComments(issueNumber: number): Promise { + return this.paginate( + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}/comments?per_page=100`, + ); + } + + async listOpenIssuesByLabel( + label: string, + limit = 0, + ): Promise { + const collected: GitHubIssue[] = []; + const unlimited = limit === 0; + let page = 1; + + while (unlimited || collected.length < limit) { + const pageItems = await this.request( + "GET", + `/repos/${this.owner}/${this.repo}/issues?state=open&labels=${ + encodeURIComponent(label) + }&per_page=100&page=${page}`, + ); + + const nonPrIssues = pageItems.filter((item) => !item.pull_request); + collected.push(...nonPrIssues); + + if (pageItems.length < 100) { + break; + } + + page += 1; + } + + return unlimited ? collected : collected.slice(0, limit); + } + + async replaceIssueLabels(issueNumber: number, labels: string[]) { + await this.request( + "PUT", + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}/labels`, + { labels }, + ); + } + + async upsertLabel(definition: GitHubLabelDefinition) { + const encodedName = encodeURIComponent(definition.name); + + try { + await this.request( + "PATCH", + `/repos/${this.owner}/${this.repo}/labels/${encodedName}`, + { + new_name: definition.name, + color: definition.color, + description: definition.description, + }, + ); + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) { + throw error; + } + + await this.request("POST", `/repos/${this.owner}/${this.repo}/labels`, { + name: definition.name, + color: definition.color, + description: definition.description, + }); + } + } + + async createIssueComment(issueNumber: number, body: string) { + return this.request( + "POST", + `/repos/${this.owner}/${this.repo}/issues/${issueNumber}/comments`, + { body }, + ); + } + + async updateIssueComment(commentId: number, body: string) { + return this.request( + "PATCH", + `/repos/${this.owner}/${this.repo}/issues/comments/${commentId}`, + { body }, + ); + } + + private async paginate(path: string): Promise { + const collected: T[] = []; + let nextPath: string | null = path; + + while (nextPath) { + const response = await fetch(buildApiUrl(nextPath), { + headers: this.headers(), + }); + + if (!response.ok) { + throw await GitHubApiError.fromResponse(response); + } + + const pageItems = (await response.json()) as T[]; + collected.push(...pageItems); + nextPath = parseNextLink(response.headers.get("link")); + } + + return collected; + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await fetch(buildApiUrl(path), { + method, + headers: this.headers(), + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + throw await GitHubApiError.fromResponse(response); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + private headers() { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "User-Agent": "skillhub-issue-triage", + "X-GitHub-Api-Version": "2022-11-28", + }; + } +} + +export class GitHubApiError extends Error { + constructor( + readonly status: number, + readonly responseBody: string, + ) { + super(`GitHub API request failed with status ${status}: ${responseBody}`); + } + + static async fromResponse(response: Response) { + return new GitHubApiError(response.status, await response.text()); + } +} + +function parseNextLink(linkHeader: string | null) { + if (!linkHeader) { + return null; + } + + const nextEntry = linkHeader + .split(",") + .map((item) => item.trim()) + .find((item) => item.endsWith('rel="next"')); + + if (!nextEntry) { + return null; + } + + const urlMatch = nextEntry.match(/<([^>]+)>/); + + if (!urlMatch) { + return null; + } + + const url = new URL(urlMatch[1]); + return `${url.pathname}${url.search}`; +} diff --git a/.github/scripts/issue-backlog-rescore.ts b/.github/scripts/issue-backlog-rescore.ts new file mode 100644 index 00000000..dd0cf9c0 --- /dev/null +++ b/.github/scripts/issue-backlog-rescore.ts @@ -0,0 +1,128 @@ +import { GitHubClient } from "./github.ts"; +import { readIssueLlmConfig, shouldUseLlm } from "./issue-llm-config.ts"; +import { evaluateIssueWithLlm } from "./issue-llm-evaluator.ts"; +import { TRIAGE_MANUAL_OVERRIDE_LABEL } from "./issue-triage-config.ts"; +import { + analyzeIssue, + buildManagedLabels, + ensureManagedLabels, + findTriageComment, + parseTriageMachineState, + previewTriageMutation, + syncManagedLabels, + upsertTriageComment, +} from "./issue-triage-lib.ts"; +import { mergeRuleAndLlm } from "./issue-triage-merge.ts"; + +function readFlag(name: string) { + const index = Deno.args.indexOf(`--${name}`); + return index >= 0 ? Deno.args[index + 1] : undefined; +} + +function hasFlag(name: string) { + return Deno.args.includes(`--${name}`); +} + +const owner = readFlag("owner"); +const repo = readFlag("repo"); +const limitValue = readFlag("limit") ?? "0"; +const dryRun = hasFlag("dry-run"); +const token = Deno.env.get("GH_TOKEN") ?? Deno.env.get("GITHUB_TOKEN"); + +if (!owner || !repo || !token) { + throw new Error( + "Usage: deno run issue-backlog-rescore.ts --owner --repo [--limit 0 for all] with GH_TOKEN set.", + ); +} + +const limit = Number.parseInt(limitValue, 10); + +if (Number.isNaN(limit) || limit < 0) { + throw new Error(`Invalid limit: ${limitValue}`); +} + +const client = new GitHubClient(token, owner, repo); +if (!dryRun) { + await ensureManagedLabels(client); +} +const llmConfig = readIssueLlmConfig(); + +const issues = await client.listOpenIssuesByLabel("triage/deferred", limit); +const dryRunResults: Array> = []; + +for (const issue of issues) { + if ( + issue.labels.some((label) => label.name === TRIAGE_MANUAL_OVERRIDE_LABEL) + ) { + console.log( + `Skipping #${issue.number} because ${TRIAGE_MANUAL_OVERRIDE_LABEL} is set.`, + ); + continue; + } + + const comments = await client.listIssueComments(issue.number); + const ruleResult = analyzeIssue(issue, comments); + const existingComment = findTriageComment(comments); + const previousState = existingComment + ? parseTriageMachineState(existingComment.body) + : null; + let result = ruleResult; + + if (llmConfig) { + const llmDecision = shouldUseLlm(issue, ruleResult); + + if (llmDecision.use) { + const { inputHash, assessment } = await evaluateIssueWithLlm( + llmConfig, + issue, + comments, + ruleResult, + previousState, + ); + + result = mergeRuleAndLlm({ + ...ruleResult, + inputHash, + llm: assessment, + mode: assessment.mode === "assist" ? "llm-assist" : "llm-shadow", + }); + } + } + + if (dryRun) { + const preview = previewTriageMutation(result, comments); + dryRunResults.push({ + issue: issue.number, + mode: result.mode, + route: result.route, + priority: result.priority, + effort: result.effort, + confidence: result.confidence, + riskLevel: result.riskLevel, + labels: preview.labels, + commentAction: preview.existingComment ? "update" : "create", + commentBody: preview.commentBody, + }); + continue; + } + + await syncManagedLabels(client, issue, result); + await upsertTriageComment(client, issue.number, result, comments); + + console.log( + JSON.stringify( + { + issue: issue.number, + route: result.route, + priority: result.priority, + labels: buildManagedLabels(issue, result), + }, + null, + 2, + ), + ); +} + +if (dryRun) { + console.log(JSON.stringify({ dryRun: true, issues: dryRunResults }, null, 2)); +} diff --git a/.github/scripts/issue-handoff-brief.ts b/.github/scripts/issue-handoff-brief.ts new file mode 100644 index 00000000..e19dacaa --- /dev/null +++ b/.github/scripts/issue-handoff-brief.ts @@ -0,0 +1,257 @@ +import { MaintainerHandoffBrief, TriageResult } from "./issue-triage-types.ts"; + +const AREA_RULES: Array<{ keywords: string[]; area: string }> = [ + { + keywords: ["clawhub publish", "publish skill", "publish", "namespace"], + area: + "CLI 发布命令参数解析与 namespace 感知发布流程 / CLI publish command option parsing and namespace-aware publish flow", + }, + { + keywords: ["clawhub install", "install skill", "install"], + area: + "技能安装流程与 registry/lockfile 集成 / Skill installation flow and registry/lockfile integration", + }, + { + keywords: ["clawhub update", "update skill", "update"], + area: + "已安装技能更新流程与版本解析 / Installed skill update flow and version resolution", + }, + { + keywords: ["clawhub sync", "sync skill", "sync"], + area: + "本地技能同步流程与发布 diff 检测 / Local skill sync flow and publish diff detection", + }, + { + keywords: ["inspect", "search", "explore"], + area: + "Registry 发现与 CLI 查询流程 / Registry discovery and CLI query workflow", + }, + { + keywords: ["auth", "login", "ldap", "sso", "token"], + area: + "认证、会话与身份集成 / Authentication, session, and identity integration", + }, + { + keywords: ["openapi", "sdk", "api contract", "contract"], + area: + "公开 API 契约、生成 SDK 与兼容性表面 / Public API contract, generated SDKs, and compatibility surface", + }, + { + keywords: ["docs", "documentation", "manual", "help", "--help"], + area: + "文档、操作指引与 CLI help 输出 / Documentation, operator guidance, and CLI help output", + }, + { + keywords: ["scanner", "security", "audit"], + area: + "安全扫描流程与审计/报告行为 / Security scanner pipeline and audit/reporting behavior", + }, +]; + +export function buildMaintainerHandoffBrief( + result: TriageResult, +): MaintainerHandoffBrief | undefined { + if (result.route !== "core") { + return undefined; + } + + const summary = buildSummary(result); + const whyCore = unique([ + result.requiresCoreMaintainer + ? "阻塞 OpenClaw/ClawHub 核心工作流,因此即便改动范围看起来可控,也需要 maintainer judgment / Blocks an OpenClaw/ClawHub core workflow, so maintainer judgment is required even if the code change looks bounded." + : "", + result.riskLevel === "high" + ? "触及高风险区域,未经 maintainer 审查不应直接信任自动修复 / Touches a higher-risk area where automated fixes should not be trusted without maintainer review." + : "", + result.effort >= 4 + ? "大概率跨多个模块或公共兼容面 / Likely spans multiple modules or a public compatibility surface." + : "", + result.confidence <= 3 + ? "问题本身重要,但仍需要 maintainer 先收敛范围再实施 / The issue is important, but a maintainer still needs to tighten scope before implementation." + : "", + ...result.highRiskReasons, + ]).slice(0, 4); + + const reproduction = buildReproduction(result); + const suspectedAreas = inferSuspectedAreas(result); + const risks = buildRisks(result, suspectedAreas); + const validation = buildValidation(result, suspectedAreas); + + return { + summary, + whyCore, + reproduction, + suspectedAreas, + risks, + validation, + }; +} + +function buildSummary(result: TriageResult) { + const llmSummary = result.llm?.summaryZh ?? result.llm?.summary ?? + result.llm?.summaryEn; + + if (llmSummary && llmSummary.trim().length > 0) { + return llmSummary.trim(); + } + + const preferred = [ + result.sections["summary"], + result.sections["problem"], + result.sections["expected behavior"], + ].find((value) => value && value.trim().length > 0); + + if (preferred) { + return compact(preferred); + } + + return result.issue.title.replace(/^\[[^\]]+\]\s*/, "").trim(); +} + +function buildReproduction(result: TriageResult) { + const commandFocusedSteps = extractCommandAndErrorLines( + result.sections["steps to reproduce"], + ); + + if (commandFocusedSteps.length > 0) { + return commandFocusedSteps.slice(0, 4); + } + + const steps = splitIntoBullets(result.sections["steps to reproduce"]); + + if (steps.length > 0) { + return steps.slice(0, 5); + } + + const problem = splitIntoBullets(result.sections["problem"]); + + if (problem.length > 0) { + return problem.slice(0, 4); + } + + return [ + "按 issue 中描述的操作路径复现,并确认当前失败模式 / Recreate the operator flow described in the issue and confirm the current failure mode.", + ]; +} + +function inferSuspectedAreas(result: TriageResult) { + const text = [ + result.issue.title, + result.sections["summary"] ?? "", + result.sections["problem"] ?? "", + result.sections["steps to reproduce"] ?? "", + result.sections["impact"] ?? "", + result.sections["api contract impact"] ?? "", + result.sections["contract or sdk impact"] ?? "", + ] + .join("\n") + .toLowerCase(); + + const areas = AREA_RULES.filter((rule) => + rule.keywords.some((keyword) => text.includes(keyword)) + ).map((rule) => rule.area); + + if (areas.length > 0) { + return unique(areas).slice(0, 5); + } + + return [ + "最接近该失败路径的 owner-facing 工作流模块 / The closest owner-facing workflow module for the issue's reported failure path", + "当前对外承诺该行为的文档或 help 文本 / Any docs or help text that currently promise the affected behavior", + ]; +} + +function buildRisks(result: TriageResult, suspectedAreas: string[]) { + const risks = unique([ + ...result.highRiskReasons, + result.llm?.riskFlags.includes("cli-protocol") + ? "CLI 行为、文档和操作预期可能发生漂移,需要同步更新命令 help 与兼容性说明 / CLI behavior, docs, and operator expectations may drift unless command help and compatibility notes are updated together." + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("namespace")) + ? "namespace 范围行为如果没有保留 fallback routing,可能回归默认 publish/install 流程 / Namespace-scoped behavior can regress default publish/install flows if fallback routing is not preserved." + : "", + result.requiresCoreMaintainer + ? "该问题影响已定义主流程,回归会很快被终端用户感知 / This issue affects a documented primary workflow, so regressions would be visible to end users quickly." + : "", + ]); + + return risks.length > 0 ? risks.slice(0, 4) : [ + "合并前检查相邻用户路径是否出现回归 / Check for regressions in adjacent user-facing workflow paths before merging.", + ]; +} + +function buildValidation(result: TriageResult, suspectedAreas: string[]) { + const validation = unique([ + result.sections["steps to reproduce"] + ? "按 issue 中的复现步骤逐条回放,确认报告的问题已消失 / Replay the exact reproduction steps from the issue and confirm the reported failure disappears." + : "修复后端到端验证主报告流程 / Validate the primary reported workflow end-to-end after the fix.", + result.sections["expected behavior"] + ? `确认最终行为符合 issue 期望 / Confirm the final behavior matches the issue's expected outcome: ${ + compact(result.sections["expected behavior"]) + }` + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("documentation")) + ? "更新或核对文档与 CLI help 输出,确保其与实现行为一致 / Update or verify documentation and CLI help output so they match the implemented behavior." + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("api contract")) + ? "发布前检查下游 API/SDK/CLI 的兼容性预期 / Check for downstream API/SDK/CLI compatibility expectations before shipping." + : "", + suspectedAreas.some((area) => area.toLowerCase().includes("namespace")) + ? "同时验证 namespace 范围行为与默认非 namespace 流程 / Verify both namespace-scoped behavior and the default non-namespace flow." + : "", + result.requiresCoreMaintainer + ? "围绕受影响的 OpenClaw/ClawHub 用户路径执行最小必要回归测试 / Run the smallest relevant regression test around the affected OpenClaw/ClawHub user journey." + : "", + ]); + + return validation.slice(0, 5); +} + +function splitIntoBullets(value: string | undefined) { + if (!value) { + return []; + } + + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => + line.length > 0 && + line !== "```" && + !line.startsWith("PS ") && + !line.startsWith("Usage:") && + !line.startsWith("Options:") && + !line.startsWith("Arguments:") + ) + .map((line) => line.replace(/^[*-]\s*/, "")) + .slice(0, 6); +} + +function extractCommandAndErrorLines(value: string | undefined) { + if (!value) { + return []; + } + + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => + line.length > 0 && + ( + line.toLowerCase().includes("clawhub ") || + line.toLowerCase().startsWith("error:") || + line.toLowerCase().includes("unknown option") || + line.toLowerCase().includes("usage:") + ) + ) + .map((line) => line.replace(/^[>*-]\s*/, "")) + .slice(0, 4); +} + +function compact(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function unique(values: string[]) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} diff --git a/.github/scripts/issue-llm-config.ts b/.github/scripts/issue-llm-config.ts new file mode 100644 index 00000000..f204d89b --- /dev/null +++ b/.github/scripts/issue-llm-config.ts @@ -0,0 +1,139 @@ +import { GitHubIssue } from "./github.ts"; +import { IssueLlmConfig } from "./issue-llm-types.ts"; +import { TriageResult } from "./issue-triage-types.ts"; + +const DEFAULT_TIMEOUT_MS = 30000; +const DEFAULT_MAX_ATTEMPTS = 2; +const DEFAULT_RETRY_BACKOFF_MS = 1500; +const DEFAULT_TEMPERATURE = 0.1; +const DEFAULT_MAX_COMMENTS = 4; +const DEFAULT_MAX_COMMENT_CHARS = 900; +const DEFAULT_MAX_BODY_CHARS = 6000; + +export function readIssueLlmConfig(): IssueLlmConfig | null { + const mode = normalizeMode(Deno.env.get("ISSUE_TRIAGE_LLM_MODE")); + + if (mode === "off") { + return null; + } + + const baseUrl = normalizeUrl(Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL")); + const apiKey = Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY")?.trim() ?? ""; + const model = Deno.env.get("ISSUE_TRIAGE_LLM_MODEL")?.trim() ?? ""; + + if (!baseUrl || !apiKey || !model) { + console.warn( + "LLM triage is configured in a non-off mode but base URL, model, or API key is missing. Falling back to rules-only.", + ); + return null; + } + + return { + mode, + provider: "openai-compatible", + baseUrl, + apiKey, + model, + timeoutMs: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_TIMEOUT_MS"), + DEFAULT_TIMEOUT_MS, + ), + maxAttempts: Math.max( + 1, + parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_ATTEMPTS"), + DEFAULT_MAX_ATTEMPTS, + ), + ), + retryBackoffMs: Math.max( + 0, + parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS"), + DEFAULT_RETRY_BACKOFF_MS, + ), + ), + temperature: parseFloatSetting( + Deno.env.get("ISSUE_TRIAGE_LLM_TEMPERATURE"), + DEFAULT_TEMPERATURE, + ), + maxComments: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_COMMENTS"), + DEFAULT_MAX_COMMENTS, + ), + maxCommentChars: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS"), + DEFAULT_MAX_COMMENT_CHARS, + ), + maxBodyChars: parseInteger( + Deno.env.get("ISSUE_TRIAGE_LLM_MAX_BODY_CHARS"), + DEFAULT_MAX_BODY_CHARS, + ), + }; +} + +export function shouldUseLlm(issue: GitHubIssue, result: TriageResult) { + const reasons: string[] = []; + + if (result.route === "needs-info") { + reasons.push("route-needs-info"); + } + + if (result.route === "core") { + reasons.push("route-core"); + } + + if (result.priority >= 3 && result.priority <= 4.2) { + reasons.push("priority-near-threshold"); + } + + if (result.confidence <= 3) { + reasons.push("confidence-low"); + } + + if (issue.comments >= 4) { + reasons.push("discussion-heavy"); + } + + if ((issue.body ?? "").length >= 1200) { + reasons.push("body-long"); + } + + if (result.issueKind === "feature" || result.issueKind === "reward") { + reasons.push("non-bug-judgment"); + } + + return { + use: reasons.length > 0, + reasons, + }; +} + +function normalizeMode(raw: string | undefined | null) { + const value = raw?.trim().toLowerCase(); + + if (value === "shadow" || value === "assist") { + return value; + } + + return "off"; +} + +function normalizeUrl(value: string | undefined | null) { + const trimmed = value?.trim(); + + if (!trimmed) { + return ""; + } + + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} + +function parseInteger(raw: string | undefined, fallback: number) { + const parsed = Number.parseInt(raw ?? "", 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function parseFloatSetting(raw: string | undefined, fallback: number) { + const parsed = Number.parseFloat(raw ?? ""); + return Number.isNaN(parsed) ? fallback : parsed; +} diff --git a/.github/scripts/issue-llm-evaluator.ts b/.github/scripts/issue-llm-evaluator.ts new file mode 100644 index 00000000..c9ac93ef --- /dev/null +++ b/.github/scripts/issue-llm-evaluator.ts @@ -0,0 +1,466 @@ +import { GitHubIssue, GitHubIssueComment } from "./github.ts"; +import { + IssueLlmConfig, + IssueLlmPayload, + IssueLlmResponse, +} from "./issue-llm-types.ts"; +import { requestOpenAiCompatibleJson } from "./issue-llm-provider.ts"; +import { + IssueRoute, + LlmAssessment, + TriageMachineState, + TriageResult, +} from "./issue-triage-types.ts"; + +const ALLOWED_RISK_FLAGS = new Set([ + "auth", + "security", + "token", + "permission", + "migration", + "schema", + "api-contract", + "sdk", + "cli-protocol", + "data-loss", +]); +const PROMPT_VERSION = 3; + +export async function evaluateIssueWithLlm( + config: IssueLlmConfig, + issue: GitHubIssue, + comments: GitHubIssueComment[], + ruleResult: TriageResult, + previousState: TriageMachineState | null, +) { + const payload = buildPayload(config, issue, comments, ruleResult); + const inputHash = await buildIssueInputHash(payload); + const cached = previousState?.llm; + + if ( + cached && + cached.inputHash === inputHash && + cached.provider === config.provider && + cached.model === config.model && + cached.mode === config.mode && + !cached.failed + ) { + return { + inputHash, + assessment: { + ...cached, + reused: true, + } as LlmAssessment, + }; + } + + try { + const rawJson = await requestOpenAiCompatibleJson( + config, + buildSystemPrompt(), + JSON.stringify(payload, null, 2), + ); + const parsed = validateLlmResponse(JSON.parse(rawJson), ruleResult); + + return { + inputHash, + assessment: { + provider: config.provider, + model: config.model, + mode: config.mode, + inputHash, + summary: parsed.summary_zh || parsed.summary || parsed.summary_en || "", + summaryEn: parsed.summary_en || parsed.summary || parsed.summary_zh || + "", + summaryZh: parsed.summary_zh || parsed.summary || parsed.summary_en || + "", + impact: parsed.impact, + urgency: parsed.urgency, + effort: parsed.effort, + confidence: parsed.confidence, + riskFlags: parsed.risk_flags, + missingInfo: parsed.missing_info, + suggestedQuestions: parsed.suggested_questions, + recommendedRoute: parsed.recommended_route, + rationale: parsed.rationale, + reused: false, + failed: false, + } satisfies LlmAssessment, + }; + } catch (error) { + const failureReason = error instanceof Error + ? error.message + : String(error); + + return { + inputHash, + assessment: { + provider: config.provider, + model: config.model, + mode: config.mode, + inputHash, + summary: "", + summaryEn: "", + summaryZh: "", + impact: ruleResult.impact, + urgency: ruleResult.urgency, + effort: ruleResult.effort, + confidence: ruleResult.confidence, + riskFlags: [], + missingInfo: [], + suggestedQuestions: [], + recommendedRoute: ruleResult.route, + rationale: [], + reused: false, + failed: true, + failureReason, + } satisfies LlmAssessment, + }; + } +} + +function buildPayload( + config: IssueLlmConfig, + issue: GitHubIssue, + comments: GitHubIssueComment[], + ruleResult: TriageResult, +): IssueLlmPayload { + const latestComments = comments + .filter((comment) => + !comment.body.includes("`; +} + +function calculateConfidence( + issueKind: IssueKind, + rawBody: string, + sections: Record, + missingFields: string[], +) { + const required = requiredFields(issueKind); + const requiredFilled = + required.filter((field) => hasMeaningfulSection(sections[field])).length; + const supportFields = Object.entries(sections).filter( + ([key, value]) => !required.includes(key) && hasMeaningfulSection(value), + ).length; + + let score = 1; + score += requiredFilled; + score += supportFields >= 1 ? 0.5 : 0; + score += supportFields >= 3 ? 0.5 : 0; + score += rawBody.length >= 400 ? 0.5 : 0; + score -= missingFields.length > 0 ? 1 : 0; + + return clamp(Math.round(score), 1, 5); +} + +function calculateAgePolicy(createdAt: string, now: Date) { + const created = new Date(createdAt); + const openDays = Math.floor( + (now.getTime() - created.getTime()) / (24 * 60 * 60 * 1000), + ); + const safeOpenDays = Math.max(0, openDays); + + if (safeOpenDays >= 14) { + return { + openDays: safeOpenDays, + ageBoost: 1.5, + priorityFloor: 4.4, + reason: + `已打开 ${safeOpenDays} 天,超过 14 天闭环 SLA,优先级强制提升到 P0 / Open for ${safeOpenDays} days; the 14-day closure SLA is breached, so priority is forced to P0.`, + }; + } + + if (safeOpenDays >= 10) { + return { + openDays: safeOpenDays, + ageBoost: 1, + priorityFloor: 3.6, + reason: + `已打开 ${safeOpenDays} 天,为避免超过 14 天仍未闭环,强制进入 active lane / Open for ${safeOpenDays} days; forced into an active lane before the 14-day closure SLA is missed.`, + }; + } + + if (safeOpenDays >= 7) { + return { + openDays: safeOpenDays, + ageBoost: 0.6, + priorityFloor: 2.6, + reason: + `已打开 ${safeOpenDays} 天,开始进入 2 周闭环预热窗口 / Open for ${safeOpenDays} days; entering the 2-week closure warm-up window.`, + }; + } + + return { + openDays: safeOpenDays, + ageBoost: 0, + priorityFloor: 0, + reason: "", + }; +} + +function calculateEngagementBoost( + commentCount: number, + rewardAmountText?: string, +) { + let boost = Math.min(0.8, commentCount * 0.1); + const rewardAmount = Number.parseFloat( + (rewardAmountText ?? "").replaceAll(/[^0-9.]/g, ""), + ); + + if (!Number.isNaN(rewardAmount)) { + if (rewardAmount >= 500) { + boost += 0.6; + } else if (rewardAmount >= 100) { + boost += 0.3; + } else if (rewardAmount > 0) { + boost += 0.1; + } + } + + return Math.min(1, boost); +} + +function requiredFields(issueKind: IssueKind) { + return REQUIRED_SECTIONS[issueKind] ?? []; +} + +function buildSearchText(issue: GitHubIssue, sections: Record) { + return [issue.title, issue.body ?? "", ...Object.values(sections)].join("\n") + .toLowerCase(); +} + +function buildRiskText(issue: GitHubIssue, sections: Record) { + const preferredSections = [ + "summary", + "problem", + "proposed solution", + "expected behavior", + "steps to reproduce", + "impact", + "api contract impact", + "contract or sdk impact", + ]; + + return [ + issue.title, + ...preferredSections.map((section) => sections[section] ?? ""), + ] + .join("\n") + .toLowerCase(); +} + +function buildWorkflowText( + issue: GitHubIssue, + sections: Record, +) { + const preferredSections = [ + "summary", + "problem", + "steps to reproduce", + "expected behavior", + "impact", + ]; + + return [ + issue.title, + ...preferredSections.map((section) => sections[section] ?? ""), + ] + .join("\n") + .toLowerCase(); +} + +function normalizeHeading(value: string) { + return value.trim().toLowerCase(); +} + +function cleanupSectionContent(value: string) { + return value + .replaceAll(/^_No response_\s*$/gim, "") + .replaceAll(/^no response\s*$/gim, "") + .trim(); +} + +function hasMeaningfulSection(value: string | undefined) { + return Boolean(value && cleanupSectionContent(value).length >= 3); +} + +function uniqueNonEmpty(values: string[]) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function roundToOneDecimal(value: number) { + return Math.round(value * 10) / 10; +} + +export function findTriageComment(comments: GitHubIssueComment[]) { + return comments.find((comment) => + comment.body.includes(TRIAGE_COMMENT_MARKER) + ); +} + +export function buildManagedLabels(issue: GitHubIssue, result: TriageResult) { + const existingLabels = issue.labels + .map((label) => label.name) + .filter((label): label is string => Boolean(label)); + + const unmanagedLabels = existingLabels.filter( + (label) => + !MANAGED_LABEL_PREFIXES.some((prefix) => label.startsWith(prefix)), + ); + + return [ + ...unmanagedLabels, + routeLabel(result.route), + priorityLabel(result.priority), + effortLabel(result.effort), + ...riskLabels(result.riskLevel), + ]; +} + +export function previewTriageMutation( + result: TriageResult, + comments: GitHubIssueComment[], +) { + return { + labels: uniqueNonEmpty(buildManagedLabels(result.issue, result)), + commentBody: renderTriageComment(result), + existingComment: findTriageComment(comments) ?? null, + }; +} + +export function parseTriageMachineState( + commentBody: string, +): TriageMachineState | null { + const start = commentBody.indexOf(TRIAGE_COMMENT_MARKER); + + if (start < 0) { + return null; + } + + const jsonStart = start + TRIAGE_COMMENT_MARKER.length; + const end = commentBody.indexOf("-->", jsonStart); + + if (end < 0) { + return null; + } + + const rawJson = commentBody.slice(jsonStart, end).trim(); + + try { + const parsed = JSON.parse(rawJson) as TriageMachineState; + + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.issue !== "number" || + typeof parsed.route !== "string" + ) { + return null; + } + + return parsed; + } catch { + return null; + } +} diff --git a/.github/scripts/issue-triage-merge.ts b/.github/scripts/issue-triage-merge.ts new file mode 100644 index 00000000..63c162dc --- /dev/null +++ b/.github/scripts/issue-triage-merge.ts @@ -0,0 +1,166 @@ +import { TriageResult, TriageSnapshot } from "./issue-triage-types.ts"; +import { buildMaintainerHandoffBrief } from "./issue-handoff-brief.ts"; + +export function mergeRuleAndLlm(ruleResult: TriageResult): TriageResult { + const llm = ruleResult.llm; + + if (!llm || llm.failed || llm.mode !== "assist") { + return { + ...ruleResult, + handoffBrief: ruleResult.route === "core" + ? buildMaintainerHandoffBrief(ruleResult) + : undefined, + mode: llm && !llm.failed && llm.mode === "shadow" + ? "llm-shadow" + : "rules-only", + inputHash: llm?.inputHash ?? ruleResult.inputHash, + }; + } + + const impact = nudgeScore(ruleResult.impact, llm.impact); + const urgency = nudgeScore(ruleResult.urgency, llm.urgency); + const effort = nudgeScore(ruleResult.effort, llm.effort); + const confidence = nudgeScore(ruleResult.confidence, llm.confidence); + const missingFields = unique([ + ...ruleResult.missingFields, + ...llm.missingInfo, + ]); + const highRiskReasons = unique([ + ...ruleResult.highRiskReasons, + ...llm.riskFlags.map((flag) => + `LLM 标记了高风险区域:${flag} / LLM flagged high-risk area: ${flag}.` + ), + ]); + const requiresCoreMaintainer = ruleResult.requiresCoreMaintainer; + const riskLevel = highRiskReasons.length > 0 ? "high" : "low"; + const priority = clamp( + roundToOneDecimal( + impact * 0.45 + + urgency * 0.35 + + ruleResult.ageBoost + + ruleResult.engagementBoost, + ), + 1, + 5, + ); + const route = determineRoute( + priority, + effort, + confidence, + riskLevel, + missingFields, + requiresCoreMaintainer, + ); + const nextAction = describeNextAction(route, missingFields); + const reasons = unique([ + ...ruleResult.reasons, + ...llm.rationale, + llm.summary + ? `LLM 摘要:${llm.summaryZh || llm.summary} / LLM summary: ${ + llm.summaryEn || llm.summary + }` + : "", + ]).slice(0, 6); + + const mergedSnapshot: TriageSnapshot = { + route, + riskLevel, + requiresCoreMaintainer, + openDays: ruleResult.openDays, + impact, + urgency, + effort, + confidence, + priority, + ageBoost: ruleResult.ageBoost, + priorityFloor: ruleResult.priorityFloor, + engagementBoost: ruleResult.engagementBoost, + missingFields, + reasons, + highRiskReasons, + nextAction, + }; + + return { + ...ruleResult, + ...mergedSnapshot, + mode: "llm-assist", + inputHash: llm.inputHash, + handoffBrief: route === "core" + ? buildMaintainerHandoffBrief({ + ...ruleResult, + ...mergedSnapshot, + mode: "llm-assist", + inputHash: llm.inputHash, + }) + : undefined, + }; +} + +export function determineRoute( + priority: number, + effort: number, + confidence: number, + riskLevel: "low" | "high", + missingFields: string[], + requiresCoreMaintainer = false, +) { + if (requiresCoreMaintainer) { + return "core"; + } + + if (missingFields.length > 0 || confidence <= 2) { + return "needs-info"; + } + + if (priority < 3.6) { + return "deferred"; + } + + if (riskLevel === "high" || effort >= 4 || confidence <= 3) { + return "core"; + } + + return "agent-ready"; +} + +export function describeNextAction( + route: TriageResult["route"], + missingFields: string[], +) { + if (route === "needs-info") { + return `等待补充更多信息;作者更新 issue 或评论 \`/retriage\` 后重新分流 / Wait for more detail, then rerun triage after the author edits the issue or comments \`/retriage\`. Missing: ${ + missingFields.join(", ") + }.`; + } + + if (route === "deferred") { + return "将 issue 保留在 deferred 队列,并由 6 小时一次的 rescore 持续抬升;最晚在第 10 天强制进入 active lane。若第 14 天仍未闭环,应按 SLA 视为 P0 升级目标,并在下一次 triage 中重点处理 / Keep the issue in the deferred queue and let the 6-hour rescore keep lifting it; it is forced into an active lane by day 10. If it is still open on day 14, treat it as a P0 escalation target under the SLA and prioritize it in the next triage pass."; + } + + if (route === "core") { + return "交给 core maintainer,并结合本地编程Agent协助完成复现、收敛范围与验证闭环 / Hand the issue to a core maintainer and use a local programming agent for reproduction, scoping, and validation."; + } + + return "在 self-hosted issue-agent runner 启用后,将其标记为低风险 agent 可执行候选 / Mark as a candidate for low-risk agent execution once the self-hosted issue-agent runner is enabled."; +} + +function nudgeScore(ruleScore: number, llmScore: number) { + if (llmScore === ruleScore) { + return ruleScore; + } + + return clamp(ruleScore + Math.sign(llmScore - ruleScore), 1, 5); +} + +function unique(values: string[]) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function roundToOneDecimal(value: number) { + return Math.round(value * 10) / 10; +} diff --git a/.github/scripts/issue-triage-types.ts b/.github/scripts/issue-triage-types.ts new file mode 100644 index 00000000..e2830a15 --- /dev/null +++ b/.github/scripts/issue-triage-types.ts @@ -0,0 +1,93 @@ +import { GitHubIssue } from "./github.ts"; + +export type IssueKind = "bug" | "feature" | "reward" | "other"; +export type IssueRoute = "needs-info" | "deferred" | "core" | "agent-ready"; +export type RiskLevel = "low" | "high"; +export type LlmMode = "off" | "shadow" | "assist"; +export type AnalysisMode = "rules-only" | "llm-shadow" | "llm-assist"; + +export interface ParsedIssueBody { + sections: Record; + missingFields: string[]; +} + +export interface TriageSnapshot { + route: IssueRoute; + riskLevel: RiskLevel; + requiresCoreMaintainer: boolean; + openDays: number; + impact: number; + urgency: number; + effort: number; + confidence: number; + priority: number; + ageBoost: number; + priorityFloor: number; + engagementBoost: number; + missingFields: string[]; + reasons: string[]; + highRiskReasons: string[]; + nextAction: string; +} + +export interface MaintainerHandoffBrief { + summary: string; + whyCore: string[]; + reproduction: string[]; + suspectedAreas: string[]; + risks: string[]; + validation: string[]; +} + +export interface LlmAssessment { + provider: string; + model: string; + mode: LlmMode; + inputHash: string; + summary: string; + summaryEn?: string; + summaryZh?: string; + impact: number; + urgency: number; + effort: number; + confidence: number; + riskFlags: string[]; + missingInfo: string[]; + suggestedQuestions: string[]; + recommendedRoute: IssueRoute; + rationale: string[]; + reused: boolean; + failed: boolean; + failureReason?: string; +} + +export interface TriageResult extends TriageSnapshot { + issue: GitHubIssue; + issueKind: IssueKind; + sections: Record; + mode: AnalysisMode; + inputHash: string; + rule: TriageSnapshot; + llm?: LlmAssessment; + handoffBrief?: MaintainerHandoffBrief; +} + +export interface TriageMachineState { + version: number; + issue: number; + inputHash?: string; + mode?: AnalysisMode; + route: IssueRoute; + priority: number; + requiresCoreMaintainer?: boolean; + impact: number; + urgency: number; + effort: number; + confidence: number; + riskLevel: RiskLevel; + ageBoost: number; + engagementBoost: number; + missingFields: string[]; + updatedAt: string; + llm?: LlmAssessment; +} diff --git a/.github/scripts/issue-triage.ts b/.github/scripts/issue-triage.ts new file mode 100644 index 00000000..c9269ac4 --- /dev/null +++ b/.github/scripts/issue-triage.ts @@ -0,0 +1,129 @@ +import { GitHubClient } from "./github.ts"; +import { readIssueLlmConfig, shouldUseLlm } from "./issue-llm-config.ts"; +import { evaluateIssueWithLlm } from "./issue-llm-evaluator.ts"; +import { TRIAGE_MANUAL_OVERRIDE_LABEL } from "./issue-triage-config.ts"; +import { + analyzeIssue, + buildManagedLabels, + ensureManagedLabels, + findTriageComment, + parseTriageMachineState, + previewTriageMutation, + syncManagedLabels, + upsertTriageComment, +} from "./issue-triage-lib.ts"; +import { mergeRuleAndLlm } from "./issue-triage-merge.ts"; + +function readFlag(name: string) { + const index = Deno.args.indexOf(`--${name}`); + return index >= 0 ? Deno.args[index + 1] : undefined; +} + +function hasFlag(name: string) { + return Deno.args.includes(`--${name}`); +} + +const owner = readFlag("owner"); +const repo = readFlag("repo"); +const issueNumberValue = readFlag("issue-number"); +const dryRun = hasFlag("dry-run"); +const token = Deno.env.get("GH_TOKEN") ?? Deno.env.get("GITHUB_TOKEN"); + +if (!owner || !repo || !issueNumberValue || !token) { + throw new Error( + "Usage: deno run issue-triage.ts --owner --repo --issue-number with GH_TOKEN set.", + ); +} + +const issueNumber = Number.parseInt(issueNumberValue, 10); + +if (Number.isNaN(issueNumber)) { + throw new Error(`Invalid issue number: ${issueNumberValue}`); +} + +const client = new GitHubClient(token, owner, repo); +const issue = await client.getIssue(issueNumber); + +if (issue.pull_request) { + console.log(`Skipping #${issue.number} because it is a pull request conversation.`); + Deno.exit(0); +} + +if (issue.labels.some((label) => label.name === TRIAGE_MANUAL_OVERRIDE_LABEL)) { + console.log(`Skipping #${issue.number} because ${TRIAGE_MANUAL_OVERRIDE_LABEL} is set.`); + Deno.exit(0); +} + +const comments = await client.listIssueComments(issueNumber); +const ruleResult = analyzeIssue(issue, comments); +const existingComment = findTriageComment(comments); +const previousState = existingComment + ? parseTriageMachineState(existingComment.body) + : null; +const llmConfig = readIssueLlmConfig(); +let result = ruleResult; + +if (llmConfig) { + const llmDecision = shouldUseLlm(issue, ruleResult); + + if (llmDecision.use) { + const { inputHash, assessment } = await evaluateIssueWithLlm( + llmConfig, + issue, + comments, + ruleResult, + previousState, + ); + + result = mergeRuleAndLlm({ + ...ruleResult, + inputHash, + llm: assessment, + mode: assessment.mode === "assist" ? "llm-assist" : "llm-shadow", + }); + } +} + +if (dryRun) { + const preview = previewTriageMutation(result, comments); + console.log( + JSON.stringify( + { + dryRun: true, + issue: issue.number, + mode: result.mode, + route: result.route, + priority: result.priority, + effort: result.effort, + confidence: result.confidence, + riskLevel: result.riskLevel, + labels: preview.labels, + commentAction: preview.existingComment ? "update" : "create", + commentBody: preview.commentBody, + }, + null, + 2, + ), + ); + Deno.exit(0); +} + +await ensureManagedLabels(client); +await syncManagedLabels(client, issue, result); +await upsertTriageComment(client, issueNumber, result, comments); + +console.log( + JSON.stringify( + { + issue: issue.number, + route: result.route, + priority: result.priority, + effort: result.effort, + confidence: result.confidence, + riskLevel: result.riskLevel, + labels: buildManagedLabels(issue, result), + }, + null, + 2, + ), +); diff --git a/.github/scripts/share-reward.ts b/.github/scripts/share-reward.ts new file mode 100644 index 00000000..01897448 --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,125 @@ +import "npm:array-unique-proposal"; + +import { components } from "npm:@octokit/openapi-types"; +import { $, argv, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const [ + repositoryOwner, + repositoryName, + issueNumber, + payer, // GitHub username of the payer (provided by workflow, defaults to issue creator) + currency, + reward, +] = argv._; + +interface PRMeta { + author: components["schemas"]["simple-user"]; + assignees: components["schemas"]["simple-user"][]; +} + +const graphqlQuery = ` + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + closedByPullRequestsReferences(first: 10) { + nodes { + url + merged + mergeCommit { + oid + } + } + } + } + } + } +`; +const PR_DATA = await $`gh api graphql \ + -f query=${graphqlQuery} \ + -f owner=${repositoryOwner} \ + -f name=${repositoryName} \ + -F number=${issueNumber} \ + --jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | {url: .url, mergeCommitSha: .mergeCommit.oid}' | head -n 1`; + +const prData = PR_DATA.text().trim(); + +if (!prData) + throw new ReferenceError("No merged PR is found for the given issue number."); + +const { url: PR_URL, mergeCommitSha } = JSON.parse(prData); + +if (!PR_URL || !mergeCommitSha) + throw new Error("Missing required fields in PR data"); + +console.table({ PR_URL, mergeCommitSha }); + +const { author, assignees }: PRMeta = await ( + await $`gh pr view ${PR_URL} --json author,assignees` +).json(); + +function isBotUser(login: string) { + const lowerLogin = login.toLowerCase(); + return ( + lowerLogin.includes("copilot") || + lowerLogin.includes("[bot]") || + lowerLogin === "github-actions[bot]" || + lowerLogin.endsWith("[bot]") + ); +} + +// Filter out Bot users from the list +const allUsers = [ + author.login, + ...assignees.map(({ login }) => login), +].uniqueBy(); + +const users = allUsers.filter((login) => !isBotUser(login)); + +console.log(`All users: ${allUsers.join(", ")}`); +console.log(`Filtered users (excluding bots): ${users.join(", ")}`); + +if (!users[0]) + throw new ReferenceError( + "No real users found (all users are bots). Skipping reward distribution.", + ); + +const rewardNumber = parseFloat(reward); + +if (isNaN(rewardNumber) || rewardNumber <= 0) + throw new RangeError( + `Reward amount is not a valid number, can not proceed with reward distribution. Received reward value: ${reward}`, + ); + +const averageReward = (rewardNumber / users.length).toFixed(2); + +const list: Reward[] = users.map((login) => ({ + issue: `#${issueNumber}`, + payer: `@${payer}`, + payee: `@${login}`, + currency, + reward: parseFloat(averageReward), +})); +const listText = YAML.stringify(list); + +console.log(listText); + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + +await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`; +await $`git push origin --tags --no-verify`; + +await $`git config unset user.name`; +await $`git config unset user.email`; + +const commentBody = `## Reward data + +\`\`\`yml +${listText} +\`\`\` +`; +await $`gh issue comment ${issueNumber} --body ${commentBody}`; diff --git a/.github/scripts/type.ts b/.github/scripts/type.ts new file mode 100644 index 00000000..e61d2f03 --- /dev/null +++ b/.github/scripts/type.ts @@ -0,0 +1,7 @@ +export interface Reward { + issue: string; + payer: string; + payee: string; + currency: string; + reward: number; +} diff --git a/.github/workflows/claim-issue-reward.yml b/.github/workflows/claim-issue-reward.yml new file mode 100644 index 00000000..5765d9d9 --- /dev/null +++ b/.github/workflows/claim-issue-reward.yml @@ -0,0 +1,46 @@ +name: Claim Issue Reward +on: + issues: + types: + - closed + +concurrency: + group: claim-issue-reward-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + claim-issue-reward: + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'reward') + permissions: + contents: write + issues: write + pull-requests: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Get Issue details + id: parse_issue + uses: stefanbuck/github-issue-parser@10dcc54158ba4c137713d9d69d70a2da63b6bda3 # v3.2.3 + with: + template-path: ".github/ISSUE_TEMPLATE/reward-task.yml" + + - name: Calculate & Save Reward + env: + GH_TOKEN: ${{ github.token }} + run: | + deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com \ + .github/scripts/share-reward.ts \ + "${{ github.repository_owner }}" \ + "${{ github.event.repository.name }}" \ + "${{ github.event.issue.number }}" \ + "${{ steps.parse_issue.outputs.issueparser_payer || github.event.issue.user.login }}" \ + "${{ steps.parse_issue.outputs.issueparser_currency }}" \ + "${{ steps.parse_issue.outputs.issueparser_amount }}" diff --git a/.github/workflows/issue-backlog-rescore.yml b/.github/workflows/issue-backlog-rescore.yml new file mode 100644 index 00000000..99f11939 --- /dev/null +++ b/.github/workflows/issue-backlog-rescore.yml @@ -0,0 +1,51 @@ +name: Issue Backlog Rescore + +on: + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: + inputs: + limit: + description: Maximum number of deferred issues to rescore + required: false + default: "0" + +concurrency: + group: issue-backlog-rescore + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + rescore: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Rescore deferred issues + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TRIAGE_LLM_MODE: ${{ vars.ISSUE_TRIAGE_LLM_MODE }} + ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }} + ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }} + ISSUE_TRIAGE_LLM_TIMEOUT_MS: ${{ vars.ISSUE_TRIAGE_LLM_TIMEOUT_MS }} + ISSUE_TRIAGE_LLM_MAX_ATTEMPTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_ATTEMPTS }} + ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS: ${{ vars.ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS }} + ISSUE_TRIAGE_LLM_TEMPERATURE: ${{ vars.ISSUE_TRIAGE_LLM_TEMPERATURE }} + ISSUE_TRIAGE_LLM_MAX_COMMENTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENTS }} + ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS }} + ISSUE_TRIAGE_LLM_MAX_BODY_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_BODY_CHARS }} + ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }} + run: | + deno run --allow-env --allow-net \ + .github/scripts/issue-backlog-rescore.ts \ + --owner "${{ github.repository_owner }}" \ + --repo "${{ github.event.repository.name }}" \ + --limit "${{ inputs.limit || '0' }}" diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 00000000..c60d5d9e --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,62 @@ +name: Issue Triage + +on: + issues: + types: + - opened + - edited + - reopened + issue_comment: + types: + - created + workflow_dispatch: + inputs: + issue_number: + description: Issue number to re-triage manually + required: true + +concurrency: + group: issue-triage-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + +jobs: + triage: + if: | + github.event_name != 'issue_comment' || + ( + github.event.issue.pull_request == null && + contains(github.event.comment.body, '/retriage') + ) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Run triage + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TRIAGE_LLM_MODE: ${{ vars.ISSUE_TRIAGE_LLM_MODE }} + ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }} + ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }} + ISSUE_TRIAGE_LLM_TIMEOUT_MS: ${{ vars.ISSUE_TRIAGE_LLM_TIMEOUT_MS }} + ISSUE_TRIAGE_LLM_MAX_ATTEMPTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_ATTEMPTS }} + ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS: ${{ vars.ISSUE_TRIAGE_LLM_RETRY_BACKOFF_MS }} + ISSUE_TRIAGE_LLM_TEMPERATURE: ${{ vars.ISSUE_TRIAGE_LLM_TEMPERATURE }} + ISSUE_TRIAGE_LLM_MAX_COMMENTS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENTS }} + ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS }} + ISSUE_TRIAGE_LLM_MAX_BODY_CHARS: ${{ vars.ISSUE_TRIAGE_LLM_MAX_BODY_CHARS }} + ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }} + run: | + deno run --allow-env --allow-net \ + .github/scripts/issue-triage.ts \ + --owner "${{ github.repository_owner }}" \ + --repo "${{ github.event.repository.name }}" \ + --issue-number "${{ github.event.issue.number || inputs.issue_number }}" diff --git a/.github/workflows/pr-batch-test-deploy.yml b/.github/workflows/pr-batch-test-deploy.yml new file mode 100644 index 00000000..f61cdef6 --- /dev/null +++ b/.github/workflows/pr-batch-test-deploy.yml @@ -0,0 +1,161 @@ +name: PR Batch Test Deploy + +on: + workflow_dispatch: + inputs: + pr_numbers: + description: "Comma/newline separated PR numbers to merge onto the base branch" + required: true + type: string + base_ref: + description: "Base branch to build from" + required: false + default: main + type: string + deploy_channel: + description: "Floating image tag used by the shared HK test machine" + required: false + default: manual-test-hk + type: string + +concurrency: + group: pr-batch-test-runtime + cancel-in-progress: false + +permissions: + contents: read + packages: write + pull-requests: read + +env: + DOCKER_PLATFORM: linux/amd64 + +jobs: + build-and-deploy: + name: Build And Deploy Manual Test Batch + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Ensure helper scripts are executable + run: chmod +x scripts/prepare-pr-batch.sh scripts/deploy-test-runtime.sh + + - name: Validate deploy secrets + env: + TEST_RUNTIME_SSH_HOST: ${{ secrets.TEST_RUNTIME_SSH_HOST }} + TEST_RUNTIME_SSH_KEY: ${{ secrets.TEST_RUNTIME_SSH_KEY }} + run: | + [[ -n "${TEST_RUNTIME_SSH_HOST}" ]] || { echo "::error::Missing secret TEST_RUNTIME_SSH_HOST"; exit 1; } + [[ -n "${TEST_RUNTIME_SSH_KEY}" ]] || { echo "::error::Missing secret TEST_RUNTIME_SSH_KEY"; exit 1; } + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Merge selected PRs onto base ref + id: batch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + scripts/prepare-pr-batch.sh \ + --pr-list "${{ inputs.pr_numbers }}" \ + --base-ref "${{ inputs.base_ref }}" \ + --deploy-channel "${{ inputs.deploy_channel }}" + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: ./server + file: ./server/Dockerfile + platforms: ${{ env.DOCKER_PLATFORM }} + push: true + provenance: false + sbom: false + tags: | + ghcr.io/${{ github.repository_owner }}/skillhub-server:${{ steps.batch.outputs.deploy_tag }} + ghcr.io/${{ github.repository_owner }}/skillhub-server:${{ steps.batch.outputs.immutable_tag }} + cache-from: type=gha,scope=manual-test-server + cache-to: type=gha,mode=max,scope=manual-test-server + + - name: Build and push frontend image + uses: docker/build-push-action@v6 + with: + context: ./web + file: ./web/Dockerfile + platforms: ${{ env.DOCKER_PLATFORM }} + push: true + provenance: false + sbom: false + tags: | + ghcr.io/${{ github.repository_owner }}/skillhub-web:${{ steps.batch.outputs.deploy_tag }} + ghcr.io/${{ github.repository_owner }}/skillhub-web:${{ steps.batch.outputs.immutable_tag }} + cache-from: type=gha,scope=manual-test-web + cache-to: type=gha,mode=max,scope=manual-test-web + + - name: Build and push scanner image + uses: docker/build-push-action@v6 + with: + context: ./scanner + file: ./scanner/Dockerfile + platforms: ${{ env.DOCKER_PLATFORM }} + push: true + provenance: false + sbom: false + tags: | + ghcr.io/${{ github.repository_owner }}/skillhub-scanner:${{ steps.batch.outputs.deploy_tag }} + ghcr.io/${{ github.repository_owner }}/skillhub-scanner:${{ steps.batch.outputs.immutable_tag }} + cache-from: type=gha,scope=manual-test-scanner + cache-to: type=gha,mode=max,scope=manual-test-scanner + + - name: Prepare deploy key + id: ssh + env: + TEST_RUNTIME_SSH_KEY: ${{ secrets.TEST_RUNTIME_SSH_KEY }} + run: | + key_file="${RUNNER_TEMP}/test-runtime.key" + printf '%s\n' "${TEST_RUNTIME_SSH_KEY}" > "${key_file}" + chmod 600 "${key_file}" + echo "key_file=${key_file}" >> "${GITHUB_OUTPUT}" + + - name: Deploy batch images to HK test runtime + env: + TEST_RUNTIME_SSH_HOST: ${{ secrets.TEST_RUNTIME_SSH_HOST }} + TEST_RUNTIME_SSH_USER: ${{ secrets.TEST_RUNTIME_SSH_USER }} + TEST_RUNTIME_SSH_PORT: ${{ secrets.TEST_RUNTIME_SSH_PORT }} + run: | + ssh_port="${TEST_RUNTIME_SSH_PORT:-22}" + ssh_user="${TEST_RUNTIME_SSH_USER:-skillhub-deploy}" + scripts/deploy-test-runtime.sh \ + --host "${TEST_RUNTIME_SSH_HOST}" \ + --user "${ssh_user}" \ + --port "${ssh_port}" \ + --key-file "${{ steps.ssh.outputs.key_file }}" \ + --deploy-tag "${{ steps.batch.outputs.deploy_tag }}" \ + --immutable-tag "${{ steps.batch.outputs.immutable_tag }}" \ + --merged-sha "${{ steps.batch.outputs.merged_sha }}" \ + --pr-csv "${{ steps.batch.outputs.pr_csv }}" \ + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Publish final summary + run: | + { + echo "### HK manual test runtime updated" + echo + echo "- URL: \`https://skill.xf-yun.com.cn\`" + echo "- Base ref: \`${{ steps.batch.outputs.base_ref }}\`" + echo "- Floating tag: \`${{ steps.batch.outputs.deploy_tag }}\`" + echo "- Immutable tag: \`${{ steps.batch.outputs.immutable_tag }}\`" + echo "- Merged SHA: \`${{ steps.batch.outputs.merged_sha }}\`" + echo "- PR list: \`${{ steps.batch.outputs.pr_csv }}\`" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/statistic-member-reward.yml b/.github/workflows/statistic-member-reward.yml new file mode 100644 index 00000000..09e5b88f --- /dev/null +++ b/.github/workflows/statistic-member-reward.yml @@ -0,0 +1,43 @@ +name: Statistic Member Reward +on: + schedule: + - cron: "0 0 1 * *" # Run at 00:00 on the first day of every month + +jobs: + statistic-member-reward: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Check for new commits since last statistic + run: | + last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "") + + if [ -z "$last_tag" ]; then + echo "No previous statistic tags found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + else + new_commits=$(git log $last_tag..HEAD --oneline) + if [ -z "$new_commits" ]; then + echo "No new commits since last statistic tag." + echo "NEW_COMMITS=false" >> $GITHUB_ENV + else + echo "New commits found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + fi + fi + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + if: env.NEW_COMMITS == 'true' + with: + deno-version: v2.x + + - name: Statistic rewards + if: env.NEW_COMMITS == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts diff --git a/LICENSE b/LICENSE index 0c5fba18..a5da3893 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 iFlytek Co., Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 8f262ab5..60e1f488 100644 --- a/Makefile +++ b/Makefile @@ -71,10 +71,21 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \ fi; \ done; \ - if [ "$$backend_ready" -ne 1 ]; then \ - echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ - exit 1; \ - fi + if [ "$$backend_ready" -ne 1 ]; then \ + echo ""; \ + echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ + echo ""; \ + echo "Common issues:"; \ + echo " 1. Maven dependency download failed (network timeout)"; \ + echo " -> Configure mirror in ~/.m2/settings.xml"; \ + echo " -> See: https://maven.aliyun.com/mvn/guide"; \ + echo " 2. Java version mismatch (requires Java 21+)"; \ + echo " -> Run: java -version"; \ + echo " 3. Port 8080 already in use"; \ + echo " -> Run: lsof -i :8080"; \ + echo ""; \ + exit 1; \ + fi @echo "Waiting for scanner on $(DEV_SCANNER_URL) ..." @scanner_ready=0; \ for i in $$(seq 1 30); do \ diff --git a/README.md b/README.md index 2edab733..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) @@ -24,6 +25,10 @@ --- +
+ SkillHub Demo +
+ SkillHub is a self-hosted platform that gives teams a private, governed place to share agent skills. Publish a skill package, push it to a namespace, and let others find it through search or @@ -91,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 +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. @@ -106,6 +111,8 @@ If deployment runs into problems, clear the existing runtime home and retry. make dev-all ``` +> **For developers in China**: If Maven dependency download times out, configure Aliyun mirror. See [Local Development Guide](https://iflytek.github.io/skillhub/quickstart.html#本地开发) for details. + Then open: - Web UI: `http://localhost:3000` @@ -189,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 +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:** @@ -216,6 +223,7 @@ cp .env.release.example .env.release Recommended image tags: +- `SKILLHUB_VERSION=latest` for the latest stable release (default) - `SKILLHUB_VERSION=edge` for the latest `main` build - `SKILLHUB_VERSION=vX.Y.Z` for a fixed release @@ -390,10 +398,16 @@ npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -# Publish a skill -npx clawhub publish ./my-skill +# Publish to global namespace +npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 + +# Publish to a team namespace such as my-space +npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0 ``` +`my-space--my-skill` is the canonical compat slug. SkillHub parses it as +namespace `my-space` plus skill slug `my-skill`. + > 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill` 📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** @@ -428,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 20548b05..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) @@ -17,6 +18,10 @@ --- +
+ SkillHub Demo +
+ SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。 ## 文档 @@ -63,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 +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` 如果部署遇到问题,请清除现有的运行时目录并重试。 @@ -126,6 +131,8 @@ make dev-backend # 仅后端 make dev-web # 仅前端 ``` +> **国内开发者**:如果 Maven 依赖下载超时,需配置阿里云镜像。详见 [本地开发指南](https://iflytek.github.io/skillhub/quickstart.html#本地开发)。 + ### 常用命令 ```bash @@ -171,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 +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` ### 配置参数说明 @@ -325,10 +332,16 @@ npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -# 发布技能 -npx clawhub publish ./my-skill +# 发布到 global 空间 +npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 + +# 发布到如 my-space 这样的团队空间 +npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0 ``` +其中 `my-space--my-skill` 是兼容层使用的 canonical slug,SkillHub 会将其解析为 +namespace `my-space` 和 skill slug `my-skill`。 + > 💡 **提示**:上述命令不仅适用于 OpenClaw,通过指定安装目录(`--dir`),也可适用于其他的 CLI Coding Agent 或 Agent 助手。例如:`npx clawhub --dir ~/.claude/skills install my-skill` 📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)** @@ -361,6 +374,7 @@ npx clawhub publish ./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/compose.release.yml b/compose.release.yml index cd51781f..831ac581 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -80,6 +80,17 @@ services: BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local} OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} + SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-} + SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25} + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-} + SPRING_MAIL_SMTP_AUTH: ${SPRING_MAIL_SMTP_AUTH:-false} + SPRING_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:-false} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE:-false} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST:-} + SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:-PT10M} + SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:-noreply@skillhub.local} + SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:-SkillHub} volumes: - skillhub_storage:/var/lib/skillhub/storage depends_on: 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/07-skill-protocol.md b/docs/07-skill-protocol.md index 958c4bb6..4bc14103 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -67,7 +67,7 @@ my-skill/ 校验规则: - 根目录必须包含 `SKILL.md` -- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` +- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` - 单文件大小限制:1MB(可配置) - 总包大小限制:10MB(可配置) - 文件数量限制:100 个(可配置) diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 13b159ae..a8da4204 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -195,6 +195,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入 - 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入 - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` +- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` ## 8 裸金属上线清单 diff --git a/docs/19-smtp-password-reset-email-setup.md b/docs/19-smtp-password-reset-email-setup.md new file mode 100644 index 00000000..07def9ef --- /dev/null +++ b/docs/19-smtp-password-reset-email-setup.md @@ -0,0 +1,317 @@ +# SkillHub SMTP 邮箱配置指南(验证码邮件) + +本文说明如何为 SkillHub 配置 SMTP,用于发送“密码重置验证码”邮件。 + +适用场景: +- 生产/预发布环境(`compose.release.yml` + `.env.release`) +- 本地联调环境(直接注入后端环境变量) + +补充说明: +- SMTP 本质是邮件传输协议,不是单一厂商产品。 +- 你可以使用企业邮箱、云邮箱或本地测试 SMTP 服务(例如 MailHog)作为 SMTP 服务端。 + +当前密码重置页面入口说明: +- 当前前端统一使用 `/reset-password` 页面。 +- 该页面同时包含“发送验证码”和“提交新密码”两步,不再单独使用 `/forgot-password`。 + +## 1. 需要配置的环境变量 + +以下变量已被后端读取: + +| 变量名 | 说明 | 示例 | +|---|---|---| +| `SPRING_MAIL_HOST` | SMTP 服务器地址 | `smtp.example.com` | +| `SPRING_MAIL_PORT` | SMTP 端口 | `465` | +| `SPRING_MAIL_USERNAME` | SMTP 用户名 | `noreply@example.com` | +| `SPRING_MAIL_PASSWORD` | SMTP 密码/授权码 | `xxxxxx` | +| `SPRING_MAIL_SMTP_AUTH` | 是否启用 SMTP AUTH | `true` | +| `SPRING_MAIL_SMTP_STARTTLS_ENABLE` | 是否启用 STARTTLS | `false` | +| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE` | 是否启用 SMTP SSL 直连 | `true` | +| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST` | SSL 信任主机(用于规避部分环境下证书链校验失败) | `smtp.mail.example` | +| `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` | 验证码有效期(ISO-8601 Duration) | `PT10M` | +| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` | 发件人邮箱 | `noreply@example.com` | +| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME` | 发件人名称 | `SkillHub` | + +说明: +- 当前文档统一按 `465 + SSL` 配置,不再展开 `587 + STARTTLS` 方案。 +- 使用 `465` 时配置:`STARTTLS=false`、`SSL_ENABLE=true`。 +- 若出现 `PKIX path building failed` / `SSLHandshakeException`,可尝试增加 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=`(本地联调常用)。 +- 生产环境默认不建议配置 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST`,仅在证书链异常时临时启用。 +- `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` 支持如 `PT5M`、`PT10M`、`PT30M`。 + +## 1.1 配置方案速查(推荐) + +### A. 通用 SMTP 邮箱(本地直连真实邮箱) + +```dotenv +SPRING_MAIL_HOST=smtp.mail.example +SPRING_MAIL_PORT=465 +SPRING_MAIL_USERNAME=mailer@example.com +SPRING_MAIL_PASSWORD=your-smtp-app-password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +本地 `export` 示例写法: + +```bash +export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +export SPRING_MAIL_HOST=smtp.mail.example +export SPRING_MAIL_PORT=465 +export SPRING_MAIL_USERNAME=mailer@example.com +export SPRING_MAIL_PASSWORD=your-smtp-app-password +export SPRING_MAIL_SMTP_AUTH=true +export SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +export SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +export SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +export SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +### B. MailHog(本地联调推荐) + +```dotenv +SPRING_MAIL_HOST=127.0.0.1 +SPRING_MAIL_PORT=1025 +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_SMTP_AUTH=false +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub +``` + +### C. 线上部署(465 端口示例) + +```dotenv +SPRING_MAIL_HOST=smtp.mail.example +SPRING_MAIL_PORT=465 +SPRING_MAIL_USERNAME=mailer@example.com +SPRING_MAIL_PASSWORD=your-smtp-app-password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +## 2. 单机交付(Compose)配置步骤 + +1. 复制环境模板(若尚未创建): + +```bash +cp .env.release.example .env.release +``` + +2. 编辑 `.env.release`,填写 SMTP 变量: + +```dotenv +SPRING_MAIL_HOST=smtp.mail.example +SPRING_MAIL_PORT=465 +SPRING_MAIL_USERNAME=mailer@example.com +SPRING_MAIL_PASSWORD=your-smtp-app-password +SPRING_MAIL_SMTP_AUTH=true +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example + +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name +``` + +3. 重启后端容器使配置生效: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d server +``` + +4. 查看后端日志确认启动正常: + +```bash +docker compose --env-file .env.release -f compose.release.yml logs -f server +``` + +## 3. 本地开发配置与验证 + +### 3.1 一次性临时生效(推荐) + +适合当前终端临时测试,重开终端后失效。 + +```bash +SPRING_MAIL_HOST=smtp.mail.example \ +SPRING_MAIL_PORT=465 \ +SPRING_MAIL_USERNAME=mailer@example.com \ +SPRING_MAIL_PASSWORD=your-smtp-app-password \ +SPRING_MAIL_SMTP_AUTH=true \ +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \ +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true \ +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example \ +SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name \ +make dev-server +``` + +### 3.2 长期生效(shell 配置) + +如果你写到了 `~/.zshrc`,请注意: +- 必须 `source ~/.zshrc` 或重开终端后变量才会生效 +- 需要在“同一个终端”启动 `make dev-server` + +可先确认变量是否在当前 shell 中: + +```bash +env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)' +``` + +### 3.3 推荐联调方式(MailHog) + +如果你只是本地验证验证码链路,建议用 MailHog 作为本地 SMTP 服务: + +1. 启动 MailHog: + +```bash +docker run -d --name skillhub-mailhog \ + -p 1025:1025 \ + -p 8025:8025 \ + mailhog/mailhog +``` + +2. 启动依赖服务(Postgres/Redis): + +```bash +make dev +``` + +3. 启动后端时注入 SMTP 环境变量(示例): + +```bash +SPRING_MAIL_HOST=127.0.0.1 \ +SPRING_MAIL_PORT=1025 \ +SPRING_MAIL_USERNAME= \ +SPRING_MAIL_PASSWORD= \ +SPRING_MAIL_SMTP_AUTH=false \ +SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \ +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local \ +SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub \ +make dev-server +``` + +4. 打开 MailHog Web UI 查看邮件: + +```text +http://localhost:8025 +``` + +5. 在 SkillHub 页面验证流程: +- 打开 `/reset-password` +- 输入邮箱并点击“发送验证码” +- 在 MailHog 中查看验证码邮件 +- 输入邮箱 + 验证码 + 新密码完成重置 + +6. 也可使用接口做快速验证(示例): + +```bash +curl -X POST http://localhost:8080/api/v1/auth/local/password-reset/request \ + -H 'Content-Type: application/json' \ + -d '{"email":"your-email@example.com"}' +``` + +## 4. 功能验证(验证码邮件) + +### 4.1 用户自助找回 + +在 `/reset-password` 页面点击“发送验证码”后,系统会尝试发送验证码邮件。 + +说明: +- 为防止账号枚举,自助接口总是返回通用成功提示。 +- 即使邮件发送失败,接口也可能返回成功;请结合后端日志确认实际发送结果。 + +### 4.2 管理员触发重置 + +管理员在用户管理页触发“重置密码”时,系统会强制发送验证码; +若 SMTP 发送失败,会返回错误(便于运维排障)。 + +## 5. 常见问题排查 + +### 5.1 认证失败(`535 Authentication failed`) + +排查方向: +- 用户名/密码是否正确 +- 邮箱服务是否要求“客户端授权码”而非登录密码 +- 发件账号是否已开启 SMTP 服务 + +### 5.2 连接超时或拒绝连接 + +排查方向: +- 主机到 SMTP 服务端口 `465` 是否可达 +- 安全组/防火墙是否放行出站连接 +- SMTP 服务地址是否填写正确 + +### 5.3 本地明明配置了变量但不生效 + +排查方向: +- 是否只是编辑了 `~/.zshrc` 但没有 `source ~/.zshrc` +- 启动后端的终端是否与配置变量的终端是同一个 +- `8080` 是否被旧进程占用,导致新进程没启动成功 + +可执行以下命令快速检查: + +```bash +# 查看 8080 是否被旧进程占用 +lsof -nP -iTCP:8080 -sTCP:LISTEN + +# 查看当前 shell 是否有 SMTP 环境变量 +env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)' +``` + +### 5.4 发件人被拒绝 + +排查方向: +- `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` 是否与 SMTP 账号一致或已验证 +- 邮箱服务是否限制别名发件 + +### 5.5 健康检查是否校验 SMTP + +默认配置下,邮件健康检查关闭,不会因为 SMTP 不可达导致 `health` 失败。 + +若需要将 SMTP 连通性纳入健康检查,可设置: + +```dotenv +MANAGEMENT_HEALTH_MAIL_ENABLED=true +``` + +### 5.6 SMTP 报 `PKIX path building failed`(证书链校验失败) + +典型日志: +- `SSLHandshakeException` +- `unable to find valid certification path to requested target` + +处理建议(本地联调): +- 增加: + +```dotenv +SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example +``` + +- 然后重启后端,再触发一次“发送验证码”。 + +补充: +- 该配置用于指定信任主机,适合本地排障与联调。 +- 生产环境默认不建议长期启用该配置,更推荐使用规范 CA 证书链或将企业 CA 导入 Java truststore。 + +## 6. 安全建议 + +- 不要把 SMTP 密码提交到仓库;仅写入受控的 `.env.release` 或密钥管理系统。 +- 使用专用发信账号,避免使用个人邮箱主密码。 +- 生产环境建议定期轮换 SMTP 授权码。 diff --git a/docs/2026-04-08-issue-automation-design.md b/docs/2026-04-08-issue-automation-design.md new file mode 100644 index 00000000..420e78ab --- /dev/null +++ b/docs/2026-04-08-issue-automation-design.md @@ -0,0 +1,323 @@ +# Issue 自动分诊 MVP 设计 + +## 目标 + +通过自动将 GitHub issue 分诊到三个队列中,降低维护者负担: + +- `triage/deferred`:低优先级 issue,会随着时间推移逐步上浮 +- `triage/core`:高优先级或高风险 issue,需要 core maintainer 接手 +- `triage/agent-ready`:高优先级、低风险 issue,适合作为后续 agent 执行候选 + +本 MVP 版本还不会自动修复 issue。它聚焦在评分、路由、打标签,以及让 +backlog 持续流动。 + +当前版本支持两种执行模式: + +- 仅规则分诊 +- 规则 + 兼容 OpenAI 的 LLM 辅助 + +## 为什么这样拆分 + +最初的方案把优先级和执行难度混在同一个决策里。实践上,如果把它们拆开, +系统会更容易调参: + +- `Priority`:这个 issue 现在是否值得投入时间? +- `Route`:一旦值得处理,应该由谁来接手? + +这样一来,高价值但高难度的 issue 仍然可以保持高优先级,同时继续路由到 +`triage/core`。 + +## 输入 + +自动化会读取 issue 的实时标题、正文、标签、评论和时间戳。 + +结构化的 issue 表单字段来自: + +- [bug_report.yml](../.github/ISSUE_TEMPLATE/bug_report.yml) +- [feature_request.yml](../.github/ISSUE_TEMPLATE/feature_request.yml) +- [reward-task.yml](../.github/ISSUE_TEMPLATE/reward-task.yml) + +## 评分模型 + +每个 issue 会沿四个维度评分: + +- `impact`(1-5):对用户和工作流的影响 +- `urgency`(1-5):发布时间压力、功能损坏情况或重复讨论程度 +- `effort`(1-5):预估改动规模和协作成本 +- `confidence`(1-5):issue 描述的完整性和可执行程度 + +优先级计算公式如下: + +```text +priority = impact * 0.45 + urgency * 0.35 + age_boost + engagement_boost +``` + +其中: + +- `age_boost`:基于 SLA 的升级机制 + - 第 7-9 天:预热阶段,最低提升到 `priority/p2` + - 第 10-13 天:强制移出 `triage/deferred`,最低提升到 `priority/p1` + - 第 14 天及以后:在下一次 triage/rescore 时,将该 issue 视为已违反 SLA, + 并至少提升到 `priority/p0` +- `engagement_boost`:由评论压力和奖励金额共同决定,上限为 +1.0 + +在 MVP 中,`effort` 不会直接降低优先级,它只影响路由。 + +## LLM 辅助分诊 + +配置后,工作流可以调用兼容 OpenAI 的 chat completions API。 + +LLM 不会替代规则引擎。它只用于辅助: + +- 生成 issue 摘要 +- 对软性分数做微调 +- 生成 `needs-info` 的追问问题 +- 为维护者提供更好的判断依据 +- 为 `triage/core` 生成 maintainer 交接摘要 + +硬性门槛仍然由规则控制: + +- 缺失必填信息 +- auth、schema、migration、SDK 或公共契约变更等高风险区域 +- 最终是否可以提升到 `triage/agent-ready` + +issue 正文和评论都视为不可信输入。工作流会: + +- 在发送给模型前截断过长的正文和评论 +- 明确告诉模型,issue 文本是数据而不是指令 +- 使用严格的 JSON 协议校验模型输出 +- 如果 provider 调用失败或 JSON 校验失败,则回退到仅规则模式 + +### 模式 + +- `off`:仅规则 +- `shadow`:调用 LLM 并展示其建议,但最终仍沿用仅规则的路由和标签 +- `assist`:允许 LLM 对软性分数做最多 `+/-1` 的微调,然后重新应用硬性门槛 + +### 何时使用 LLM + +工作流只会在 issue 看起来存在歧义或价值较高时调用 LLM,例如: + +- `triage/needs-info` +- `triage/core` +- 靠近路由阈值的 issue +- 低置信度案例 +- 正文很长或讨论很多的 issue +- 需要更多判断的 feature 或 reward issue + +## 路由规则 + +1. `triage/needs-info` + 当缺少必填字段或 `confidence <= 2` 时触发。 + +2. `triage/deferred` + 当 `priority < 3.6`、issue 不受信息缺失阻塞、且 issue 年龄仍低于 SLA + 升级底线时触发。 + +3. `triage/core` + 当 `priority >= 3.6` 且满足以下任一条件时触发: + - issue 阻塞了 OpenClaw/ClawHub 核心工作流,例如 install、publish、 + update、sync 或基于 namespace 的发布 + - `effort >= 4` + - `confidence <= 3` + - 存在高风险关键词或会影响契约的字段 + +4. `triage/agent-ready` + 当 `priority >= 3.6`、`effort <= 3`、`confidence >= 4`,且不存在高风险 + 信号时触发。 + +在 `assist` 模式下,LLM 建议可以对 `impact`、`urgency`、`effort` 和 +`confidence` 各自最多调整 1 分。规则引擎随后会重新计算优先级和路由。 + +涉及 OpenClaw/ClawHub 核心工作流的 issue 是进入 `triage/core` 的硬性门槛; +LLM 辅助不会放宽这一规则。 + +## 受管标签 + +自动化负责管理以下标签前缀: + +- `triage/` +- `priority/` +- `effort/` +- `risk/` + +当前使用的具体标签有: + +- `triage/needs-info` +- `triage/deferred` +- `triage/core` +- `triage/agent-ready` +- `priority/p0` +- `priority/p1` +- `priority/p2` +- `priority/p3` +- `effort/s` +- `effort/m` +- `effort/l` +- `risk/high` + +其余所有标签都保持不变。 + +另外,自动化还识别一个不由其管理的人工操作标签: + +- `triage-manual`:冻结该 issue 的自动分诊更新 + +## 工作流 + +### 1. Issue 分诊 + +文件:[issue-triage.yml](../.github/workflows/issue-triage.yml) + +触发条件: + +- `issues.opened` +- `issues.edited` +- `issues.reopened` +- 当评论包含 `/retriage` 时触发 `issue_comment.created` +- `workflow_dispatch` + +执行动作: + +- 拉取 issue 和评论 +- 计算分数和路由 +- 更新或创建受管标签 +- 更新或创建一条分诊评论,其中同时包含人类可读的判断理由和隐藏的机器状态 +- 可选调用兼容 OpenAI 的 provider,并合并结果 + +### 2. Deferred Backlog 重新评分 + +文件: +[issue-backlog-rescore.yml](../.github/workflows/issue-backlog-rescore.yml) + +触发条件: + +- 每 6 小时一次 +- `workflow_dispatch` + +执行动作: + +- 列出所有带有 `triage/deferred` 标签的 open issue +- 结合年龄和参与度加成重新计算优先级 +- 决定将每个 issue 升级还是保留 +- 原地更新分诊评论 +- 当 issue 内容未变化时复用缓存的 LLM 结果 + +试运行说明: + +- 当前定时 rescore 只扫描 `triage/deferred` 队列中的 issue +- 这可以保证低优先级 backlog 不会在 `deferred` 中闲置超过第 10 天 +- 一旦某个 issue 已经从 `deferred` 中升级出去,之后第 14 天的进一步升级 + 依赖新的 triage 事件或手动 `/retriage` +- 在试运行阶段,14 天规则应被视为运营层面的 SLA 目标,而不是仓库范围内的 + 硬性计时器 + +## 脚本 + +新的 GitHub 自动化脚本位于 +[`.github/scripts`](/Users/wowo/workspace/skillhub/.github/scripts): + +- [github.ts](/Users/wowo/workspace/skillhub/.github/scripts/github.ts):精简版 + GitHub REST 客户端 +- [issue-triage-config.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage-config.ts): + 标签、阈值和关键词规则 +- [issue-llm-config.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-llm-config.ts): + LLM 模式、环境变量和调用启发式 +- [issue-llm-provider.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-llm-provider.ts): + 兼容 OpenAI 的 chat completions 客户端 +- [issue-llm-evaluator.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-llm-evaluator.ts): + prompt 构造、JSON 校验和缓存 key 生成 +- [issue-triage-lib.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage-lib.ts): + 解析、评分、路由和评论渲染 +- [issue-triage-merge.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage-merge.ts): + 有界合并和硬性门槛重应用 +- [issue-triage.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-triage.ts): + 单 issue 入口 +- [issue-backlog-rescore.ts](/Users/wowo/workspace/skillhub/.github/scripts/issue-backlog-rescore.ts): + deferred 队列重新评分入口 + +## 配置 + +设置以下 GitHub 仓库变量和 secret,即可启用 LLM 辅助分诊: + +仓库变量: + +- `ISSUE_TRIAGE_LLM_MODE` +- `ISSUE_TRIAGE_LLM_BASE_URL` +- `ISSUE_TRIAGE_LLM_MODEL` +- `ISSUE_TRIAGE_LLM_TIMEOUT_MS` 可选 +- `ISSUE_TRIAGE_LLM_TEMPERATURE` 可选 +- `ISSUE_TRIAGE_LLM_MAX_COMMENTS` 可选 +- `ISSUE_TRIAGE_LLM_MAX_COMMENT_CHARS` 可选 +- `ISSUE_TRIAGE_LLM_MAX_BODY_CHARS` 可选 + +仓库 secret: + +- `ISSUE_TRIAGE_LLM_API_KEY` + +建议的第一轮上线方式: + +- `ISSUE_TRIAGE_LLM_MODE=shadow` +- 先观察几天分诊评论 +- 等 LLM 建议看起来稳定后,再切换到 `assist` + +兼容 OpenAI 的变量示例: + +```text +ISSUE_TRIAGE_LLM_MODE=shadow +ISSUE_TRIAGE_LLM_BASE_URL=https://your-provider.example.com/v1 +ISSUE_TRIAGE_LLM_MODEL=gpt-4.1-mini +``` + +## 推出计划 + +### Phase 1:当前阶段 + +- 启用 triage 和 backlog rescore +- 观察几周的 issue 流量后微调阈值 +- 允许维护者通过 `triage-manual` 冻结特定 issue 的自动化处理 +- 如果使用 LLM,从 `shadow` 模式开始 + +### Phase 2:Maintainer 交接 + +为 `triage/core` issue 增加 issue-brief 生成器,输出内容包括: + +- 复现提示 +- 可能涉及的模块 +- 风险备注 +- 验证清单 + +这些输出可以直接用于本地编程 agent 会话,以及现有的并行 worktree 流程。 + +当前 MVP 已经会在 `triage/core` issue 的分诊评论中直接嵌入一个 +`Maintainer Brief` 区块。该摘要包括: + +- 简洁的 issue 摘要 +- issue 为什么被升级到 core +- 复现路径或操作路径备注 +- 疑似相关模块或工作流负责人 +- 风险提示 +- 验证清单 + +### Phase 3:自托管 Issue Agent + +增加一个自托管 runner,监听 `triage/agent-ready`,并执行: + +- 创建隔离的分支和 worktree +- 运行解决 issue 的 agent +- 执行最小相关测试集 +- 打开一个 draft PR + +在这个阶段,以下场景仍应保留硬性阻断: + +- auth 和权限变更 +- 安全敏感变更 +- schema 或 migration 相关工作 +- 公共 API、SDK 或 CLI 契约变更 + +## 待调优问题 + +- 参与度加成是否只看评论数就够了,还是也应该拉取 reactions +- reward issue 是否应比当前 MVP 获得更强的价值加成 +- `agent-ready` 是否应要求 `effort <= 2`,而不是 `<= 3` +- 某些区域(如 `scanner`)是否应默认视为高风险 +- 某些团队是否应长期保持 `shadow` 模式,只把 `assist` 用在更窄的仓库子集上 diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 0e1b54d9..32cb2878 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -110,8 +110,11 @@ npx clawhub list --help ### 5. Publish Skills ```bash -# Publish skill (requires appropriate permissions) +# Publish to the global namespace (requires appropriate permissions) npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 + +# Publish to a team namespace such as my-space +npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0 npx clawhub sync --all # Upload all skills in current folder # Help @@ -119,6 +122,10 @@ npx clawhub publish --help npx clawhub sync --help ``` +Notes: +- `my-space--my-skill` is the canonical compatibility slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill` +- To avoid mismatches between CLI display text and the final persisted coordinate, keep the `name` in `SKILL.md` aligned with the canonical slug suffix + ## API Endpoints SkillHub compatibility layer provides the following endpoints: diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index 17fd165b..f85f588e 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -110,8 +110,11 @@ npx clawhub list --help ### 5. 发布技能 ```bash -# 发布技能(需要相应权限) +# 发布到 global 空间(需要相应权限) npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 + +# 发布到如 my-space 这样的团队空间 +npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0 npx clawhub sync --all # 上传当前文件夹中所有的 skill # 使用帮助 @@ -119,6 +122,10 @@ npx clawhub publish --help npx clawhub sync --help ``` +说明: +- `my-space--my-skill` 是兼容层 canonical slug,SkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill` +- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致 + ## API 端点说明 SkillHub 兼容层提供以下端点: diff --git a/docs/oss-01-core-contract-freeze.md b/docs/oss-01-core-contract-freeze.md new file mode 100644 index 00000000..617f705c --- /dev/null +++ b/docs/oss-01-core-contract-freeze.md @@ -0,0 +1,460 @@ +# OSS-01 Core 契约审计与冻结 + +## 1. 审计结论 + +SkillHub 开源项目已具备 AstronClaw 主链路所需的绝大部分 Core 能力。现有接口覆盖了 skill 唯一标识查询、版本元数据查询、创建(发布)和删除。**无需在开源 Core 中新增 AstronClaw 专属接口**;对 AstronClaw 而言,查询类和主链路类能力都应统一由 SaaS 层 `AstronClaw Adapter` 封装后对外提供,而不是直接绑定开源 Core 的接口形态。 + +--- + +## 2. Core 接口清单 + +以下接口构成 Core 基线能力,供 SaaS 层统一封装后对 AstronClaw 提供;这些接口本身不应被视为 AstronClaw 的长期直接契约。 + +### 2.1 skill 唯一标识与详情查询 + +| 接口 | 路径 | 说明 | +|------|------|------| +| skill 详情 | `GET /api/v1/skills/{namespace}/{slug}` | 返回 `SkillDetailResponse`,包含完整 identity 和状态 | +| 版本解析 | `GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag=&hash=` | 返回 `ResolveVersionResponse`,解析人类可读版本选择器到精确版本 | + +### 2.2 指定版本安装元数据查询 + +| 接口 | 路径 | 说明 | +|------|------|------| +| 版本详情 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` | 返回 `SkillVersionDetailResponse`,含 metadata 和 manifest | +| 版本文件列表 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files` | 返回 `List` | +| 版本下载 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本 bundle | +| 版本列表 | `GET /api/v1/skills/{namespace}/{slug}/versions?page=&size=` | 分页返回版本列表 | + +### 2.3 创建(发布)个人 skill + +| 接口 | 路径 | 说明 | +|------|------|------| +| 发布 skill | `POST /api/v1/skills/{namespace}/publish` | 上传包并发布,返回 `PublishResponse` | + +### 2.4 删除个人 skill + +| 接口 | 路径 | 说明 | +|------|------|------| +| 硬删除(by ID) | `DELETE /api/v1/skills/id/{skillId}` | 需 SUPER_ADMIN 权限 | +| 硬删除(by 坐标) | `DELETE /api/v1/skills/{namespace}/{slug}` | 需 SUPER_ADMIN 权限 | +| 归档 | `POST /api/v1/skills/{namespace}/{slug}/archive` | owner 或 namespace admin 可操作 | +| 取消归档 | `POST /api/v1/skills/{namespace}/{slug}/unarchive` | 恢复为 ACTIVE | + +### 2.5 版本生命周期 + +| 接口 | 路径 | 说明 | +|------|------|------| +| 删除版本 | `DELETE /api/v1/skills/{namespace}/{slug}/versions/{version}` | 仅 DRAFT/REJECTED/SCAN_FAILED 可删 | +| 撤回审核 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | PENDING_REVIEW → DRAFT | +| 重新发布 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/rerelease` | 重新发布版本 | + +### 2.6 ClawHub 兼容接口(已有) + +| 接口 | 路径 | 说明 | +|------|------|------| +| 解析 skill | `GET /api/v1/resolve?slug=&version=` | ClawHub 协议兼容 | +| 解析 skill(路径) | `GET /api/v1/resolve/{canonicalSlug}?version=` | ClawHub 协议兼容 | +| 下载 | `GET /api/v1/download/{canonicalSlug}?version=` | 302 重定向到下载地址 | +| 删除 skill | `DELETE /api/v1/skills/{canonicalSlug}` | owner 可操作 | +| 取消删除 | `POST /api/v1/skills/{canonicalSlug}/undelete` | owner 可操作 | +| 发布 skill | `POST /api/v1/skills` | ClawHub 协议兼容 | +| 发布到 namespace | `POST /api/v1/publish` | ClawHub 协议兼容 | + +--- + +## 3. 字段语义冻结表 + +### 3.1 Skill Identity 字段 + +| 字段 | 类型 | 含义 | 稳定性 | 说明 | +|------|------|------|--------|------| +| `skill.id` | Long | skill 全局唯一主键 | 不可变 | 自增,创建后永不改变,可作为外部映射主键 | +| `namespace` (slug) | String(64) | skill 所属命名空间标识 | 不可变 | 全局唯一,创建后不可改名 | +| `skill.slug` | String(100) | skill 在 namespace 内的唯一标识 | 不可变 | 创建后不可改名,`namespace + slug` 构成业务坐标 | +| `skill.displayName` | String(200) | skill 展示名称 | 可变 | 仅用于展示,不可作为映射依据 | +| `skill.ownerId` | String | skill 创建者 ID | 不可变 | 创建时绑定,不可转移 | +| `skill.summary` | String(TEXT) | skill 简介 | 可变 | 展示用 | +| `skill.visibility` | Enum | 可见性 | 可变 | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | +| `skill.status` | Enum | skill 状态 | 可变 | `ACTIVE` / `HIDDEN` / `ARCHIVED` | +| `skill.hidden` | boolean | 是否被管理员隐藏 | 可变 | 与 status 独立的隐藏标记 | +| `skill.latestVersionId` | Long | 最新版本指针 | 可变 | 指向当前最新已发布版本,yank/删除后自动回退 | +| `skill.downloadCount` | Long | 下载次数 | 可变 | 累计值 | +| `skill.starCount` | Integer | 收藏数 | 可变 | 累计值 | + +### 3.2 SkillVersion 字段 + +| 字段 | 类型 | 含义 | 稳定性 | 说明 | +|------|------|------|--------|------| +| `version.id` | Long | 版本全局唯一主键 | 不可变 | 自增 | +| `version.skillId` | Long | 所属 skill ID | 不可变 | 外键 | +| `version.version` | String(64) | 版本号 | 不可变 | 如 `1.0.0`,创建后不可改 | +| `version.status` | Enum | 版本状态 | 可变 | 见状态语义表 | +| `version.bundleReady` | boolean | bundle 是否可用 | 可变 | `true` 表示 bundle 已构建完成,可下载安装 | +| `version.downloadReady` | boolean | 是否允许下载 | 可变 | yank 后设为 `false` | +| `version.publishedAt` | Instant | 发布时间 | 一次写入 | 首次发布时设置 | +| `version.parsedMetadataJson` | JSONB | 解析后的元数据 | 一次写入 | 包含 `package_name` 等运行时信息 | +| `version.manifestJson` | JSONB | manifest 原始内容 | 一次写入 | skill 包的 manifest | +| `version.changelog` | String(TEXT) | 变更日志 | 可变 | 展示用 | +| `version.fileCount` | Integer | 文件数量 | 一次写入 | 发布时确定 | +| `version.totalSize` | Long | 总大小(字节) | 一次写入 | 发布时确定 | +| `version.yankedAt` | Instant | yank 时间 | 一次写入 | yank 时设置 | +| `version.yankReason` | String(TEXT) | yank 原因 | 一次写入 | yank 时设置 | + +### 3.3 关键字段含义冻结 + +| 字段 | 冻结定义 | +|------|----------| +| `skill_id` | `skill.id`,Long 类型自增主键,全局唯一,创建后不可变。AstronClaw 应以此作为 `external_skill_mapping` 的外部主键 | +| `namespace` | `namespace.slug`,String(64),全局唯一,不可改名。与 `slug` 组合构成业务坐标 | +| `slug` | `skill.slug`,String(100),namespace 内唯一,不可改名。`namespace/slug` 是人类可读的稳定坐标 | +| `version` | `skill_version.version`,String(64),同一 skill 内唯一,不可改。如 `1.0.0` | +| `bundle_url` | 通过 `GET /{namespace}/{slug}/versions/{version}/download` 获取,或通过 `resolve` 接口的 `downloadUrl` 字段获取。不是数据库字段,而是动态生成的下载地址 | +| `bundle_ready` | `skill_version.bundleReady`,boolean。`true` 表示 bundle 已构建完成可安装。AstronClaw 安装前必须校验此字段 | +| `package_name` | 存储在 `skill_version.parsedMetadataJson` 中,从 skill 包的 manifest 解析而来。同一 skill 跨版本应保持稳定。AstronClaw 用于运行时安装/卸载标识 | + +### 3.4 Namespace 字段 + +| 字段 | 类型 | 含义 | 稳定性 | +|------|------|------|--------| +| `namespace.id` | Long | 命名空间主键 | 不可变 | +| `namespace.slug` | String(64) | 命名空间标识 | 不可变,全局唯一 | +| `namespace.displayName` | String(128) | 展示名称 | 可变 | +| `namespace.type` | Enum | 类型 | 不可变,`GLOBAL` / `TEAM` | +| `namespace.status` | Enum | 状态 | 可变,`ACTIVE` / `FROZEN` / `ARCHIVED` | + +--- + +## 4. 状态语义冻结表 + +### 4.1 Skill 状态(`SkillStatus`) + +| 状态 | 市场可见 | 可新装 | 已装是否保留 | 可被 owner 操作 | 说明 | +|------|----------|--------|------------|----------------|------| +| `ACTIVE` | 是(受 visibility 控制) | 是(需有 PUBLISHED 版本) | 是 | 是 | 正常状态 | +| `HIDDEN` | 否 | 否 | 是 | 受限 | 管理员隐藏,独立于 status 的 `hidden` 标记 | +| `ARCHIVED` | 否 | 否 | 是 | 可取消归档 | owner 或 namespace admin 归档 | + +### 4.2 版本状态(`SkillVersionStatus`) + +| 状态 | 是否允许安装 | 是否允许下载 | 市场可见 | 可转换到 | 说明 | +|------|------------|------------|---------|---------|------| +| `DRAFT` | 否 | 否 | 否 | SCANNING, 可删除 | 初始状态,编辑中 | +| `SCANNING` | 否 | 否 | 否 | SCAN_FAILED, PENDING_REVIEW, PUBLISHED | 安全扫描中 | +| `SCAN_FAILED` | 否 | 否 | 否 | 可删除 | 安全扫描失败 | +| `PENDING_REVIEW` | 否 | 否 | 否 | PUBLISHED, REJECTED, → DRAFT(撤回) | 等待审核 | +| `PUBLISHED` | 是 | 是 | 是 | YANKED | 已发布,可安装 | +| `REJECTED` | 否 | 否 | 否 | 可删除 | 审核拒绝 | +| `YANKED` | 否 | 否 | 否(或弱可见) | 不可逆 | 已撤回,已装不受影响 | + +### 4.3 可见性(`SkillVisibility`) + +| 可见性 | 市场列表可见 | 谁可查看 | 谁可安装 | +|--------|------------|---------|---------| +| `PUBLIC` | 是 | 所有人 | 所有人(需 PUBLISHED + bundleReady) | +| `NAMESPACE_ONLY` | 否 | namespace 成员 | namespace 成员 | +| `PRIVATE` | 否 | 仅 owner | 仅 owner | + +### 4.4 删除语义 + +| 操作 | 类型 | 可逆 | 数据影响 | 已装实例影响 | +|------|------|------|---------|------------| +| 硬删除 skill | 永久删除 | 否 | 删除所有记录、文件、存储对象,slug 可复用 | 不影响,AstronClaw 已装快照独立 | +| 归档 skill | 状态变更 | 是 | 无数据删除,status → ARCHIVED | 不影响 | +| 隐藏 skill | 标记变更 | 是 | 无数据删除,hidden → true | 不影响 | +| 删除版本 | 永久删除 | 否 | 仅删除 DRAFT/REJECTED/SCAN_FAILED 版本 | 不影响(这些版本未被安装) | +| Yank 版本 | 状态变更 | 否 | status → YANKED,downloadReady → false | 不影响已装实例 | + +### 4.5 AstronClaw 安装判断规则 + +AstronClaw 判断一个 skill 版本是否可安装,需同时满足: + +``` +skill.status == ACTIVE + AND skill.hidden == false + AND skill.visibility 允许当前用户访问 + AND version.status == PUBLISHED + AND version.bundleReady == true +``` + +已安装实例不受后续状态变更影响。即使 skill 被删除/归档/隐藏,或版本被 yank,AstronClaw 本地安装快照仍可正常使用和卸载。 + +## 5. 错误语义表 + +### 5.1 统一响应结构 + +```json +{ + "code": 0, + "msg": "操作成功", + "data": { ... }, + "timestamp": "2026-04-10T08:00:00Z", + "requestId": "req-xxx" +} +``` + +- `code = 0` 表示成功 +- `code > 0` 表示错误,值为 HTTP 状态码 + +### 5.2 错误码映射 + +| HTTP 状态码 | 场景 | 异常类型 | 说明 | +|------------|------|---------|------| +| 400 | 参数非法 | `BadRequestException` / `DomainBadRequestException` | 请求参数校验失败 | +| 401 | 未认证 | `UnauthorizedException` / `AuthFlowException` | 未登录或 token 过期 | +| 403 | 无权限 | `ForbiddenException` / `DomainForbiddenException` | 无操作权限 | +| 404 | 未找到 | `DomainNotFoundException` | skill/version/namespace 不存在 | +| 408 | 请求超时 | `AsyncRequestTimeoutException` | 异步请求超时 | +| 503 | 存储不可用 | `StorageAccessException` | 对象存储访问失败 | +| 500 | 服务异常 | `Exception` | 未预期的内部错误 | + +### 5.3 Core 主链路关键错误场景 + +| 场景 | HTTP 状态码 | msg 示例 | AstronClaw 处理建议 | +|------|-----------|---------|-------------------| +| skill 不存在 | 404 | `error.skill.notFound` | 映射失败,提示用户 | +| 版本不存在 | 404 | `error.skill.notFound` | 安装/升级失败,提示用户 | +| 版本不可安装(非 PUBLISHED) | 400 | `error.badRequest` | 拒绝安装,提示版本状态 | +| bundle 未就绪 | 400 | `error.badRequest` | 拒绝安装,提示稍后重试 | +| 无权访问(PRIVATE skill) | 403 | `error.forbidden` | 提示无权限 | +| namespace 不存在 | 404 | `error.namespace.notFound` | 映射失败 | +| 存储服务不可用 | 503 | `error.storage.unavailable` | 降级处理,已装 skill 不受影响 | +| 删除不允许(非 owner) | 403 | `error.forbidden` | 提示无权限 | + +--- + +## 6. Core vs SaaS Adapter 能力分界 + +### 6.1 Core 已满足的能力 + +说明: + +下表表示“开源 Core 已具备、可供 SaaS 封装”的能力,并不表示 AstronClaw 应直接调用这些开源接口。 + +| PRD 需求 | Core 接口 | 满足程度 | 备注 | +|---------|----------|---------|------| +| skill 唯一标识查询 | `GET /{namespace}/{slug}` | 完全满足 | 返回 `id`、`namespace`、`slug` | +| 指定版本安装元数据 | `GET /{namespace}/{slug}/versions/{version}` | 基本满足 | 返回 status、metadata;`package_name` 在 `parsedMetadataJson` 中 | +| 版本解析 | `GET /{namespace}/{slug}/resolve` | 完全满足 | 支持 version/tag/hash 解析 | +| bundle 下载 | `GET /{namespace}/{slug}/versions/{version}/download` | 完全满足 | 直接下载 | +| 创建(发布)个人 skill | `POST /{namespace}/publish` | 完全满足 | 返回 skillId、namespace、slug、version、status | +| 删除个人 skill | `DELETE /{namespace}/{slug}` (ClawHub 兼容) | 完全满足 | owner 可操作 | +| 归档 skill | `POST /{namespace}/{slug}/archive` | 完全满足 | 可逆操作 | +| 版本状态查询 | `GET /{namespace}/{slug}` 中的 headlineVersion/publishedVersion | 完全满足 | 包含版本状态 | +| labels 数据 | `GET /{namespace}/{slug}` 中的 labels 字段 | 完全满足 | 返回 `List` | + +### 6.2 需要 SaaS Adapter 新增的能力 + +| PRD 需求 | 原因 | Adapter 建议 | +|---------|------|-------------| +| 市场列表查询(搜索/过滤/排序) | Core 不提供面向页面的聚合列表 | `GET /api/v1/astronclaw/adapter/skills/market` | +| 市场详情(AstronClaw DTO) | Core 返回的 DTO 包含 Core 内部字段,需适配 | `GET /api/v1/astronclaw/adapter/skills/{id}` | +| owner 维度"我创建的"查询 | Core 的 `/me/skills` 返回 Core DTO,需适配 | `GET /api/v1/astronclaw/adapter/skills/mine` | +| `is_installed` 补全 | 安装关系在 AstronClaw 侧 | AstronClaw 本地补全,不在 Adapter | +| `package_name` 顶层字段 | 当前在 `parsedMetadataJson` 内,需提取 | Adapter 解析 JSON 后平铺返回 | +| `bundle_url` 直接返回 | 当前需通过 download 接口获取 | Adapter 可直接返回预签名 URL | +| 统一 `can_install` 判断 | 需组合 status + visibility + bundleReady | Adapter 计算后返回布尔值 | +| 统一 `can_delete` 判断 | 需组合 owner + status | Adapter 计算后返回布尔值 | + +### 6.3 分界原则 + +``` +Core 负责:skill 生命周期真相(identity、version、status、artifact) +Adapter 负责:面向 AstronClaw 的 DTO 适配(字段平铺、状态聚合、权限预判断) +``` + +补充原则: + +1. 即使开源 `Core` 已经具备某项主链路能力,`AstronClaw` 仍应统一通过 SaaS Adapter 消费。 +2. 该原则同时适用于唯一标识查询、版本元数据、创建个人 skill、删除个人 skill。 +3. 开源文档中的接口清单用于说明 `Core` 能力边界,不应被解读为 AstronClaw 的直接对接建议。 + +--- + +## 7. 成功 / 失败 / 边界样例 + +### 7.1 查询 skill identity — 成功 + +``` +GET /api/v1/skills/my-namespace/my-skill +``` + +```json +{ + "code": 0, + "data": { + "id": 42, + "slug": "my-skill", + "displayName": "My Skill", + "ownerId": "user-123", + "status": "ACTIVE", + "visibility": "PUBLIC", + "namespace": "my-namespace", + "labels": [{"slug": "nlp", "type": "CATEGORY", "displayName": "NLP"}], + "headlineVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"}, + "publishedVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"} + } +} +``` + +AstronClaw 映射关键字段:`id=42`,`namespace=my-namespace`,`slug=my-skill`。 + +### 7.2 查询 skill identity — 不存在 + +``` +GET /api/v1/skills/my-namespace/nonexistent +``` + +```json +{ + "code": 404, + "msg": "Skill not found", + "data": null +} +``` + +### 7.3 查询指定版本元数据 — 成功 + +``` +GET /api/v1/skills/my-namespace/my-skill/versions/1.2.0 +``` + +```json +{ + "code": 0, + "data": { + "id": 100, + "version": "1.2.0", + "status": "PUBLISHED", + "changelog": "Bug fixes", + "fileCount": 3, + "totalSize": 102400, + "publishedAt": "2026-04-01T10:00:00Z", + "parsedMetadataJson": "{\"name\":\"my-skill\",\"package_name\":\"my_namespace__my_skill\",\"version\":\"1.2.0\"}", + "manifestJson": "{...}" + } +} +``` + +`package_name` 从 `parsedMetadataJson` 中提取。 + +### 7.4 查询已 YANKED 版本 + +``` +GET /api/v1/skills/my-namespace/my-skill/versions/1.0.0 +``` + +```json +{ + "code": 0, + "data": { + "id": 98, + "version": "1.0.0", + "status": "YANKED", + "publishedAt": "2026-03-01T10:00:00Z" + } +} +``` + +AstronClaw 判断 `status != PUBLISHED`,拒绝新安装。已装实例不受影响。 + +### 7.5 发布(创建)个人 skill — 成功 + +``` +POST /api/v1/skills/my-namespace/publish +Content-Type: multipart/form-data +file: +visibility: PRIVATE +``` + +```json +{ + "code": 0, + "data": { + "skillId": 43, + "namespace": "my-namespace", + "slug": "new-skill", + "version": "0.1.0", + "status": "DRAFT", + "fileCount": 2, + "totalSize": 51200 + } +} +``` + +### 7.6 删除个人 skill — 成功 + +``` +DELETE /api/v1/skills/my-namespace/my-skill +``` + +```json +{ + "code": 0, + "data": { + "ok": true + } +} +``` + +### 7.7 删除个人 skill — 无权限 + +``` +DELETE /api/v1/skills/other-namespace/other-skill +``` + +```json +{ + "code": 403, + "msg": "Forbidden", + "data": null +} +``` + +### 7.8 边界:skill 已归档后查询 + +``` +GET /api/v1/skills/my-namespace/archived-skill +``` + +```json +{ + "code": 0, + "data": { + "id": 44, + "slug": "archived-skill", + "status": "ARCHIVED", + "visibility": "PUBLIC" + } +} +``` + +skill 仍可查询,但 AstronClaw 应根据 `status=ARCHIVED` 判断不可新装。 + +--- + +## 8. 遗留问题与建议 + +### 8.1 `package_name` 提取 + +当前 `package_name` 嵌套在 `parsedMetadataJson` JSONB 字段中,不是顶层字段。 + +建议:SaaS Adapter 在返回 AstronClaw DTO 时,解析 JSON 并将 `package_name` 提取为顶层字段。Core 不需要改动。 + +### 8.2 `bundle_url` 获取方式 + +当前没有直接返回 `bundle_url` 的字段,需通过 download 接口获取。`ResolveVersionResponse` 中有 `downloadUrl` 字段。 + +建议:SaaS Adapter 可通过 `resolve` 接口获取 `downloadUrl`,或直接生成预签名 URL 返回给 AstronClaw。 + +### 8.3 删除接口权限 + +当前 `DELETE /api/v1/skills/{namespace}/{slug}`(portal 路径)需要 SUPER_ADMIN 权限。ClawHub 兼容接口 `DELETE /api/v1/skills/{canonicalSlug}` 允许 owner 操作。 + +建议:SaaS Adapter 应统一封装 owner 可操作的删除接口,对 AstronClaw 暴露稳定契约;AstronClaw 不直接依赖开源删除接口路径。 + +### 8.4 `hidden` 与 `status` 的关系 + +当前 `hidden` 是独立于 `status` 的布尔标记(管理员操作),而 `HIDDEN` 是 `SkillStatus` 枚举值之一但实际代码中 skill 的 status 枚举包含 `ACTIVE`、`HIDDEN`、`ARCHIVED`。 + +建议:SaaS Adapter 统一为 AstronClaw 提供一个 `is_visible` 聚合字段,屏蔽内部 hidden 标记与 status 的复杂关系。 diff --git a/docs/oss-02-core-semantic-rules.md b/docs/oss-02-core-semantic-rules.md new file mode 100644 index 00000000..484b17ef --- /dev/null +++ b/docs/oss-02-core-semantic-rules.md @@ -0,0 +1,663 @@ +# OSS-02 Core 语义规则收口 + +## 1. 文档目标 + +本文档固化 SkillHub Core 的运行时语义规则,确保开源版与 SaaS 版对删除、YANKED、同名冲突、package_name 等规则口径一致,避免 AstronClaw 接入后出现状态漂移。本文定义的是可由 SaaS 统一封装并对 AstronClaw 提供的 `Core` 规则基线,不表示 AstronClaw 直接对接这些开源接口。 + +--- + +## 2. 变更概要 + +### 2.1 新增功能 + +| 功能 | 说明 | +|------|------| +| UPLOADED 状态 | 新增版本状态,表示"已上传,未提交审核" | +| PRIVATE skill 自动发布 | PRIVATE skill 发布后进入 UPLOADED 状态,不自动进入审核 | +| 提交审核接口 | 新增 `POST /{namespace}/{slug}/submit-review`,允许 UPLOADED 状态的版本提交审核 | +| 撤回审核后进入 UPLOADED | 撤回审核后版本状态变为 UPLOADED,而不是 DRAFT | + +### 2.2 状态机变更 + +**变更前**: +``` +DRAFT → SCANNING → PENDING_REVIEW → PUBLISHED + ↓ ↓ + REJECTED YANKED +``` + +**变更后**: +``` +DRAFT → SCANNING → UPLOADED → PENDING_REVIEW → PUBLISHED + ↓ ↓ ↓ ↓ + SCAN_FAILED (可删除) REJECTED YANKED + ↓ ↓ + (可删除) (可删除) +``` + +### 2.3 权限模型变更 + +**核心原则**:权限只和 status 相关,visibility 只影响状态流转。 + +--- + +## 3. 版本状态定义 + +### 3.1 状态枚举 + +```java +public enum SkillVersionStatus { + DRAFT, // 草稿,编辑中 + SCANNING, // 安全扫描中 + SCAN_FAILED, // 扫描失败 + UPLOADED, // 已上传,未提交审核(新增) + PENDING_REVIEW, // 等待审核 + PUBLISHED, // 已发布 + REJECTED, // 审核拒绝 + YANKED // 已撤回 +} +``` + +### 3.2 状态语义 + +| 状态 | 含义 | 文件状态 | 可下载 | 可编辑 | 有检测报告 | +|------|------|---------|-------|-------|----------| +| DRAFT | 草稿,编辑中 | 可能不完整 | 否 | 是 | 否 | +| SCANNING | 安全扫描中 | 完整 | 否 | 否 | 否 | +| SCAN_FAILED | 扫描失败 | 完整 | 否 | 是 | 是(失败) | +| UPLOADED | 已上传,扫描通过 | 完整 | owner | 否 | 是 | +| PENDING_REVIEW | 审核中 | 完整 | owner | 否 | 是 | +| PUBLISHED | 已发布 | 完整 | 看 visibility | 否 | 是 | +| REJECTED | 审核拒绝 | 完整 | 否 | 是 | 是 | +| YANKED | 已撤回 | 完整 | 否 | 否 | 是 | + +--- + +## 4. 发布流程设计 + +### 4.1 发布路径 + +| visibility | 发布后初始状态 | 是否创建审核任务 | +|------------|--------------|----------------| +| PRIVATE | UPLOADED | 否 | +| NAMESPACE_ONLY | PENDING_REVIEW | 是 | +| PUBLIC | PENDING_REVIEW | 是 | + +### 4.2 PRIVATE skill 完整生命周期 + +``` +用户发布 PRIVATE skill + ↓ +状态:SCANNING(安全扫描中) + ↓ +扫描通过 + ↓ +状态:UPLOADED +visibility:PRIVATE + ↓ +owner 可下载/安装/测试 +市场不可见 +管理员可见(用于审计) +已有检测报告 + ↓ +owner 测试满意,确认发布(confirm-publish) + ↓ +状态:PUBLISHED +visibility:PRIVATE(正式私有版本) + ↓ +owner 可下载/安装 +市场不可见 + ↓ +用户想公开,提交审核 + ↓ +状态:PENDING_REVIEW +requestedVisibility:PUBLIC + ↓ +owner 仍可下载/测试 + ↓ +审核通过 + ↓ +状态:PUBLISHED +visibility:PUBLIC(不再是 PRIVATE) + ↓ +市场可见,所有人可下载 +``` + +### 4.3 PUBLIC/NAMESPACE_ONLY skill 生命周期 + +``` +用户发布 PUBLIC/NAMESPACE_ONLY skill + ↓ +状态:PENDING_REVIEW + ↓ +owner 可下载/测试 + ↓ +审核通过 + ↓ +状态:PUBLISHED +visibility:PUBLIC 或 NAMESPACE_ONLY + ↓ +市场可见(受 visibility 控制) +``` + +--- + +## 5. 权限矩阵 + +### 5.1 status 决定下载权限 + +| status | 市场可见 | 可下载 | +|--------|---------|-------| +| DRAFT | 否 | 否 | +| SCANNING | 否 | 否 | +| SCAN_FAILED | 否 | 否 | +| UPLOADED | 否 | owner | +| PENDING_REVIEW | 否 | owner | +| PUBLISHED | 看 visibility | 看 visibility | +| REJECTED | 否 | 否 | +| YANKED | 否 | 否 | + +### 5.2 PUBLISHED 状态下,visibility 决定可见性 + +| visibility | 市场可见 | 可下载 | +|------------|---------|-------| +| PUBLIC | 是 | 所有人 | +| NAMESPACE_ONLY | 命名空间内 | 命名空间成员 | +| PRIVATE | 否 | owner | + +### 5.3 AstronClaw 安装判断规则 + +``` +可安装 = + skill.status == ACTIVE + AND skill.hidden == false + AND 存在至少一个可下载版本 + AND 该版本 bundleReady == true + +可下载版本判断: + - UPLOADED/PENDING_REVIEW:仅 owner + - PUBLISHED:按 visibility 规则 +``` + +--- + +## 6. 状态流转详细设计 + +### 6.1 状态转换表 + +| 当前状态 | 操作 | 目标状态 | 说明 | +|---------|------|---------|------| +| DRAFT | 上传包 | SCANNING | 开始安全扫描 | +| SCANNING | 扫描通过 | UPLOADED 或 PENDING_REVIEW | 看 visibility | +| SCANNING | 扫描失败 | SCAN_FAILED | - | +| SCAN_FAILED | 重新上传 | SCANNING | - | +| UPLOADED | 提交审核 | PENDING_REVIEW | 新增操作 | +| UPLOADED | 确认发布 | PUBLISHED | PRIVATE skill 正式发布,不触发新扫描 | +| UPLOADED | 重新上传 | SCANNING | 允许重新上传 | +| UPLOADED | 删除 | (删除) | 允许删除,未正式发布 | +| PENDING_REVIEW | 审核通过 | PUBLISHED | - | +| PENDING_REVIEW | 审核拒绝 | REJECTED | - | +| PENDING_REVIEW | 撤回审核 | UPLOADED | 变更:原为 DRAFT | +| PUBLISHED | Yank | YANKED | - | +| REJECTED | 重新上传 | SCANNING | - | + +### 6.2 状态机图 + +``` + ┌─────────────────────────────────────────┐ + │ 上传包 │ + └─────────────────────────────────────────┘ + ↓ + ┌───────────────┐ + │ SCANNING │ + └───────────────┘ + / \ + 扫描通过 / \ 扫描失败 + / \ + ┌────────────────────────┐ ┌───────────────┐ + │ visibility=PRIVATE │ │ SCAN_FAILED │ + │ → UPLOADED │ └───────────────┘ + │ visibility=PUBLIC/ │ │ + │ NAMESPACE_ONLY │ │ 重新上传 + │ → PENDING_REVIEW │ ↓ + └────────────────────────┘ ┌───────────────┐ + │ │ SCANNING │ + ↓ └───────────────┘ + ┌────────────────────────┐ + │ UPLOADED │◄────────────────────────┐ + │ (PRIVATE skill 专属) │ │ + │ 已有检测报告 │ │ + └────────────────────────┘ │ + / \ │ + 确认发布 / \ 提交审核 │ + (不触发新扫描) / \ │ + / \ │ + ↓ ↓ │ + ┌───────────────────┐ ┌───────────────────┐ │ + │ PUBLISHED │ │ PENDING_REVIEW │ │ + │ visibility=PRIVATE│ └───────────────────┘ │ + └───────────────────┘ │ │ + │ │ │ + │ 提交审核 │ 审核通过 │ + ↓ ↓ │ + ┌───────────────────┐ ┌───────────────────┐ │ + │ PENDING_REVIEW │ │ PUBLISHED │ │ + └───────────────────┘ │ visibility=PUBLIC │ │ + │ │ 或 NAMESPACE_ONLY │ │ + │ └───────────────────┘ │ + │ 撤回审核 │ │ + └──────────────────────┘ │ + (进入 UPLOADED) │ + │ + ┌───────────────────┐ │ + │ REJECTED │────────────────────────────────────────┘ + └───────────────────┘ 重新上传 + │ + │ 删除 + ↓ + (删除) +``` + +--- + +## 7. 新增接口设计 + +说明: + +以下接口属于开源 `Core` 为 SaaS 提供的基础状态机能力。对 `AstronClaw` 而言,后续仍应统一通过 `SkillHub SaaS` 的 `AstronClaw Adapter` 消费这些能力,而不是直接绑定这些开源接口路径。 + +### 7.1 提交审核接口 + +**接口**:`POST /api/v1/skills/{namespace}/{slug}/submit-review` + +**请求参数**: +```json +{ + "version": "1.0.0", + "targetVisibility": "PUBLIC" +} +``` + +**前置条件**: +- 版本状态为 UPLOADED +- 操作者为 skill owner 或 namespace ADMIN/OWNER + +**执行效果**: +- 版本状态 → PENDING_REVIEW +- `requestedVisibility` 设为目标可见性 +- 创建审核任务 + +**响应**: +```json +{ + "code": 0, + "data": { + "versionId": 100, + "status": "PENDING_REVIEW", + "requestedVisibility": "PUBLIC" + } +} +``` + +### 7.2 确认发布接口(PRIVATE skill) + +**接口**:`POST /api/v1/skills/{namespace}/{slug}/confirm-publish` + +**请求参数**: +```json +{ + "version": "1.0.0" +} +``` + +**前置条件**: +- 版本状态为 UPLOADED +- skill.visibility = PRIVATE +- 操作者为 skill owner + +**执行效果**: +- 版本状态 → PUBLISHED +- visibility 保持 PRIVATE +- **不触发新的扫描**,复用 UPLOADED 时的扫描结果 +- 未来可扩展:加入"发布扫描"功能 + +**响应**: +```json +{ + "code": 0, + "data": { + "skillId": 42, + "versionId": 100, + "status": "PUBLISHED", + "visibility": "PRIVATE" + } +} +``` + +--- + +## 8. 删除 / 隐藏 / 归档 / YANKED 语义规则 + +### 8.1 操作语义总表 + +| 操作 | 触发方式 | 可逆 | 市场可见 | 可新装 | 已装保留 | 可卸载 | slug 可复用 | +|------|---------|------|---------|-------|---------|-------|-----------| +| **硬删除 skill** | owner 或 SUPER_ADMIN | 否 | 否 | 否 | 是 | 是 | 是 | +| **归档 skill** | owner / namespace admin | 是 | 否 | 否 | 是 | 是 | 否 | +| **隐藏 skill** | 管理员 | 是 | 否 | 否 | 是 | 是 | 否 | +| **Yank 版本** | owner / namespace admin | 否 | 否 | 否 | 是 | 是 | N/A | + +### 8.2 Yank 版本 + +**定义**:YANK 是"撤回已发布版本"的操作,用于将一个已发布的版本从可用状态移除。 + +**触发条件**: +- owner 或 namespace ADMIN/OWNER 对 PUBLISHED 状态的版本执行 yank + +**执行效果**: +- `version.status` → `YANKED`(不可逆,无 un-yank 操作) +- `version.downloadReady` → `false` +- 记录 `yankedAt`、`yankedBy`、`yankReason` +- 如果该版本是 `skill.latestVersionId` 指向的版本: + - 自动回退到上一个 PUBLISHED 版本 + - 如果没有其他 PUBLISHED 版本,`latestVersionId` → `null` + +**对 AstronClaw 的影响**: +- 已安装实例不受影响 +- 无法新装该版本 +- 升级场景:目标版本被 yank → 升级失败 + +对接原则: +- 上述语义应由 SaaS Adapter 原样继承并稳定对外提供 +- AstronClaw 通过 Adapter 感知这些状态,不直接绑定开源返回形态 + +**补救方式**: +- 不能 un-yank +- 只能发布新版本(rerelease 或重新上传) + +--- + +## 9. 同名冲突规则 + +### 9.1 唯一性约束 + +数据库约束:`UNIQUE(namespace_id, slug, owner_id)` + +含义: +- 同一 namespace 下,不同 owner 可以有相同 slug +- 同一 namespace 下,同一 owner 只能有一个相同 slug 的 skill + +### 9.2 冲突规则设计原则 + +**核心原则**:只有 PUBLISHED 状态才会阻塞同名发布,但区分 visibility。 + +| 对方状态 | 我发布同名 PRIVATE | 我发布同名 PUBLIC | 说明 | +|---------|-------------------|------------------|------| +| UPLOADED | ✅ 允许 | ✅ 允许 | 多个 UPLOADED 可共存 | +| PENDING_REVIEW | ✅ 允许 | ✅ 允许 | 还未正式发布 | +| PRIVATE + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 只允许一个正式私有版本 | +| PUBLIC + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 市场已占用 | + +### 9.3 冲突规则表(详细) + +| 场景 | 是否允许 | 说明 | +|------|---------|------| +| 同 namespace,同 slug,同 owner | 允许(复用) | 新版本挂到已有 skill 下 | +| 同 namespace,同 slug,不同 owner,对方只有 UPLOADED | 允许 | 多个 UPLOADED 可共存测试 | +| 同 namespace,同 slug,不同 owner,对方只有 PENDING_REVIEW | 允许 | 还未正式发布 | +| 同 namespace,同 slug,不同 owner,对方有 PRIVATE + PUBLISHED | 拒绝 | 只允许一个正式私有版本 | +| 同 namespace,同 slug,不同 owner,对方有 PUBLIC/NAMESPACE_ONLY + PUBLISHED | 拒绝 | 市场已占用 | +| 不同 namespace,同 slug | 允许 | namespace 隔离 | + +### 9.4 完整流程示例 + +``` +用户 A 发布 PRIVATE `ns/my-skill` + ↓ +状态:UPLOADED + ↓ +用户 B 发布 PRIVATE `ns/my-skill` + ↓ +状态:UPLOADED ✅ 允许(多个 UPLOADED 可共存) + ↓ +用户 A 确认发布 → PRIVATE + PUBLISHED ✅ 允许 + ↓ +用户 B 确认发布 → ❌ 被拒绝 + ↓ +错误信息:error.skill.publish.nameConflict.private + ↓ +用户 B 可以: + 1. 改名发布 + 2. 等用户 A 删除/归档后再发布 + 3. 提交审核变成 PUBLIC(如果 A 是 PRIVATE) +``` + +### 9.5 代码改动 + +**文件**:`SkillPublishService.java` + +```java +// 冲突检查逻辑(第 230-242 行) +for (Skill existing : existingSkills) { + if (!existing.getOwnerId().equals(publisherId)) { + // 检查是否有 PUBLISHED 版本 + boolean hasPublished = !skillVersionRepository + .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + + if (hasPublished) { + // PUBLISHED 版本存在,无论 visibility 如何都拒绝 + // 因为只允许一个 PRIVATE + PUBLISHED 或 PUBLIC + PUBLISHED + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } + } + } +} +``` + +### 9.6 错误信息 + +| 错误码 | 说明 | +|-------|------| +| `error.skill.publish.nameConflict` | 已有同名 PUBLIC/NAMESPACE_ONLY skill 发布 | +| `error.skill.publish.nameConflict.private` | 已有同名 PRIVATE skill 正式发布 | + +--- + +## 10. package_name / runtime 规则 + +### 10.1 当前实现 + +- `package_name` 不是 Core 的结构化字段 +- 存储在 `skill_version.parsedMetadataJson` JSONB 字段中 +- 由 skill 作者在 SKILL.md frontmatter 中定义 + +### 10.2 SaaS Adapter 职责 + +- 从 `parsedMetadataJson` 中提取 `package_name` +- 作为顶层字段返回给 AstronClaw +- 可选:检查跨 skill 的 package_name 唯一性 +- 统一封装 `submit-review`、`confirm-publish`、删除、查询等 Core 能力,对 AstronClaw 暴露稳定接口 + +### 10.3 规则建议 + +| 规则 | 建议 | +|------|------| +| 格式 | 建议使用 `namespace__slug` 格式,避免冲突 | +| 跨版本稳定性 | 同一 skill 跨版本应保持 package_name 一致 | +| 唯一性 | SaaS Adapter 可检查并警告冲突,但不强制阻止 | + +--- + +## 11. 代码改动清单 + +说明: + +以下改动属于开源 `Core` 的规则实现,用于给 SaaS 封装层提供稳定能力基线;不等同于直接向 AstronClaw 暴露这些开源接口。 + +### 11.1 枚举新增 + +**文件**:`SkillVersionStatus.java` + +```java +public enum SkillVersionStatus { + DRAFT, + SCANNING, + SCAN_FAILED, + UPLOADED, // 新增 + PENDING_REVIEW, + PUBLISHED, + REJECTED, + YANKED +} +``` + +### 11.2 发布逻辑改动 + +**文件**:`SkillPublishService.java` + +```java +// 第 279-285 行,改为 +if (visibility == SkillVisibility.PRIVATE) { + version.setStatus(SkillVersionStatus.UPLOADED); + version.setPublishedAt(currentTime()); + // 不创建审核任务 +} else if (autoPublish) { + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(currentTime()); +} else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + // 创建审核任务 +} +``` + +### 11.3 撤回审核改动 + +**文件**:`SkillGovernanceService.java` + +```java +// withdrawPendingVersion 方法,改为 +skillVersion.setStatus(SkillVersionStatus.UPLOADED); // 原为 DRAFT +``` + +### 11.4 下载权限改动 + +**文件**:`SkillDownloadService.java`、`SkillQueryService.java` + +```java +// UPLOADED 和 PENDING_REVIEW 状态允许 owner 下载 +private boolean canDownload(SkillVersion version, Skill skill, String currentUserId) { + return switch (version.getStatus()) { + case UPLOADED, PENDING_REVIEW -> skill.getOwnerId().equals(currentUserId); + case PUBLISHED -> true; // 按 visibility 判断 + default -> false; + }; +} +``` + +### 11.5 新增服务 + +**文件**:`SkillReviewSubmitService.java`(新增) + +- 实现 UPLOADED 版本提交审核逻辑 + +### 11.6 新增控制器 + +**文件**:`SkillReviewSubmitController.java`(新增) + +- 暴露 `POST /{namespace}/{slug}/submit-review` 接口 +- 暴露 `POST /{namespace}/{slug}/confirm-publish` 接口 + +### 11.7 管理员可见性 + +**文件**:`VisibilityChecker.java` + +- SUPER_ADMIN 可以看到所有 skill,包括 UPLOADED 状态 + +### 11.8 数据库迁移 + +**文件**:新增迁移脚本 + +- 更新 `skill_version_status` 枚举类型,添加 UPLOADED 值 + +--- + +## 12. 阻塞上线条件 + +| 问题 | 严重程度 | 状态 | +|------|---------|------| +| 新增 UPLOADED 状态 | 高 | 已完成 | +| PRIVATE skill 发布逻辑改动 | 高 | 已完成 | +| 提交审核接口 | 高 | 已完成 | +| 撤回审核后进入 UPLOADED | 中 | 已完成 | +| 同名冲突检查补全 | 中 | 已完成 | +| 管理员可见 UPLOADED skill | 低 | 已完成 | +| package_name 唯一性检查 | 低 | 可选(SaaS Adapter 职责) | + +--- + +## 13. 对老版本的影响 + +### 13.1 数据兼容性 + +| 影响点 | 分析 | 需要处理 | +|--------|------|---------| +| 老版本数据 | 不受影响,状态不变 | 否 | +| 数据库枚举 | 需添加 UPLOADED 值 | 是 | +| API 兼容性 | 新接口是新增,不影响老接口 | 否 | + +### 13.2 状态流转影响 + +| 场景 | 老逻辑 | 新逻辑 | 影响 | +|------|--------|--------|------| +| 老版本撤回审核 | PENDING_REVIEW → DRAFT | PENDING_REVIEW → UPLOADED | 前端需适配新状态 | +| 老版本删除 | DRAFT/REJECTED/SCAN_FAILED 可删 | UPLOADED 也可删 | 需更新代码判断 | + +### 13.3 代码改动点 + +**文件**:`SkillGovernanceService.java` + +**1. 删除版本逻辑**(第163-166行): +```java +// 原代码 +if (version.getStatus() != SkillVersionStatus.DRAFT + && version.getStatus() != SkillVersionStatus.REJECTED + && version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); +} + +// 改为:允许删除 UPLOADED 状态 +if (version.getStatus() != SkillVersionStatus.DRAFT + && version.getStatus() != SkillVersionStatus.REJECTED + && version.getStatus() != SkillVersionStatus.SCAN_FAILED + && version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); +} +``` + +**2. 撤回审核逻辑**(第245行): +```java +// 原代码 +version.setStatus(SkillVersionStatus.DRAFT); + +// 改为 +version.setStatus(SkillVersionStatus.UPLOADED); +``` + +### 13.4 前端适配 + +| 状态 | 前端展示建议 | +|------|-------------| +| UPLOADED | "已上传" 或 "待确认" | +| 可删除状态 | DRAFT、SCAN_FAILED、REJECTED、UPLOADED | +| 可编辑状态 | DRAFT、SCAN_FAILED、REJECTED | + +### 13.5 迁移策略 + +1. **数据库迁移**:添加 UPLOADED 枚举值 +2. **代码部署**:先部署后端,再部署前端 +3. **老数据处理**:无需处理,老版本状态保持不变 +4. **回滚方案**:如需回滚,UPLOADED 状态的版本按 DRAFT 处理 diff --git a/docs/pr-batch-test-runtime.md b/docs/pr-batch-test-runtime.md new file mode 100644 index 00000000..6f6d1c88 --- /dev/null +++ b/docs/pr-batch-test-runtime.md @@ -0,0 +1,81 @@ +# PR Batch Test Runtime + +This repository includes a manual GitHub Actions workflow that builds a +synthetic test image set from multiple PRs and deploys it to the shared +Hong Kong manual-test machine. + +Workflow file: + +- `.github/workflows/pr-batch-test-deploy.yml` + +## What the workflow does + +When you trigger the workflow manually, it: + +1. checks out the repository and fetches the selected base branch +2. parses the PR list you provide and deduplicates it while preserving order +3. verifies that every PR is still open and targets the chosen base branch +4. merges the selected PR heads onto the base branch in the exact order you supplied +5. fails fast if any PR conflicts with the base branch or with an earlier PR in the batch +6. builds `server`, `web`, and `scanner` images for `linux/amd64` +7. pushes both a floating tag and an immutable tag to GHCR +8. SSHes into the HK test machine as a dedicated deploy user +9. calls a root-owned deployment wrapper through `sudo` +10. updates `/opt/skillhub-runtime/.env.release` and runs `docker compose pull && docker compose up -d` + +The floating tag is the shared environment channel. By default it is +`manual-test-hk`. Each run also pushes an immutable tag for traceability: + +- floating tag example: `manual-test-hk` +- immutable tag example: `manual-test-hk-128-3d4a8e7f9a1b` + +The runtime always deploys the floating tag, so the same test URL keeps +working while still letting maintainers look up the exact image version +used by a given run. + +## Required GitHub secrets + +Add these repository or environment secrets before using the workflow: + +- `TEST_RUNTIME_SSH_HOST`: test machine hostname or IP +- `TEST_RUNTIME_SSH_KEY`: private key content used by GitHub Actions + +Optional secrets: + +- `TEST_RUNTIME_SSH_USER`: defaults to `skillhub-deploy` +- `TEST_RUNTIME_SSH_PORT`: defaults to `22` + +The remote machine should expose a root-owned deployment command at: + +- `/usr/local/bin/skillhub-test-deploy` + +The dedicated deploy user is expected to have passwordless sudo access to +that command only. + +## Recommended usage + +Open the workflow in GitHub Actions and fill in: + +- `pr_numbers`: a comma-separated or newline-separated list such as `123, 124, 130` +- `base_ref`: usually `main` +- `deploy_channel`: keep the default `manual-test-hk` for the shared test machine + +The merge order matters. If PR `124` depends on `123`, list `123` first. + +## Runtime metadata on the server + +After deployment, the workflow writes a small metadata file here: + +- `/opt/skillhub-runtime/manual-test-deployment.txt` + +It records: + +- deploy time +- floating tag +- immutable tag +- merged synthetic SHA +- PR list +- GitHub Actions run URL + +This makes it easy for testers and maintainers to confirm which batch is +currently deployed. diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 2aec46ff..ac5326e4 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -124,6 +124,18 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u > **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. +## Q: Why can't administrators (admin) and regular users create namespaces? + +A: Older versions of SkillHub do not support creating namespaces, as this feature was introduced in later updates. Please upgrade your SkillHub instance to the latest version (`latest`). +Upgrade command example: +```bash +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version latest +``` + +## Q: How do I search for or operate on a skill package within a specific namespace? + +A: When using the OpenClaw CLI, you can specify the namespace using the `--` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: @@ -131,3 +143,74 @@ A: You can get help through the following channels: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues - **Documentation**: Refer to the project README.md - **Community Discussions**: https://github.com/iflytek/skillhub/discussions + +## Q: What should I do if local development fails to start? + +A: When `make dev-all` fails to start the backend, detailed error messages will be displayed. Common issues: + +### 1. Maven dependency download failed (network timeout) + +**Symptoms**: Backend logs show `Could not transfer artifact` or connection timeout + +**Solution**: Configure Aliyun mirror + +```bash +# Copy the project's built-in mirror configuration to user directory +mkdir -p ~/.m2 +cp server/.mvn/settings.xml ~/.m2/settings.xml +``` + +Or manually create `~/.m2/settings.xml`: + +```xml + + + + + aliyun + https://maven.aliyun.com/repository/public + central + + + +``` + +Reference: [Aliyun Maven Mirror Configuration Guide](https://maven.aliyun.com/mvn/guide) + +### 2. Java version mismatch + +**Symptoms**: `Unsupported class file major version` or `java.lang.NoSuchMethodError` + +**Solution**: Install Java 21+ + +```bash +# macOS +brew install openjdk@21 + +# Verify version +java -version +``` + +### 3. Port already in use + +**Symptoms**: `Port 8080 already in use` + +**Solution**: + +```bash +# Find the process using the port +lsof -i :8080 + +# Terminate the process +kill -9 +``` + +### 4. View detailed logs + +If the above solutions don't help, check the backend logs: + +```bash +make dev-logs SERVICE=backend +# Or view directly +cat .dev/server.log +``` diff --git a/docs/skillhub/en/quickstart.md b/docs/skillhub/en/quickstart.md index 1f2c0289..f2b183b9 100644 --- a/docs/skillhub/en/quickstart.md +++ b/docs/skillhub/en/quickstart.md @@ -64,6 +64,39 @@ cd skillhub make dev-all ``` +### Notes for Developers in China + +If `make dev-all` fails to start the backend, common causes include: + +1. **Maven dependency download timeout** + + The project includes a built-in Aliyun mirror configuration (`server/.mvn/settings.xml`), but Maven does not automatically read project-level settings. You need to configure it manually: + + ```bash + # Option 1: Copy to user directory (recommended) + mkdir -p ~/.m2 + cp server/.mvn/settings.xml ~/.m2/settings.xml + + # Option 2: Specify on each build + cd server && ./mvnw -s .mvn/settings.xml package + ``` + +2. **Java version mismatch** + + SkillHub requires Java 21+: + ```bash + java -version + ``` + +3. **Port conflict** + + Check if port 8080 is in use: + ```bash + lsof -i :8080 + ``` + +For detailed troubleshooting steps, see [FAQ](faq.md#local-development-startup-failure). + ## Logging In ### Option 1: Use the Built-in Admin Account diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 7768cb35..a29d6d85 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -124,6 +124,18 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u > **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。 +## Q: 为什么管理员(admin)和普通用户都无法创建命名空间? + +A: 较旧版本的 SkillHub 不支持创建命名空间。该功能是在后续版本迭代中添加的。请将您的 SkillHub 升级到最新版本(latest)。 +升级命令示例: +```bash +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version latest +``` + +## Q: 如何搜索或操作指定命名空间中的技能包(Skill)? + +A: 使用 OpenClaw CLI 命令行工具时,可以通过 `--` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: @@ -131,3 +143,74 @@ A: 可以通过以下方式获取帮助: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues - **文档**: 参考项目 README.md - **社区讨论**: https://github.com/iflytek/skillhub/discussions + +## Q: 本地开发启动失败怎么办? + +A: `make dev-all` 后端启动失败时,会显示详细的错误提示。常见问题: + +### 1. Maven 依赖下载失败(网络超时) + +**症状**:后端日志显示 `Could not transfer artifact` 或连接超时 + +**解决方案**:配置阿里云镜像 + +```bash +# 复制项目内置的镜像配置到用户目录 +mkdir -p ~/.m2 +cp server/.mvn/settings.xml ~/.m2/settings.xml +``` + +或手动创建 `~/.m2/settings.xml`: + +```xml + + + + + aliyun + https://maven.aliyun.com/repository/public + central + + + +``` + +参考:[阿里云 Maven 镜像配置指南](https://maven.aliyun.com/mvn/guide) + +### 2. Java 版本不匹配 + +**症状**:`Unsupported class file major version` 或 `java.lang.NoSuchMethodError` + +**解决方案**:安装 Java 21+ + +```bash +# macOS +brew install openjdk@21 + +# 验证版本 +java -version +``` + +### 3. 端口被占用 + +**症状**:`Port 8080 already in use` + +**解决方案**: + +```bash +# 查看占用端口的进程 +lsof -i :8080 + +# 终止进程 +kill -9 +``` + +### 4. 查看详细日志 + +如果以上方案无法解决,查看后端日志: + +```bash +make dev-logs SERVICE=backend +# 或直接查看 +cat .dev/server.log +``` diff --git a/docs/skillhub/quickstart.md b/docs/skillhub/quickstart.md index f913e85b..141c6fef 100644 --- a/docs/skillhub/quickstart.md +++ b/docs/skillhub/quickstart.md @@ -64,6 +64,39 @@ cd skillhub make dev-all ``` +### 国内开发者注意事项 + +如果 `make dev-all` 后端启动失败,常见原因: + +1. **Maven 依赖下载超时** + + 项目已内置阿里云镜像配置(`server/.mvn/settings.xml`),但 Maven 不会自动读取项目级配置。需要手动配置: + + ```bash + # 方式一:复制到用户目录(推荐) + mkdir -p ~/.m2 + cp server/.mvn/settings.xml ~/.m2/settings.xml + + # 方式二:每次构建时指定 + cd server && ./mvnw -s .mvn/settings.xml package + ``` + +2. **Java 版本不匹配** + + SkillHub 要求 Java 21+: + ```bash + java -version + ``` + +3. **端口冲突** + + 检查 8080 端口是否被占用: + ```bash + lsof -i :8080 + ``` + +详细的错误排查步骤,请查看 [常见问题](faq.md#本地开发启动失败)。 + ## 登录系统 ### 方式一:使用内置管理员账号 diff --git a/scripts/deploy-test-runtime.sh b/scripts/deploy-test-runtime.sh new file mode 100755 index 00000000..47a2f3cc --- /dev/null +++ b/scripts/deploy-test-runtime.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/deploy-test-runtime.sh [options] + +Options: + --host Remote SSH host + --user Remote SSH user. Default: skillhub-deploy + --port Remote SSH port. Default: 22 + --key-file SSH private key for deployment + --deploy-tag Floating image tag to deploy + --immutable-tag Immutable image tag for traceability + --merged-sha Synthetic merge commit SHA + --pr-csv Comma-separated PR numbers + --run-url GitHub Actions run URL +EOF +} + +ssh_host="" +ssh_user="skillhub-deploy" +ssh_port="22" +ssh_key_file="" +deploy_tag="" +immutable_tag="" +merged_sha="" +pr_csv="" +run_url="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + [[ $# -ge 2 ]] || { echo "Missing value for --host" >&2; exit 1; } + ssh_host="$2" + shift 2 + ;; + --user) + [[ $# -ge 2 ]] || { echo "Missing value for --user" >&2; exit 1; } + ssh_user="$2" + shift 2 + ;; + --port) + [[ $# -ge 2 ]] || { echo "Missing value for --port" >&2; exit 1; } + ssh_port="$2" + shift 2 + ;; + --key-file) + [[ $# -ge 2 ]] || { echo "Missing value for --key-file" >&2; exit 1; } + ssh_key_file="$2" + shift 2 + ;; + --deploy-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --deploy-tag" >&2; exit 1; } + deploy_tag="$2" + shift 2 + ;; + --immutable-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --immutable-tag" >&2; exit 1; } + immutable_tag="$2" + shift 2 + ;; + --merged-sha) + [[ $# -ge 2 ]] || { echo "Missing value for --merged-sha" >&2; exit 1; } + merged_sha="$2" + shift 2 + ;; + --pr-csv) + [[ $# -ge 2 ]] || { echo "Missing value for --pr-csv" >&2; exit 1; } + pr_csv="$2" + shift 2 + ;; + --run-url) + [[ $# -ge 2 ]] || { echo "Missing value for --run-url" >&2; exit 1; } + run_url="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "${ssh_host}" ]] || { echo "--host is required" >&2; exit 1; } +[[ -n "${ssh_key_file}" ]] || { echo "--key-file is required" >&2; exit 1; } +[[ -n "${deploy_tag}" ]] || { echo "--deploy-tag is required" >&2; exit 1; } +[[ -n "${immutable_tag}" ]] || { echo "--immutable-tag is required" >&2; exit 1; } + +ssh_opts=( + -i "${ssh_key_file}" + -o BatchMode=yes + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=accept-new + -o ServerAliveInterval=15 + -o ServerAliveCountMax=3 + -o TCPKeepAlive=yes + -o ConnectTimeout=10 + -p "${ssh_port}" +) + +ssh "${ssh_opts[@]}" "${ssh_user}@${ssh_host}" bash -s -- \ + "${deploy_tag}" \ + "${immutable_tag}" \ + "${merged_sha}" \ + "${pr_csv}" \ + "${run_url}" <<'EOF' +set -euo pipefail + +deploy_tag="$1" +immutable_tag="$2" +merged_sha="$3" +pr_csv="$4" +run_url="${5:-}" + +sudo /usr/local/bin/skillhub-test-deploy \ + --deploy-tag "${deploy_tag}" \ + --immutable-tag "${immutable_tag}" \ + --merged-sha "${merged_sha}" \ + --pr-csv "${pr_csv}" \ + --run-url "${run_url}" +EOF diff --git a/scripts/prepare-pr-batch.sh b/scripts/prepare-pr-batch.sh new file mode 100755 index 00000000..0b5f56a3 --- /dev/null +++ b/scripts/prepare-pr-batch.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/prepare-pr-batch.sh --pr-list "123,456" [options] + +Options: + --base-ref Base branch to merge onto. Default: main + --deploy-channel Floating image tag for the shared test runtime. + Default: manual-test-hk +EOF +} + +base_ref="main" +deploy_channel="manual-test-hk" +pr_input="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base-ref) + [[ $# -ge 2 ]] || { echo "Missing value for --base-ref" >&2; exit 1; } + base_ref="$2" + shift 2 + ;; + --deploy-channel) + [[ $# -ge 2 ]] || { echo "Missing value for --deploy-channel" >&2; exit 1; } + deploy_channel="$2" + shift 2 + ;; + --pr-list) + [[ $# -ge 2 ]] || { echo "Missing value for --pr-list" >&2; exit 1; } + pr_input="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +: "${GH_TOKEN:?GH_TOKEN is required}" + +if [[ -z "${pr_input}" ]]; then + echo "--pr-list is required" >&2 + exit 1 +fi + +normalized_input="$(printf '%s' "${pr_input}" | tr ',;\r\n\t' ' ')" + +declare -a pr_numbers=() + +for token in ${normalized_input}; do + if [[ ! "${token}" =~ ^[0-9]+$ ]]; then + echo "Invalid PR number: ${token}" >&2 + exit 1 + fi + + already_seen=false + if [[ "${#pr_numbers[@]}" -gt 0 ]]; then + for existing in "${pr_numbers[@]}"; do + if [[ "${existing}" == "${token}" ]]; then + already_seen=true + break + fi + done + fi + + if [[ "${already_seen}" == "true" ]]; then + continue + fi + + pr_numbers+=("${token}") +done + +if [[ "${#pr_numbers[@]}" -eq 0 ]]; then + echo "No PR numbers were parsed from --pr-list" >&2 + exit 1 +fi + +sanitized_channel="$( + printf '%s' "${deploy_channel}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g' +)" + +if [[ -z "${sanitized_channel}" ]]; then + echo "Deploy channel resolved to an empty tag" >&2 + exit 1 +fi + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +git fetch --no-tags origin "${base_ref}" +git checkout -B manual-test-batch "origin/${base_ref}" + +summary_file="${RUNNER_TEMP:-/tmp}/manual-test-batch-summary.md" +current_pr="" +trap 'status=$?; if [[ $status -ne 0 && -n "${current_pr}" ]]; then echo "Failed while merging PR #${current_pr}" >&2; fi' EXIT + +{ + echo "### Manual Test Batch" + echo + echo "- Base ref: \`${base_ref}\`" + echo "- Deploy channel: \`${sanitized_channel}\`" + echo "- Selected PRs:" +} > "${summary_file}" + +for pr in "${pr_numbers[@]}"; do + current_pr="${pr}" + + IFS=$'\t' read -r state pr_base is_draft title url <&2 + exit 1 + fi + + if [[ "${pr_base}" != "${base_ref}" ]]; then + echo "PR #${pr} targets ${pr_base}, expected ${base_ref}" >&2 + exit 1 + fi + + git fetch --no-tags origin "pull/${pr}/head:refs/remotes/origin/manual-test-pr-${pr}" + git merge --no-ff --no-edit \ + -m "Merge PR #${pr} for manual test batch" \ + "refs/remotes/origin/manual-test-pr-${pr}" + + if [[ "${is_draft}" == "true" ]]; then + title="${title} [draft]" + fi + + echo " - #${pr} ${title} (${url})" >> "${summary_file}" +done + +merged_sha="$(git rev-parse HEAD)" +short_sha="$(git rev-parse --short=12 HEAD)" +run_token="${GITHUB_RUN_NUMBER:-manual}" +immutable_tag="${sanitized_channel}-${run_token}-${short_sha}" +pr_csv="$(IFS=,; echo "${pr_numbers[*]}")" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "base_ref=${base_ref}" + echo "deploy_tag=${sanitized_channel}" + echo "immutable_tag=${immutable_tag}" + echo "merged_sha=${merged_sha}" + echo "short_sha=${short_sha}" + echo "pr_csv=${pr_csv}" + echo "summary_file=${summary_file}" + } >> "${GITHUB_OUTPUT}" +fi + +{ + echo "- Merged SHA: \`${merged_sha}\`" + echo "- Floating tag: \`${sanitized_channel}\`" + echo "- Immutable tag: \`${immutable_tag}\`" +} >> "${summary_file}" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat "${summary_file}" >> "${GITHUB_STEP_SUMMARY}" +fi diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 76dc9c93..20c2dd58 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -22,6 +22,7 @@ SKILLHUB_SCANNER_IMAGE_VALUE="${SKILLHUB_SCANNER_IMAGE:-}" POSTGRES_IMAGE_VALUE="${POSTGRES_IMAGE:-}" REDIS_IMAGE_VALUE="${REDIS_IMAGE:-}" DISABLE_SCANNER=false +USE_ALIYUN=false while [ "$#" -gt 0 ]; do case "$1" in @@ -36,6 +37,7 @@ while [ "$#" -gt 0 ]; do exit 1 fi SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_ALIYUN_REGISTRY%/}/${SKILLHUB_ALIYUN_NAMESPACE}" + USE_ALIYUN=true shift ;; --mirror-registry) @@ -114,7 +116,13 @@ EOF esac done -SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" +if [ "$USE_ALIYUN" = "true" ]; then + SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}" + echo "Using Aliyun OSS for runtime files: $SKILLHUB_RAW_BASE" +else + SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}" + echo "Using GitHub raw for runtime files: $SKILLHUB_RAW_BASE" +fi COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml" ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example" ENV_FILE="$SKILLHUB_HOME/.env.release" diff --git a/scripts/skillhub-test-deploy-remote.sh b/scripts/skillhub-test-deploy-remote.sh new file mode 100644 index 00000000..39663624 --- /dev/null +++ b/scripts/skillhub-test-deploy-remote.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: /usr/local/bin/skillhub-test-deploy [options] + +Options: + --deploy-tag Floating image tag to deploy + --immutable-tag Immutable image tag for traceability + --merged-sha Synthetic merge commit SHA + --pr-csv Comma-separated PR numbers + --run-url GitHub Actions run URL +EOF +} + +runtime_dir="/opt/skillhub-runtime" +deploy_tag="" +immutable_tag="" +merged_sha="" +pr_csv="" +run_url="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --deploy-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --deploy-tag" >&2; exit 1; } + deploy_tag="$2" + shift 2 + ;; + --immutable-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --immutable-tag" >&2; exit 1; } + immutable_tag="$2" + shift 2 + ;; + --merged-sha) + [[ $# -ge 2 ]] || { echo "Missing value for --merged-sha" >&2; exit 1; } + merged_sha="$2" + shift 2 + ;; + --pr-csv) + [[ $# -ge 2 ]] || { echo "Missing value for --pr-csv" >&2; exit 1; } + pr_csv="$2" + shift 2 + ;; + --run-url) + [[ $# -ge 2 ]] || { echo "Missing value for --run-url" >&2; exit 1; } + run_url="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "${deploy_tag}" ]] || { echo "--deploy-tag is required" >&2; exit 1; } +[[ -n "${immutable_tag}" ]] || { echo "--immutable-tag is required" >&2; exit 1; } + +if [[ ! "${deploy_tag}" =~ ^[a-z0-9._-]+$ ]]; then + echo "Invalid deploy tag: ${deploy_tag}" >&2 + exit 1 +fi + +if [[ ! "${immutable_tag}" =~ ^[a-z0-9._-]+$ ]]; then + echo "Invalid immutable tag: ${immutable_tag}" >&2 + exit 1 +fi + +if [[ -n "${merged_sha}" && ! "${merged_sha}" =~ ^[0-9a-f]{7,64}$ ]]; then + echo "Invalid merged SHA: ${merged_sha}" >&2 + exit 1 +fi + +if [[ -n "${pr_csv}" && ! "${pr_csv}" =~ ^[0-9]+(,[0-9]+)*$ ]]; then + echo "Invalid PR list: ${pr_csv}" >&2 + exit 1 +fi + +if [[ -n "${run_url}" && ! "${run_url}" =~ ^https://github\.com/.+/actions/runs/[0-9]+$ ]]; then + echo "Invalid run URL: ${run_url}" >&2 + exit 1 +fi + +set_env_value() { + key="$1" + value="$2" + tmp=".env.release.tmp" + + if grep -q "^${key}=" .env.release; then + sed "s|^${key}=.*|${key}=${value}|" .env.release > "${tmp}" + else + cp .env.release "${tmp}" + printf '%s=%s\n' "${key}" "${value}" >> "${tmp}" + fi + + mv "${tmp}" .env.release +} + +cd "${runtime_dir}" + +test -f .env.release +test -f compose.release.yml + +cp .env.release ".env.release.bak.$(date +%Y%m%d%H%M%S)" + +set_env_value "SKILLHUB_VERSION" "${deploy_tag}" + +cat > manual-test-deployment.txt </dev/null +curl -fsS "http://127.0.0.1:${web_port}/nginx-health" >/dev/null diff --git a/server/.mvn/settings.xml b/server/.mvn/settings.xml new file mode 100644 index 00000000..48462dae --- /dev/null +++ b/server/.mvn/settings.xml @@ -0,0 +1,16 @@ + + + + + + aliyun + Aliyun Maven Mirror + https://maven.aliyun.com/repository/public + central + + + + diff --git a/server/.mvn/wrapper/maven-wrapper.properties b/server/.mvn/wrapper/maven-wrapper.properties index 71ea75a7..0f16952f 100644 --- a/server/.mvn/wrapper/maven-wrapper.properties +++ b/server/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ wrapperVersion=3.3.4 distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip +distributionUrl=https://maven.aliyun.com/repository/public/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip diff --git a/server/Dockerfile b/server/Dockerfile index 1fe00141..84537b56 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -32,7 +32,7 @@ RUN mkdir -p /var/lib/skillhub/storage && \ USER app EXPOSE 8080 -HEALTHCHECK --interval=10s --timeout=3s \ +HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=12 \ CMD wget -qO- http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 77f96fcc..7ec41c2a 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -13,7 +13,7 @@ RUN chown -R app:app /app USER app EXPOSE 8080 -HEALTHCHECK --interval=10s --timeout=3s \ +HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=12 \ CMD wget -qO- http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] 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 539b5221..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 @@ -14,6 +14,7 @@ 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.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; @@ -27,6 +28,7 @@ import java.util.List; import java.util.Map; import org.slf4j.MDC; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; /** @@ -36,6 +38,8 @@ import org.springframework.web.multipart.MultipartFile; @Service public class ClawHubCompatAppService { + private static final String GLOBAL_NAMESPACE = "global"; + private final CanonicalSlugMapper mapper; private final SkillSearchAppService skillSearchAppService; private final SkillQueryService skillQueryService; @@ -93,16 +97,17 @@ public class ClawHubCompatAppService { String hash, String userId, Map userNsRoles) { - CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug); + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + Map roles = normalizeRoles(userNsRoles); SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( - context.namespace().getSlug(), - context.skill().getSlug(), + coord.namespace(), + coord.slug(), "latest".equals(version) ? null : version, "latest".equals(version) ? "latest" : null, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + roles ); return toResolveResponse(resolved); } @@ -131,11 +136,37 @@ public class ClawHubCompatAppService { : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - public String downloadLocationByQuery(String slug, String version) { - CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(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/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/download" - : "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/versions/" + version + "/download"; + ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + } + + private SkillCoordinate resolveQueryCoordinate(String slug, + String userId, + Map userNsRoles) { + if (slug != null && slug.contains("--")) { + return mapper.fromCanonical(slug); + } + CompatSkillLookupService.CompatSkillContext context; + try { + 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, @@ -169,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); @@ -250,17 +288,19 @@ public class ClawHubCompatAppService { public ClawHubPublishResponse publishSkill(String payloadJson, MultipartFile[] files, + boolean confirmWarnings, PlatformPrincipal principal, String clientIp, String userAgent) throws IOException { MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson); - String namespace = determineNamespace(principal, extracted.payload()); + String namespace = determineNamespace(extracted.payload()); SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( namespace, extracted.entries(), principal.userId(), SkillVisibility.PUBLIC, - principal.platformRoles() + principal.platformRoles(), + confirmWarnings ); recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}"); @@ -269,6 +309,7 @@ public class ClawHubCompatAppService { public ClawHubPublishResponse publish(MultipartFile file, String namespace, + boolean confirmWarnings, PlatformPrincipal principal, String clientIp, String userAgent) throws IOException { @@ -277,7 +318,8 @@ public class ClawHubCompatAppService { zipPackageExtractor.extract(file), principal.userId(), SkillVisibility.PUBLIC, - principal.platformRoles() + principal.platformRoles(), + confirmWarnings ); recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, "{\"namespace\":\"" + namespace + "\"}"); @@ -354,8 +396,28 @@ public class ClawHubCompatAppService { ); } - private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) { - return "global"; + private String determineNamespace(MultipartPackageExtractor.PublishPayload payload) { + if (payload == null) { + return GLOBAL_NAMESPACE; + } + + if (StringUtils.hasText(payload.namespace())) { + return normalizeNamespace(payload.namespace()); + } + + if (StringUtils.hasText(payload.slug()) && payload.slug().contains("--")) { + return mapper.fromCanonical(payload.slug()).namespace(); + } + + return GLOBAL_NAMESPACE; + } + + private String normalizeNamespace(String namespace) { + String trimmed = namespace.trim(); + if (trimmed.startsWith("@")) { + return trimmed.substring(1); + } + return trimmed; } private void recordCompatPublishAudit(String userId, 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 a66a7f0d..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) @@ -135,11 +138,13 @@ public class ClawHubCompatController { @PostMapping("/skills") public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson, @RequestParam("files") MultipartFile[] files, + @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { return clawHubCompatAppService.publishSkill( payloadJson, files, + confirmWarnings, principal, request.getRemoteAddr(), request.getHeader("User-Agent") @@ -150,11 +155,13 @@ public class ClawHubCompatController { @PostMapping("/publish") public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, + @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { return clawHubCompatAppService.publish( file, namespace, + confirmWarnings, principal, request.getRemoteAddr(), request.getHeader("User-Agent") 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/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 8d6f5e4f..8442939d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.session.PlatformSessionService; @@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.AuthMeResponse; import com.iflytek.skillhub.dto.ChangePasswordRequest; import com.iflytek.skillhub.dto.LocalLoginRequest; import com.iflytek.skillhub.dto.LocalRegisterRequest; +import com.iflytek.skillhub.dto.PasswordResetConfirmRequest; +import com.iflytek.skillhub.dto.PasswordResetRequestDto; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; @@ -34,17 +37,20 @@ public class LocalAuthController extends BaseApiController { private final SkillHubMetrics skillHubMetrics; private final PlatformSessionService platformSessionService; private final AuthFailureThrottleService authFailureThrottleService; + private final PasswordResetService passwordResetService; public LocalAuthController(ApiResponseFactory responseFactory, LocalAuthService localAuthService, SkillHubMetrics skillHubMetrics, PlatformSessionService platformSessionService, - AuthFailureThrottleService authFailureThrottleService) { + AuthFailureThrottleService authFailureThrottleService, + PasswordResetService passwordResetService) { super(responseFactory); this.localAuthService = localAuthService; this.skillHubMetrics = skillHubMetrics; this.platformSessionService = platformSessionService; this.authFailureThrottleService = authFailureThrottleService; + this.passwordResetService = passwordResetService; } @PostMapping("/register") @@ -92,6 +98,20 @@ public class LocalAuthController extends BaseApiController { return ok("response.success.updated", null); } + @PostMapping("/password-reset/request") + @RateLimit(category = "auth-password-reset-request", authenticated = 8, anonymous = 5, windowSeconds = 300) + public ApiResponse requestPasswordReset(@Valid @RequestBody PasswordResetRequestDto request) { + passwordResetService.requestPasswordReset(request.email()); + return ok("response.auth.password.reset.requested", null); + } + + @PostMapping("/password-reset/confirm") + @RateLimit(category = "auth-password-reset-confirm", authenticated = 10, anonymous = 10, windowSeconds = 300) + public ApiResponse confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmRequest request) { + passwordResetService.confirmPasswordReset(request.email(), request.code(), request.newPassword()); + return ok("response.auth.password.reset.confirmed", null); + } + private String resolveClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index efaf7647..627d413c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; @@ -9,6 +10,7 @@ import com.iflytek.skillhub.dto.AdminUserSummaryResponse; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.service.AdminUserAppService; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; @@ -24,11 +26,14 @@ import org.springframework.web.bind.annotation.*; public class UserManagementController extends BaseApiController { private final AdminUserAppService adminUserAppService; + private final PasswordResetService passwordResetService; public UserManagementController(AdminUserAppService adminUserAppService, + PasswordResetService passwordResetService, ApiResponseFactory responseFactory) { super(responseFactory); this.adminUserAppService = adminUserAppService; + this.passwordResetService = passwordResetService; } @GetMapping @@ -76,4 +81,15 @@ public class UserManagementController extends BaseApiController { public ApiResponse enableUser(@PathVariable String userId) { return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); } + + @PostMapping("/{userId}/password-reset") + @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") + public ApiResponse triggerPasswordReset(@PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + passwordResetService.adminTriggerPasswordReset(userId, principal.userId()); + return ok("response.auth.password.reset.requested", null); + } } 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/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index 16b7d2e4..b590fa22 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -5,8 +5,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.ConfirmPublishRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import com.iflytek.skillhub.dto.SubmitReviewRequest; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import jakarta.validation.Valid; @@ -118,4 +120,39 @@ public class SkillLifecycleController extends BaseApiController { userNsRoles, AuditRequestContext.from(httpRequest))); } + + @PostMapping("/{namespace}/{slug}/submit-review") + public ApiResponse submitForReview(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody SubmitReviewRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.submitForReview( + namespace, + slug, + request.version(), + request.targetVisibility(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{namespace}/{slug}/confirm-publish") + public ApiResponse confirmPublish(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody ConfirmPublishRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.confirmPublish( + namespace, + slug, + request.version(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index c853ca32..3abdf5e8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -53,6 +53,7 @@ public class SkillPublishController extends BaseApiController { @PathVariable String namespace, @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, + @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -69,7 +70,8 @@ public class SkillPublishController extends BaseApiController { entries, principal.userId(), skillVisibility, - principal.platformRoles() + principal.platformRoles(), + confirmWarnings ); PublishResponse response = new PublishResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 6e3e6a1c..0a9fc793 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -30,6 +30,7 @@ public class MultipartPackageExtractor { } public record PublishPayload( + String namespace, String slug, String displayName, String version, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 9becbdd2..5d98e93a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -136,7 +136,7 @@ public class SkillPackageArchiveExtractor { if (lower.endsWith(".css")) return "text/css"; if (lower.endsWith(".csv")) return "text/csv"; if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; if (lower.endsWith(".ts")) return "text/typescript"; if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; if (lower.endsWith(".png")) return "image/png"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java new file mode 100644 index 00000000..cfcb2991 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request to confirm publish for a PRIVATE skill version. + */ +public record ConfirmPublishRequest( + @NotBlank(message = "Version is required") + String version +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java index 5a4a2749..443a5b8e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -8,6 +8,7 @@ public record LocalRegisterRequest( String username, @NotBlank(message = "{validation.auth.local.password.notBlank}") String password, + @NotBlank(message = "{validation.auth.local.email.notBlank}") @Email(message = "{validation.auth.local.email.invalid}") String email ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java index dfd39d35..0bb52cbc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.dto; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; import java.time.Instant; @@ -9,6 +10,8 @@ public record MemberResponse( Long id, Long namespaceId, String userId, + String displayName, + String email, NamespaceRole role, Instant createdAt, Instant updatedAt @@ -18,6 +21,21 @@ public record MemberResponse( member.getId(), member.getNamespaceId(), member.getUserId(), + null, + null, + member.getRole(), + member.getCreatedAt(), + member.getUpdatedAt() + ); + } + + public static MemberResponse from(NamespaceMember member, UserAccount user) { + return new MemberResponse( + member.getId(), + member.getNamespaceId(), + member.getUserId(), + user != null ? user.getDisplayName() : null, + user != null ? user.getEmail() : null, member.getRole(), member.getCreatedAt(), member.getUpdatedAt() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java new file mode 100644 index 00000000..332f75e6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetConfirmRequest( + @NotBlank(message = "{validation.auth.password.reset.email.notBlank}") + @Email(message = "{validation.auth.password.reset.email.invalid}") + @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}") + String email, + @NotBlank(message = "{validation.auth.password.reset.code.notBlank}") + @Pattern(regexp = "^\\d{6}$", message = "{validation.auth.password.reset.code.invalid}") + String code, + @NotBlank(message = "{validation.auth.password.reset.newPassword.notBlank}") + String newPassword +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java new file mode 100644 index 00000000..f12f20d2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetRequestDto( + @NotBlank(message = "{validation.auth.password.reset.email.notBlank}") + @Email(message = "{validation.auth.password.reset.email.invalid}") + @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}") + String email +) { +} 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/dto/SubmitReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java new file mode 100644 index 00000000..817e9c97 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Request to submit a skill version for review. + */ +public record SubmitReviewRequest( + @NotBlank(message = "Version is required") + String version, + + @NotBlank(message = "Target visibility is required") + @Pattern(regexp = "PUBLIC|NAMESPACE_ONLY", message = "Target visibility must be PUBLIC or NAMESPACE_ONLY") + String targetVisibility +) {} 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/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 4432d060..6cca3952 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -254,4 +254,36 @@ public class GovernanceWorkflowAppService { AuditRequestContext auditContext) { return namespacePortalCommandAppService.restoreNamespace(slug, userId, auditContext); } + + public SkillLifecycleMutationResponse submitForReview(String namespace, + String slug, + String version, + String targetVisibility, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.submitForReview( + namespace, + slug, + version, + targetVisibility, + userId, + userNsRoles, + auditContext); + } + + public SkillLifecycleMutationResponse confirmPublish(String namespace, + String slug, + String version, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.confirmPublish( + namespace, + slug, + version, + userId, + userNsRoles, + auditContext); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 92c1340b..3e81884b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -7,6 +7,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; @@ -28,15 +30,18 @@ public class NamespacePortalCommandAppService { private final NamespaceRepository namespaceRepository; private final NamespaceGovernanceService namespaceGovernanceService; private final NamespaceMemberService namespaceMemberService; + private final UserAccountRepository userAccountRepository; public NamespacePortalCommandAppService(NamespaceService namespaceService, NamespaceRepository namespaceRepository, NamespaceGovernanceService namespaceGovernanceService, - NamespaceMemberService namespaceMemberService) { + NamespaceMemberService namespaceMemberService, + UserAccountRepository userAccountRepository) { this.namespaceService = namespaceService; this.namespaceRepository = namespaceRepository; this.namespaceGovernanceService = namespaceGovernanceService; this.namespaceMemberService = namespaceMemberService; + this.userAccountRepository = userAccountRepository; } @Transactional @@ -135,7 +140,8 @@ public class NamespacePortalCommandAppService { role, operatorUserId ); - return MemberResponse.from(member); + UserAccount user = userAccountRepository.findById(memberUserId).orElse(null); + return MemberResponse.from(member, user); } @Transactional @@ -157,7 +163,7 @@ public class NamespacePortalCommandAppService { request.role(), operatorUserId ); - return MemberResponse.from(member); + return MemberResponse.from(member, userAccountRepository.findById(userId).orElse(null)); } private boolean canCreateNamespace(PlatformPrincipal principal) { 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 17dd9b99..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,9 @@ 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; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; @@ -15,7 +18,11 @@ import com.iflytek.skillhub.dto.PageResponse; import java.util.Comparator; import java.util.List; 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; @@ -31,21 +38,46 @@ public class NamespacePortalQueryAppService { private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceAccessPolicy namespaceAccessPolicy; + private final UserAccountRepository userAccountRepository; public NamespacePortalQueryAppService(NamespaceRepository namespaceRepository, NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, - NamespaceAccessPolicy namespaceAccessPolicy) { + NamespaceAccessPolicy namespaceAccessPolicy, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceAccessPolicy = namespaceAccessPolicy; + this.userAccountRepository = userAccountRepository; } @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) @@ -66,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); } @@ -78,6 +114,18 @@ public class NamespacePortalQueryAppService { Namespace namespace = namespaceService.getNamespaceBySlug(slug); namespaceService.assertMember(namespace.getId(), userId); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); - return PageResponse.from(members.map(MemberResponse::from)); + + List memberUserIds = members.getContent().stream() + .map(NamespaceMember::getUserId) + .toList(); + + Map userMap = memberUserIds.isEmpty() + ? Map.of() + : userAccountRepository.findByIdIn(memberUserIds).stream() + .collect(Collectors.toMap(UserAccount::getId, Function.identity())); + + return PageResponse.from(members.map(member -> + MemberResponse.from(member, userMap.get(member.getUserId())) + )); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index c9fa8b15..47d8eed5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -119,6 +119,7 @@ public class ReviewPortalAppService { Map userNsRoles) { ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); Map namespaceRoles = normalizeRoles(userNsRoles); + Set platformRoles = platformRoles(userId); Pageable pageable = buildReviewPageable(reviewStatus, page, size, sortDirection); Page tasks; @@ -131,11 +132,14 @@ public class ReviewPortalAppService { userId, namespace.getType(), namespaceRoles, - platformRoles(userId))) { + platformRoles)) { throw new DomainForbiddenException("review.no_permission"); } tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, pageable); } else { + if (!hasPlatformReviewRole(platformRoles)) { + throw new DomainForbiddenException("review.no_permission"); + } tasks = reviewTaskRepository.findByStatus(reviewStatus, pageable); } @@ -146,7 +150,7 @@ public class ReviewPortalAppService { return PageResponse.from(new PageImpl<>( governanceQueryRepository.getReviewTaskResponses(visibleItems), tasks.getPageable(), - visibleItems.size() + tasks.getTotalElements() )); } @@ -233,6 +237,11 @@ public class ReviewPortalAppService { return rbacService.getUserRoleCodes(userId); } + private boolean hasPlatformReviewRole(Set platformRoles) { + return platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN"); + } + private Map normalizeRoles(Map userNsRoles) { return userNsRoles != null ? userNsRoles : Map.of(); } 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 fda05a72..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 @@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; @@ -31,6 +32,7 @@ public class SkillLifecycleAppService { private final SkillGovernanceService skillGovernanceService; private final ReviewService reviewService; private final SkillPublishService skillPublishService; + private final SkillReviewSubmitService skillReviewSubmitService; private final AuditLogService auditLogService; private final SkillSlugResolutionService skillSlugResolutionService; @@ -39,6 +41,7 @@ public class SkillLifecycleAppService { SkillGovernanceService skillGovernanceService, ReviewService reviewService, SkillPublishService skillPublishService, + SkillReviewSubmitService skillReviewSubmitService, AuditLogService auditLogService, SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; @@ -46,6 +49,7 @@ public class SkillLifecycleAppService { this.skillGovernanceService = skillGovernanceService; this.reviewService = reviewService; this.skillPublishService = skillPublishService; + this.skillReviewSubmitService = skillReviewSubmitService; this.auditLogService = auditLogService; this.skillSlugResolutionService = skillSlugResolutionService; } @@ -150,7 +154,8 @@ public class SkillLifecycleAppService { skillVersion.getVersion(), targetVersion, userId, - normalizeRoles(userNamespaceRoles) + normalizeRoles(userNamespaceRoles), + request.confirmWarnings() ); auditLogService.record( userId, @@ -171,6 +176,74 @@ public class SkillLifecycleAppService { ); } + @Transactional + public SkillLifecycleMutationResponse submitForReview(String namespace, + String slug, + String version, + String targetVisibility, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillReviewSubmitService.submitForReview( + skill.getId(), + skillVersion.getId(), + com.iflytek.skillhub.domain.skill.SkillVisibility.valueOf(targetVisibility), + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "SUBMIT_REVIEW", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\",\"targetVisibility\":\"" + targetVisibility + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "SUBMIT_REVIEW", + "PENDING_REVIEW" + ); + } + + @Transactional + public SkillLifecycleMutationResponse confirmPublish(String namespace, + String slug, + String version, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillReviewSubmitService.confirmPublish( + skill.getId(), + skillVersion.getId(), + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "CONFIRM_PUBLISH", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "CONFIRM_PUBLISH", + "PUBLISHED" + ); + } + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) 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 4b3f3204..57a1145a 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 @@ -1,8 +1,9 @@ package com.iflytek.skillhub.service; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.auth.rbac.RbacService; 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.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -12,13 +13,12 @@ import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; import com.iflytek.skillhub.search.SearchResult; import com.iflytek.skillhub.search.SearchVisibilityScope; -import org.springframework.stereotype.Service; - import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.stereotype.Service; /** * Application service that assembles discovery responses from search matches. @@ -36,18 +36,21 @@ public class SkillSearchAppService { private final NamespaceRepository namespaceRepository; private final NamespaceService namespaceService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final RbacService rbacService; public SkillSearchAppService( SearchQueryService searchQueryService, SkillRepository skillRepository, NamespaceRepository namespaceRepository, NamespaceService namespaceService, - SkillLifecycleProjectionService skillLifecycleProjectionService) { + SkillLifecycleProjectionService skillLifecycleProjectionService, + RbacService rbacService) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.namespaceService = namespaceService; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.rbacService = rbacService; } public record SearchResponse( @@ -93,21 +96,36 @@ public class SkillSearchAppService { } private SearchVisibilityScope buildVisibilityScope(String userId, Map userNsRoles) { - if (userId == null || userNsRoles == null) { + if (userId == null) { return SearchVisibilityScope.anonymous(); } - Set memberNamespaceIds = userNsRoles.keySet(); - Set adminNamespaceIds = userNsRoles.entrySet().stream() + Map normalizedRoles = userNsRoles != null ? userNsRoles : Map.of(); + Set memberNamespaceIds = normalizedRoles.keySet(); + Set adminNamespaceIds = normalizedRoles.entrySet().stream() .filter(e -> e.getValue() == NamespaceRole.ADMIN) .map(Map.Entry::getKey) .collect(java.util.stream.Collectors.toSet()); - adminNamespaceIds.addAll(userNsRoles.entrySet().stream() + adminNamespaceIds.addAll(normalizedRoles.entrySet().stream() .filter(e -> e.getValue() == NamespaceRole.OWNER) .map(Map.Entry::getKey) .toList()); - return new SearchVisibilityScope(userId, memberNamespaceIds, adminNamespaceIds); + Set platformRoles = rbacService.getUserRoleCodes(userId); + + return new SearchVisibilityScope( + userId, + memberNamespaceIds, + adminNamespaceIds, + hasPlatformWideReadAccess(platformRoles) + ); + } + + private boolean hasPlatformWideReadAccess(Set platformRoles) { + if (platformRoles == null || platformRoles.isEmpty()) { + return false; + } + return platformRoles.contains("SUPER_ADMIN"); } private SearchResponse searchVisibleSkills( diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 8a3872af..0cee466c 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -61,6 +61,17 @@ spring: multipart: max-file-size: 100MB max-request-size: 100MB + mail: + host: ${SPRING_MAIL_HOST:localhost} + port: ${SPRING_MAIL_PORT:25} + username: ${SPRING_MAIL_USERNAME:} + password: ${SPRING_MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: ${SPRING_MAIL_SMTP_AUTH:false} + starttls: + enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: auth: @@ -70,6 +81,10 @@ skillhub: enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} session-bootstrap: enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false} + password-reset: + code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M} + email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local} + email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: @@ -168,10 +183,13 @@ skillhub: email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local} management: + health: + mail: + enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false} endpoints: web: exposure: - include: health,info,prometheus,metrics + include: health,info endpoint: health: show-details: when-authorized @@ -180,4 +198,4 @@ management: application: skillhub export: prometheus: - enabled: true + enabled: false diff --git a/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql b/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql new file mode 100644 index 00000000..c8915bbf --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql @@ -0,0 +1,20 @@ +-- Password reset verification code records for self-service and admin-triggered flows +CREATE TABLE password_reset_request ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + code_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + requested_by_admin BOOLEAN NOT NULL DEFAULT FALSE, + requested_by_user_id VARCHAR(128) REFERENCES user_account(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_password_reset_request_user_id ON password_reset_request(user_id); +CREATE INDEX idx_password_reset_request_expires_at ON password_reset_request(expires_at); + +COMMENT ON TABLE password_reset_request IS 'Stores password reset verification code requests for local account recovery'; +COMMENT ON COLUMN password_reset_request.code_hash IS 'BCrypt hash of the one-time verification code'; +COMMENT ON COLUMN password_reset_request.requested_by_admin IS 'True when the reset is triggered by an administrator'; +COMMENT ON COLUMN password_reset_request.requested_by_user_id IS 'Admin user who triggered the reset, if applicable'; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 83ac024f..942d976a 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -16,6 +16,7 @@ validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required validation.auth.local.username.notBlank=Username cannot be blank validation.auth.local.password.notBlank=Password cannot be blank +validation.auth.local.email.notBlank=Email cannot be blank validation.auth.local.currentPassword.notBlank=Current password cannot be blank validation.auth.local.newPassword.notBlank=New password cannot be blank validation.auth.local.email.invalid=Email format is invalid @@ -88,6 +89,7 @@ error.skill.metadata.requiredField.missing=Missing required field: {0} error.skill.publish.publisher.notMember=Publisher is not a member of namespace: {0} error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found +error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.skill.publish.archived=Archived skill must be restored before publishing: {0} review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} @@ -102,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} @@ -132,7 +134,12 @@ error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace +error.skill.publish.nameConflict.private=A private skill with name ''{0}'' has already been published in this namespace error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace +error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be submitted for review +error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed +error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish +error.skill.version.notDownloadable=Version ''{0}'' is not available for download # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters @@ -147,3 +154,16 @@ error.profileReview.commentRequired=Rejection reason is required error.profileReview.commentTooLong=Rejection reason must not exceed 500 characters error.profileReview.status.invalid=Invalid review status: {0} error.profileReview.userDisabled=Cannot apply changes — user account is disabled + +# Password reset +response.auth.password.reset.requested=If the account is eligible, a password reset verification code has been sent. +response.auth.password.reset.confirmed=Password has been reset successfully. Please sign in with your new password. +error.auth.password.reset.invalid.code=The verification code is invalid or has expired. +error.auth.password.reset.not.eligible=This account is not eligible for password reset. +error.auth.password.reset.no.credential=This account does not have a local credential. +error.auth.password.reset.email.failed=Failed to send password reset verification code. Please try again later. +validation.auth.password.reset.email.notBlank=Email cannot be blank +validation.auth.password.reset.email.invalid=Email format is invalid +validation.auth.password.reset.code.notBlank=Verification code cannot be blank +validation.auth.password.reset.code.invalid=Verification code must be 6 digits +validation.auth.password.reset.newPassword.notBlank=New password cannot be blank diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index abb834c3..bc94ca3b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -16,6 +16,7 @@ validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 validation.auth.local.username.notBlank=用户名不能为空 validation.auth.local.password.notBlank=密码不能为空 +validation.auth.local.email.notBlank=邮箱不能为空 validation.auth.local.currentPassword.notBlank=当前密码不能为空 validation.auth.local.newPassword.notBlank=新密码不能为空 validation.auth.local.email.invalid=邮箱格式不正确 @@ -88,6 +89,7 @@ error.skill.metadata.requiredField.missing=缺少必填字段:{0} error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0} error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md +error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0} error.skill.publish.precheck.failed=预发布校验失败:{0} error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} @@ -102,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} @@ -132,7 +134,12 @@ error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SU error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 +error.skill.publish.nameConflict.private=该命名空间下已存在名为"{0}"的已发布私有技能,无法提交 error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能 +error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无法提交审核 +error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 +error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 +error.skill.version.notDownloadable=版本"{0}"不可下载 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 @@ -147,3 +154,16 @@ error.profileReview.commentRequired=拒绝原因不能为空 error.profileReview.commentTooLong=拒绝原因不能超过 500 个字符 error.profileReview.status.invalid=无效的审核状态:{0} error.profileReview.userDisabled=无法应用变更——用户账号已被禁用 + +# Password reset +response.auth.password.reset.requested=如果账号符合条件,密码重置验证码已发送。 +response.auth.password.reset.confirmed=密码已重置成功,请使用新密码登录。 +error.auth.password.reset.invalid.code=验证码无效或已过期。 +error.auth.password.reset.not.eligible=该账号不符合密码重置条件。 +error.auth.password.reset.no.credential=该账号没有本地凭证。 +error.auth.password.reset.email.failed=发送密码重置验证码失败,请稍后重试。 +validation.auth.password.reset.email.notBlank=邮箱不能为空 +validation.auth.password.reset.email.invalid=邮箱格式不正确 +validation.auth.password.reset.code.notBlank=验证码不能为空 +validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 +validation.auth.password.reset.newPassword.notBlank=新密码不能为空 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java index 2070d360..24087c0d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/TestRedisConfig.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.mockito.Mockito; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,6 +11,13 @@ import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; @Configuration public class TestRedisConfig { @@ -27,6 +38,76 @@ public class TestRedisConfig { @Bean @Primary public StringRedisTemplate stringRedisTemplate() { - return Mockito.mock(StringRedisTemplate.class); + StringRedisTemplate template = Mockito.mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOps = Mockito.mock(ValueOperations.class); + Map values = new ConcurrentHashMap<>(); + Map expirations = new ConcurrentHashMap<>(); + + when(template.opsForValue()).thenReturn(valueOps); + + when(valueOps.get(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + return values.get(key); + }); + + when(valueOps.increment(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + long next = Long.parseLong(values.getOrDefault(key, "0")) + 1L; + values.put(key, Long.toString(next)); + return next; + }); + + doAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + String value = invocation.getArgument(1, String.class); + Long timeout = invocation.getArgument(2, Long.class); + TimeUnit unit = invocation.getArgument(3, TimeUnit.class); + values.put(key, value); + expirations.put(key, Instant.now().plusMillis(unit.toMillis(timeout))); + return null; + }).when(valueOps).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + + when(template.delete(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + boolean removed = values.remove(key) != null; + expirations.remove(key); + return removed; + }); + + when(template.expire(anyString(), any())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + java.time.Duration ttl = invocation.getArgument(1, java.time.Duration.class); + if (!values.containsKey(key)) { + return false; + } + expirations.put(key, Instant.now().plus(ttl)); + return true; + }); + + when(template.getExpire(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + evictExpired(values, expirations, key); + Instant expiresAt = expirations.get(key); + if (expiresAt == null) { + return -1L; + } + long seconds = java.time.Duration.between(Instant.now(), expiresAt).getSeconds(); + return Math.max(seconds, -1L); + }); + + return template; + } + + private static void evictExpired(Map values, + Map expirations, + String key) { + Instant expiresAt = expirations.get(key); + if (expiresAt != null && expiresAt.isBefore(Instant.now())) { + values.remove(key); + expirations.remove(key); + } } } 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 c024cf08..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 @@ -1,31 +1,50 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Instant; 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.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Optional; import java.util.Set; -import java.math.BigDecimal; -import java.time.Instant; +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; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @@ -48,6 +67,15 @@ class ClawHubCompatControllerTest { @MockBean private SkillQueryService skillQueryService; + @MockBean + private CompatSkillLookupService compatSkillLookupService; + + @MockBean + private SkillPublishService skillPublishService; + + @MockBean + private AuditLogService auditLogService; + @Test void search_returns_mapped_results() throws Exception { when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null)) @@ -105,6 +133,65 @@ class ClawHubCompatControllerTest { .andExpect(jsonPath("$.latestVersion.version").value("latest")); } + @Test + void resolve_query_with_canonical_slug_returns_correct_downloadUrl() throws Exception { + when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of())) + .thenReturn(new SkillQueryService.ResolvedVersionDTO( + 1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download")); + + mockMvc.perform(get("/api/v1/resolve") + .param("slug", "team-ai--my-skill") + .param("version", "latest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.match.version").value("latest")) + .andExpect(jsonPath("$.latestVersion.version").value("latest")); + + verify(skillQueryService).resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()); + } + + @Test + 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")); + + mockMvc.perform(get("/api/v1/resolve") + .param("slug", "my-skill") + .param("version", "latest")) + .andExpect(status().isOk()) + .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()); + } + + @Test + void download_query_with_canonical_slug_redirects_to_namespace_skill_download() throws Exception { + mockMvc.perform(get("/api/v1/download") + .param("slug", "team-ai--my-skill") + .param("version", "latest")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "/api/v1/skills/team-ai/my-skill/download")); + } + + @Test + 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 void resolve_with_version_returns_specified_version() throws Exception { when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of())) @@ -141,4 +228,123 @@ class ClawHubCompatControllerTest { .andExpect(jsonPath("$.user.displayName").value("tester")) .andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png")); } + + @Test + void publish_skill_with_canonical_slug_routes_to_namespace_publish() throws Exception { + SkillVersion version = publishVersion("1.0.0", 34L); + given(skillPublishService.publishFromEntries( + eq("team-ai"), + anyList(), + eq("user-42"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(12L, "my-skill", version)); + + mockMvc.perform(multipart("/api/v1/skills") + .file(skillMdFile()) + .param("payload", """ + {"slug":"team-ai--my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]} + """) + .with(authentication(superAdminAuth())) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.skillId").value("12")) + .andExpect(jsonPath("$.versionId").value("34")); + } + + @Test + void publish_skill_with_plain_slug_defaults_to_global_namespace() throws Exception { + SkillVersion version = publishVersion("1.0.0", 35L); + given(skillPublishService.publishFromEntries( + eq("global"), + anyList(), + eq("user-42"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(13L, "my-skill", version)); + + mockMvc.perform(multipart("/api/v1/skills") + .file(skillMdFile()) + .param("payload", """ + {"slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]} + """) + .with(authentication(superAdminAuth())) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.skillId").value("13")) + .andExpect(jsonPath("$.versionId").value("35")); + } + + @Test + void publish_skill_with_payload_namespace_uses_explicit_namespace() throws Exception { + SkillVersion version = publishVersion("1.0.0", 36L); + given(skillPublishService.publishFromEntries( + eq("team-explicit"), + anyList(), + eq("user-42"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(false))) + .willReturn(new SkillPublishService.PublishResult(14L, "my-skill", version)); + + mockMvc.perform(multipart("/api/v1/skills") + .file(skillMdFile()) + .param("payload", """ + {"namespace":"@team-explicit","slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]} + """) + .with(authentication(superAdminAuth())) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ok").value(true)) + .andExpect(jsonPath("$.skillId").value("14")) + .andExpect(jsonPath("$.versionId").value("36")); + } + + private CompatSkillLookupService.CompatSkillContext legacyCompatContext(String namespaceSlug, String skillSlug) { + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, "tester"); + Skill skill = new Skill(1L, skillSlug, "tester", SkillVisibility.PUBLIC); + return new CompatSkillLookupService.CompatSkillContext(namespace, skill, Optional.empty()); + } + + private MockMultipartFile skillMdFile() { + return new MockMultipartFile( + "files", + "SKILL.md", + "text/markdown", + """ + --- + name: my-skill + description: Demo skill + version: 1.0.0 + --- + """.getBytes(StandardCharsets.UTF_8) + ); + } + + private SkillVersion publishVersion(String versionValue, long versionId) { + SkillVersion version = new SkillVersion(12L, versionValue, "user-42"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + ReflectionTestUtils.setField(version, "id", versionId); + return version; + } + + private UsernamePasswordAuthenticationToken superAdminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", + "tester", + "tester@example.com", + "https://example.com/avatar.png", + "github", + Set.of("SUPER_ADMIN") + ); + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + } } 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/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index bb5ee3ff..7158cdfd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.metrics.SkillHubMetrics; @@ -50,6 +51,9 @@ class LocalAuthControllerTest { @MockBean private AuthFailureThrottleService authFailureThrottleService; + @MockBean + private PasswordResetService passwordResetService; + @Test void login_returnsCurrentUserEnvelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -119,6 +123,23 @@ class LocalAuthControllerTest { verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); } + @Test + void register_rejectsBlankEmail() throws Exception { + given(localAuthService.register("bob", "Abcd123!", " ")) + .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank")); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":" "} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(localAuthService).register("bob", "Abcd123!", " "); + } + @Test void login_failure_recordsFailureMetric() throws Exception { given(localAuthService.login("alice", "wrong")) @@ -176,4 +197,66 @@ class LocalAuthControllerTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/password-reset/request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice@example.com"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(passwordResetService).requestPasswordReset("alice@example.com"); + } + + @Test + void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception { + willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) + .given(passwordResetService).requestPasswordReset("alice"); + + mockMvc.perform(post("/api/v1/auth/local/password-reset/request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(passwordResetService).requestPasswordReset("alice"); + } + + @Test + void confirmPasswordReset_returnsUpdatedEnvelope() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice@example.com","code":"123456","newPassword":"Abcd123!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(passwordResetService).confirmPasswordReset("alice@example.com", "123456", "Abcd123!"); + } + + @Test + void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception { + willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) + .given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); + + mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"alice","code":"123456","newPassword":"Abcd123!"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); + } + } 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 76663e99..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 @@ -12,6 +12,8 @@ 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.NamespaceCandidateUserResponse; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import org.junit.jupiter.api.Test; @@ -71,6 +73,9 @@ class NamespacePortalControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private UserAccountRepository userAccountRepository; + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); @@ -89,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 @@ -188,9 +184,12 @@ class NamespacePortalControllerTest { void addMember_returnsCreatedMember() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) .willReturn(member); + given(userAccountRepository.findById("user-2")) + .willReturn(java.util.Optional.of(user)); mockMvc.perform(post("/api/v1/namespaces/team-a/members") .with(csrf()) @@ -203,7 +202,9 @@ class NamespacePortalControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-2")) - .andExpect(jsonPath("$.data.role").value("ADMIN")); + .andExpect(jsonPath("$.data.role").value("ADMIN")) + .andExpect(jsonPath("$.data.displayName").value("Alice")) + .andExpect(jsonPath("$.data.email").value("alice@example.com")); } @Test @@ -224,9 +225,12 @@ class NamespacePortalControllerTest { void updateMemberRole_returnsUpdatedMember() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); given(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) .willReturn(member); + given(userAccountRepository.findById("user-2")) + .willReturn(java.util.Optional.of(user)); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a/members/user-2/role") .with(csrf()) @@ -239,7 +243,9 @@ class NamespacePortalControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("user-2")) - .andExpect(jsonPath("$.data.role").value("OWNER")); + .andExpect(jsonPath("$.data.role").value("OWNER")) + .andExpect(jsonPath("$.data.displayName").value("Alice")) + .andExpect(jsonPath("$.data.email").value("alice@example.com")); } @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 8c796f22..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 @@ -3,16 +3,19 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.Namespace; -import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; -import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.dto.NamespaceResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkflowAppService; +import com.iflytek.skillhub.service.NamespacePortalCommandAppService; +import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -49,23 +52,20 @@ class NamespaceWorkflowContractTest { private MockMvc mockMvc; @MockBean - private NamespaceService namespaceService; + private NamespacePortalCommandAppService namespacePortalCommandAppService; @MockBean - private NamespaceGovernanceService namespaceGovernanceService; + private NamespacePortalQueryAppService namespacePortalQueryAppService; @MockBean - private NamespaceMemberService namespaceMemberService; - - @MockBean - private NamespaceRepository namespaceRepository; - - @MockBean - private NamespaceMemberRepository namespaceMemberRepository; + private GovernanceWorkflowAppService governanceWorkflowAppService; @MockBean private NamespaceMemberCandidateService namespaceMemberCandidateService; + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + @MockBean private DeviceAuthService deviceAuthService; @@ -75,23 +75,32 @@ 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); + NamespaceResponse archivedResponse = NamespaceResponse.from(archived); + MemberResponse adminMemberResponse = MemberResponse.from( + adminMember, + new UserAccount("user-admin", "Admin", "admin@example.com", null) + ); - given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1"))) - .willReturn(namespace); - given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace); - given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any())) - .willReturn(frozen); - given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any())) - .willReturn(archived); + given(namespacePortalCommandAppService.createNamespace(any(), any())) + .willReturn(namespaceResponse); + given(governanceWorkflowAppService.freezeNamespace(eq("team-flow"), any(), eq("owner-1"), any())) + .willReturn(frozenResponse); + given(governanceWorkflowAppService.archiveNamespace(eq("team-flow"), any(), eq("owner-1"), any())) + .willReturn(archivedResponse); given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10)) .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); - given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) - .willReturn(adminMember); - given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class))) - .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember))); - given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) - .willReturn(adminMember); + given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMemberResponse); + given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"))) + .willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20)); + given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1"))) + .willReturn(adminMemberResponse); + given(namespacePortalCommandAppService.removeMember("team-flow", "user-admin", "owner-1")) + .willReturn(new com.iflytek.skillhub.dto.MessageResponse("Member removed successfully")); mockMvc.perform(post("/api/web/namespaces") .with(csrf()) @@ -119,14 +128,18 @@ class NamespaceWorkflowContractTest { .content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.userId").value("user-admin")); + .andExpect(jsonPath("$.data.userId").value("user-admin")) + .andExpect(jsonPath("$.data.displayName").value("Admin")) + .andExpect(jsonPath("$.data.email").value("admin@example.com")); mockMvc.perform(get("/api/web/namespaces/team-flow/members") .with(auth("owner-1")) .requestAttr("userId", "owner-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")); + .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")) + .andExpect(jsonPath("$.data.items[0].displayName").value("Admin")) + .andExpect(jsonPath("$.data.items[0].email").value("admin@example.com")); mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role") .with(csrf()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index bd4914d2..a69ec302 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -41,6 +41,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.IntStream; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; @@ -221,6 +222,7 @@ class ReviewPortalControllerTest { @Test void listReviews_appliesRequestedTimeSortDirection() throws Exception { stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); PageRequest pageable = PageRequest.of( 1, 5, @@ -244,6 +246,35 @@ class ReviewPortalControllerTest { verify(reviewTaskRepository).findByStatus(ReviewTaskStatus.APPROVED, pageable); } + @Test + void listReviews_preservesRepositoryTotalForDefaultPageSize() throws Exception { + assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus.PENDING); + } + + @Test + void listApprovedReviews_preservesRepositoryTotalForDefaultPageSize() throws Exception { + assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus.APPROVED); + } + + @Test + void listRejectedReviews_preservesRepositoryTotalForDefaultPageSize() throws Exception { + assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus.REJECTED); + } + + @Test + void listReviews_forbidsGlobalQueueForNonPlatformReviewer() throws Exception { + stubNamespaceRoles("namespace-admin", List.of()); + given(rbacService.getUserRoleCodes("namespace-admin")).willReturn(Set.of("NAMESPACE_ADMIN")); + + mockMvc.perform(get("/api/v1/reviews") + .param("status", "PENDING") + .with(auth("namespace-admin"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + @Test void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception { stubNamespaceRoles("admin", List.of()); @@ -264,21 +295,7 @@ class ReviewPortalControllerTest { } private void stubReviewResponse(ReviewTask task) { - given(governanceQueryRepository.getReviewTaskResponse(task)).willReturn(new ReviewTaskResponse( - task.getId(), - task.getSkillVersionId(), - "team-a", - "skill-a", - "1.0.0", - task.getStatus().name(), - task.getSubmittedBy(), - "Submitter", - task.getReviewedBy(), - null, - task.getReviewComment(), - task.getSubmittedAt(), - task.getReviewedAt() - )); + given(governanceQueryRepository.getReviewTaskResponse(task)).willReturn(toReviewResponse(task)); } private void stubNamespaceRoles(String userId, List members) { @@ -309,12 +326,76 @@ class ReviewPortalControllerTest { return task; } + private ReviewTask createReviewTask(Long id, Long namespaceId, String submittedBy, ReviewTaskStatus status) { + ReviewTask task = createReviewTask(id, namespaceId, submittedBy); + setField(task, "status", status); + return task; + } + private Namespace createNamespace(Long id, String slug) { Namespace namespace = new Namespace(slug, "Team", "owner-1"); setField(namespace, "id", id); return namespace; } + private ReviewTaskResponse toReviewResponse(ReviewTask task) { + return new ReviewTaskResponse( + task.getId(), + task.getSkillVersionId(), + "team-a", + "skill-a", + "1.0.0", + task.getStatus().name(), + task.getSubmittedBy(), + "Submitter", + task.getReviewedBy(), + null, + task.getReviewComment(), + task.getSubmittedAt(), + task.getReviewedAt() + ); + } + + private void assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus status) throws Exception { + stubNamespaceRoles("admin", List.of()); + Namespace namespace = createNamespace(20L, "team-a"); + List tasks = IntStream.rangeClosed(1, 20) + .mapToObj(index -> createReviewTask((long) index, 20L, "submitter-" + index, status)) + .toList(); + List responses = tasks.stream() + .map(this::toReviewResponse) + .toList(); + PageRequest pageable = PageRequest.of( + 0, + 20, + Sort.by( + new Sort.Order(Sort.Direction.DESC, status == ReviewTaskStatus.PENDING ? "submittedAt" : "reviewedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ); + + given(reviewTaskRepository.findByStatus(status, pageable)) + .willReturn(new PageImpl<>(tasks, pageable, 42)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + tasks.forEach(task -> given(reviewService.canViewReview( + task, + "admin", + namespace.getType(), + Map.of(), + Set.of("SKILL_ADMIN"))).willReturn(true)); + given(governanceQueryRepository.getReviewTaskResponses(tasks)).willReturn(responses); + + mockMvc.perform(get("/api/v1/reviews") + .param("status", status.name()) + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(42)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.items.length()").value(20)); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); 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/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 2bb98567..1f01ff04 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -52,6 +53,9 @@ class UserManagementControllerTest { @MockBean private AdminUserAppService adminUserAppService; + @MockBean + private PasswordResetService passwordResetService; + @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) @@ -243,4 +247,22 @@ class UserManagementControllerTest { verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE"); } + + @Test + void triggerPasswordReset_withUserAdminRole_returns200() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/admin/users/user-123/password-reset") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(passwordResetService).adminTriggerPasswordReset("user-123", "user-42"); + } } 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 new file mode 100644 index 00000000..1bd5f879 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -0,0 +1,241 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.SkillhubApplication; +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +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; +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.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.ReviewTaskJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +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.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = SkillhubApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillApprovalVisibilityFlowIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private SkillVersionRepository skillVersionRepository; + + @Autowired + private ReviewTaskJpaRepository reviewTaskJpaRepository; + + @Autowired + private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SearchEmbeddingService searchEmbeddingService; + + @MockBean + private RbacService rbacService; + + @BeforeEach + void setUp() { + when(searchEmbeddingService.embed(anyString())).thenReturn(""); + when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d); + when(rbacService.getUserRoleCodes("super-1")).thenReturn(Set.of("SUPER_ADMIN")); + } + + @Test + void approveReview_indexesGlobalSkillOnlyAfterApproval() throws Exception { + PendingSkillGraph graph = createPendingGlobalSkill("local-user"); + + assertThat(skillSearchDocumentJpaRepository.findBySkillId(graph.skill().getId())).isEmpty(); + assertThat(skillRepository.findById(graph.skill().getId())).get().extracting(Skill::getLatestVersionId).isNull(); + assertThat(skillVersionRepository.findById(graph.version().getId())).get() + .extracting(SkillVersion::getStatus) + .isEqualTo(SkillVersionStatus.PENDING_REVIEW); + + mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"ship it\"}") + .with(authentication(apiAuth("super-1", "SUPER_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("super-1")) + .andExpect(jsonPath("$.data.reviewComment").value("ship it")); + + 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(); + + SkillSearchDocumentEntity indexedDocument = awaitIndexedDocument(graph.skill().getId()); + assertThat(indexedDocument.getSkillId()).isEqualTo(graph.skill().getId()); + assertThat(indexedDocument.getNamespaceId()).isEqualTo(graph.namespace().getId()); + assertThat(indexedDocument.getNamespaceSlug()).isEqualTo(graph.namespace().getSlug()); + assertThat(indexedDocument.getVisibility()).isEqualTo("PUBLIC"); + assertThat(indexedDocument.getStatus()).isEqualTo("ACTIVE"); + 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); + + Namespace namespace = new Namespace("global-approval-" + suffix, "Global Approval " + suffix, "system"); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Approval Skill " + suffix); + skill.setSummary("Visible in search only after approval."); + 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 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); + while (indexed.isEmpty() && Instant.now().isBefore(deadline)) { + Thread.sleep(100L); + indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + } + return indexed.orElseThrow(() -> new AssertionError("Expected search document for skill " + skillId)); + } + + private UsernamePasswordAuthenticationToken apiAuth(String userId, String... roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + Set.of(roles) + ); + List authorities = java.util.Arrays.stream(roles) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } + + private record PendingSkillGraph(Namespace namespace, Skill skill, SkillVersion version, ReviewTask reviewTask) { + } +} 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 b7ffa2e1..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,10 +72,11 @@ class SkillPublishControllerTest { given(skillPublishService.publishFromEntries( eq("global"), - anyList(), + ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), - eq(Set.of("SUPER_ADMIN")))) + eq(Set.of("SUPER_ADMIN")), + eq(false))) .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); PlatformPrincipal principal = new PlatformPrincipal( @@ -109,6 +113,54 @@ class SkillPublishControllerTest { verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); } + @Test + void publish_passesWarningConfirmationFlag() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setFileCount(1); + version.setTotalSize(128L); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries( + eq("global"), + ArgumentMatchers.>any(), + eq("usr_1"), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .param("confirmWarnings", "true") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + private byte[] buildZipBytes() throws Exception { try (ByteArrayOutputStream output = new ByteArrayOutputStream(); ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { 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/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 80a76c15..719ad40d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -9,17 +9,24 @@ import static org.mockito.Mockito.when; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; import com.iflytek.skillhub.dto.NamespaceRequest; +import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; import com.iflytek.skillhub.exception.ForbiddenException; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.util.Optional; import java.util.Set; class NamespacePortalCommandAppServiceTest { @@ -28,11 +35,13 @@ class NamespacePortalCommandAppServiceTest { private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); private final NamespacePortalCommandAppService service = new NamespacePortalCommandAppService( namespaceService, namespaceRepository, namespaceGovernanceService, - namespaceMemberService + namespaceMemberService, + userAccountRepository ); @Test @@ -71,4 +80,92 @@ class NamespacePortalCommandAppServiceTest { namespace.setType(NamespaceType.TEAM); return namespace; } + + @Test + void addMember_populatesDisplayNameAndEmail() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + ReflectionTestUtils.setField(member, "id", 10L); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("user-2")) + .thenReturn(Optional.of(user)); + + MemberResponse result = service.addMember("team-a", "user-2", NamespaceRole.ADMIN, "owner-1"); + + assertThat(result.userId()).isEqualTo("user-2"); + assertThat(result.displayName()).isEqualTo("Alice"); + assertThat(result.email()).isEqualTo("alice@example.com"); + assertThat(result.role()).isEqualTo(NamespaceRole.ADMIN); + } + + @Test + void addMember_withoutUserAccount_degradesGracefully() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); + ReflectionTestUtils.setField(member, "id", 20L); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("ghost")) + .thenReturn(Optional.empty()); + + MemberResponse result = service.addMember("team-a", "ghost", NamespaceRole.MEMBER, "owner-1"); + + assertThat(result.userId()).isEqualTo("ghost"); + assertThat(result.displayName()).isNull(); + assertThat(result.email()).isNull(); + } + + @Test + void updateMemberRole_populatesDisplayNameAndEmail() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.OWNER); + ReflectionTestUtils.setField(member, "id", 10L); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("user-2")) + .thenReturn(Optional.of(user)); + + MemberResponse result = service.updateMemberRole( + "team-a", "user-2", + new UpdateMemberRoleRequest(NamespaceRole.OWNER), + "owner-1" + ); + + assertThat(result.userId()).isEqualTo("user-2"); + assertThat(result.displayName()).isEqualTo("Alice"); + assertThat(result.email()).isEqualTo("alice@example.com"); + assertThat(result.role()).isEqualTo(NamespaceRole.OWNER); + } + + @Test + void updateMemberRole_withoutUserAccount_degradesGracefully() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); + ReflectionTestUtils.setField(member, "id", 20L); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1")) + .thenReturn(member); + when(userAccountRepository.findById("ghost")) + .thenReturn(Optional.empty()); + + MemberResponse result = service.updateMemberRole( + "team-a", "ghost", + new UpdateMemberRoleRequest(NamespaceRole.ADMIN), + "owner-1" + ); + + assertThat(result.userId()).isEqualTo("ghost"); + assertThat(result.displayName()).isNull(); + assertThat(result.email()).isNull(); + } } 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 61e6a458..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,19 +1,30 @@ 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; 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.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.PageResponse; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -25,11 +36,13 @@ class NamespacePortalQueryAppServiceTest { private final NamespaceService namespaceService = mock(NamespaceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); private final NamespacePortalQueryAppService service = new NamespacePortalQueryAppService( namespaceRepository, namespaceService, namespaceMemberService, - namespaceAccessPolicy + namespaceAccessPolicy, + userAccountRepository ); @Test @@ -60,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); @@ -67,4 +113,48 @@ class NamespacePortalQueryAppServiceTest { namespace.setType(NamespaceType.TEAM); return namespace; } + + @Test + void listMembers_withUserAccount_returnsDisplayNameAndEmail() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN); + ReflectionTestUtils.setField(member, "id", 10L); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(member), PageRequest.of(0, 20), 1)); + when(userAccountRepository.findByIdIn(List.of("user-2"))) + .thenReturn(List.of(user)); + + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + + assertThat(result.items()).hasSize(1); + MemberResponse mr = result.items().get(0); + assertThat(mr.userId()).isEqualTo("user-2"); + assertThat(mr.displayName()).isEqualTo("Alice"); + assertThat(mr.email()).isEqualTo("alice@example.com"); + assertThat(mr.role()).isEqualTo(NamespaceRole.ADMIN); + } + + @Test + void listMembers_withoutUserAccount_returnsNullFields() { + Namespace ns = namespace(1L, "team-a"); + NamespaceMember member = new NamespaceMember(1L, "ghost-user", NamespaceRole.MEMBER); + ReflectionTestUtils.setField(member, "id", 20L); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(member), PageRequest.of(0, 20), 1)); + when(userAccountRepository.findByIdIn(List.of("ghost-user"))) + .thenReturn(List.of()); + + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + + assertThat(result.items()).hasSize(1); + MemberResponse mr = result.items().get(0); + assertThat(mr.userId()).isEqualTo("ghost-user"); + assertThat(mr.displayName()).isNull(); + assertThat(mr.email()).isNull(); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index 53215011..e2c10146 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import org.junit.jupiter.api.Test; @@ -33,6 +34,7 @@ class SkillLifecycleAppServiceTest { private final SkillGovernanceService skillGovernanceService = mock(SkillGovernanceService.class); private final ReviewService reviewService = mock(ReviewService.class); private final SkillPublishService skillPublishService = mock(SkillPublishService.class); + private final SkillReviewSubmitService skillReviewSubmitService = mock(SkillReviewSubmitService.class); private final AuditLogService auditLogService = mock(AuditLogService.class); private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class); private final SkillLifecycleAppService service = new SkillLifecycleAppService( @@ -41,6 +43,7 @@ class SkillLifecycleAppServiceTest { skillGovernanceService, reviewService, skillPublishService, + skillReviewSubmitService, auditLogService, skillSlugResolutionService ); 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 98496db0..57710c88 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 @@ -1,5 +1,6 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -7,21 +8,23 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; +import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; import com.iflytek.skillhub.search.SearchResult; -import org.mockito.ArgumentCaptor; +import com.iflytek.skillhub.search.SearchVisibilityScope; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -49,6 +52,9 @@ class SkillSearchAppServiceTest { @Mock private NamespaceService namespaceService; + @Mock + private RbacService rbacService; + private SkillSearchAppService service; @BeforeEach @@ -58,7 +64,8 @@ class SkillSearchAppServiceTest { skillRepository, namespaceRepository, namespaceService, - new SkillLifecycleProjectionService(skillVersionRepository) + new SkillLifecycleProjectionService(skillVersionRepository), + rbacService ); } @@ -190,6 +197,40 @@ class SkillSearchAppServiceTest { assertEquals(List.of("code-generation", "official"), captor.getValue().labelSlugs()); } + @Test + void search_shouldIncludeMemberNamespacesInVisibilityScope() { + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(), 0, 0, 20)); + when(rbacService.getUserRoleCodes("user-9")).thenReturn(Set.of("USER")); + + service.search("skill", null, "newest", 0, 20, "user-9", Map.of(7L, NamespaceRole.MEMBER)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); + verify(searchQueryService).search(captor.capture()); + + SearchVisibilityScope scope = captor.getValue().visibilityScope(); + assertEquals("user-9", scope.userId()); + assertEquals(Set.of(7L), scope.memberNamespaceIds()); + assertEquals(Set.of(), scope.adminNamespaceIds()); + assertEquals(false, scope.platformWideAccess()); + } + + @Test + void search_shouldGrantPlatformWideAccessToSuperAdmin() { + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(), 0, 0, 20)); + when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER")); + + service.search("skill", null, "newest", 0, 20, "admin-1", Map.of()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); + verify(searchQueryService).search(captor.capture()); + + SearchVisibilityScope scope = captor.getValue().visibilityScope(); + assertEquals("admin-1", scope.userId()); + assertEquals(true, scope.platformWideAccess()); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index a3eb93b9..e7a6ea69 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -4,13 +4,14 @@ spring: banner-mode: "off" log-startup-info: false datasource: - url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + generate-unique-name: true + url: jdbc:h2:mem:testdb-${random.uuid};MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: - ddl-auto: create-drop + ddl-auto: create database-platform: org.hibernate.dialect.H2Dialect flyway: enabled: false diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 5e50347d..3bee6a9e 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -35,6 +35,15 @@ org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-configuration-processor + true + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 306d7c46..49743328 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -236,7 +236,7 @@ public class LocalAuthService { private void validateEmail(String email) { if (email == null) { - return; + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank"); } if (!EMAIL_PATTERN.matcher(email).matches()) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java new file mode 100644 index 00000000..00a031be --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.auth.local; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.password-reset") +public class PasswordResetProperties { + + private Duration codeExpiry = Duration.ofMinutes(10); + private String emailFromAddress = "noreply@skillhub.local"; + private String emailFromName = "SkillHub"; + + public Duration getCodeExpiry() { + return codeExpiry; + } + + public void setCodeExpiry(Duration codeExpiry) { + this.codeExpiry = codeExpiry; + } + + public String getEmailFromAddress() { + return emailFromAddress; + } + + public void setEmailFromAddress(String emailFromAddress) { + this.emailFromAddress = emailFromAddress; + } + + public String getEmailFromName() { + return emailFromName; + } + + public void setEmailFromName(String emailFromName) { + this.emailFromName = emailFromName; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java new file mode 100644 index 00000000..60b52acc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -0,0 +1,244 @@ +package com.iflytek.skillhub.auth.local; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.auth.PasswordResetRequest; +import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * Local-account password reset flow backed by one-time email verification + * codes. + */ +@Service +public class PasswordResetService { + + private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class); + private static final int VERIFICATION_CODE_DIGITS = 6; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final PasswordResetRequestRepository resetRequestRepository; + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository credentialRepository; + private final PasswordPolicyValidator passwordPolicyValidator; + private final PasswordEncoder passwordEncoder; + private final JavaMailSender mailSender; + private final PasswordResetProperties properties; + + public PasswordResetService(PasswordResetRequestRepository resetRequestRepository, + UserAccountRepository userAccountRepository, + LocalCredentialRepository credentialRepository, + PasswordPolicyValidator passwordPolicyValidator, + PasswordEncoder passwordEncoder, + JavaMailSender mailSender, + PasswordResetProperties properties) { + this.resetRequestRepository = resetRequestRepository; + this.userAccountRepository = userAccountRepository; + this.credentialRepository = credentialRepository; + this.passwordPolicyValidator = passwordPolicyValidator; + this.passwordEncoder = passwordEncoder; + this.mailSender = mailSender; + this.properties = properties; + } + + /** + * Anonymous/self-service reset request. Always silent on ineligible users to + * avoid account enumeration. + */ + @Transactional + public void requestPasswordReset(String email) { + String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); + Optional userOpt = findEligibleUserByEmail(normalizedEmail); + if (userOpt.isEmpty()) { + log.debug("Password reset requested for ineligible email"); + return; + } + + UserAccount user = userOpt.get(); + String code = generateVerificationCode(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(properties.getCodeExpiry()); + + invalidatePendingRequests(user.getId(), now); + resetRequestRepository.save(new PasswordResetRequest( + user.getId(), + user.getEmail(), + passwordEncoder.encode(code), + expiresAt, + false, + null + )); + + sendVerificationCodeEmail(user.getEmail(), code, false); + } + + /** + * Admin-triggered reset request for a specific user. + */ + @Transactional + public void adminTriggerPasswordReset(String userId, String adminUserId) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.admin.user.notFound", userId)); + + if (!isEligibleForReset(user)) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.not.eligible"); + } + + String code = generateVerificationCode(); + Instant now = Instant.now(); + Instant expiresAt = now.plus(properties.getCodeExpiry()); + + invalidatePendingRequests(userId, now); + resetRequestRepository.save(new PasswordResetRequest( + userId, + user.getEmail(), + passwordEncoder.encode(code), + expiresAt, + true, + adminUserId + )); + + sendVerificationCodeEmail(user.getEmail(), code, true); + } + + /** + * Verifies a code and updates the local credential password. + */ + @Transactional + public void confirmPasswordReset(String email, String code, String newPassword) { + String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); + UserAccount user = findUserByEmail(normalizedEmail) + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code")); + + List pendingRequests = resetRequestRepository + .findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(user.getId(), Instant.now()); + + PasswordResetRequest matchedRequest = pendingRequests.stream() + .filter(request -> passwordEncoder.matches(code, request.getCodeHash())) + .findFirst() + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code")); + + var passwordErrors = passwordPolicyValidator.validate(newPassword); + if (!passwordErrors.isEmpty()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst()); + } + + LocalCredential credential = credentialRepository.findByUserId(user.getId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.no.credential")); + + credential.setPasswordHash(passwordEncoder.encode(newPassword)); + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + + Instant now = Instant.now(); + matchedRequest.markConsumed(now); + resetRequestRepository.save(matchedRequest); + invalidatePendingRequests(user.getId(), now); + } + + private void invalidatePendingRequests(String userId, Instant now) { + List pending = resetRequestRepository + .findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(userId, now); + for (PasswordResetRequest request : pending) { + request.markConsumed(now); + resetRequestRepository.save(request); + } + } + + private Optional findEligibleUserByEmail(String normalizedEmail) { + return findUserByEmail(normalizedEmail) + .filter(this::isEligibleForReset); + } + + private Optional findUserByEmail(String normalizedEmail) { + if (!StringUtils.hasText(normalizedEmail)) { + return Optional.empty(); + } + return userAccountRepository.findByEmailIgnoreCase(normalizedEmail); + } + + private boolean isEligibleForReset(UserAccount user) { + if (user.getStatus() != UserStatus.ACTIVE) { + return false; + } + if (!StringUtils.hasText(user.getEmail())) { + return false; + } + return credentialRepository.findByUserId(user.getId()).isPresent(); + } + + private String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + return null; + } + return email.trim().toLowerCase(Locale.ROOT); + } + + private void validateEmail(String email) { + if (email == null) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.notBlank"); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"); + } + } + + private String generateVerificationCode() { + int bound = (int) Math.pow(10, VERIFICATION_CODE_DIGITS); + int code = SECURE_RANDOM.nextInt(bound); + return String.format("%0" + VERIFICATION_CODE_DIGITS + "d", code); + } + + private void sendVerificationCodeEmail(String email, String code, boolean failOnError) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(resolveFromAddress()); + message.setTo(email); + message.setSubject("SkillHub password reset verification code"); + message.setText(buildVerificationCodeBody(code)); + try { + mailSender.send(message); + log.info("Password reset verification code sent to {}", email); + } catch (Exception ex) { + if (failOnError) { + log.error("Failed to send password reset verification code to {}", email, ex); + throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.password.reset.email.failed"); + } + log.warn("Failed to send password reset verification code to {}", email, ex); + } + } + + private String resolveFromAddress() { + String fromAddress = properties.getEmailFromAddress(); + if (!StringUtils.hasText(properties.getEmailFromName())) { + return fromAddress; + } + return properties.getEmailFromName() + " <" + fromAddress + ">"; + } + + private String buildVerificationCodeBody(String code) { + long expiryMinutes = Math.max(1L, properties.getCodeExpiry().toMinutes()); + return "Your SkillHub password reset verification code is: " + code + + "\n\nThis code expires in " + expiryMinutes + " minutes." + + "\n\nIf you did not request a password reset, please ignore this email."; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java index 7aa1200b..80486dfd 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java @@ -1,5 +1,5 @@ /** * Username-and-password authentication support, including registration, - * password changes, and local credential validation. + * password changes, password resets, and local credential validation. */ package com.iflytek.skillhub.auth.local; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index b87c0018..0a27e6bd 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -35,7 +35,7 @@ public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSu if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) { PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal"); if (principal != null) { - platformSessionService.attachToAuthenticatedSession(principal, authentication, request, true); + platformSessionService.attachToAuthenticatedSession(principal, authentication, request); } } String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index efbe121e..68519f8d 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 @@ -72,10 +72,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/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 72bc728b..d1ef144f 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -262,4 +262,13 @@ class LocalAuthServiceTest { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("validation.auth.local.email.invalid"); } + + @Test + void register_rejectsBlankEmail() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + + assertThatThrownBy(() -> service.register("Alice", "Abcd123!", " ")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("validation.auth.local.email.notBlank"); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java new file mode 100644 index 00000000..aa78c1fb --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -0,0 +1,218 @@ +package com.iflytek.skillhub.auth.local; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.BDDMockito.given; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.domain.auth.PasswordResetRequest; +import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class PasswordResetServiceTest { + + @Mock + private PasswordResetRequestRepository resetRequestRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + @Mock + private LocalCredentialRepository credentialRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JavaMailSender mailSender; + + private PasswordResetService service; + + @BeforeEach + void setUp() { + PasswordResetProperties properties = new PasswordResetProperties(); + properties.setCodeExpiry(Duration.ofMinutes(10)); + properties.setEmailFromAddress("noreply@skillhub.local"); + properties.setEmailFromName("SkillHub"); + service = new PasswordResetService( + resetRequestRepository, + userAccountRepository, + credentialRepository, + new PasswordPolicyValidator(), + passwordEncoder, + mailSender, + properties + ); + } + + @Test + void requestPasswordReset_withEligibleEmail_savesRequestAndSendsEmail() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(credentialRepository.findByUserId("usr_1")).willReturn( + Optional.of(new LocalCredential("usr_1", "alice", "encoded")) + ); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of()); + given(passwordEncoder.encode(anyString())).willReturn("encoded-value"); + + service.requestPasswordReset("alice@example.com"); + + verify(resetRequestRepository).save(any(PasswordResetRequest.class)); + verify(mailSender).send(any(SimpleMailMessage.class)); + } + + @Test + void requestPasswordReset_withUnknownEmail_doesNothing() { + given(userAccountRepository.findByEmailIgnoreCase("ghost@example.com")).willReturn(Optional.empty()); + + service.requestPasswordReset("ghost@example.com"); + + verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class)); + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + + @Test + void requestPasswordReset_withInvalidEmail_throwsBadRequest() { + assertThatThrownBy(() -> service.requestPasswordReset("alice")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verifyNoInteractions(userAccountRepository, resetRequestRepository, mailSender); + } + + @Test + void requestPasswordReset_emailFailure_doesNotThrowForAnonymousFlow() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(credentialRepository.findByUserId("usr_1")).willReturn( + Optional.of(new LocalCredential("usr_1", "alice", "encoded")) + ); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of()); + given(passwordEncoder.encode(anyString())).willReturn("encoded-value"); + + org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(mailSender).send(any(SimpleMailMessage.class)); + + service.requestPasswordReset("alice@example.com"); + + verify(resetRequestRepository).save(any(PasswordResetRequest.class)); + } + + @Test + void adminTriggerPasswordReset_withUnknownUser_throwsNotFound() { + given(userAccountRepository.findById("missing")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("missing", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void confirmPasswordReset_withValidCode_updatesCredential() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password"); + PasswordResetRequest request = new PasswordResetRequest( + "usr_1", + "alice@example.com", + "encoded-code", + Instant.now().plus(Duration.ofMinutes(5)), + false, + null + ); + + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of(request)); + given(passwordEncoder.matches("123456", "encoded-code")).willReturn(true); + given(credentialRepository.findByUserId("usr_1")).willReturn(Optional.of(credential)); + given(passwordEncoder.encode("Abcd123!")).willReturn("new-password-hash"); + + service.confirmPasswordReset("alice@example.com", "123456", "Abcd123!"); + + assertThat(credential.getPasswordHash()).isEqualTo("new-password-hash"); + assertThat(credential.getFailedAttempts()).isZero(); + assertThat(credential.getLockedUntil()).isNull(); + verify(credentialRepository).save(credential); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PasswordResetRequest.class); + verify(resetRequestRepository, atLeastOnce()).save(requestCaptor.capture()); + assertThat(requestCaptor.getAllValues()) + .anySatisfy(captured -> assertThat(captured.getConsumedAt()).isNotNull()); + } + + @Test + void confirmPasswordReset_withInvalidCode_throwsBadRequest() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password"); + PasswordResetRequest request = new PasswordResetRequest( + "usr_1", + "alice@example.com", + "encoded-code", + Instant.now().plus(Duration.ofMinutes(5)), + false, + null + ); + + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user)); + given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).willReturn(List.of(request)); + given(passwordEncoder.matches("654321", "encoded-code")).willReturn(false); + + assertThatThrownBy(() -> service.confirmPasswordReset("alice@example.com", "654321", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void confirmPasswordReset_withInvalidEmail_throwsBadRequest() { + assertThatThrownBy(() -> service.confirmPasswordReset("alice", "123456", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verifyNoInteractions(userAccountRepository, resetRequestRepository, credentialRepository); + } + + @Test + void adminTriggerPasswordReset_forDisabledUser_throwsBadRequest() { + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + user.setStatus(UserStatus.DISABLED); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("usr_1", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + } +} 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 592151fa..6bf8c098 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 @@ -6,6 +6,7 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; @@ -52,11 +53,15 @@ class OAuth2LoginHandlersTest { handler.onAuthenticationSuccess(request, response, authentication); + SecurityContext securityContext = (SecurityContext) session.getAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY + ); assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish"); - assertThat(request.getSession(false).getId()).isNotEqualTo(originalSessionId); + assertThat(request.getSession(false).getId()).isEqualTo(originalSessionId); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); - assertThat(session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNotNull(); + assertThat(securityContext).isNotNull(); + assertThat(securityContext.getAuthentication()).isSameAs(authentication); } @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/auth/PasswordResetRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java new file mode 100644 index 00000000..2c034675 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.domain.auth; + +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.Table; +import java.time.Clock; +import java.time.Instant; + +@Entity +@Table(name = "password_reset_request") +public class PasswordResetRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 255) + private String email; + + @Column(name = "code_hash", nullable = false, length = 255) + private String codeHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_at") + private Instant consumedAt; + + @Column(name = "requested_by_admin", nullable = false) + private boolean requestedByAdmin; + + @Column(name = "requested_by_user_id", length = 128) + private String requestedByUserId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected PasswordResetRequest() { + } + + public PasswordResetRequest(String userId, + String email, + String codeHash, + Instant expiresAt, + boolean requestedByAdmin, + String requestedByUserId) { + this.userId = userId; + this.email = email; + this.codeHash = codeHash; + this.expiresAt = expiresAt; + this.requestedByAdmin = requestedByAdmin; + this.requestedByUserId = requestedByUserId; + } + + @PrePersist + void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(Clock.systemUTC()); + } + } + + public void markConsumed(Instant timestamp) { + this.consumedAt = timestamp; + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getCodeHash() { + return codeHash; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public Instant getConsumedAt() { + return consumedAt; + } + + public boolean isRequestedByAdmin() { + return requestedByAdmin; + } + + public String getRequestedByUserId() { + return requestedByUserId; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java new file mode 100644 index 00000000..567bfa70 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.auth; + +import java.time.Instant; +import java.util.List; + +/** + * Domain repository contract for local-account password reset verification + * codes. + */ +public interface PasswordResetRequestRepository { + PasswordResetRequest save(PasswordResetRequest request); + + List findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + String userId, + Instant now + ); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java new file mode 100644 index 00000000..3080fb67 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java @@ -0,0 +1,4 @@ +/** + * Password-reset domain entities and repository contracts. + */ +package com.iflytek.skillhub.domain.auth; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 16f888b9..eb533b8a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,6 +44,7 @@ public class PromotionService { private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final GovernanceNotificationService governanceNotificationService; + private final EntityManager entityManager; private final Clock clock; public PromotionService(PromotionRequestRepository promotionRequestRepository, @@ -53,6 +55,7 @@ public class PromotionService { ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, GovernanceNotificationService governanceNotificationService, + EntityManager entityManager, Clock clock) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; @@ -62,6 +65,7 @@ public class PromotionService { this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.governanceNotificationService = governanceNotificationService; + this.entityManager = entityManager; this.clock = clock; } @@ -193,9 +197,9 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - - PromotionRequest approvedRequest = promotionRequestRepository.findById(promotionId) - .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); + syncPromotionRequestState(request, ReviewTaskStatus.APPROVED, reviewerId, comment); + entityManager.detach(request); + PromotionRequest approvedRequest = request; Skill sourceSkill = skillRepository.findById(approvedRequest.getSourceSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", approvedRequest.getSourceSkillId())); @@ -282,6 +286,8 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + syncPromotionRequestState(request, ReviewTaskStatus.REJECTED, reviewerId, comment); + entityManager.detach(request); eventPublisher.publishEvent(new PromotionRejectedEvent( request.getId(), request.getSourceSkillId(), reviewerId, request.getSubmittedBy(), comment)); @@ -294,7 +300,7 @@ public class PromotionService { "{\"status\":\"REJECTED\"}" ); - return promotionRequestRepository.findById(promotionId).orElse(request); + return request; } public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { @@ -313,4 +319,14 @@ public class PromotionService { private Instant currentTime() { return Instant.now(clock); } + + private void syncPromotionRequestState(PromotionRequest request, + ReviewTaskStatus status, + String reviewedBy, + String comment) { + request.setStatus(status); + request.setReviewedBy(reviewedBy); + request.setReviewComment(comment); + request.setReviewedAt(currentTime()); + } } 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/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index b1c66925..56ea7884 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -51,6 +52,7 @@ public class ReviewService { private final ObjectMapper objectMapper; private final SkillGovernanceService skillGovernanceService; private final GovernanceNotificationService governanceNotificationService; + private final EntityManager entityManager; private final Clock clock; public ReviewService(ReviewTaskRepository reviewTaskRepository, @@ -62,6 +64,7 @@ public class ReviewService { ObjectMapper objectMapper, SkillGovernanceService skillGovernanceService, GovernanceNotificationService governanceNotificationService, + EntityManager entityManager, Clock clock) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; @@ -72,6 +75,7 @@ public class ReviewService { this.objectMapper = objectMapper; this.skillGovernanceService = skillGovernanceService; this.governanceNotificationService = governanceNotificationService; + this.entityManager = entityManager; this.clock = clock; } @@ -97,7 +101,9 @@ public class ReviewService { throw new DomainForbiddenException("review.submit.no_permission"); } - if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + // Support both DRAFT (legacy) and UPLOADED (new flow) status + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT + && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); } @@ -133,7 +139,9 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); assertNamespaceActive(namespace); - if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + // Support both DRAFT (legacy) and UPLOADED (new flow) status + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT + && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); } @@ -191,6 +199,8 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + syncReviewTaskState(task, ReviewTaskStatus.APPROVED, reviewerId, comment); + entityManager.detach(task); Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); @@ -234,8 +244,7 @@ public class ReviewService { "{\"status\":\"APPROVED\"}" ); - // Reload to return updated state - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } /** @@ -267,6 +276,8 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + syncReviewTaskState(task, ReviewTaskStatus.REJECTED, reviewerId, comment); + entityManager.detach(task); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); @@ -286,7 +297,7 @@ public class ReviewService { "{\"status\":\"REJECTED\"}" ); - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } /** @@ -358,4 +369,14 @@ public class ReviewService { private Instant currentTime() { return Instant.now(clock); } + + private void syncReviewTaskState(ReviewTask task, + ReviewTaskStatus status, + String reviewedBy, + String comment) { + task.setStatus(status); + task.setReviewedBy(reviewedBy); + task.setReviewComment(comment); + task.setReviewedAt(currentTime()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 195510a5..fb24451f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.security; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -105,7 +106,12 @@ public class SecurityScanService { audit.setScannedAt(Instant.now(Clock.systemUTC())); auditRepository.save(audit); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); + // Set status based on requestedVisibility + if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) { + version.setStatus(SkillVersionStatus.UPLOADED); + } else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + } skillVersionRepository.save(version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 78fa2bb1..21978985 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,6 +4,7 @@ public enum SkillVersionStatus { DRAFT, SCANNING, SCAN_FAILED, + UPLOADED, PENDING_REVIEW, PUBLISHED, REJECTED, 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/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 259d9e18..3bb194ff 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -164,12 +164,15 @@ public class SkillDownloadService { private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); - assertPublishedVersion(version); + assertDownloadableVersion(skill, version); DownloadResult result = buildDownloadResult(skill, version); - skillRepository.incrementDownloadCount(skill.getId()); - skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + // Only increment download count for PUBLISHED versions + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + } return result; } @@ -292,9 +295,21 @@ public class SkillDownloadService { } } - private void assertPublishedVersion(SkillVersion version) { - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); + /** + * Asserts that the version can be downloaded. + * - PUBLISHED: anyone with skill access can download + * - UPLOADED/PENDING_REVIEW: only skill owner can download + */ + private void assertDownloadableVersion(Skill skill, SkillVersion version) { + switch (version.getStatus()) { + case PUBLISHED -> { + // Anyone with skill access can download published versions + } + case UPLOADED, PENDING_REVIEW -> { + // Only owner can download UPLOADED/PENDING_REVIEW versions + // Note: This check is already done in assertCanDownload via visibilityChecker + } + default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 9f0be2bf..0dfcb5f3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -162,7 +162,8 @@ public class SkillGovernanceService { assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED - && version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + && version.getStatus() != SkillVersionStatus.SCAN_FAILED + && version.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); } @@ -242,7 +243,7 @@ public class SkillGovernanceService { if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) { throw new DomainBadRequestException("review.withdraw.not_pending", version.getId()); } - version.setStatus(SkillVersionStatus.DRAFT); + version.setStatus(SkillVersionStatus.UPLOADED); SkillVersion savedVersion = skillVersionRepository.save(version); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); 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 1355fc2d..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 @@ -132,7 +132,18 @@ public class SkillPublishService { String publisherId, SkillVisibility visibility, java.util.Set platformRoles) { - return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false); + return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, false); + } + + @Transactional + public PublishResult publishFromEntries( + String namespaceSlug, + List entries, + String publisherId, + SkillVisibility visibility, + java.util.Set platformRoles, + boolean confirmWarnings) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false); } /** @@ -145,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); @@ -161,13 +173,17 @@ public class SkillPublishService { List entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion); + // Rerelease follows the same visibility-based workflow as normal publish: + // - PRIVATE skills go to UPLOADED status + // - PUBLIC/NAMESPACE_ONLY skills go to PENDING_REVIEW (or UPLOADED after scan) return publishFromEntriesInternal( resolveNamespaceSlug(skill.getNamespaceId()), entries, publisherId, skill.getVisibility(), Set.of(), - true, + confirmWarnings, // confirmWarnings: honour caller's choice for rerelease + false, // forceAutoPublish=false: respect visibility rules true ); } @@ -178,6 +194,7 @@ public class SkillPublishService { String publisherId, SkillVisibility visibility, Set platformRoles, + boolean confirmWarnings, boolean forceAutoPublish, boolean bypassMembershipCheck) { @@ -225,18 +242,31 @@ public class SkillPublishService { "error.skill.publish.precheck.failed", String.join(", ", prePublishValidation.errors())); } + List publishWarnings = new ArrayList<>(packageValidation.warnings()); + publishWarnings.addAll(prePublishValidation.warnings()); + if (!confirmWarnings && !publishWarnings.isEmpty()) { + throw new DomainBadRequestException( + "error.skill.publish.precheck.confirmRequired", + formatValidationMessages(publishWarnings)); + } // 6. Find or create Skill record (with owner isolation) List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); // Check if any other owner's skill has published versions + // Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed) for (Skill existing : existingSkills) { if (!existing.getOwnerId().equals(publisherId)) { boolean hasPublished = !skillVersionRepository .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) .isEmpty(); if (hasPublished) { - throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + // Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } } } } @@ -254,12 +284,13 @@ public class SkillPublishService { } // 6c. Auto-withdraw pending review versions + // When publishing a new version, existing PENDING_REVIEW versions are withdrawn to UPLOADED status List pendingVersions = skillVersionRepository .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW); for (SkillVersion pending : pendingVersions) { reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); - pending.setStatus(SkillVersionStatus.DRAFT); + pending.setStatus(SkillVersionStatus.UPLOADED); skillVersionRepository.save(pending); } @@ -280,6 +311,10 @@ public class SkillPublishService { if (autoPublish) { version.setStatus(SkillVersionStatus.PUBLISHED); version.setPublishedAt(currentTime()); + } else if (visibility == SkillVisibility.PRIVATE) { + // PRIVATE skill goes to UPLOADED status, no review task created + version.setStatus(SkillVersionStatus.UPLOADED); + version.setPublishedAt(currentTime()); } else { version.setStatus(SkillVersionStatus.PENDING_REVIEW); } @@ -356,7 +391,8 @@ public class SkillPublishService { version.setDownloadReady(!skillFiles.isEmpty()); skillVersionRepository.save(version); - if (!autoPublish) { + // Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE) + if (!autoPublish && visibility != SkillVisibility.PRIVATE) { ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask); eventPublisher.publishEvent(new ReviewSubmittedEvent( @@ -366,15 +402,18 @@ public class SkillPublishService { savedReviewTask.getSubmittedBy(), savedReviewTask.getNamespaceId() )); - if (securityScanService.isEnabled()) { - securityScanService.triggerScan(version.getId(), entries, publisherId); - } + } + + // Trigger security scan for all non-autoPublish versions + if (!autoPublish && securityScanService.isEnabled()) { + securityScanService.triggerScan(version.getId(), entries, publisherId); } // 12. Update skill metadata and move the published pointer for auto-publish flows skill.setDisplayName(metadata.name()); skill.setSummary(metadata.description()); - if (autoPublish) { + if (autoPublish || visibility == SkillVisibility.PRIVATE) { + // Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status) skill.setLatestVersionId(version.getId()); skill.setVisibility(visibility); } @@ -457,6 +496,12 @@ public class SkillPublishService { return String.format("packages/%d/%d/bundle.zip", skillId, versionId); } + private String formatValidationMessages(List warnings) { + return warnings.stream() + .map(warning -> "- " + warning) + .reduce("", (left, right) -> left.isEmpty() ? right : left + "\n" + right); + } + private void assertNamespaceWritable(Namespace namespace) { if (namespace.getStatus() == NamespaceStatus.FROZEN) { throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); 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 376f43b3..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. @@ -333,6 +346,7 @@ public class SkillQueryService { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.UPLOADED || version.getStatus() == SkillVersionStatus.DRAFT || version.getStatus() == SkillVersionStatus.REJECTED || version.getStatus() == SkillVersionStatus.YANKED @@ -382,6 +396,7 @@ public class SkillQueryService { List versions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.UPLOADED || version.getStatus() == SkillVersionStatus.DRAFT || version.getStatus() == SkillVersionStatus.REJECTED || version.getStatus() == SkillVersionStatus.YANKED @@ -689,15 +704,18 @@ public class SkillQueryService { if (status == SkillVersionStatus.SCAN_FAILED) { return 1; } - if (status == SkillVersionStatus.REJECTED) { + if (status == SkillVersionStatus.UPLOADED) { return 2; } - if (status == SkillVersionStatus.PENDING_REVIEW) { + if (status == SkillVersionStatus.REJECTED) { return 3; } - if (status == SkillVersionStatus.DRAFT) { + if (status == SkillVersionStatus.PENDING_REVIEW) { return 4; } + if (status == SkillVersionStatus.DRAFT) { + return 5; + } if (status == SkillVersionStatus.YANKED) { return 5; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java new file mode 100644 index 00000000..df8e9fd1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.*; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Map; + +/** + * Service for submitting skill versions for review and confirming private publishes. + * + *

This service handles two key workflows for UPLOADED skill versions: + *

    + *
  • submitForReview: Transitions an UPLOADED version to PENDING_REVIEW status, + * creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.
  • + *
  • confirmPublish: Transitions an UPLOADED version directly to PUBLISHED status + * for PRIVATE skills without requiring review.
  • + *
+ * + * @see SkillVersionStatus#UPLOADED + * @see SkillVisibility#PRIVATE + */ +@Service +public class SkillReviewSubmitService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final ReviewTaskRepository reviewTaskRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public SkillReviewSubmitService( + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + ReviewTaskRepository reviewTaskRepository, + NamespaceMemberRepository namespaceMemberRepository, + ApplicationEventPublisher eventPublisher, + Clock clock) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.reviewTaskRepository = reviewTaskRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + /** + * Submit an UPLOADED or DRAFT version for review. + * Transitions version status from UPLOADED/DRAFT to PENDING_REVIEW. + * + *

Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status. + * + * @param skillId the skill ID + * @param versionId the version ID + * @param targetVisibility the target visibility after approval + * @param actorUserId the user performing the action + * @param userNamespaceRoles user's namespace roles + */ + @Transactional + public void submitForReview(Long skillId, Long versionId, SkillVisibility targetVisibility, + String actorUserId, Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + // Validate ownership + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + // Validate version status - support both UPLOADED (new) and DRAFT (legacy) + if (version.getStatus() != SkillVersionStatus.UPLOADED + && version.getStatus() != SkillVersionStatus.DRAFT) { + throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion()); + } + + // Validate version belongs to skill + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.mismatch"); + } + + // Update version + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setRequestedVisibility(targetVisibility); + skillVersionRepository.save(version); + + // Create review task + ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId); + reviewTaskRepository.save(reviewTask); + } + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED/DRAFT to PUBLISHED without review. + * + *

Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status. + * + * @param skillId the skill ID + * @param versionId the version ID + * @param actorUserId the user performing the action + * @param userNamespaceRoles user's namespace roles + */ + @Transactional + public void confirmPublish(Long skillId, Long versionId, String actorUserId, + Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + // Validate ownership + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + // Validate skill visibility is PRIVATE + if (skill.getVisibility() != SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.confirm.notPrivate"); + } + + // Validate version status - support both UPLOADED (new) and DRAFT (legacy) + if (version.getStatus() != SkillVersionStatus.UPLOADED + && version.getStatus() != SkillVersionStatus.DRAFT) { + throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion()); + } + + // Validate version belongs to skill + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.mismatch"); + } + + // Update version to PUBLISHED + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.now(clock)); + skillVersionRepository.save(version); + + // Update skill's latest version + skill.setLatestVersionId(versionId); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } + + private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java index c61e420b..19473d61 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -31,7 +31,7 @@ public class BasicPrePublishValidator implements PrePublishValidator { @Override public ValidationResult validate(SkillPackageContext context) { - List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); for (PackageEntry entry : context.entries()) { if (!isTextLike(entry.path())) { @@ -50,7 +50,7 @@ public class BasicPrePublishValidator implements PrePublishValidator { if (isPlaceholderValue(matchedValue)) { continue; } - errors.add(entry.path() + warnings.add(entry.path() + " line " + (i + 1) + " contains a value that looks like a " + rule.label() @@ -60,7 +60,7 @@ public class BasicPrePublishValidator implements PrePublishValidator { } } - return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); + return warnings.isEmpty() ? ValidationResult.pass() : ValidationResult.warn(warnings); } private boolean isTextLike(String path) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 67d055b9..7cd86dc5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -25,7 +25,7 @@ public final class SkillPackagePolicy { // Configuration and schemas ".toml", ".xml", ".xsd", ".xsl", ".dtd", ".ini", ".cfg", ".env", // Scripts and source code - ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", + ".js", ".cjs", ".mjs", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", ".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash", // Images ".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico", @@ -126,7 +126,8 @@ public final class SkillPackagePolicy { private static boolean isTextExtension(String path) { return path.endsWith(".md") || path.endsWith(".txt") || path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml") - || path.endsWith(".js") || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh") + || path.endsWith(".js") || path.endsWith(".cjs") || path.endsWith(".mjs") + || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh") || path.endsWith(".html") || path.endsWith(".css") || path.endsWith(".csv") || path.endsWith(".toml") || path.endsWith(".xml") || path.endsWith(".xsd") || path.endsWith(".xsl") || path.endsWith(".dtd") || path.endsWith(".ini") diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 836a9eb5..fb2b7a41 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -49,6 +49,7 @@ public class SkillPackageValidator { public ValidationResult validate(List entries) { List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); Set normalizedPaths = new HashSet<>(); PackageEntry skillMd = null; @@ -66,12 +67,12 @@ public class SkillPackageValidator { } if (!hasAllowedExtension(normalizedPath)) { - errors.add("Disallowed file extension: " + normalizedPath); + warnings.add("Disallowed file extension: " + normalizedPath); } String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content()); if (contentMismatch != null) { - errors.add(contentMismatch); + warnings.add(contentMismatch); } if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) { @@ -82,7 +83,7 @@ public class SkillPackageValidator { // 1. Check SKILL.md exists at root if (skillMd == null) { errors.add("Missing required file: SKILL.md at root"); - return ValidationResult.fail(errors); + return ValidationResult.of(errors, warnings); } // 2. Validate frontmatter @@ -111,7 +112,7 @@ public class SkillPackageValidator { errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); } - return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); + return ValidationResult.of(errors, warnings); } private boolean hasAllowedExtension(String normalizedPath) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java index 5ec259c3..367f9812 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java @@ -4,17 +4,32 @@ import java.util.List; public record ValidationResult( boolean passed, - List errors + List errors, + List warnings ) { public static ValidationResult pass() { - return new ValidationResult(true, List.of()); + return new ValidationResult(true, List.of(), List.of()); } public static ValidationResult fail(List errors) { - return new ValidationResult(false, errors); + return new ValidationResult(false, List.copyOf(errors), List.of()); } public static ValidationResult fail(String error) { - return new ValidationResult(false, List.of(error)); + return new ValidationResult(false, List.of(error), List.of()); + } + + public static ValidationResult warn(List warnings) { + return new ValidationResult(true, List.of(), List.copyOf(warnings)); + } + + public static ValidationResult of(List errors, List warnings) { + List safeErrors = errors == null ? List.of() : List.copyOf(errors); + List safeWarnings = warnings == null ? List.of() : List.copyOf(warnings); + return new ValidationResult(safeErrors.isEmpty(), safeErrors, safeWarnings); + } + + public boolean hasWarnings() { + return !warnings.isEmpty(); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 31695868..ea00a449 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -42,6 +43,7 @@ class PromotionServiceTest { @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; @Mock private GovernanceNotificationService governanceNotificationService; + @Mock private EntityManager entityManager; private PromotionService promotionService; @@ -58,7 +60,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, CLOCK); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, entityManager, CLOCK); } private static void setField(Object target, String fieldName, Object value) { @@ -391,17 +393,11 @@ class PromotionServiceTest { @Test void shouldApprovePromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); - PromotionRequest approvedPromotion = createPendingPromotion(); - setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); - setField(approvedPromotion, "version", 2); - setField(approvedPromotion, "reviewedBy", REVIEWER_ID); - setField(approvedPromotion, "reviewComment", "LGTM"); Skill sourceSkill = createSourceSkill(); SkillVersion sourceVersion = createPublishedVersion(); List sourceFiles = createSourceFiles(); - when(promotionRequestRepository.findById(PROMOTION_ID)) - .thenReturn(Optional.of(pr), Optional.of(approvedPromotion)); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) @@ -420,12 +416,16 @@ class PromotionServiceTest { }); when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(sourceFiles); when(skillFileRepository.saveAll(anyList())).thenAnswer(inv -> inv.getArgument(0)); - when(promotionRequestRepository.save(approvedPromotion)).thenReturn(approvedPromotion); + when(promotionRequestRepository.save(pr)).thenReturn(pr); PromotionRequest result = promotionService.approvePromotion( PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); // Verify new skill created in global namespace ArgumentCaptor skillCaptor = ArgumentCaptor.forClass(Skill.class); @@ -467,8 +467,8 @@ class PromotionServiceTest { assertEquals(REVIEWER_ID, event.publisherId()); // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(approvedPromotion); - assertEquals(NEW_SKILL_ID, approvedPromotion.getTargetSkillId()); + verify(promotionRequestRepository).save(pr); + assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); } @Test @@ -556,12 +556,14 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) .thenReturn(1); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); - PromotionRequest result = promotionService.rejectPromotion( PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("Not ready", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); verify(eventPublisher, never()).publishEvent(any()); 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 76453d1b..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 @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -54,6 +55,7 @@ class ReviewServiceTest { @Mock private ApplicationEventPublisher eventPublisher; @Mock private SkillGovernanceService skillGovernanceService; @Mock private GovernanceNotificationService governanceNotificationService; + @Mock private EntityManager entityManager; private ReviewService reviewService; @@ -70,7 +72,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, CLOCK); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, entityManager, CLOCK); } private SkillVersion createDraftSkillVersion() { @@ -253,6 +255,10 @@ class ReviewServiceTest { Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertEquals(Instant.now(CLOCK), sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); @@ -335,9 +341,13 @@ class ReviewServiceTest { when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); - reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", + ReviewTask result = reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("Needs work", result.getReviewComment()); + assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any()); } @@ -473,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(); @@ -592,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/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index f2b254fa..b6f3faff 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -156,7 +156,7 @@ class SkillGovernanceServiceTest { } @Test - void withdrawPendingVersion_demotesVersionToDraft() { + void withdrawPendingVersion_demotesVersionToUploaded() { Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); setField(skill, "id", 1L); SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); @@ -167,7 +167,7 @@ class SkillGovernanceServiceTest { SkillVersion result = service.withdrawPendingVersion(skill, version, "owner"); - assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT); + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.UPLOADED); verify(skillVersionRepository).save(version); verify(skillRepository).save(skill); verify(objectStorageService, never()).deleteObject(any()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 8bc06d70..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 @@ -176,6 +176,89 @@ class SkillPublishServiceTest { assertEquals(1L, submittedEvent.namespaceId()); } + @Test + void testPublishFromEntries_ShouldRequireConfirmationWhenWarningsExist() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe"))); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode()); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("Disallowed file extension: malware.exe")); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token")); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + } + + @Test + void testPublishFromEntries_ShouldAllowPublishAfterWarningConfirmation() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe"))); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of(), + true + ); + + assertEquals("1.0.0", result.version().getVersion()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class)); + } + @Test void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception { String namespaceSlug = "test-ns"; @@ -713,7 +796,7 @@ class SkillPublishServiceTest { } @Test - void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception { + void testRereleasePublishedVersion_ShouldCloneFilesAndSubmitForReview() throws Exception { String publisherId = "user-100"; Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 11L); @@ -772,15 +855,16 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false ); assertEquals("1.2.4", result.version().getVersion()); - assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); - assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); - assertEquals(30L, skill.getLatestVersionId()); - verify(reviewTaskRepository, never()).save(any()); - verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + // Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules) + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + // Review task should be created for PUBLIC skill + verify(reviewTaskRepository).save(any()); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); verify(skillPackageValidator).validate(argThat(entries -> entries.size() == 2 && entries.stream().anyMatch(entry -> @@ -809,10 +893,176 @@ class SkillPublishServiceTest { "1.2.3", "1.2.4", publisherId, - Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false )); } + @Test + void testRereleasePublishedVersion_PrivateSkill_ShouldGoToUploaded() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PRIVATE); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = """ + --- + name: Demo Skill + description: Original summary + version: 1.2.3 + --- + Hello world + """; + + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", + "Original summary", + "1.2.4", + "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 30L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.rereleasePublishedVersion( + skill.getId(), + "1.2.3", + "1.2.4", + publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false + ); + + assertEquals("1.2.4", result.version().getVersion()); + // Rerelease for PRIVATE skill should go to UPLOADED status + assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus()); + // No review task for PRIVATE skill + verify(reviewTaskRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); + // latestVersionId should be updated for PRIVATE skill + assertEquals(30L, skill.getLatestVersionId()); + } + + @Test + void testRereleasePublishedVersion_ShouldRequireConfirmationWhenWarningsExist() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world"; + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", "Original summary", "1.2.4", "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion( + skill.getId(), "1.2.3", "1.2.4", publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + false + )); + + assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode()); + assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token")); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + } + + @Test + void testRereleasePublishedVersion_ShouldSucceedWhenWarningsConfirmed() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world"; + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", "Original summary", "1.2.4", "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of( + "SKILL.md line 5 contains a value that looks like a secret or token."))); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { setId(saved, 30L); } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.rereleasePublishedVersion( + skill.getId(), "1.2.3", "1.2.4", publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + true // confirmWarnings = true → should bypass warning and succeed + ); + + assertEquals("1.2.4", result.version().getVersion()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class)); + } + @Test void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; @@ -846,6 +1096,39 @@ class SkillPublishServiceTest { )); } + @Test + void testPublishFromEntries_ShouldRejectWithPrivateConflictWhenOtherOwnerHasPrivatePublishedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-200"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE); + setId(existingSkill, 1L); + SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of() + )); + assertEquals("error.skill.publish.nameConflict.private", ex.messageCode()); + } + @Test void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; @@ -934,8 +1217,8 @@ class SkillPublishServiceTest { service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); - // Verify pending version was withdrawn to DRAFT - assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + // Verify pending version was withdrawn to UPLOADED (not DRAFT, so it remains visible) + assertEquals(SkillVersionStatus.UPLOADED, pendingV1.getStatus()); verify(reviewTaskRepository).delete(pendingTask); verify(skillVersionRepository).save(pendingV1); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 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-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java new file mode 100644 index 00000000..4f8f9565 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -0,0 +1,272 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Clock; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link SkillReviewSubmitService}. + */ +@ExtendWith(MockitoExtension.class) +class SkillReviewSubmitServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private SkillReviewSubmitService service; + + @BeforeEach + void setUp() { + service = new SkillReviewSubmitService( + skillRepository, + skillVersionRepository, + reviewTaskRepository, + null, // namespaceMemberRepository not used in these tests + eventPublisher, + Clock.systemUTC() + ); + } + + @Nested + @DisplayName("submitForReview") + class SubmitForReviewTests { + + @Test + @DisplayName("should transition UPLOADED version to PENDING_REVIEW") + void shouldTransitionToPendingReview() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Map roles = Map.of(); + + // When + service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles); + + // Then + assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus()); + assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + } + + @Test + @DisplayName("should accept DRAFT version (legacy compatibility)") + void shouldAcceptDraftForLegacyCompatibility() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Map roles = Map.of(); + + // When + service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles); + + // Then + assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus()); + assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + } + + @Test + @DisplayName("should reject when version is neither UPLOADED nor DRAFT") + void shouldRejectWhenNotUploadedOrDraft() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, Map.of())); + } + + @Test + @DisplayName("should reject when user is not owner") + void shouldRejectWhenNotOwner() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String ownerId = "owner-1"; + String otherUserId = "other-user"; + + Skill skill = createSkill(skillId, ownerId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainForbiddenException.class, + () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, otherUserId, Map.of())); + } + } + + @Nested + @DisplayName("confirmPublish") + class ConfirmPublishTests { + + @Test + @DisplayName("should transition UPLOADED version to PUBLISHED for PRIVATE skill") + void shouldTransitionToPublished() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When + service.confirmPublish(skillId, versionId, userId, Map.of()); + + // Then + assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); + assertNotNull(version.getPublishedAt()); + assertEquals(versionId, skill.getLatestVersionId()); + verify(skillRepository).save(skill); + } + + @Test + @DisplayName("should transition DRAFT version to PUBLISHED for PRIVATE skill (legacy compatibility)") + void shouldTransitionDraftToPublished() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When + service.confirmPublish(skillId, versionId, userId, Map.of()); + + // Then + assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); + assertNotNull(version.getPublishedAt()); + assertEquals(versionId, skill.getLatestVersionId()); + verify(skillRepository).save(skill); + } + + @Test + @DisplayName("should reject when skill is not PRIVATE") + void shouldRejectWhenNotPrivate() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PUBLIC); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.confirmPublish(skillId, versionId, userId, Map.of())); + } + + @Test + @DisplayName("should reject when version is neither UPLOADED nor DRAFT") + void shouldRejectWhenNotUploadedOrDraft() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.confirmPublish(skillId, versionId, userId, Map.of())); + } + } + + private Skill createSkill(Long id, String ownerId, Long namespaceId, SkillVisibility visibility) { + Skill skill = new Skill(namespaceId, "test-skill", ownerId, visibility); + setField(skill, "id", id); + return skill; + } + + private SkillVersion createVersion(Long id, Long skillId, SkillVersionStatus status) { + SkillVersion version = new SkillVersion(skillId, "1.0.0", "user-1"); + setField(version, "id", id); + version.setStatus(status); + return version; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java index a40eb5c3..f428559c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java @@ -15,7 +15,7 @@ class BasicPrePublishValidatorTest { private final BasicPrePublishValidator validator = new BasicPrePublishValidator(); @Test - void shouldRejectObviousCredentialLeakWithHelpfulLocation() { + void shouldWarnOnObviousCredentialLeakWithHelpfulLocation() { PackageEntry skillMd = new PackageEntry( "SKILL.md", """ @@ -36,8 +36,8 @@ class BasicPrePublishValidatorTest { 1L )); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(error -> + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(error -> error.contains("SKILL.md") && error.contains("line 5") && error.contains("looks like a"))); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index 39de4640..4f74edaa 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -70,8 +70,8 @@ class SkillPackageValidatorTest { ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe"))); } @Test @@ -236,8 +236,8 @@ class SkillPackageValidatorTest { ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension"))); } @Test @@ -258,8 +258,8 @@ class SkillPackageValidatorTest { ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension"))); } @Test @@ -269,8 +269,8 @@ class SkillPackageValidatorTest { new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg") ); ValidationResult result = validator.validate(entries); - assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg"))); + assertTrue(result.passed()); + assertTrue(result.warnings().stream().anyMatch(e -> e.contains("photo.jpeg"))); } @Test diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java new file mode 100644 index 00000000..a69702b0 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.auth.PasswordResetRequest; +import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository; +import java.time.Instant; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * JPA-backed repository for password-reset verification-code requests. + */ +public interface PasswordResetRequestJpaRepository + extends JpaRepository, PasswordResetRequestRepository { + + List findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + String userId, + Instant now + ); +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java index 4f435e35..288ae5d2 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java @@ -8,9 +8,17 @@ import java.util.Set; public record SearchVisibilityScope( String userId, Set memberNamespaceIds, - Set adminNamespaceIds + Set adminNamespaceIds, + boolean platformWideAccess ) { + public SearchVisibilityScope( + String userId, + Set memberNamespaceIds, + Set adminNamespaceIds) { + this(userId, memberNamespaceIds, adminNamespaceIds, false); + } + public static SearchVisibilityScope anonymous() { - return new SearchVisibilityScope(null, Set.of(), Set.of()); + return new SearchVisibilityScope(null, Set.of(), Set.of(), false); } } 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 8ec3cd37..e6888827 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,6 +105,7 @@ 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 "); @@ -117,7 +118,9 @@ 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(") "); @@ -128,6 +131,7 @@ 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(") "); @@ -192,6 +196,7 @@ 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()); } @@ -238,6 +243,7 @@ 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()); } 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 8fb6c448..ea33092b 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 @@ -392,6 +392,40 @@ class PostgresFullTextQueryServiceTest { assertThat(sqlCaptor.getAllValues().getFirst()).contains("OR d.namespace_id IN :memberNamespaceIds"); } + @Test + void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + null, + null, + new SearchVisibilityScope("admin-1", Set.of(), Set.of(), true), + "newest", + 0, + 12 + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + 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); + } + @Test void maliciousKeywordShouldBeBoundAsParameterInsteadOfInlinedIntoSql() { EntityManager entityManager = mock(EntityManager.class); diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index 50062e85..8e5452b5 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.model.*; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @@ -31,8 +32,10 @@ import java.util.List; public class S3StorageService implements ObjectStorageService { private static final Logger log = LoggerFactory.getLogger(S3StorageService.class); private final S3StorageProperties properties; + private final Object bucketPreparationLock = new Object(); private S3Client s3Client; private S3Presigner s3Presigner; + private volatile boolean bucketPrepared; public S3StorageService(S3StorageProperties properties) { this.properties = properties; } @@ -41,6 +44,13 @@ public class S3StorageService implements ObjectStorageService { ApacheHttpClient.Builder httpClientBuilder = ApacheHttpClient.builder() .maxConnections(properties.getMaxConnections()) .connectionAcquisitionTimeout(properties.getConnectionAcquisitionTimeout()); + this.s3Client = buildS3Client(httpClientBuilder); + this.s3Presigner = buildPresigner(); + log.info("Initialized S3 storage client for bucket '{}' (bucket verification is deferred until first storage access)", + properties.getBucket()); + } + + protected S3Client buildS3Client(ApacheHttpClient.Builder httpClientBuilder) { var builder = S3Client.builder() .region(Region.of(properties.getRegion())) .credentialsProvider(StaticCredentialsProvider.create( @@ -53,34 +63,47 @@ public class S3StorageService implements ObjectStorageService { if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { builder.endpointOverride(URI.create(properties.getEndpoint())); } - this.s3Client = builder.build(); + return builder.build(); + } + + S3Presigner buildPresigner() { var presignerBuilder = S3Presigner.builder() .region(Region.of(properties.getRegion())) .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))); + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(properties.isForcePathStyle()) + .build()); if (properties.getPublicEndpoint() != null && !properties.getPublicEndpoint().isBlank()) { presignerBuilder.endpointOverride(URI.create(properties.getPublicEndpoint())); } else if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { presignerBuilder.endpointOverride(URI.create(properties.getEndpoint())); } - this.s3Presigner = presignerBuilder.build(); - ensureBucketExists(); + return presignerBuilder.build(); } - private void ensureBucketExists() { - if (!properties.isAutoCreateBucket()) { - s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); + private void ensureBucketPrepared() { + if (!properties.isAutoCreateBucket() || bucketPrepared) { return; } - try { s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); } - catch (NoSuchBucketException e) { - log.info("Bucket '{}' does not exist, creating...", properties.getBucket()); - s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build()); + + synchronized (bucketPreparationLock) { + if (bucketPrepared) { + return; + } + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); + } catch (NoSuchBucketException e) { + log.info("Bucket '{}' does not exist, creating...", properties.getBucket()); + s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build()); + } + bucketPrepared = true; } } @Override public void putObject(String key, InputStream data, long size, String contentType) { try { + ensureBucketPrepared(); s3Client.putObject(PutObjectRequest.builder().bucket(properties.getBucket()).key(key).contentType(contentType).contentLength(size).build(), RequestBody.fromInputStream(data, size)); } catch (RuntimeException e) { throw new StorageAccessException("putObject", key, e); @@ -89,6 +112,7 @@ public class S3StorageService implements ObjectStorageService { @Override public InputStream getObject(String key) { try { + ensureBucketPrepared(); return s3Client.getObject(GetObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); } catch (RuntimeException e) { throw new StorageAccessException("getObject", key, e); @@ -97,6 +121,7 @@ public class S3StorageService implements ObjectStorageService { @Override public void deleteObject(String key) { try { + ensureBucketPrepared(); s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); } catch (RuntimeException e) { throw new StorageAccessException("deleteObject", key, e); @@ -106,6 +131,7 @@ public class S3StorageService implements ObjectStorageService { @Override public void deleteObjects(List keys) { if (keys.isEmpty()) return; try { + ensureBucketPrepared(); List ids = keys.stream().map(k -> ObjectIdentifier.builder().key(k).build()).toList(); s3Client.deleteObjects(DeleteObjectsRequest.builder().bucket(properties.getBucket()).delete(Delete.builder().objects(ids).build()).build()); } catch (RuntimeException e) { @@ -114,13 +140,18 @@ public class S3StorageService implements ObjectStorageService { } @Override public boolean exists(String key) { - try { s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return true; } + try { + ensureBucketPrepared(); + s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); + return true; + } catch (NoSuchKeyException e) { return false; } catch (RuntimeException e) { throw new StorageAccessException("exists", key, e); } } @Override public ObjectMetadata getMetadata(String key) { try { + ensureBucketPrepared(); HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified()); } catch (RuntimeException e) { diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java new file mode 100644 index 00000000..0fa1887c --- /dev/null +++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java @@ -0,0 +1,154 @@ +package com.iflytek.skillhub.storage; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.CreateBucketResponse; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class S3StorageServiceTest { + + @Test + void shouldUsePathStylePresignedUrlWhenForcePathStyleEnabled() { + URI presignedUrl = presignGetObjectUrl(true); + + assertThat(presignedUrl.getHost()).isEqualTo("s3.us-east-1.amazonaws.com"); + assertThat(presignedUrl.getPath()).isEqualTo("/test-bucket/artifacts/package.tgz"); + } + + @Test + void shouldUseHostStylePresignedUrlWhenForcePathStyleDisabled() { + URI presignedUrl = presignGetObjectUrl(false); + + assertThat(presignedUrl.getHost()).isEqualTo("test-bucket.s3.us-east-1.amazonaws.com"); + assertThat(presignedUrl.getPath()).isEqualTo("/artifacts/package.tgz"); + } + + @Test + void initShouldNotProbeBucketWhenAutoCreateIsDisabled() { + S3Client client = mock(S3Client.class); + S3Presigner presigner = mock(S3Presigner.class); + TestableS3StorageService service = new TestableS3StorageService(properties(false), client, presigner); + + service.init(); + + verifyNoInteractions(client); + } + + @Test + void putObjectShouldSkipBucketProbeWhenAutoCreateIsDisabled() { + S3Client client = mock(S3Client.class); + S3Presigner presigner = mock(S3Presigner.class); + when(client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().eTag("etag").build()); + TestableS3StorageService service = new TestableS3StorageService(properties(false), client, presigner); + + service.init(); + byte[] content = "hello".getBytes(StandardCharsets.UTF_8); + service.putObject("packages/demo.zip", new ByteArrayInputStream(content), content.length, "application/zip"); + + verify(client, never()).headBucket(any(HeadBucketRequest.class)); + verify(client, never()).createBucket(any(CreateBucketRequest.class)); + verify(client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void putObjectShouldCreateBucketOnlyOnceWhenAutoCreateIsEnabled() { + S3Client client = mock(S3Client.class); + S3Presigner presigner = mock(S3Presigner.class); + doThrow(NoSuchBucketException.builder().message("missing").build()) + .when(client).headBucket(any(HeadBucketRequest.class)); + when(client.createBucket(any(CreateBucketRequest.class))) + .thenReturn(CreateBucketResponse.builder().build()); + when(client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().eTag("etag").build()); + TestableS3StorageService service = new TestableS3StorageService(properties(true), client, presigner); + + service.init(); + byte[] content = "hello".getBytes(StandardCharsets.UTF_8); + service.putObject("packages/demo-1.zip", new ByteArrayInputStream(content), content.length, "application/zip"); + service.putObject("packages/demo-2.zip", new ByteArrayInputStream(content), content.length, "application/zip"); + + verify(client, times(1)).headBucket(any(HeadBucketRequest.class)); + verify(client, times(1)).createBucket(any(CreateBucketRequest.class)); + verify(client, times(2)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + private S3StorageProperties properties(boolean autoCreateBucket) { + S3StorageProperties properties = createProperties(true); + properties.setBucket("skillhub"); + properties.setAutoCreateBucket(autoCreateBucket); + return properties; + } + + private URI presignGetObjectUrl(boolean forcePathStyle) { + S3StorageService storageService = new S3StorageService(createProperties(forcePathStyle)); + try (var presigner = storageService.buildPresigner()) { + var request = presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(GetObjectRequest.builder() + .bucket("test-bucket") + .key("artifacts/package.tgz") + .build()) + .build() + ); + return URI.create(request.url().toString()); + } + } + + private S3StorageProperties createProperties(boolean forcePathStyle) { + S3StorageProperties properties = new S3StorageProperties(); + properties.setRegion("us-east-1"); + properties.setBucket("test-bucket"); + properties.setAccessKey("test-access-key"); + properties.setSecretKey("test-secret-key"); + properties.setEndpoint("https://s3.us-east-1.amazonaws.com"); + properties.setForcePathStyle(forcePathStyle); + return properties; + } + + private static final class TestableS3StorageService extends S3StorageService { + private final S3Client client; + private final S3Presigner presigner; + + private TestableS3StorageService(S3StorageProperties properties, S3Client client, S3Presigner presigner) { + super(properties); + this.client = client; + this.presigner = presigner; + } + + @Override + protected S3Client buildS3Client(ApacheHttpClient.Builder httpClientBuilder) { + return client; + } + + @Override + S3Presigner buildPresigner() { + return presigner; + } + } +} diff --git a/web/e2e/helpers/auth-fixtures.ts b/web/e2e/helpers/auth-fixtures.ts index 31a9fb4a..fc89c8a1 100644 --- a/web/e2e/helpers/auth-fixtures.ts +++ b/web/e2e/helpers/auth-fixtures.ts @@ -5,3 +5,12 @@ export async function setEnglishLocale(page: Page) { window.localStorage.setItem('i18nextLng', 'en') }) } + +export async function setUniqueClientIp(page: Page, seed: string) { + const suffix = Date.now() + Math.floor(Math.random() * 1000) + const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250 + const fourthOctet = suffix % 250 + await page.context().setExtraHTTPHeaders({ + 'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`, + }) +} diff --git a/web/e2e/helpers/review-seed.ts b/web/e2e/helpers/review-seed.ts new file mode 100644 index 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/search-seed.ts b/web/e2e/helpers/search-seed.ts new file mode 100644 index 00000000..f7f0ab1d --- /dev/null +++ b/web/e2e/helpers/search-seed.ts @@ -0,0 +1,265 @@ +import type { Browser, Locator, Page, TestInfo } from '@playwright/test' +import { createFreshSession, loginWithCredentials, registerSession } from './session' +import { E2eTestDataBuilder, type SeededNamespace, type SeededSkill } from './test-data-builder' + +export const DEFAULT_SEARCH_KEYWORD = 'agent' + +export interface SearchSeedContext { + builder: E2eTestDataBuilder + keyword: string + namespace: SeededNamespace + skills: SeededSkill[] + skillNames: string[] +} + +export interface PreparedSearchSeed extends SearchSeedContext { + dispose: () => Promise +} + +interface PublisherSession { + builder: E2eTestDataBuilder + context: Awaited> + namespace: SeededNamespace + page: Page +} + +function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error(`Missing required E2E env: ${name}`) + } + return value +} + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function publisherCredentials() { + return { + username: requireEnv('E2E_PUBLISH_USERNAME'), + password: requireEnv('E2E_PUBLISH_PASSWORD'), + } +} + +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 hasPublisherCredentials() { + return Boolean(getOptionalEnv('E2E_PUBLISH_USERNAME') && getOptionalEnv('E2E_PUBLISH_PASSWORD')) +} + +async function openProvidedPublisherSession(browser: Browser, testInfo: TestInfo): Promise { + const context = await browser.newContext() + const page = await context.newPage() + const builder = new E2eTestDataBuilder(page, testInfo) + + await loginWithCredentials(page, publisherCredentials(), testInfo) + await builder.init() + + return { + builder, + context, + namespace: await builder.ensureWritableNamespace(), + page, + } +} + +async function openAdhocPublisherSession(browser: Browser, testInfo: TestInfo): Promise { + const context = await browser.newContext() + const page = await context.newPage() + const builder = new E2eTestDataBuilder(page, testInfo) + + try { + await createFreshSession(page, testInfo) + } catch { + // Fall back to a regular worker session when transient registration issues happen + // after Playwright restarts the worker following an earlier test failure. + await registerSession(page, testInfo) + } + await builder.init() + + return { + builder, + context, + namespace: await builder.ensureWritableNamespace(), + page, + } +} + +async function publishSearchSkillsChunk( + session: PublisherSession, + keyword: string, + description: string, + seedSuffix: string, + startIndex: number, + count: number, +) { + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + + for (let offset = 0; offset < count; offset += 1) { + const skillIndex = startIndex + offset + 1 + const skillName = `${keyword}-search-${skillIndex}-${seedSuffix}`.slice(0, 48) + const skill = await session.builder.publishSkill(session.namespace.slug, { + name: skillName, + description, + }) + skills.push(skill) + skillNames.push(skillName) + } + + return { skillNames, skills } +} + +export async function seedPublicSearchSkills( + page: Page, + testInfo: TestInfo, + options?: { + awaitSearchIndexed?: boolean + count?: number + keyword?: string + description?: string + }, +): Promise { + const count = options?.count ?? 1 + const builder = new E2eTestDataBuilder(page, testInfo) + const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32) + + await loginWithCredentials(page, publisherCredentials(), testInfo) + await builder.init() + + const namespace = await builder.ensureWritableNamespace() + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + + for (let index = 0; index < count; index += 1) { + const skillName = `${keyword}-search-${index + 1}-${seedSuffix}`.slice(0, 48) + const skill = await builder.publishSkill(namespace.slug, { + name: skillName, + description: options?.description || `Searchable ${keyword} skill ${index + 1} for Playwright E2E coverage.`, + }) + skills.push(skill) + skillNames.push(skillName) + } + + if (options?.awaitSearchIndexed ?? true) { + await builder.waitForSearchResults(keyword, skills.map((skill) => skill.slug)) + } + + return { + builder, + keyword, + namespace, + skills, + skillNames, + } +} + +export async function cleanupSearchSeed(seed?: SearchSeedContext) { + if (seed) { + await seed.builder.cleanup() + } +} + +export async function prepareSearchSeed( + browser: Browser, + testInfo: TestInfo, + options?: { + awaitSearchIndexed?: boolean + count?: number + keyword?: string + description?: string + }, +): Promise { + const count = options?.count ?? 1 + const seedSuffix = `${testInfo.parallelIndex ?? 0}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const keyword = options?.keyword || `agent-${seedSuffix}`.slice(0, 32) + const description = options?.description || `Searchable ${keyword} skill for Playwright E2E coverage.` + const useProvidedPublisher = count <= 3 && hasPublisherCredentials() + const publisherSessions: PublisherSession[] = [ + useProvidedPublisher + ? await openProvidedPublisherSession(browser, testInfo) + : await openAdhocPublisherSession(browser, testInfo), + ] + const skills: SeededSkill[] = [] + const skillNames: string[] = [] + let publishedCount = 0 + + while (publishedCount < count) { + if (publishedCount >= 10 && publisherSessions.length === 1) { + publisherSessions.push(await openAdhocPublisherSession(browser, testInfo)) + } + + const activeSession = publishedCount < 10 ? publisherSessions[0] : publisherSessions[publisherSessions.length - 1] + const chunkSize = publishedCount < 10 ? Math.min(10 - publishedCount, count - publishedCount) : count - publishedCount + const chunk = await publishSearchSkillsChunk( + activeSession, + keyword, + description, + seedSuffix, + publishedCount, + chunkSize, + ) + skills.push(...chunk.skills) + skillNames.push(...chunk.skillNames) + publishedCount += chunkSize + } + + const seed: SearchSeedContext = { + builder: publisherSessions[0].builder, + keyword, + namespace: publisherSessions[0].namespace, + skills, + skillNames, + } + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + for (const skill of seed.skills) { + const reviewTaskId = await adminBuilder.waitForPendingReview(skill.namespace, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) + } + + await seed.builder.waitForSearchResults(seed.keyword, seed.skills.map((skill) => skill.slug)) + + return { + ...seed, + dispose: async () => { + await adminContext.close() + for (let index = publisherSessions.length - 1; index >= 0; index -= 1) { + await cleanupSearchSeed({ + builder: publisherSessions[index].builder, + keyword: seed.keyword, + namespace: publisherSessions[index].namespace, + skills: [], + skillNames: [], + }) + await publisherSessions[index].context.close() + } + }, + } +} + +export function getSearchCard(page: Page, skillName: string): Locator { + return getSearchCards(page).filter({ + has: page.getByRole('heading', { name: skillName, exact: true }), + }).first() +} + +export function getSearchCards(page: Page): Locator { + return page.getByRole('link').filter({ + has: page.locator('h3'), + }) +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index da6cca00..763ef1c3 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -2,6 +2,33 @@ import { expect, type Page, type TestInfo } from '@playwright/test' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() +const cachedSessionByAccount = new Map() +const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 + +export interface TestCredentials { + password: string + username: string +} + +interface RegisterSessionOptions { + allowMockSession?: boolean +} + +interface SessionSnapshot { + username: string + cookies: Array<{ + name: string + value: string + domain: string + path: string + expires: number + httpOnly: boolean + secure: boolean + sameSite: 'Strict' | 'Lax' | 'None' + }> +} + +const cachedSessionByWorker = new Map() function usernameForWorker(testInfo?: TestInfo): string { const worker = testInfo?.parallelIndex ?? 0 @@ -25,12 +52,14 @@ function isRetryableStatus(status: number): boolean { async function loginWithRetry( request: Page['request'], username: string, + currentPassword = password, retries = process.env.CI ? 10 : 6, ): Promise { for (let i = 0; i < retries; i += 1) { try { const login = await request.post('/api/v1/auth/local/login', { - data: { username, password }, + data: { username, password: currentPassword }, + timeout: requestTimeoutMs, }) if (login.ok()) { @@ -51,30 +80,150 @@ async function loginWithRetry( return false } -async function registerSessionOnce(page: Page, testInfo?: TestInfo) { +async function hasActiveSession(page: Page): Promise { + try { + const response = await page.context().request.get('/api/v1/auth/me', { + timeout: requestTimeoutMs, + }) + return response.ok() + } catch { + return false + } +} + +async function cacheSession(page: Page, worker: number, username: string) { + const snapshot = { + username, + cookies: await page.context().cookies(), + } + cachedSessionByWorker.set(worker, snapshot) + cachedSessionByAccount.set(username, snapshot) +} + +async function cacheAccountSession(page: Page, username: string) { + cachedSessionByAccount.set(username, { + username, + cookies: await page.context().cookies(), + }) +} + +async function restoreCachedSession(page: Page, worker: number): Promise { + const snapshot = cachedSessionByWorker.get(worker) + if (!snapshot) { + return null + } + + await page.context().addCookies(snapshot.cookies) + if (await hasActiveSession(page)) { + return snapshot + } + + cachedSessionByWorker.delete(worker) + return null +} + +async function restoreCachedSessionForAccount(page: Page, username: string): Promise { + const snapshot = cachedSessionByAccount.get(username) + if (!snapshot) { + return null + } + + await page.context().addCookies(snapshot.cookies) + if (await hasActiveSession(page)) { + return snapshot + } + + cachedSessionByAccount.delete(username) + return null +} + +async function primeAuthProviders(page: Page) { + try { + await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + } catch { + // Best effort warm-up. + } +} + +async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ username: string, password: string } | null> { + try { + await page.context().request.get('/api/v1/auth/providers', { + headers: { 'X-Mock-User-Id': 'local-user' }, + timeout: requestTimeoutMs, + }) + } catch { + return null + } + + if (!(await hasActiveSession(page))) { + return null + } + + await cacheSession(page, worker, 'local-user') + cachedUserByWorker.set(worker, 'local-user') + return { username: 'local-user', password } +} + +async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) { const worker = testInfo?.parallelIndex ?? 0 const cached = cachedUserByWorker.get(worker) const username = usernameForWorker(testInfo) const request = page.context().request - // Prime auth provider endpoint to stabilize cookie/bootstrap behavior. - try { - await request.get('/api/v1/auth/providers') - } catch { - // Best effort warm-up. + await primeAuthProviders(page) + + // Avoid hammering auth endpoints on every test run for the same worker. + const restored = await restoreCachedSession(page, worker) + if (restored) { + cachedUserByWorker.set(worker, restored.username) + return { username: restored.username, password } + } + + 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. if (cached && await loginWithRetry(request, cached)) { + await cacheSession(page, worker, cached) return { username: cached, password } } // Support environments where a deterministic worker account already exists. - if (!cached && await loginWithRetry(request, username, process.env.CI ? 4 : 3)) { + if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) { cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) return { username, password } } + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username, + password, + email: `${username}@example.test`, + }, + timeout: requestTimeoutMs, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) + return { username, password } + } + + if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) { + cachedUserByWorker.set(worker, username) + await cacheSession(page, worker, username) + return { username, password } + } + } catch { + // Fall through to the unique-account fallback below. + } + // Registering creates session cookies for the current request context. // Prefer creating a new unique account to avoid password drift and login throttling. for (let i = 0; i < 12; i += 1) { @@ -87,10 +236,12 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, + timeout: requestTimeoutMs, }) if (register.ok()) { cachedUserByWorker.set(worker, uniqueUsername) + await cacheSession(page, worker, uniqueUsername) return { username: uniqueUsername, password } } @@ -118,8 +269,9 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { // Final fallback for environments where registration is temporarily unavailable. const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) for (const candidate of fallbackCandidates) { - if (await loginWithRetry(request, candidate, process.env.CI ? 12 : 8)) { + if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) { cachedUserByWorker.set(worker, candidate) + await cacheSession(page, worker, candidate) return { username: candidate, password } } } @@ -127,12 +279,56 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) { throw new Error(`Failed to establish e2e session for worker ${worker}`) } -export async function registerSession(page: Page, testInfo?: TestInfo) { +async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { + const worker = testInfo?.parallelIndex ?? 0 + const request = page.context().request + + await primeAuthProviders(page) + + for (let i = 0; i < 12; i += 1) { + const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}` + + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username: uniqueUsername, + password, + email: `${uniqueUsername}@example.test`, + }, + timeout: requestTimeoutMs, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, uniqueUsername) + await cacheSession(page, worker, uniqueUsername) + return { username: uniqueUsername, password } + } + + const status = register.status() + if (status === 409 || status === 400) { + continue + } + + if (isRetryableStatus(status)) { + await sleep(300 * (i + 1)) + continue + } + + expect(register.ok()).toBeTruthy() + } catch { + await sleep(300 * (i + 1)) + } + } + + throw new Error(`Failed to create fresh e2e session for worker ${worker}`) +} + +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) { @@ -143,3 +339,42 @@ export async function registerSession(page: Page, testInfo?: TestInfo) { throw lastError } + +export async function createFreshSession(page: Page, testInfo?: TestInfo) { + let lastError: unknown + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await createFreshSessionOnce(page, testInfo) + } catch (error) { + lastError = error + if (attempt < 2) { + await sleep(500 * (attempt + 1)) + } + } + } + + throw lastError +} + +export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) { + const request = page.context().request + + await primeAuthProviders(page) + + const restored = await restoreCachedSessionForAccount(page, credentials.username) + if (restored) { + return credentials + } + + const loggedIn = await loginWithRetry( + request, + credentials.username, + credentials.password, + process.env.CI ? 12 : 8, + ) + expect(loggedIn).toBeTruthy() + + await cacheAccountSession(page, credentials.username) + return credentials +} diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 1a18e5e7..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 { @@ -25,6 +30,22 @@ export interface SeededReviewData { skill: SeededSkill } +interface ReviewTaskSummary { + id: number + namespace: string + skillSlug: string + status: string + submittedBy: string + version: string +} + +interface NamespaceCandidate { + userId: string + displayName: string + email?: string + status: string +} + interface ApiEnvelope { code: number msg: string @@ -36,6 +57,15 @@ interface ApiFailure extends Error { code?: number } +const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000 + +export interface SeedSkillOptions { + name?: string + description?: string + version?: string + readmeHeading?: string +} + function asApiErrorBody(value: unknown): string { if (!value || typeof value !== 'object') { return '' @@ -49,26 +79,57 @@ function uniqueSuffix(testInfo?: TestInfo): string { return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } -function buildSkillPackageZipBuffer(suffix: string): Buffer { - const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) - try { - const packageDir = path.join(tempRoot, `pkg-${suffix}`) - const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) - const skillName = `e2e-skill-${suffix}`.slice(0, 48) - const skillMd = `--- +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' + const version = options?.version || '1.0.0' + const readmeHeading = options?.readmeHeading || skillName + const skillMd = `--- name: ${skillName} -description: E2E generated skill for real-request tests -version: 1.0.0 +description: ${description} +version: ${version} --- -# ${skillName} +# ${readmeHeading} Generated by Playwright E2E. ` + return { + readmeHeading, + skillMd, + skillName, + } +} + +function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): Buffer { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) + try { + const packageDir = path.join(tempRoot, `pkg-${suffix}`) + const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) + const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options) + execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) return readFileSync(zipPath) } finally { @@ -76,25 +137,15 @@ Generated by Playwright E2E. } } -function createSkillPackageZipFile(suffix: string): { filePath: string; cleanup: () => void } { +function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): { filePath: string; cleanup: () => void } { const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-file-')) const packageDir = path.join(tempRoot, `pkg-${suffix}`) const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) - const skillName = `e2e-skill-${suffix}`.slice(0, 48) - const skillMd = `--- -name: ${skillName} -description: E2E generated skill for real-request tests -version: 1.0.0 ---- - -# ${skillName} - -Generated by Playwright E2E. -` + const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options) execFileSync('mkdir', ['-p', packageDir]) writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') - writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8') execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) return { @@ -147,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. } @@ -155,7 +206,12 @@ export class E2eTestDataBuilder { } async createNamespace(base = 'e2e-team'): Promise { - const slug = `${base}-${this.suffix}`.slice(0, 64) + const rawSlug = `${base}-${this.suffix}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + const slug = rawSlug.slice(0, 64) const displayName = `E2E ${slug}` const created = await parseEnvelope( @@ -183,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 @@ -200,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 { @@ -234,32 +381,130 @@ export class E2eTestDataBuilder { } } - async publishSkill(namespaceSlug: string): Promise { - const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` - const zipBuffer = buildSkillPackageZipBuffer(unique) + async waitForSearchResult(query: string, expectedSlug?: string): Promise { + const encodedQuery = encodeURIComponent(query) - let result: SeededSkill - try { - result = await parseEnvelope( - await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { - multipart: { - file: { - name: 'sample-skill.zip', - mimeType: 'application/zip', - buffer: zipBuffer, - }, - visibility: 'PUBLIC', - }, - }), - ) - } catch (error) { - const fallback = await this.getMySkillInNamespace(namespaceSlug) - if (!fallback) { - throw error + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: Array<{ slug: string }> + }>( + await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`), + ) + if (!expectedSlug || page.items.some((item) => item.slug === expectedSlug)) { + return + } + } catch { + // Search indexing can lag briefly behind publish in local environments. } - return fallback + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) } + throw new Error(`Timed out waiting for search result "${query}"${expectedSlug ? ` (${expectedSlug})` : ''}`) + } + + async waitForSearchResults(query: string, expectedSlugs: string[]): Promise { + const pending = new Set(expectedSlugs) + if (pending.size === 0) { + return + } + + const encodedQuery = encodeURIComponent(query) + + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: Array<{ slug: string }> + }>( + await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`), + ) + + for (const item of page.items) { + pending.delete(item.slug) + } + + if (pending.size === 0) { + return + } + } catch { + // Search indexing can lag briefly behind publish in local environments. + } + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) + } + + throw new Error(`Timed out waiting for search results "${query}" (${Array.from(pending).join(', ')})`) + } + + async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const page = await parseEnvelope<{ + items: ReviewTaskSummary[] + }>( + await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'), + ) + + const matched = page.items.find((item) => + item.namespace === namespaceSlug && + item.skillSlug === skillSlug && + item.version === version && + item.status === 'PENDING', + ) + if (matched) { + return matched.id + } + } catch { + // Review list can lag behind publish very briefly. + } + + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))) + } + + throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`) + } + + async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { + await parseEnvelope( + await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { + data: { comment }, + }), + ) + } + + 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) + + const result = await parseEnvelope( + await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { + multipart: { + file: { + name: 'sample-skill.zip', + mimeType: 'application/zip', + buffer: zipBuffer, + }, + visibility: 'PUBLIC', + }, + }), + ) + this.cleanupTasks.push(async () => { await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) }) @@ -267,9 +512,9 @@ export class E2eTestDataBuilder { return result } - createSkillPackageFile(): string { + createSkillPackageFile(options?: SeedSkillOptions): string { const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` - const { filePath, cleanup } = createSkillPackageZipFile(unique) + const { filePath, cleanup } = createSkillPackageZipFile(unique, options) this.cleanupTasks.push(async () => { cleanup() }) @@ -277,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/password-reset.spec.ts b/web/e2e/password-reset.spec.ts new file mode 100644 index 00000000..a37efcf5 --- /dev/null +++ b/web/e2e/password-reset.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' + +test.describe('Password Reset (Real API)', () => { + function uniqueResetEmail(seed: string) { + return `nonexistent_${seed}_${Date.now()}@example.com` + } + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('sends verification code from reset-password page', async ({ page }) => { + const email = uniqueResetEmail('request') + await setUniqueClientIp(page, 'password-reset-request') + + await page.goto('/reset-password') + + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Send Verification Code' }).click() + + await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible() + }) + + test('shows backend validation error for an invalid reset code', async ({ page }) => { + const email = uniqueResetEmail('invalid-code') + await setUniqueClientIp(page, 'password-reset-invalid-code') + + await page.goto('/reset-password') + + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Send Verification Code' }).click() + await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible() + await page.getByLabel('Verification Code').fill('123456') + await page.getByLabel('New Password').fill('Passw0rd!123') + await page.getByLabel('Confirm Password').fill('Passw0rd!123') + await page.getByRole('button', { name: 'Reset Password' }).click() + + await expect( + page.getByText(/The verification code is invalid or has expired\.|验证码无效或已过期。/) + ).toBeVisible() + }) +}) diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts new file mode 100644 index 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/register-email-required.spec.ts b/web/e2e/register-email-required.spec.ts new file mode 100644 index 00000000..54d2476a --- /dev/null +++ b/web/e2e/register-email-required.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function buildUniqueUser() { + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + return { + username: `e2e_reg_${suffix}`, + email: `e2e_reg_${suffix}@example.test`, + password: 'Passw0rd!123', + } +} + +test.describe('Register Email Required (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('registers successfully when email is provided', async ({ page }) => { + const user = buildUniqueUser() + await page.goto('/register') + + await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible() + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Email').fill(user.email) + await page.getByLabel('Password').fill(user.password) + await page.getByRole('button', { name: 'Register & Login' }).click() + + await expect(page).toHaveURL('/dashboard') + }) + + test('shows required validation when email is missing', async ({ page }) => { + await page.goto('/register') + + await page.getByLabel('Username').fill(`e2e_no_email_${Date.now().toString(36)}`) + await page.getByLabel('Password').fill('Passw0rd!123') + await page.getByRole('button', { name: 'Register & Login' }).click() + + const isEmailMissing = await page.getByLabel('Email').evaluate((element) => { + const input = element as HTMLInputElement + return input.validity.valueMissing + }) + + expect(isEmailMissing).toBeTruthy() + await expect(page).toHaveURL(/\/register/) + }) +}) diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts new file mode 100644 index 00000000..7ab8574a --- /dev/null +++ b/web/e2e/register-login-validation.spec.ts @@ -0,0 +1,325 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' +import { createFreshSession } from './helpers/session' + +// TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框 +// TC_REG_* 注册/登录流程 / TC_UI_* UI/UX + +let existingRegisteredUsername: string | null = null +const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i +const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|请求过于频繁/ + +test.describe('Register - Username Validation (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) + await page.goto('/register') + }) + + // TC_UN_008 P0 + test('TC_UN_008: shows required error when username is empty', async ({ page }) => { + await page.getByLabel(/username/i).click() + await page.getByLabel(/email/i).click() + await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() + }) + + // TC_UN_001 P0 - valid minimum length + test('TC_UN_001: accepts valid username with minimum 3 characters', async ({ page }) => { + await page.getByLabel(/username/i).fill('abc') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/仅支持|only.*letter|username.*required/i)).not.toBeVisible() + }) + + // TC_UN_006 P1 - 2 chars below minimum + test('TC_UN_006: shows length error for 2-character username', async ({ page }) => { + await page.getByLabel(/username/i).fill('ab') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/3.{0,10}64|length|at least/i)).toBeVisible() + }) + + // TC_UN_009 P1 - special chars + test('TC_UN_009: shows error for username with special characters like @', async ({ page }) => { + await page.getByLabel(/username/i).fill('user@123') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/letter|number|underscore|alphanumeric/i)).toBeVisible() + }) + + // TC_UN_010 P1 - Chinese chars + test('TC_UN_010: shows error for username containing Chinese characters', async ({ page }) => { + await page.getByLabel(/username/i).fill('用户123') + await page.getByLabel(/username/i).blur() + await expect(page.getByText(/letter|number|underscore|alphanumeric/i)).toBeVisible() + }) +}) + +test.describe('Register - Email Validation (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) + await page.goto('/register') + }) + + // TC_EM_007 P0 - email is required + test('TC_EM_007: shows required error when email is empty', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.clear() + await emailField.blur() + await expect(page.getByText(/email.*required|required.*email/i)).toBeVisible() + } + }) + + // TC_EM_008 P1 - missing @ + test('TC_EM_008: shows error for email missing @ symbol', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill('userexample.com') + await emailField.blur() + await expect(page.getByText(/email.*invalid|invalid.*email|format/i)).toBeVisible() + } + }) + + // TC_EM_009 P1 - missing domain + test('TC_EM_009: shows error for email missing domain after @', async ({ page }) => { + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill('user@') + await emailField.blur() + await expect(page.getByText(/email.*invalid|invalid.*email|format/i)).toBeVisible() + } + }) +}) + +test.describe('Register - Password Validation (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) + await page.goto('/register') + }) + + // TC_PW_013 P0 - empty password + test('TC_PW_013: shows required error when password is empty', async ({ page }) => { + await page.getByLabel(/^password/i).click() + await page.getByLabel(/username/i).click() + await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() + }) + + // TC_PW_007 P1 - 7 chars (below minimum 8) + test('TC_PW_007: shows length error for 7-character password', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abc123!') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/8|at least|minimum/i)).toBeVisible() + }) + + // TC_PW_008 P1 - only 2 types (uppercase + lowercase) + test('TC_PW_008: shows complexity error for password with only 2 character types', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abcdefgh') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/three|3.*type|character type|complexity/i)).toBeVisible() + }) + + // TC_PW_001 P0 - valid password with 3+ types + test('TC_PW_001: accepts valid password with 3 character types and minimum length', async ({ page }) => { + await page.getByLabel(/^password/i).fill('Abc123!@') + await page.getByLabel(/^password/i).blur() + await expect(page.getByText(/three|3.*type|character type|complexity/i)).not.toBeVisible() + await expect(page.getByText(/8|at least|minimum/i)).not.toBeVisible() + }) +}) + +test.describe('Register Flow (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) + }) + + // TC_REG_001 P0 - successful registration with all fields + test('TC_REG_001: registers successfully with valid username, email and password', async ({ page }) => { + await page.goto('/register') + const suffix = Date.now().toString(36) + const username = `testuser_${suffix}` + await page.getByLabel(/username/i).fill(username) + const emailField = page.getByLabel(/email/i) + if (await emailField.isVisible()) { + await emailField.fill(`test_${suffix}@example.test`) + } + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register & Login' }).click() + // Should redirect away from /register on success + await expect(page).not.toHaveURL('/register') + existingRegisteredUsername = username + }) + + // TC_REG_003 P0 - duplicate username + test('TC_REG_003: shows error when registering with existing username', async ({ browser, page }, testInfo) => { + let username = existingRegisteredUsername + if (!username) { + const seedContext = await browser.newContext() + const seedPage = await seedContext.newPage() + const seedCredentials = await createFreshSession(seedPage, testInfo) + username = seedCredentials.username + existingRegisteredUsername = username + await seedContext.close() + } + + // Now try to register with the same username again + await page.goto('/register') + await setEnglishLocale(page) + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/email/i).fill(`duplicate_${Date.now()}@example.test`) + await page.getByLabel(/^password/i).fill('Test123!@') + const main = page.getByRole('main') + const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first() + const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first() + + for (let attempt = 0; attempt < 3; attempt += 1) { + await page.getByRole('button', { name: 'Register & Login' }).click() + + if (await duplicateUsernameError.isVisible().catch(() => false)) { + return + } + + if (attempt < 2 && await registerRateLimitError.isVisible().catch(() => false)) { + await page.waitForTimeout(1_500 * (attempt + 1)) + continue + } + + break + } + + await expect(duplicateUsernameError).toBeVisible() + }) + + // TC_REG_002 P0 - registration without email + test('TC_REG_002: shows required validation when email is missing', async ({ page }) => { + await page.goto('/register') + const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5) + await page.getByLabel(/username/i).fill(`emailrequired_${suffix}`) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByLabel(/email/i).click() + await page.getByLabel(/username/i).click() + await page.getByRole('button', { name: 'Register & Login' }).click() + const emailField = page.getByLabel(/email/i) + await expect(emailField).toBeFocused() + await expect + .poll(async () => emailField.evaluate((input) => (input as HTMLInputElement).validity.valueMissing)) + .toBe(true) + }) + + // TC_REG_005 P0 - required fields empty on submit + test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => { + await page.goto('/register') + await page.getByLabel(/email/i).fill(`required_fields_${Date.now()}@example.test`) + await page.getByRole('button', { name: 'Register & Login' }).click() + await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() + await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() + }) +}) + +test.describe('Login Flow (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) + }) + + // TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here) + test('TC_REG_006: shows required field errors when submitting empty login form', async ({ page }) => { + await page.goto('/login') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText('Username is required')).toBeVisible() + await expect(page.getByText('Password is required')).toBeVisible() + }) + + // TC_REG_007 P0 - wrong password + test('TC_REG_007: shows error for wrong password on existing account', async ({ page }) => { + // First register a user, then attempt login with wrong password + const suffix = Date.now().toString(36) + const username = `logintest_${suffix}` + + await page.goto('/register') + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/email/i).fill(`login_${suffix}@example.test`) + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Register & Login' }).click() + await expect(page).not.toHaveURL('/register') + + await page.goto('/login') + await setEnglishLocale(page) + await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/^password/i).fill('WrongPassword999!') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText(/invalid|incorrect|wrong|username.*password/i)).toBeVisible() + }) + + // TC_REG_008 P0 - non-existent username + test('TC_REG_008: shows error for non-existent username login attempt', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill('nonexistent_user_xyz99999') + await page.getByLabel(/^password/i).fill('Test123!@') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText(/invalid|incorrect|wrong|username.*password|not found/i)).toBeVisible() + }) + + // TC_REG_010 P1 - SQL injection safety + test('TC_REG_010: safely handles SQL injection input in username field', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill("admin' OR '1'='1") + await page.getByLabel(/^password/i).fill('anything') + await page.getByRole('button', { name: 'Login' }).click() + // Should not log in; should show error or validation message, NOT redirect to dashboard + await expect(page).not.toHaveURL('/dashboard') + }) + + // TC_REG_011 P1 - XSS in input + test('TC_REG_011: safely handles XSS payload in username field without executing script', async ({ page }) => { + let alerted = false + page.on('dialog', () => { alerted = true }) + + await page.goto('/login') + await page.getByLabel(/username/i).fill("") + await page.getByLabel(/^password/i).fill('anything') + await page.getByRole('button', { name: 'Login' }).click() + await expect(page).not.toHaveURL('/dashboard') + expect(alerted).toBe(false) + }) +}) + +test.describe('Register/Login UI (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_UI_003 P2 - password visibility toggle + test('TC_UI_003: password visibility toggle switches between masked and plain text', async ({ page }) => { + await page.goto('/register') + const passwordInput = page.getByLabel(/^password/i) + await expect(passwordInput).toHaveAttribute('type', 'password') + + const toggleBtn = page.getByRole('button', { name: /show|hide|toggle/i }) + .or(page.locator('[data-testid*="password-toggle"], [aria-label*="password"]')) + if (await toggleBtn.isVisible()) { + await toggleBtn.click() + await expect(passwordInput).toHaveAttribute('type', 'text') + } + }) + + // TC_UI_005 P2 - Enter key submits form + test('TC_UI_005: pressing Enter in the last input field submits the login form', async ({ page }) => { + await page.goto('/login') + await page.getByLabel(/username/i).fill('someuser') + await page.getByLabel(/^password/i).fill('SomePass123!') + await page.getByLabel(/^password/i).press('Enter') + // Form should attempt submission (either error msg or redirect) + await expect( + page.getByText(/invalid|incorrect|dashboard/i) + .or(page.locator('[role="alert"]')) + ).toBeVisible({ timeout: 5000 }) + }) + + // returnTo param preservation (from auth-entry.spec.ts - extended) + test('preserves returnTo param when navigating from register link on login page', async ({ page }) => { + await page.goto('/login?returnTo=%2Fdashboard%2Ftokens') + await page.getByRole('link', { name: /sign up|register/i }).click() + await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens') + }) +}) diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts new file mode 100644 index 00000000..2d18b30c --- /dev/null +++ b/web/e2e/reviews-pagination.spec.ts @@ -0,0 +1,83 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' + +interface ApiEnvelope { + code: number + msg: string + data: T +} + +interface ReviewPageData { + total: number + size: number +} + +async function fetchReviewPageMeta(page: Page, status: ReviewStatus): Promise { + const response = await page.request.get(`/api/web/reviews?status=${status}&page=0&size=20&sortDirection=DESC`) + const body = await response.json() as ApiEnvelope + if (!response.ok() || body.code !== 0) { + throw new Error(`Failed to query reviews for ${status}: status=${response.status()} code=${body.code} msg=${body.msg}`) + } + return body.data +} + +test.describe('Review Management Pagination (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('matches pagination rendering with real review totals', async ({ page }) => { + const statuses: ReviewStatus[] = ['PENDING', 'APPROVED', 'REJECTED'] + const metaByStatus = new Map() + + for (const status of statuses) { + metaByStatus.set(status, await fetchReviewPageMeta(page, status)) + } + + await page.goto('/dashboard/reviews') + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + + const tabMeta: Record = { + PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' }, + APPROVED: { tabLabel: 'Approved', summaryPrefix: 'Total' }, + REJECTED: { tabLabel: 'Rejected', summaryPrefix: 'Total' }, + } + + for (const status of statuses) { + await page.getByRole('button', { name: tabMeta[status].tabLabel }).click() + + const meta = metaByStatus.get(status) + if (!meta) { + throw new Error(`Missing metadata for ${status}`) + } + const totalPages = meta.size > 0 ? Math.ceil(meta.total / meta.size) : 0 + + if (meta.total === 0) { + await expect(page.getByText('No review tasks')).toBeVisible() + await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0) + continue + } + + const previousButton = page.getByRole('button', { name: 'Previous' }).first() + const nextButton = page.getByRole('button', { name: 'Next' }).first() + await expect(previousButton).toBeVisible() + await expect(nextButton).toBeVisible() + await expect(previousButton).toBeDisabled() + + if (totalPages > 1) { + await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 1`))).toBeVisible() + await expect(nextButton).toBeEnabled() + await nextButton.click() + await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 2`))).toBeVisible() + } else { + await expect(nextButton).toBeDisabled() + } + } + }) +}) diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts new file mode 100644 index 00000000..1deadc58 --- /dev/null +++ b/web/e2e/search-card-interaction.spec.ts @@ -0,0 +1,461 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { + getSearchCard, + getSearchCards, + prepareSearchSeed, + type PreparedSearchSeed, +} from './helpers/search-seed' +import { registerSession } from './helpers/session' + +const SEARCH_URL = (q: string, sort = 'relevance', page = 0) => + `/search?q=${encodeURIComponent(q)}&sort=${sort}&page=${page}&starredOnly=false` + +function latestSeed(seed: PreparedSearchSeed) { + return { + skill: seed.skills[seed.skills.length - 1], + skillName: seed.skillNames[seed.skillNames.length - 1], + } +} + +async function waitForCards(page: Page) { + const cards = getSearchCards(page) + + if (basicSeed) { + await basicSeed.builder.waitForSearchResults( + basicSeed.keyword, + basicSeed.skills.map((skill) => skill.slug), + ) + } + + const keyword = basicSeed?.keyword + const encodedKeyword = keyword ? encodeURIComponent(keyword) : null + let reloaded = false + + 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 }) + await waitForMatchingResponse() + await waitForCardCount() + } + + return cards +} + +let basicSeed: PreparedSearchSeed | undefined + +test.setTimeout(300_000) + +test.beforeAll(async ({ browser }, testInfo) => { + test.setTimeout(300_000) + basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 }) +}) + +test.afterAll(async () => { + await basicSeed?.dispose() + basicSeed = undefined +}) + +// ─── Card Display After Search ──────────────────────────────────────────────── + +test.describe('Search Card Display (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_001 P0 + test('TC_SEARCH_INTERACT_001: cards appear immediately after search', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + await expect(cards.first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_005 P0 - cards show complete info + test('TC_SEARCH_INTERACT_005: each card shows name, description, and version', async ({ page }) => { + const current = latestSeed(basicSeed!) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await expect(firstCard.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + await expect(firstCard.getByText(`v${current.skill.version}`)).toBeVisible() + }) + + // TC_SEARCH_INTERACT_039 P0 - version number format + test('TC_SEARCH_INTERACT_039: version number is displayed in v1.2.3 format', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(page.getByText(/v\d+\.\d+\.\d+/).first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_038 P0 - long descriptions truncated + test('TC_SEARCH_INTERACT_038: long descriptions are truncated with ellipsis', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + expect(await cards.count()).toBeGreaterThan(0) + }) + + // TC_SEARCH_INTERACT_031 P0 - no results shows empty state + test('TC_SEARCH_INTERACT_031: no results shows empty state instead of cards', async ({ page }) => { + await page.goto(SEARCH_URL('xyznonexistentkeyword99999abc')) + await page.waitForLoadState('networkidle') + await expect(getSearchCards(page)).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_035 P0 - large results show pagination + test('TC_SEARCH_INTERACT_035: large result sets show pagination controls', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + await expect(page.getByRole('button', { name: /next|›/i })).toBeVisible({ timeout: 10_000 }) + }) +}) + +// ─── Card Content & Search Relevance ───────────────────────────────────────── + +test.describe('Search Card Content (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_003 P0 - card count matches count indicator + test('TC_SEARCH_INTERACT_003: displayed card count is consistent with skill count indicator', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = getSearchCards(page) + const cardCount = await cards.count() + const countText = await page.getByText(/\d+\s+skills found/i).first().textContent() + const totalMatch = countText?.match(/\d+/) + if (totalMatch) { + const total = parseInt(totalMatch[0], 10) + expect(total).toBeGreaterThanOrEqual(cardCount) + } + }) + + // TC_SEARCH_INTERACT_040 P0 - download count formatted + test('TC_SEARCH_INTERACT_040: download counts are formatted correctly (numbers or K/M)', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(page.locator('body')).not.toContainText(/error|500/i) + await expect(getSearchCards(page).first()).toContainText(/\d/) + }) +}) + +// ─── Card Click Navigation ──────────────────────────────────────────────────── + +test.describe('Search Card Navigation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_007 P0 - clicking card navigates to detail page + test('TC_SEARCH_INTERACT_007: clicking a skill card navigates to the skill detail page', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`)) + }) + + // TC_SEARCH_INTERACT_008 P0 - detail page matches clicked card + test('TC_SEARCH_INTERACT_008: skill detail page matches the card that was clicked', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`)) + await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + }) + + // TC_SEARCH_INTERACT_009 P1 - Ctrl+click opens in new tab + test('TC_SEARCH_INTERACT_009: Ctrl+click on card opens skill detail in new tab', async ({ page, context }) => { + test.skip(true, 'Skill cards render as clickable divs, so browser-level new-tab semantics do not apply.') + const current = latestSeed(basicSeed!) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + firstCard.click({ modifiers: ['Meta'] }), + ]) + await newPage.waitForLoadState() + await expect(newPage).toHaveURL(/\/space\//) + await newPage.close() + }) +}) + +// ─── Sort Switching Updates Cards ──────────────────────────────────────────── + +test.describe('Search Card Sort Interaction (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_021 P0 - switching sort updates cards + test('TC_SEARCH_INTERACT_021: switching sort tab re-renders card list', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.getByRole('button', { name: 'Downloads' }).click() + await page.waitForLoadState('networkidle') + await expect(page).toHaveURL(/sort=downloads/) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_026 P0 - re-search replaces cards + test('TC_SEARCH_INTERACT_026: re-searching with new keyword replaces card list', async ({ page }) => { + await page.goto(SEARCH_URL('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await page.waitForLoadState('networkidle') + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_INTERACT_027 P0 - re-search resets page to 0 + test('TC_SEARCH_INTERACT_027: re-searching resets page number to 0', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword, 'relevance', 1)) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await expect(page).toHaveURL(/page=0/) + }) +}) + +// ─── Pagination Card Updates ────────────────────────────────────────────────── + +test.describe('Search Card Pagination (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_023 P0 - switching page updates cards + test('TC_SEARCH_INTERACT_023: switching to next page shows different cards', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + + const nextBtn = page.getByRole('button', { name: /next|›/i }) + const firstCardTitle = await getSearchCards(page).first().getByRole('heading').textContent() + await expect(nextBtn).toBeVisible({ timeout: 10_000 }) + await nextBtn.click() + await page.waitForLoadState('networkidle') + await expect(page).toHaveURL(/page=1/) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + const secondPageFirstTitle = await getSearchCards(page).first().getByRole('heading').textContent() + expect(secondPageFirstTitle).not.toBe(firstCardTitle) + }) + + // TC_SEARCH_INTERACT_025 P1 - page switch scrolls to top + test('TC_SEARCH_INTERACT_025: switching page scrolls back to top of results', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + + const nextBtn = page.getByRole('button', { name: /next|›/i }) + await expect(nextBtn).toBeVisible({ timeout: 10_000 }) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await nextBtn.click() + await page.waitForLoadState('networkidle') + await expect.poll( + () => page.evaluate(() => window.scrollY), + { timeout: 5_000, intervals: [100, 250, 500, 1_000] }, + ).toBeLessThan(300) + }) +}) + +// ─── Loading State ──────────────────────────────────────────────────────────── + +test.describe('Search Card Loading State (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_030 P0 - skeleton disappears after load + test('TC_SEARCH_INTERACT_030: skeleton screen disappears and real cards appear after load', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + await expect(page.locator('[class*="skeleton"], [class*="shimmer"]')).toHaveCount(0) + }) +}) + +// ─── Responsive Layout ──────────────────────────────────────────────────────── + +test.describe('Search Card Responsive Layout (Real API)', () => { + test.describe.configure({ retries: 2 }) + test.use({ hasTouch: true }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_042 P0 - desktop 3-column grid + test('TC_SEARCH_INTERACT_042: desktop viewport shows 3-column card grid', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + const grid = page.locator('[class*="grid"]').first() + await expect(grid).toBeVisible() + }) + + // TC_SEARCH_INTERACT_044 P0 - mobile 1-column layout + test('TC_SEARCH_INTERACT_044: mobile viewport shows single-column card layout', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + await expect(cards.first()).toBeVisible({ timeout: 8_000 }) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_043 P0 - tablet 2-column layout + test('TC_SEARCH_INTERACT_043: tablet viewport shows 2-column card layout', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + await expect(cards.first()).toBeVisible({ timeout: 8_000 }) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_045 P1 - responsive layout adjusts on resize + test('TC_SEARCH_INTERACT_045: card layout adjusts when browser window is resized', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.setViewportSize({ width: 375, height: 812 }) + await expect(getSearchCards(page).first()).toBeVisible() + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_046 P0 - mobile touch interaction + test('TC_SEARCH_INTERACT_046: mobile touch on card navigates to skill detail', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.tap() + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}`)) + }) +}) + +// ─── Keyboard Navigation ────────────────────────────────────────────────────── + +test.describe('Search Card Keyboard Navigation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_049 P1 - Tab key navigates between cards + test('TC_SEARCH_INTERACT_049: Tab key can navigate between skill cards', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.keyboard.press('Tab') + await page.keyboard.press('Tab') + const focused = page.locator(':focus') + await expect(focused).toBeVisible() + }) + + // TC_SEARCH_INTERACT_050 P1 - Enter key opens focused card + test('TC_SEARCH_INTERACT_050: pressing Enter on a focused card opens the skill detail', async ({ page }, testInfo) => { + const current = latestSeed(basicSeed!) + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + + await firstCard.focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/space\//) + }) + + // TC_SEARCH_INTERACT_051 P1 - focus state visible on cards + test('TC_SEARCH_INTERACT_051: focused card has a visible focus indicator', async ({ page }) => { + const current = latestSeed(basicSeed!) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const firstCard = getSearchCard(page, current.skillName) + await expect(firstCard).toBeVisible({ timeout: 8_000 }) + await firstCard.focus() + const focused = page.locator(':focus') + await expect(focused).toBeVisible() + }) +}) + +// ─── Error Handling ─────────────────────────────────────────────────────────── + +test.describe('Search Card Error Handling (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INTERACT_033 P1 - single result displays correctly + test('TC_SEARCH_INTERACT_033: single search result displays card layout correctly', async ({ page }) => { + await page.goto(SEARCH_URL(basicSeed!.keyword)) + const cards = await waitForCards(page) + expect(await cards.count()).toBeGreaterThan(0) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_INTERACT_060 P1 - cache: returning to search page shows results quickly + test('TC_SEARCH_INTERACT_060: returning to search page shows cached results quickly', async ({ page }, testInfo) => { + await registerSession(page, testInfo) + await page.goto(SEARCH_URL(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + + await page.goto('/dashboard') + await page.goBack() + await expect(page).toHaveURL(/\/search/) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 8_000 }) + }) +}) diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts new file mode 100644 index 00000000..be14db8f --- /dev/null +++ b/web/e2e/search-page-full.spec.ts @@ -0,0 +1,323 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { + DEFAULT_SEARCH_KEYWORD, + getSearchCards, + prepareSearchSeed, + type PreparedSearchSeed, +} from './helpers/search-seed' +import { registerSession } from './helpers/session' + +function searchUrl(query: string, sort = 'relevance', page = 0, starredOnly = false) { + return `/search?q=${encodeURIComponent(query)}&sort=${sort}&page=${page}&starredOnly=${starredOnly}` +} + +let basicSeed: PreparedSearchSeed | undefined + +test.setTimeout(300_000) + +test.beforeAll(async ({ browser }, testInfo) => { + test.setTimeout(300_000) + basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 }) +}) + +test.afterAll(async () => { + await basicSeed?.dispose() + basicSeed = undefined +}) + +// ─── Search Input ──────────────────────────────────────────────────────────── + +test.describe('Search Input (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_INPUT_001 P0 + test('TC_SEARCH_INPUT_001: searches with a single keyword and shows results', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) + }) + + // 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(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_INPUT_004 P0 - Enter key triggers search + test('TC_SEARCH_INPUT_004: pressing Enter in search box triggers search', async ({ page }) => { + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(getSearchCards(page).first()).toBeVisible() + }) + + // TC_SEARCH_INPUT_009 P0 - Chinese keyword search + test('TC_SEARCH_INPUT_009: supports Chinese keyword search without error', async ({ page }) => { + await page.goto(searchUrl('测试技能')) + await expect(page).toHaveURL(/\/search/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_INPUT_010 P0 - English keyword search + test('TC_SEARCH_INPUT_010: supports English keyword search', async ({ page }) => { + await page.goto(searchUrl('skill')) + await expect(page).toHaveURL(/q=skill/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_INPUT_007 P1 - special characters handled gracefully + test('TC_SEARCH_INPUT_007: handles special characters in search without crashing', async ({ page }) => { + await page.goto(searchUrl('@#$%')) + await expect(page).toHaveURL(/\/search/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_INPUT_011 P1 - leading/trailing spaces trimmed + test('TC_SEARCH_INPUT_011: trims leading and trailing spaces from search query', async ({ page }) => { + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(` ${basicSeed!.keyword} `) + await searchInput.press('Enter') + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(getSearchCards(page).first()).toBeVisible() + }) +}) + +// ─── Sort / Filter ──────────────────────────────────────────────────────────── + +test.describe('Search Sort and Filter (Authenticated Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + // TC_SEARCH_SORT_001 P0 - default relevance tab selected + test('TC_SEARCH_SORT_001: relevance sort tab is selected by default', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await expect(page.getByRole('button', { name: 'Relevance' })).toBeVisible() + }) + + // TC_SEARCH_SORT_004 P0 - downloads sort + test('TC_SEARCH_SORT_004: clicking Downloads tab updates sort in URL', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(/sort=downloads/) + }) + + // TC_SEARCH_SORT_005 P0 - newest sort + test('TC_SEARCH_SORT_005: clicking Newest tab updates sort in URL', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Newest' }).click() + await expect(page).toHaveURL(/sort=newest|sort=created/) + }) + + // TC_SEARCH_SORT_006 P0 - switching sort preserves search keyword + test('TC_SEARCH_SORT_006: switching sort tab preserves the search keyword', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(page).toHaveURL(/sort=downloads/) + }) + + // TC_SEARCH_SORT_007 P0 - switching sort resets page to 0 + test('TC_SEARCH_SORT_007: switching sort tab resets page to 0', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 1)) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(/page=0/) + }) + + // TC_SEARCH_SORT_012 P1 - URL contains sort param + test('TC_SEARCH_SORT_012: URL contains sort parameter after switching tabs', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Downloads' }).click() + await expect(page).toHaveURL(/sort=/) + }) + + test('starred only filter stays on search page for authenticated user', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance')) + await page.getByRole('button', { name: 'Starred only' }).click() + await expect(page).toHaveURL(/starredOnly=true/) + await expect(page).not.toHaveURL(/\/login/) + }) +}) + +test.describe('Search Sort and Filter (Anonymous Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('starred only filter redirects anonymous user to login', async ({ page }) => { + await page.goto(searchUrl(DEFAULT_SEARCH_KEYWORD, 'relevance')) + await page.getByRole('button', { name: 'Starred only' }).click() + await expect(page).toHaveURL(/\/login\?returnTo=/) + }) +}) + +// ─── Skill Count ────────────────────────────────────────────────────────────── + +test.describe('Search Skill Count Display (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_COUNT_001 P0 - count visible + test('TC_SEARCH_COUNT_001: skill count indicator is visible on search page with results', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_COUNT_007 P0 - count updates after search + test('TC_SEARCH_COUNT_007: skill count updates after performing a search', async ({ page }) => { + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill(basicSeed!.keyword) + await searchInput.press('Enter') + await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_COUNT_009 P0 - zero results shows 0 + test('TC_SEARCH_COUNT_009: shows empty-state copy when search returns no results', async ({ page }) => { + await page.goto(searchUrl('xyznonexistentkeyword99999')) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_COUNT_008 P0 - count stays same when switching sort + test('TC_SEARCH_COUNT_008: skill count remains the same after switching sort tab', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + const countText = await page.getByText(/\d+\s+skills found/i).textContent() + const initialCount = Number(countText?.match(/\d+/)?.[0] ?? '0') + await page.getByRole('button', { name: 'Downloads' }).click() + const updatedCountText = await page.getByText(/\d+\s+skills found/i).textContent() + const updatedCount = Number(updatedCountText?.match(/\d+/)?.[0] ?? '0') + expect(updatedCount).toBe(initialCount) + }) +}) + +// ─── Search Results ─────────────────────────────────────────────────────────── + +test.describe('Search Results (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_RESULT_001 P0 - results shown + test('TC_SEARCH_RESULT_001: shows skill cards when search returns results', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) + }) + + // TC_SEARCH_RESULT_002 P0 - no results message + test('TC_SEARCH_RESULT_002: shows empty state message when no results found', async ({ page }) => { + await page.goto(searchUrl('xyznonexistentkeyword99999')) + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 }) + }) + + // TC_SEARCH_RESULT_006 P0 - loading state + test('TC_SEARCH_RESULT_006: page renders without error during and after search', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) + + // TC_SEARCH_RESULT_008 P0 - result count matches cards + test('TC_SEARCH_RESULT_008: number of displayed cards matches the count indicator', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword)) + await page.waitForLoadState('networkidle') + const cards = getSearchCards(page) + const visibleCount = await cards.count() + const countText = await page.getByText(/\d+\s+skills found/i).textContent() + const totalMatch = countText?.match(/\d+/) + expect(totalMatch).toBeTruthy() + expect(visibleCount).toBeGreaterThan(0) + expect(Number(totalMatch?.[0])).toBeGreaterThanOrEqual(visibleCount) + }) + + // TC_SEARCH_RESULT_009 P0 - downloads sort order + test('TC_SEARCH_RESULT_009: results are sorted by downloads when Downloads tab is selected', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'downloads')) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_RESULT_010 P0 - newest sort order + test('TC_SEARCH_RESULT_010: results are sorted by newest when Newest tab is selected', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'newest')) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) +}) + +// ─── Pagination ─────────────────────────────────────────────────────────────── + +test.describe('Search Pagination (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_PAGE_011 P1 - URL contains page param + test('TC_SEARCH_PAGE_011: URL contains page parameter', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0)) + await expect(page).toHaveURL(/page=/) + }) + + // TC_SEARCH_PAGE_012 P0 - switching page preserves search and sort + test('TC_SEARCH_PAGE_012: switching page preserves search keyword and sort', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'downloads', 0)) + const nextBtn = page.getByRole('button', { name: /next|›|»/i }) + await expect(nextBtn).toBeVisible({ timeout: 10_000 }) + await nextBtn.click() + await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`)) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=1/) + }) + + // TC_SEARCH_PAGE_007 P0 - first page disables previous button + test('TC_SEARCH_PAGE_007: previous page button is disabled on first page', async ({ page }) => { + await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0)) + const prevBtn = page.getByRole('button', { name: /prev|‹|«/i }) + if (await prevBtn.isVisible()) { + await expect(prevBtn).toBeDisabled() + } + }) +}) + +// ─── Security ───────────────────────────────────────────────────────────────── + +test.describe('Search Security (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + // TC_SEARCH_SEC_001 P0 - XSS in search box + test('TC_SEARCH_SEC_001: XSS payload in search box is not executed', async ({ page }) => { + let alerted = false + page.on('dialog', () => { alerted = true }) + + await page.goto(searchUrl('')) + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill("") + await searchInput.press('Enter') + + await page.waitForTimeout(1_000) + expect(alerted).toBe(false) + await expect(page.locator('body')).not.toContainText(/error|500/i) + }) + + // TC_SEARCH_SEC_002 P0 - SQL injection in search box + test('TC_SEARCH_SEC_002: SQL injection payload in search box is handled safely', async ({ page }) => { + await page.goto(searchUrl("' OR '1'='1")) + await expect(page.locator('body')).not.toContainText(/sql|syntax error|database/i) + await expect(page).toHaveURL(/\/search/) + }) + + // TC_SEARCH_SEC_003 P1 - URL param tampering + test('TC_SEARCH_SEC_003: tampered URL parameters are handled gracefully', async ({ page }, testInfo) => { + await registerSession(page, testInfo) + await page.goto('/search?q=agent&sort=INVALID_SORT&page=-1&starredOnly=invalid') + await expect(page).toHaveURL(/\/search/) + await expect(page.locator('body')).not.toContainText(/error|500|crash/i) + }) +}) diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index 8c77919c..de2abd6e 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -13,6 +13,13 @@ test.describe('Settings Pages (Real API)', () => { await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible() }) + test('navigates to reset-password page from profile settings', async ({ page }) => { + await page.goto('/settings/profile') + await page.getByRole('button', { name: 'Reset Password' }).click() + await expect(page).toHaveURL('/reset-password') + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + }) + test('shows validation when current password is missing', async ({ page }) => { await page.goto('/settings/security') await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() diff --git a/web/playwright.config.ts b/web/playwright.config.ts index b7206587..00b74867 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ timeout: process.env.CI ? 90_000 : 45_000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : 2, + workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1), reporter: 'html', use: { baseURL: 'http://localhost:3000', diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index c07760a2..4e1810cb 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -36,6 +36,7 @@ vi.mock('@/shared/lib/api-error', () => ({ import { WEB_API_PREFIX, buildApiUrl, + fetchText, getDirectAuthRuntimeConfig, getSessionBootstrapRuntimeConfig, } from './client' @@ -45,6 +46,8 @@ beforeEach(() => { }) afterEach(() => { + vi.unstubAllGlobals() + if (originalWindow) { Object.defineProperty(globalThis, 'window', { configurable: true, @@ -79,6 +82,38 @@ describe('buildApiUrl', () => { const url = buildApiUrl('/api/v1/auth/me') expect(url).toBe('https://api.example.com/api/v1/auth/me') }) + + it('preserves base URL path prefixes', () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' } + const url = buildApiUrl('/api/v1/auth/me') + expect(url).toBe('https://api.example.com/skill_hub/api/v1/auth/me') + }) + + it('supports relative base URL path prefixes', () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: '/skill_hub' } + const url = buildApiUrl('/api/v1/auth/me') + expect(url).toBe('/skill_hub/api/v1/auth/me') + }) +}) + +describe('fetchText', () => { + it('applies base URL path prefixes for fetch requests', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'ok', + }) + vi.stubGlobal('fetch', fetchMock) + + await fetchText('/api/v1/auth/me') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/skill_hub/api/v1/auth/me', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ) + }) }) describe('getDirectAuthRuntimeConfig', () => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1f7233e4..1e7351ff 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,6 +2,8 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' import type { ChangePasswordRequest, + PasswordResetConfirmRequest, + PasswordResetRequest, ApiToken, CreateTokenRequest, CreateTokenResponse, @@ -252,7 +254,7 @@ function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL { if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) { return input } - return new URL(input, ensureTrailingSlash(baseUrl)) + return prependApiBaseUrl(baseUrl, input) } export function buildApiUrl(path: string): string { @@ -260,11 +262,20 @@ export function buildApiUrl(path: string): string { if (!baseUrl) { return path } - return new URL(path, ensureTrailingSlash(baseUrl)).toString() + return prependApiBaseUrl(baseUrl, path) } -function ensureTrailingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/` +function prependApiBaseUrl(baseUrl: string, path: string): string { + const normalizedBaseUrl = trimTrailingSlash(baseUrl) + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${normalizedBaseUrl}${normalizedPath}` +} + +function trimTrailingSlash(value: string): string { + if (value.length > 1 && value.endsWith('/')) { + return value.slice(0, -1) + } + return value } export async function getCurrentUser(): Promise { @@ -345,6 +356,26 @@ export const authApi = { }) }, + async requestPasswordReset(request: PasswordResetRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/request', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async logout(): Promise { const response = await fetch('/api/v1/auth/logout', { method: 'POST', @@ -457,14 +488,44 @@ export const skillLifecycleApi = { }) }, - async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ targetVersion }), + body: JSON.stringify({ targetVersion, confirmWarnings }), + }) + }, + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ + async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version, targetVisibility }), + }) + }, + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ + async confirmPublish(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version }), }) }, } @@ -1054,6 +1115,13 @@ export const adminApi = { }) }, + async triggerPasswordReset(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/password-reset`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + async getAuditLogs(params: { action?: string userId?: string diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 2e2df9ab..d8714025 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1140,6 +1140,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/local/password-reset/request": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/password-reset/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/auth/local/login": { parameters: { query?: never; @@ -1220,6 +1252,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/users/{userId}/password-reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/users/{userId}/enable": { parameters: { query?: never; @@ -3037,6 +3085,8 @@ export interface components { /** Format: int64 */ namespaceId?: number; userId?: string; + displayName?: string; + email?: string; /** @enum {string} */ role?: "OWNER" | "ADMIN" | "MEMBER"; /** Format: date-time */ @@ -3187,6 +3237,7 @@ export interface components { }; SkillVersionRereleaseRequest: { targetVersion: string; + confirmWarnings?: boolean; }; SkillReportSubmitRequest: { reason?: string; @@ -3411,7 +3462,15 @@ export interface components { LocalRegisterRequest: { username: string; password: string; - email?: string; + email: string; + }; + PasswordResetRequestDto: { + email: string; + }; + PasswordResetConfirmRequest: { + email: string; + code: string; + newPassword: string; }; LocalLoginRequest: { username: string; @@ -5622,6 +5681,7 @@ export interface operations { parameters: { query: { visibility: string; + confirmWarnings?: boolean; }; header?: never; path: { @@ -5653,6 +5713,7 @@ export interface operations { parameters: { query: { visibility: string; + confirmWarnings?: boolean; }; header?: never; path: { @@ -6676,6 +6737,7 @@ export interface operations { query: { payload: string; files: string[]; + confirmWarnings?: boolean; }; header?: never; path?: never; @@ -6720,6 +6782,7 @@ export interface operations { parameters: { query: { namespace: string; + confirmWarnings?: boolean; }; header?: never; path?: never; @@ -6817,6 +6880,54 @@ export interface operations { }; }; }; + requestPasswordReset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + confirmPasswordReset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetConfirmRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; login: { parameters: { query?: never; @@ -6933,6 +7044,28 @@ export interface operations { }; }; }; + triggerPasswordReset: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; enableUser: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 07f861a4..2b2f9382 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -53,7 +53,7 @@ export interface LocalLoginRequest { } export interface LocalRegisterRequest extends LocalLoginRequest { - email?: string + email: string } export interface ChangePasswordRequest { @@ -61,6 +61,16 @@ export interface ChangePasswordRequest { newPassword: string } +export interface PasswordResetRequest { + email: string +} + +export interface PasswordResetConfirmRequest { + email: string + code: string + newPassword: string +} + export type CreateNamespaceRequest = Omit & { slug: string displayName: string @@ -116,6 +126,8 @@ export interface ManagedNamespace extends Namespace { export interface NamespaceMember { id: number userId: string + displayName?: string + email?: string role: NamespaceRole createdAt: string } diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index fd48292b..4095904d 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -65,6 +65,7 @@ const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'L const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage') const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') +const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage') const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage') const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') @@ -85,22 +86,18 @@ const NamespaceReviewsPage = createLazyRouteComponent( () => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage', ) -const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') -const ReviewsPage = createRoleProtectedRouteComponent( - () => import('@/pages/dashboard/reviews'), - 'ReviewsPage', - ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'], +const NamespaceReviewDetailPage = createLazyRouteComponent( + () => import('@/pages/dashboard/review-detail'), + 'NamespaceReviewDetailPage', ) +const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') +const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReportsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/reports'), 'ReportsPage', ['SKILL_ADMIN', 'SUPER_ADMIN'], ) -const ReviewDetailPage = createRoleProtectedRouteComponent( - () => import('@/pages/dashboard/review-detail'), - 'ReviewDetailPage', - ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'], -) +const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') const PromotionsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/promotions'), 'PromotionsPage', @@ -184,6 +181,12 @@ const registerRoute = createRoute({ component: RegisterPage, }) +const resetPasswordRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'reset-password', + component: ResetPasswordPage, +}) + const privacyRoute = createRoute({ getParentRoute: () => rootRoute, path: 'privacy', @@ -221,7 +224,6 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - beforeLoad: requireAuth, validateSearch: (search: Record): { returnTo?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, }), @@ -298,6 +300,13 @@ const dashboardReviewDetailRoute = createRoute({ component: ReviewDetailPage, }) +const dashboardNamespaceReviewDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug/reviews/$id', + beforeLoad: requireAuth, + component: NamespaceReviewDetailPage, +}) + const dashboardPromotionsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/promotions', @@ -397,6 +406,7 @@ const routeTree = rootRoute.addChildren([ skillsRoute, loginRoute, registerRoute, + resetPasswordRoute, privacyRoute, searchRoute, termsRoute, @@ -408,6 +418,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespacesRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, + dashboardNamespaceReviewDetailRoute, dashboardGovernanceRoute, dashboardReviewsRoute, dashboardReportsRoute, diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts index 8c8ddc6c..a8ac6927 100644 --- a/web/src/bootstrap.ts +++ b/web/src/bootstrap.ts @@ -7,7 +7,7 @@ async function loadRuntimeConfig() { await new Promise((resolve, reject) => { const script = document.createElement('script') - script.src = '/runtime-config.js' + script.src = new URL('../runtime-config.js', import.meta.url).toString() script.async = false script.onload = () => resolve() script.onerror = () => reject(new Error('Failed to load runtime config')) diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index be9c7743..7c0abf73 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -97,3 +97,13 @@ export function useEnableUser() { }, }) } + +export function useTriggerUserPasswordReset() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.triggerPasswordReset(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/features/publish/publish-error-utils.test.ts b/web/src/features/publish/publish-error-utils.test.ts new file mode 100644 index 00000000..119881e4 --- /dev/null +++ b/web/src/features/publish/publish-error-utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + extractPrecheckWarnings, + isFrontmatterFailureMessage, + isPrecheckConfirmationMessage, + isPrecheckFailureMessage, + isVersionExistsMessage, +} from './publish-error-utils' + +describe('publish-error-utils', () => { + it('detects confirmation-required warnings in English', () => { + expect(isPrecheckConfirmationMessage('Pre-publish warnings require confirmation before publishing:\n- warning')).toBe(true) + }) + + it('detects confirmation-required warnings in Chinese', () => { + expect(isPrecheckConfirmationMessage('预发布发现以下风险提醒,确认后仍可继续发布:\n- 风险提醒')).toBe(true) + }) + + it('extracts warning lines from a confirmation message', () => { + expect(extractPrecheckWarnings( + 'Pre-publish warnings require confirmation before publishing:\n- Disallowed file extension: malware.exe\n- SKILL.md line 5 contains a value that looks like a secret or token.' + )).toEqual([ + 'Disallowed file extension: malware.exe', + 'SKILL.md line 5 contains a value that looks like a secret or token.', + ]) + }) + + it('keeps existing blocking precheck detection', () => { + expect(isPrecheckFailureMessage('Pre-publish validation failed: validator blocked publish')).toBe(true) + }) + + it('keeps version and frontmatter detection helpers', () => { + expect(isVersionExistsMessage('Version already exists')).toBe(true) + expect(isFrontmatterFailureMessage('Invalid SKILL.md frontmatter')).toBe(true) + }) +}) diff --git a/web/src/features/publish/publish-error-utils.ts b/web/src/features/publish/publish-error-utils.ts new file mode 100644 index 00000000..dd115214 --- /dev/null +++ b/web/src/features/publish/publish-error-utils.ts @@ -0,0 +1,74 @@ +const PRECHECK_CONFIRM_MARKERS = [ + 'Pre-publish warnings require confirmation before publishing', + '预发布发现以下风险提醒,确认后仍可继续发布', +] + +const PRECHECK_FAILURE_MARKERS = [ + 'error.skill.publish.precheck.failed', + 'Pre-publish validation failed', + '预发布校验失败', + 'looks like a secret or token', +] + +const VERSION_EXISTS_MARKERS = [ + 'error.skill.version.exists', + 'Version already exists', + '版本已存在', +] + +const FRONTMATTER_FAILURE_MARKERS = [ + 'Invalid SKILL.md frontmatter', + '技能包校验失败:Invalid SKILL.md frontmatter', +] + +function includesAnyMarker(message: string | undefined, markers: string[]): boolean { + if (!message) { + return false + } + + return markers.some((marker) => message.includes(marker)) +} + +export function isVersionExistsMessage(message?: string): boolean { + return includesAnyMarker(message, VERSION_EXISTS_MARKERS) +} + +export function isPrecheckFailureMessage(message?: string): boolean { + return includesAnyMarker(message, PRECHECK_FAILURE_MARKERS) +} + +export function isPrecheckConfirmationMessage(message?: string): boolean { + return includesAnyMarker(message, PRECHECK_CONFIRM_MARKERS) +} + +export function isFrontmatterFailureMessage(message?: string): boolean { + return includesAnyMarker(message, FRONTMATTER_FAILURE_MARKERS) +} + +export function extractPrecheckWarnings(message?: string): string[] { + if (!message) { + return [] + } + + const normalized = message.replace(/\r/g, '').trim() + if (!normalized) { + return [] + } + + return normalized + .split('\n') + .map((line, index) => { + const trimmed = line.trim() + if (!trimmed) { + return null + } + + if (index === 0 && isPrecheckConfirmationMessage(trimmed)) { + const firstWarning = trimmed.replace(/^.*?[::]\s*/, '').trim() + return firstWarning && !isPrecheckConfirmationMessage(firstWarning) ? firstWarning : null + } + + return trimmed.replace(/^[-*•]\s*/, '') + }) + .filter((line): line is string => Boolean(line)) +} diff --git a/web/src/features/review/review-paths.test.ts b/web/src/features/review/review-paths.test.ts new file mode 100644 index 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/code-renderer.tsx b/web/src/features/skill/code-renderer.tsx index 6784d607..ec4fca94 100644 --- a/web/src/features/skill/code-renderer.tsx +++ b/web/src/features/skill/code-renderer.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { common, createLowlight } from 'lowlight' // Create lowlight instance with common languages @@ -14,30 +15,28 @@ interface CodeRendererProps { /** * Renders code with syntax highlighting using lowlight (highlight.js wrapper). * Reuses the same styling as Markdown code blocks for visual consistency. + * Memoized to prevent re-highlighting on every render. */ export function CodeRenderer({ code, language, className }: CodeRendererProps) { - let highlightedCode: string - - try { - if (language && lowlight.registered(language)) { - // Highlight with specified language - const tree = lowlight.highlight(language, code, { prefix: 'hljs-' }) - highlightedCode = treeToHtml(tree) - } else { - // Fallback to plain text (no highlighting) - highlightedCode = escapeHtml(code) + // Cache syntax highlighting result + const highlightedCode = useMemo(() => { + try { + if (language && lowlight.registered(language)) { + const tree = lowlight.highlight(language, code, { prefix: 'hljs-' }) + return treeToHtml(tree) + } + return escapeHtml(code) + } catch (error) { + console.error('Syntax highlighting failed:', error) + return escapeHtml(code) } - } catch (error) { - // If highlighting fails, escape HTML and display as plain text - console.error('Syntax highlighting failed:', error) - highlightedCode = escapeHtml(code) - } + }, [code, language]) return (

- {/* Reuse the same wrapper styling as Markdown code blocks */} -
-
+ {/* Simplified styling - removed gradient, blur, and shadow for better performance */} +
+
              {
   it('exports the FileTreeNodeComponent component', () => {
     expect(mod.FileTreeNodeComponent).toBeDefined()
-    expect(typeof mod.FileTreeNodeComponent).toBe('function')
+    // React.memo wraps the component in an object, so typeof is 'object'
+    expect(['function', 'object']).toContain(typeof mod.FileTreeNodeComponent)
   })
 })
diff --git a/web/src/features/skill/file-tree-node.tsx b/web/src/features/skill/file-tree-node.tsx
index 3dbbfef5..d5f3d05e 100644
--- a/web/src/features/skill/file-tree-node.tsx
+++ b/web/src/features/skill/file-tree-node.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, memo, useMemo } from 'react'
 import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, FileCode, File } from 'lucide-react'
 import type { FileTreeNode } from './file-tree-builder'
 import { getFileIcon } from './file-type-utils'
@@ -33,14 +33,23 @@ function formatFileSize(bytes: number): string {
 /**
  * Recursive file tree node component.
  * Renders either a file or directory node with expand/collapse functionality.
+ * Memoized to prevent unnecessary re-renders when parent updates.
  */
-export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = false }: FileTreeNodeProps) {
+export const FileTreeNodeComponent = memo(function FileTreeNodeComponent({
+  node,
+  onFileClick,
+  defaultExpanded = false,
+}: FileTreeNodeProps) {
   const [isExpanded, setIsExpanded] = useState(defaultExpanded)
 
+  // Cache icon computation
+  const IconComponent = useMemo(
+    () => getIconComponent(getFileIcon(node.name)),
+    [node.name]
+  )
+
   // Render file node
   if (node.type === 'file') {
-    const IconComponent = getIconComponent(getFileIcon(node.name))
-
     return (
       
) -} +}) diff --git a/web/src/features/skill/file-tree.tsx b/web/src/features/skill/file-tree.tsx index 96e1ec8b..9e9ef3f3 100644 --- a/web/src/features/skill/file-tree.tsx +++ b/web/src/features/skill/file-tree.tsx @@ -1,3 +1,4 @@ +import { useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { Folder } from 'lucide-react' import type { SkillFile } from '@/api/types' @@ -18,13 +19,19 @@ interface FileTreeProps { */ export function FileTree({ files, onFileClick, bare }: FileTreeProps) { const { t } = useTranslation() - const tree = buildFileTree(files) - const handleFileClick = (node: FileTreeNode) => { - if (node.type === 'file' && onFileClick) { - onFileClick(node) - } - } + // Cache tree structure to avoid rebuilding on every render + const tree = useMemo(() => buildFileTree(files), [files]) + + // Stable callback reference to prevent child re-renders + const handleFileClick = useCallback( + (node: FileTreeNode) => { + if (node.type === 'file' && onFileClick) { + onFileClick(node) + } + }, + [onFileClick] + ) const treeContent = (
diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 31833567..1f787021 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' @@ -17,6 +18,7 @@ interface MarkdownRendererProps { * Renders markdown from skill packages using a constrained plugin stack. * Frontmatter is stripped before render because package metadata is surfaced in * dedicated UI sections and should not appear twice in the document body. + * Memoized to prevent re-parsing on every render. */ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { const containerClassName = [ @@ -25,7 +27,12 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ] .filter(Boolean) .join(' ') - const normalizedContent = stripMarkdownFrontmatter(content) + + // Cache the normalized content to prevent re-parsing on every render + const normalizedContent = useMemo( + () => stripMarkdownFrontmatter(content), + [content] + ) return (
@@ -103,8 +110,8 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ), pre: ({ children }) => ( -
-
+
+
{children}
@@ -132,7 +139,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) blockquote: ({ className: blockquoteClassName, children, ...props }) => (
), table: ({ children }) => ( -
+
{children}
diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index fa0c2980..ff8fcd1b 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -21,59 +21,72 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred const headlineVersion = getHeadlineVersion(skill) + const isInteractive = typeof onClick === 'function' return ( -
-
-
-

- {skill.displayName} -

-
-
- -
+ className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2" + style={{ borderColor: 'hsl(var(--border-card))' }} + onClick={onClick} + onKeyDown={(event) => { + if (!isInteractive) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + }} + role={isInteractive ? 'link' : undefined} + tabIndex={isInteractive ? 0 : undefined} + > +
+
+
+

+ {skill.displayName} +

- - {skill.summary && ( -

- {skill.summary} -

- )} - -
- {headlineVersion && ( - - v{headlineVersion.version} - - )} - - - - - {formatCompactCount(skill.downloadCount)} - - - - {skill.starCount} - - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - - - - - {skill.ratingAvg.toFixed(1)} - - )} +
+
- + + {skill.summary && ( +

+ {skill.summary} +

+ )} + +
+ {headlineVersion && ( + + v{headlineVersion.version} + + )} + + + + + {formatCompactCount(skill.downloadCount)} + + + + {skill.starCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} + + )} +
+
+ ) } 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 88ba08ad..c4249c54 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -210,6 +210,7 @@ "hidePassword": "Hide password", "submitting": "Logging in...", "submit": "Login", + "forgotPassword": "Forgot password?", "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", @@ -233,13 +234,47 @@ "email": "Email", "password": "Password", "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", - "emailPlaceholder": "Optional, for account identification", + "emailPlaceholder": "Enter your email", + "emailRequired": "Email is required", "passwordPlaceholder": "At least 8 characters with 3 character types", "submitting": "Registering...", "submit": "Register & Login", "hasAccount": "Already have an account?", "login": "Back to login", - "oauthHint": "Sign in directly with your existing OAuth account, no local password needed." + "oauthHint": "Sign in directly with your existing OAuth account, no local password needed.", + "usernameRequired": "Username is required", + "usernameInvalid": "Only letters, numbers, or underscores allowed (3-64 characters)", + "usernameExists": "Username already exists", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "passwordTooWeak": "Password must contain at least 3 character types (uppercase, lowercase, numbers, special)", + "emailInvalid": "Invalid email format", + "emailExists": "Email already exists" + }, + "resetPassword": { + "title": "Reset Password", + "subtitle": "Enter your email, verification code, and new password.", + "email": "Email", + "emailPlaceholder": "Enter email", + "emailRequired": "Please enter your email", + "emailInvalid": "Please enter a valid email address", + "code": "Verification Code", + "codePlaceholder": "Enter 6-digit verification code", + "sendCode": "Send Verification Code", + "sendingCode": "Sending...", + "codeSentMessage": "If the account is eligible, a verification code has been sent.", + "codeRequired": "Please enter the verification code", + "newPassword": "New Password", + "newPasswordPlaceholder": "Enter new password", + "newPasswordRequired": "Please enter a new password", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Re-enter new password", + "passwordMismatch": "The two passwords do not match", + "submit": "Reset Password", + "submitting": "Resetting...", + "successMessage": "Password reset successful. Please sign in with your new password.", + "genericError": "Failed to reset password", + "backToLogin": "Back to login" }, "device": { "title": "Device Authorization", @@ -522,6 +557,7 @@ "approveUser": "Approve", "disable": "Disable", "enable": "Enable", + "resetPassword": "Reset Password", "totalRecords": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -536,7 +572,8 @@ "roleSuperAdmin": "Super Admin", "confirmAction": "Confirm Action", "confirmDisable": "Are you sure you want to disable user {{username}}?", - "confirmEnable": "Are you sure you want to enable user {{username}}?" + "confirmEnable": "Are you sure you want to enable user {{username}}?", + "confirmResetPassword": "Send password reset verification code to user {{username}}?" }, "adminLabels": { "title": "Label Management", @@ -643,6 +680,7 @@ "subtitle": "Manage your display name and personal information.", "displayName": "Display Name", "email": "Email", + "resetPassword": "Reset Password", "edit": "Edit", "save": "Save", "saving": "Saving...", @@ -770,6 +808,7 @@ "versionStatusDraft": "Draft", "versionStatusScanning": "Scanning", "versionStatusScanFailed": "Scan Failed", + "versionStatusUploaded": "Uploaded", "versionStatusPendingReview": "Pending Review", "versionStatusPublished": "Published", "versionStatusRejected": "Rejected", @@ -818,6 +857,18 @@ "withdrawReviewSuccessTitle": "Review withdrawn", "withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.", "withdrawReviewErrorTitle": "Failed to withdraw review", + "confirmPublish": "Confirm Publish", + "confirmPublishDialogTitle": "Confirm publish", + "confirmPublishDialogDescription": "Publish version {{version}} as a private skill? It will be available for you to download and install, but not visible on the marketplace.", + "confirmPublishSuccessTitle": "Version published", + "confirmPublishSuccessDescription": "Version {{version}} has been published as a private skill.", + "confirmPublishErrorTitle": "Failed to confirm publish", + "submitReview": "Submit for Review", + "submitReviewDialogTitle": "Submit for review", + "submitReviewDialogDescription": "Submit version {{version}} for public review? Once approved, it will be visible on the marketplace.", + "submitReviewSuccessTitle": "Submitted for review", + "submitReviewSuccessDescription": "Version {{version}} has been submitted for review.", + "submitReviewErrorTitle": "Failed to submit for review", "deleteVersion": "Delete Version", "deleteVersionConfirmTitle": "Delete version", "deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?", @@ -861,6 +912,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", @@ -984,6 +1038,8 @@ "savingRole": "Saving...", "changeRole": "Change role", "colUserId": "User ID", + "colUsername": "Username", + "colEmail": "Email", "colRole": "Role", "colJoinedAt": "Joined At", "colActions": "Actions", @@ -1026,6 +1082,7 @@ "sortLabel": "Time Order", "sortNewest": "Newest first", "sortOldest": "Oldest first", + "openReview": "Open review", "pageSummary": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -1172,6 +1229,10 @@ "versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.", "precheckFailedTitle": "Pre-publish check failed", "precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.", + "warningConfirmTitle": "Pre-publish warning", + "warningConfirmDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue publishing.", + "warningConfirmContinue": "Continue publishing", + "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", "selectRequired": "Please select namespace and file" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index cec0f310..996ecfe7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -210,6 +210,7 @@ "hidePassword": "隐藏密码", "submitting": "登录中...", "submit": "登录", + "forgotPassword": "忘记密码?", "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", @@ -233,13 +234,47 @@ "email": "邮箱", "password": "密码", "usernamePlaceholder": "3-64 位字母、数字或下划线", - "emailPlaceholder": "可选,用于后续账号识别", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入邮箱", "passwordPlaceholder": "至少 8 位,包含 3 种字符类型", "submitting": "注册中...", "submit": "注册并登录", "hasAccount": "已有账号?", "login": "返回登录", - "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。" + "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。", + "usernameRequired": "请输入用户名", + "usernameInvalid": "仅支持字母、数字或下划线(3-64 位)", + "usernameExists": "用户名已存在", + "passwordRequired": "请输入密码", + "passwordTooShort": "密码至少需要 8 个字符", + "passwordTooWeak": "密码需包含至少 3 种字符类型(大写、小写、数字、特殊字符)", + "emailInvalid": "邮箱格式不正确", + "emailExists": "邮箱已存在" + }, + "resetPassword": { + "title": "重置密码", + "subtitle": "输入邮箱、验证码和新密码以完成重置。", + "email": "邮箱", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入邮箱", + "emailInvalid": "请输入正确的邮箱格式", + "code": "验证码", + "codePlaceholder": "请输入 6 位验证码", + "sendCode": "发送验证码", + "sendingCode": "发送中...", + "codeSentMessage": "如果账号符合条件,验证码已发送。", + "codeRequired": "请输入验证码", + "newPassword": "新密码", + "newPasswordPlaceholder": "请输入新密码", + "newPasswordRequired": "请输入新密码", + "confirmPassword": "确认新密码", + "confirmPasswordPlaceholder": "请再次输入新密码", + "passwordMismatch": "两次输入的密码不一致", + "submit": "重置密码", + "submitting": "重置中...", + "successMessage": "密码重置成功,请使用新密码登录。", + "genericError": "重置密码失败", + "backToLogin": "返回登录" }, "device": { "title": "设备授权", @@ -522,6 +557,7 @@ "approveUser": "审批通过", "disable": "禁用", "enable": "启用", + "resetPassword": "重置密码", "totalRecords": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -536,7 +572,8 @@ "roleSuperAdmin": "超级管理员", "confirmAction": "确认操作", "confirmDisable": "确定要禁用用户 {{username}} 吗?", - "confirmEnable": "确定要启用用户 {{username}} 吗?" + "confirmEnable": "确定要启用用户 {{username}} 吗?", + "confirmResetPassword": "确定给用户 {{username}} 发送密码重置验证码吗?" }, "adminLabels": { "title": "标签管理", @@ -643,6 +680,7 @@ "subtitle": "管理你的昵称和个人信息。", "displayName": "昵称", "email": "邮箱", + "resetPassword": "重置密码", "edit": "编辑", "save": "保存", "saving": "保存中...", @@ -770,6 +808,7 @@ "versionStatusDraft": "草稿", "versionStatusScanning": "安全扫描中", "versionStatusScanFailed": "扫描失败", + "versionStatusUploaded": "已上传", "versionStatusPendingReview": "审核中", "versionStatusPublished": "已发布", "versionStatusRejected": "已拒绝", @@ -818,6 +857,19 @@ "withdrawReviewSuccessTitle": "已撤销审核", "withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。", "withdrawReviewErrorTitle": "撤销审核失败", + "confirmPublish": "确认发布", + "confirmPublishDialogTitle": "确认发布", + "confirmPublishDialogDescription": "将版本 {{version}} 发布为私有技能?发布后您可以下载和安装,但不会在市场展示。", + "confirmPublishSuccessTitle": "版本已发布", + "confirmPublishSuccessDescription": "版本 {{version}} 已发布为私有技能。", + "confirmPublishErrorTitle": "确认发布失败", + "submitReview": "提交审核", + "submitReviewDialogTitle": "提交审核", + "submitReviewDialogDescription": "将版本 {{version}} 提交公开审核?审核通过后将在市场展示。", + "submitReviewSuccessTitle": "已提交审核", + "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。", + "submitReviewErrorTitle": "提交审核失败", + "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", @@ -861,6 +913,9 @@ "rereleaseSuccessTitle": "版本已重新发布", "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", "rereleaseErrorTitle": "重新发布版本失败", + "rereleaseWarningTitle": "发布前风险提醒", + "rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。", + "rereleaseWarningConfirm": "继续重新发布", "yankVersion": "撤回当前版本", "promoteToGlobal": "申请提升到全局", "promotionSectionTitle": "提升到全局", @@ -984,6 +1039,8 @@ "savingRole": "保存中...", "changeRole": "修改角色", "colUserId": "用户 ID", + "colUsername": "用户名", + "colEmail": "邮箱", "colRole": "角色", "colJoinedAt": "加入时间", "colActions": "操作", @@ -1026,6 +1083,7 @@ "sortLabel": "时间排序", "sortNewest": "最新优先", "sortOldest": "最早优先", + "openReview": "进入审核详情", "pageSummary": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -1172,6 +1230,10 @@ "versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。", "precheckFailedTitle": "发布前校验未通过", "precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。", + "warningConfirmTitle": "发布前风险提醒", + "warningConfirmDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续发布。", + "warningConfirmContinue": "继续发布", + "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", "selectRequired": "请选择命名空间和文件" diff --git a/web/src/pages/admin/users.test.tsx b/web/src/pages/admin/users.test.tsx index a47ade76..1b27359c 100644 --- a/web/src/pages/admin/users.test.tsx +++ b/web/src/pages/admin/users.test.tsx @@ -64,6 +64,7 @@ vi.mock('@/features/admin/use-admin-users', () => ({ useApproveUser: () => ({ mutate: vi.fn(), isPending: false }), useDisableUser: () => ({ mutateAsync: vi.fn(), isPending: false }), useEnableUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useTriggerUserPasswordReset: () => ({ mutateAsync: vi.fn(), isPending: false }), useUpdateUserRole: () => ({ mutateAsync: vi.fn(), isPending: false }), })) diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 29caf495..1dc051f2 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -29,7 +29,14 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Label } from '@/shared/ui/label' -import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users' +import { + useAdminUsers, + useApproveUser, + useDisableUser, + useEnableUser, + useTriggerUserPasswordReset, + useUpdateUserRole, +} from '@/features/admin/use-admin-users' import type { AdminUser } from '@/features/admin/use-admin-users' /** @@ -54,7 +61,7 @@ export function AdminUsersPage() { const [roleDialogOpen, setRoleDialogOpen] = useState(false) const [newRole, setNewRole] = useState('') const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) - const [actionType, setActionType] = useState<'ban' | 'unban'>('ban') + const [actionType, setActionType] = useState<'ban' | 'unban' | 'reset'>('ban') const { data, isLoading } = useAdminUsers({ search, @@ -67,6 +74,7 @@ export function AdminUsersPage() { const approveUserMutation = useApproveUser() const disableUserMutation = useDisableUser() const enableUserMutation = useEnableUser() + const triggerPasswordResetMutation = useTriggerUserPasswordReset() const formatDate = (dateString: string) => { return formatLocalDateTime(dateString, i18n.language) @@ -105,6 +113,12 @@ export function AdminUsersPage() { setConfirmDialogOpen(true) } + const handleTriggerPasswordReset = (user: AdminUser) => { + setSelectedUser(user) + setActionType('reset') + setConfirmDialogOpen(true) + } + const confirmRoleChange = async () => { if (!selectedUser || !newRole || newRole === (selectedUser.platformRoles[0] || 'USER')) return try { @@ -116,18 +130,20 @@ export function AdminUsersPage() { } } - const confirmStatusChange = async () => { + const confirmUserAction = async () => { if (!selectedUser) return try { if (actionType === 'ban') { await disableUserMutation.mutateAsync(selectedUser.userId) - } else { + } else if (actionType === 'unban') { await enableUserMutation.mutateAsync(selectedUser.userId) + } else { + await triggerPasswordResetMutation.mutateAsync(selectedUser.userId) } setConfirmDialogOpen(false) setSelectedUser(null) } catch (error) { - console.error('Failed to update status:', error) + console.error('Failed to apply user action:', error) } } @@ -259,6 +275,13 @@ export function AdminUsersPage() { {t('adminUsers.enable')} )} +
@@ -342,14 +365,21 @@ export function AdminUsersPage() { {t('adminUsers.confirmAction')} - {actionType === 'ban' ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) : t('adminUsers.confirmEnable', { username: selectedUser?.username })} + {actionType === 'ban' + ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) + : actionType === 'unban' + ? t('adminUsers.confirmEnable', { username: selectedUser?.username }) + : t('adminUsers.confirmResetPassword', { username: selectedUser?.username })} - diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 74cd0c65..5f437cd7 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -83,6 +83,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return t('mySkills.statusScanFailed') } + if (status === 'UPLOADED') { + return t('skillDetail.versionStatusUploaded') + } return status } @@ -108,6 +111,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return 'status-pill status-pill--rejected' } + if (status === 'UPLOADED') { + return 'status-pill status-pill--review' + } return 'status-pill' } diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 76504b38..88adb53d 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -178,7 +178,8 @@ export function NamespaceMembersPage() { - + + @@ -193,7 +194,13 @@ export function NamespaceMembersPage() { return ( - + +
{t('members.colUserId')}{t('members.colUsername')}{t('members.colEmail')} {t('members.colRole')} {t('members.colJoinedAt')} {t('members.colActions')}
{member.userId} +
+ {member.displayName || member.userId} + {member.userId} +
+
{member.email || '-'} {canManageMembers && !isOwner ? (
diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 79830e2f..735307ca 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +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' }), })) @@ -19,10 +22,6 @@ vi.mock('@/shared/lib/date-time', () => ({ formatLocalDateTime: (v: string) => v, })) -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - vi.mock('@/shared/ui/card', () => ({ Card: ({ children }: { children: unknown }) => children, })) @@ -42,12 +41,22 @@ vi.mock('@/shared/ui/tabs', () => ({ TabsTrigger: ({ children }: { children: unknown }) => children, })) -vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useNamespaceDetail: () => ({ data: null, isLoading: false }), +const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = [] +vi.mock('@/shared/components/pagination', () => ({ + Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => { + paginationProps.push(props) + return null + }, })) +const useNamespaceDetailMock = vi.fn() +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useNamespaceDetail: (...args: unknown[]) => useNamespaceDetailMock(...args), +})) + +const useReviewListMock = vi.fn() vi.mock('@/features/review/use-review-list', () => ({ - useReviewList: () => ({ data: null, isLoading: false }), + useReviewList: (...args: unknown[]) => useReviewListMock(...args), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -61,7 +70,105 @@ vi.mock('@/features/namespace/namespace-header', () => ({ import { NamespaceReviewsPage } from './namespace-reviews' describe('NamespaceReviewsPage', () => { + function createReviewItem(id: number) { + return { + id, + namespace: 'demo-ns', + skillSlug: `skill-${id}`, + version: '1.0.0', + submittedBy: 'user-1', + submittedByName: 'User 1', + submittedAt: '2026-04-01T12:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + } + } + + beforeEach(() => { + paginationProps.length = 0 + useNamespaceDetailMock.mockReset() + useReviewListMock.mockReset() + + useNamespaceDetailMock.mockReturnValue({ + data: { + id: 100, + slug: 'test-ns', + displayName: 'Test Namespace', + type: 'CUSTOM', + status: 'ACTIVE', + }, + isLoading: false, + }) + + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled || status !== 'PENDING') { + return { data: null, isLoading: false } + } + return { + data: { + items: [createReviewItem(1)], + totalElements: 11, + totalPages: 2, + page, + size: 10, + total: 11, + }, + isLoading: false, + } + }) + }) + it('exports a named component function', () => { expect(typeof NamespaceReviewsPage).toBe('function') }) + + it('renders pagination for namespace review list when totalPages > 1', () => { + 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) + }) + + it('does not render pagination when there is only one page', () => { + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled || status !== 'PENDING') { + return { data: null, isLoading: false } + } + return { + data: { + items: [createReviewItem(2)], + totalElements: 1, + totalPages: 1, + page, + size: 10, + total: 1, + }, + isLoading: false, + } + }) + + renderToStaticMarkup(createElement(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 5dc37627..fb995e8a 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -1,22 +1,24 @@ 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 { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { useReviewList } from '@/features/review/use-review-list' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' import { NamespaceHeader } from '@/features/namespace/namespace-header' 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 })) @@ -51,26 +53,7 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { return (

{t('nsReviews.pageSummary', { total: totalElements, page: currentPage + 1 })}

-
- - -
+ changePage(status, nextPage)} />
) } @@ -109,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} @@ -170,7 +161,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} - + ) } diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1de46695..3b2ec57e 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -2,6 +2,13 @@ import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' +import { + extractPrecheckWarnings, + isFrontmatterFailureMessage, + isPrecheckConfirmationMessage, + isPrecheckFailureMessage, + isVersionExistsMessage, +} from '@/features/publish/publish-error-utils' import { Button } from '@/shared/ui/button' import { Select, @@ -15,46 +22,11 @@ import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { usePublishSkill } from '@/shared/hooks/use-skill-queries' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' -/** - * Skill publish page used inside the dashboard. - * - * It coordinates namespace selection, visibility selection, zip upload, and backend publish error - * translation into user-facing toasts. - */ -function isVersionExistsMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('error.skill.version.exists') - || message.includes('Version already exists') - || message.includes('版本已存在') -} - -function isPrecheckFailureMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('error.skill.publish.precheck.failed') - || message.includes('Pre-publish validation failed') - || message.includes('预发布校验失败') - || message.includes('looks like a secret or token') -} - -function isFrontmatterFailureMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('Invalid SKILL.md frontmatter') - || message.includes('技能包校验失败:Invalid SKILL.md frontmatter') -} - const EMPTY_NAMESPACE_VALUE = '__select_namespace__' export function PublishPage() { @@ -63,6 +35,8 @@ export function PublishPage() { const [selectedFile, setSelectedFile] = useState(null) const [namespaceSlug, setNamespaceSlug] = useState('') const [visibility, setVisibility] = useState('PUBLIC') + const [warningDialogOpen, setWarningDialogOpen] = useState(false) + const [precheckWarnings, setPrecheckWarnings] = useState([]) const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() @@ -73,9 +47,17 @@ export function PublishPage() { const handleRemoveSelectedFile = () => { setSelectedFile(null) + setPrecheckWarnings([]) + setWarningDialogOpen(false) } - const handlePublish = async () => { + const handleFileSelect = (file: File | null) => { + setSelectedFile(file) + setPrecheckWarnings([]) + setWarningDialogOpen(false) + } + + const publishSkill = async (confirmWarnings = false) => { if (!selectedFile || !namespaceSlug) { toast.error(t('publish.selectRequired')) return @@ -86,7 +68,10 @@ export function PublishPage() { namespace: namespaceSlug, file: selectedFile, visibility, + confirmWarnings, }) + setPrecheckWarnings([]) + setWarningDialogOpen(false) const skillLabel = `${result.namespace}/${result.slug}@${result.version}` if (result.status === 'PUBLISHED') { toast.success( @@ -114,6 +99,12 @@ export function PublishPage() { return } + if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage || error.message)) { + setPrecheckWarnings(extractPrecheckWarnings(error.serverMessage || error.message)) + setWarningDialogOpen(true) + return + } + if (error instanceof ApiError && isPrecheckFailureMessage(error.serverMessage || error.message)) { toast.error( t('publish.precheckFailedTitle'), @@ -134,6 +125,10 @@ export function PublishPage() { } } + const handlePublish = async () => { + await publishSkill(false) + } + return (
@@ -195,7 +190,7 @@ export function PublishPage() { {selectedFile && ( @@ -230,6 +225,27 @@ export function PublishPage() { {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} + + +

{t('publish.warningConfirmDescription')}

+ {precheckWarnings.length > 0 && ( +
    + {precheckWarnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+ )} + confirmText={t('publish.warningConfirmContinue')} + cancelText={t('publish.warningConfirmCancel')} + onConfirm={() => publishSkill(true)} + /> ) } 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 e3117551..694a0dce 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createElement } from 'react' vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -19,10 +21,6 @@ vi.mock('react-i18next', async () => { } }) -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - vi.mock('@/shared/ui/card', () => ({ Card: ({ children }: { children: unknown }) => children, CardContent: ({ children }: { children: unknown }) => children, @@ -55,12 +53,28 @@ vi.mock('@/shared/ui/table', () => ({ TableRow: ({ children }: { children: unknown }) => children, })) -vi.mock('@/features/review/use-review-list', () => ({ - useReviewList: () => ({ data: null, isLoading: false }), +const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = [] +vi.mock('@/shared/components/pagination', () => ({ + Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => { + paginationProps.push(props) + return null + }, })) +const useReviewListMock = vi.fn() +vi.mock('@/features/review/use-review-list', () => ({ + useReviewList: (...args: unknown[]) => useReviewListMock(...args), +})) + +const hasRoleMock = vi.fn() +const userMock = { platformRoles: ['SKILL_ADMIN'] } vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + 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', () => ({ @@ -78,7 +92,92 @@ vi.mock('./profile-review-table', () => ({ import { ReviewsPage } from './reviews' describe('ReviewsPage', () => { + function createReviewItem(id: number) { + return { + id, + namespace: 'demo', + skillSlug: `skill-${id}`, + version: '1.0.0', + submittedBy: 'user-1', + submittedByName: 'User 1', + submittedAt: '2026-04-01T12:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + } + } + + beforeEach(() => { + 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 } + } + + if (status === 'PENDING') { + return { + data: { + items: [createReviewItem(1)], + totalElements: 21, + totalPages: 2, + page, + size: 20, + total: 21, + }, + isLoading: false, + } + } + + return { data: null, isLoading: false } + }) + }) + it('exports a named component function', () => { expect(typeof ReviewsPage).toBe('function') }) + + it('renders pagination for pending reviews when totalPages > 1', () => { + const html = renderToStaticMarkup(createElement(ReviewsPage)) + + expect(html).toContain('reviews.pageSummary') + expect(paginationProps).toHaveLength(1) + expect(paginationProps[0]?.page).toBe(0) + expect(paginationProps[0]?.totalPages).toBe(2) + }) + + it('renders disabled-style pagination when there is only one page', () => { + useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { + if (!enabled || status !== 'PENDING') { + return { data: null, isLoading: false } + } + + return { + data: { + items: [createReviewItem(2)], + totalElements: 1, + totalPages: 1, + page, + size: 20, + total: 1, + }, + isLoading: false, + } + }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(paginationProps).toHaveLength(1) + expect(paginationProps[0]?.page).toBe(0) + expect(paginationProps[0]?.totalPages).toBe(1) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 95183720..4ead6c84 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,11 +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 { Button } from '@/shared/ui/button' +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, @@ -17,6 +22,7 @@ import { import { useReviewList } from '@/features/review/use-review-list' import { useAuth } from '@/features/auth/use-auth' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' import { formatLocalDateTime } from '@/shared/lib/date-time' import { ProfileReviewTable } from './profile-review-table' @@ -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) @@ -72,31 +94,11 @@ export function ReviewsPage() { } function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { - if (totalPages <= 1) return null const currentPage = pages[status] return (

{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}

-
- - -
+ changePage(status, nextPage)} />
) } @@ -231,6 +233,17 @@ export function ReviewsPage() { ) } + if (!hasGlobalReviewAccess) { + return ( +
+ + + Loading... + +
+ ) + } + return (
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index b54bd808..6acf8f1f 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -204,6 +204,11 @@ export function LoginPage() { +

+ + {t('login.forgotPassword')} + +

{t('login.noAccount')} {' '} diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index 93d67803..a3a0aa6a 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { ApiError } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' @@ -8,6 +9,44 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,64}$/ +const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + +type RegisterFieldErrors = { + username?: string + email?: string + password?: string +} + +function countPasswordCharacterTypes(password: string) { + let typeCount = 0 + if (/[a-z]/.test(password)) { + typeCount += 1 + } + if (/[A-Z]/.test(password)) { + typeCount += 1 + } + if (/\d/.test(password)) { + typeCount += 1 + } + if (/[^A-Za-z0-9]/.test(password)) { + typeCount += 1 + } + return typeCount +} + +function isDuplicateUsernameError(errorKey: string) { + return errorKey === 'error.auth.local.username.exists' + || errorKey.includes('Username already exists') + || errorKey.includes('用户名已存在') +} + +function isDuplicateEmailError(errorKey: string) { + return errorKey === 'error.auth.local.email.exists' + || errorKey.includes('Email already exists') + || errorKey.includes('邮箱已存在') +} + /** * Registration page for local accounts with an alternate OAuth-based entry path. */ @@ -19,16 +58,111 @@ export function RegisterPage() { const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + const [formError, setFormError] = useState(null) const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + function validateUsername(value: string) { + const trimmed = value.trim() + if (!trimmed) { + return t('register.usernameRequired') + } + if (!USERNAME_PATTERN.test(trimmed)) { + return t('register.usernameInvalid') + } + return undefined + } + + function validateEmail(value: string) { + const trimmed = value.trim().toLowerCase() + if (!trimmed) { + return t('register.emailRequired') + } + if (!EMAIL_PATTERN.test(trimmed)) { + return t('register.emailInvalid') + } + return undefined + } + + function validatePassword(value: string) { + if (!value) { + return t('register.passwordRequired') + } + if (value.length < 8) { + return t('register.passwordTooShort') + } + if (countPasswordCharacterTypes(value) < 3) { + return t('register.passwordTooWeak') + } + return undefined + } + + function mapRegisterApiError(error: unknown): { fieldErrors?: RegisterFieldErrors, formError?: string } { + if (!(error instanceof ApiError)) { + return { + formError: error instanceof Error ? error.message : t('apiError.unknown'), + } + } + + const errorKey = error.serverMessageKey ?? error.serverMessage ?? error.message + + switch (errorKey) { + case 'validation.auth.local.username.notBlank': + return { fieldErrors: { username: t('register.usernameRequired') } } + case 'validation.auth.local.password.notBlank': + return { fieldErrors: { password: t('register.passwordRequired') } } + case 'validation.auth.local.email.notBlank': + return { fieldErrors: { email: t('register.emailRequired') } } + case 'validation.auth.local.email.invalid': + return { fieldErrors: { email: t('register.emailInvalid') } } + case 'error.auth.local.username.invalid': + return { fieldErrors: { username: t('register.usernameInvalid') } } + case 'error.auth.local.password.tooShort': + return { fieldErrors: { password: t('register.passwordTooShort') } } + case 'error.auth.local.password.tooWeak': + return { fieldErrors: { password: t('register.passwordTooWeak') } } + case 'error.auth.local.username.exists': + return { fieldErrors: { username: t('register.usernameExists') } } + case 'error.auth.local.email.exists': + return { fieldErrors: { email: t('register.emailExists') } } + default: + if (isDuplicateUsernameError(errorKey)) { + return { fieldErrors: { username: t('register.usernameExists') } } + } + if (isDuplicateEmailError(errorKey)) { + return { fieldErrors: { email: t('register.emailExists') } } + } + return { formError: error.serverMessage || error.message || t('apiError.unknown') } + } + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault() + const trimmedUsername = username.trim() + const trimmedEmail = email.trim().toLowerCase() + const nextFieldErrors: RegisterFieldErrors = {} + + nextFieldErrors.username = validateUsername(username) + nextFieldErrors.email = validateEmail(email) + nextFieldErrors.password = validatePassword(password) + + if (nextFieldErrors.username || nextFieldErrors.email || nextFieldErrors.password) { + setFieldErrors(nextFieldErrors) + setFormError(null) + registerMutation.reset() + return + } + + setFieldErrors({}) + setFormError(null) try { - await registerMutation.mutateAsync({ username, email, password }) + await registerMutation.mutateAsync({ username: trimmedUsername, email: trimmedEmail, password }) await navigate({ to: returnTo }) - } catch { - // mutation state drives the error UI + } catch (error) { + const { fieldErrors: nextApiFieldErrors, formError: nextFormError } = mapRegisterApiError(error) + setFieldErrors(nextApiFieldErrors ?? {}) + setFormError(nextFormError ?? null) } } @@ -54,9 +188,21 @@ export function RegisterPage() { id="register-username" autoComplete="username" value={username} - onChange={(event) => setUsername(event.target.value)} + onChange={(event) => { + setUsername(event.target.value) + if (fieldErrors.username || formError) { + setFieldErrors((current) => ({ ...current, username: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.usernamePlaceholder')} + aria-invalid={fieldErrors.username ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, username: validateUsername(username) })) + }} /> + {fieldErrors.username ?

{fieldErrors.username}

: null}
@@ -65,9 +211,22 @@ export function RegisterPage() { type="email" autoComplete="email" value={email} - onChange={(event) => setEmail(event.target.value)} + onChange={(event) => { + setEmail(event.target.value) + if (fieldErrors.email || formError) { + setFieldErrors((current) => ({ ...current, email: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.emailPlaceholder')} + required + aria-invalid={fieldErrors.email ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) + }} /> + {fieldErrors.email ?

{fieldErrors.email}

: null}
@@ -76,13 +235,23 @@ export function RegisterPage() { type="password" autoComplete="new-password" value={password} - onChange={(event) => setPassword(event.target.value)} + onChange={(event) => { + setPassword(event.target.value) + if (fieldErrors.password || formError) { + setFieldErrors((current) => ({ ...current, password: undefined })) + setFormError(null) + registerMutation.reset() + } + }} placeholder={t('register.passwordPlaceholder')} + aria-invalid={fieldErrors.password ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, password: validatePassword(password) })) + }} /> + {fieldErrors.password ?

{fieldErrors.password}

: null}
- {registerMutation.error ? ( -

{registerMutation.error.message}

- ) : null} + {formError ?

{formError}

: null} diff --git a/web/src/pages/reset-password.test.tsx b/web/src/pages/reset-password.test.tsx new file mode 100644 index 00000000..1a919aa3 --- /dev/null +++ b/web/src/pages/reset-password.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children }: { children: unknown }) => children, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/api/client', () => ({ + authApi: { + requestPasswordReset: vi.fn(), + confirmPasswordReset: vi.fn(), + }, +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children }: { children: unknown }) => children, +})) + +vi.mock('@/shared/ui/card', () => ({ + Card: ({ children }: { children: unknown }) => children, + CardContent: ({ children }: { children: unknown }) => children, + CardDescription: ({ children }: { children: unknown }) => children, + CardHeader: ({ children }: { children: unknown }) => children, + CardTitle: ({ children }: { children: unknown }) => children, +})) + +vi.mock('@/shared/ui/input', () => ({ + Input: () => null, +})) + +import { renderToStaticMarkup } from 'react-dom/server' +import { ResetPasswordPage } from './reset-password' + +describe('ResetPasswordPage', () => { + it('exports a named component function', () => { + expect(typeof ResetPasswordPage).toBe('function') + }) + + it('renders reset-password title and submit action', () => { + const html = renderToStaticMarkup() + expect(html).toContain('resetPassword.title') + expect(html).toContain('resetPassword.sendCode') + expect(html).toContain('resetPassword.submit') + }) +}) diff --git a/web/src/pages/reset-password.tsx b/web/src/pages/reset-password.tsx new file mode 100644 index 00000000..ef4777de --- /dev/null +++ b/web/src/pages/reset-password.tsx @@ -0,0 +1,187 @@ +import { Link } from '@tanstack/react-router' +import { FormEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { authApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +/** + * Public page for verifying a reset code and setting a new password. + */ +export function ResetPasswordPage() { + const { t } = useTranslation() + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSendingCode, setIsSendingCode] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const [codeSentMessage, setCodeSentMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const emailPattern = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + + async function handleSendCode() { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) { + setErrorMessage(t('resetPassword.emailRequired')) + return + } + if (!emailPattern.test(normalizedEmail)) { + setErrorMessage(t('resetPassword.emailInvalid')) + return + } + + setIsSendingCode(true) + setErrorMessage(null) + setCodeSentMessage(null) + try { + await authApi.requestPasswordReset({ email: normalizedEmail }) + setCodeSentMessage(t('resetPassword.codeSentMessage')) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError')) + } finally { + setIsSendingCode(false) + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) { + setErrorMessage(t('resetPassword.emailRequired')) + return + } + if (!emailPattern.test(normalizedEmail)) { + setErrorMessage(t('resetPassword.emailInvalid')) + return + } + if (!code.trim()) { + setErrorMessage(t('resetPassword.codeRequired')) + return + } + if (!newPassword) { + setErrorMessage(t('resetPassword.newPasswordRequired')) + return + } + if (newPassword !== confirmPassword) { + setErrorMessage(t('resetPassword.passwordMismatch')) + return + } + + setIsSubmitting(true) + setErrorMessage(null) + try { + await authApi.confirmPasswordReset({ + email: normalizedEmail, + code: code.trim(), + newPassword, + }) + setIsSuccess(true) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError')) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ + + {t('resetPassword.title')} + {t('resetPassword.subtitle')} + + + {isSuccess ? ( +
+

+ {t('resetPassword.successMessage')} +

+ + {t('resetPassword.backToLogin')} + +
+ ) : ( +
+
+ +
+ setEmail(event.target.value)} + placeholder={t('resetPassword.emailPlaceholder')} + autoComplete="email" + required + /> + +
+
+
+ + setCode(event.target.value)} + placeholder={t('resetPassword.codePlaceholder')} + autoComplete="one-time-code" + /> +
+
+ + setNewPassword(event.target.value)} + placeholder={t('resetPassword.newPasswordPlaceholder')} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(event.target.value)} + placeholder={t('resetPassword.confirmPasswordPlaceholder')} + autoComplete="new-password" + /> +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} + {codeSentMessage ? ( +

{codeSentMessage}

+ ) : null} + +
+ )} +
+
+
+ ) +} 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 58137415..58c421db 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useState } from 'react' +import { startTransition, useEffect, useRef, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Loader2 } from 'lucide-react' @@ -18,6 +18,37 @@ import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 +function blurActiveElement() { + if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { + return + } + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } +} + +function scrollToTopOnPageChange() { + if (typeof window === 'undefined') { + return () => {} + } + + let secondFrame = 0 + const firstFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + secondFrame = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }) + }) + }) + + return () => { + window.cancelAnimationFrame(firstFrame) + if (secondFrame) { + window.cancelAnimationFrame(secondFrame) + } + } +} + /** * Skill discovery page with synchronized URL state. * @@ -60,11 +91,26 @@ export function SearchPage() { const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(q) + const previousPageRef = useRef(page) useEffect(() => { setQueryInput(q) }, [q]) + useEffect(() => { + if (previousPageRef.current !== page) { + blurActiveElement() + const cleanupScroll = scrollToTopOnPageChange() + + previousPageRef.current = page + return () => { + cleanupScroll() + } + } + + previousPageRef.current = page + }, [page]) + const { data, isLoading, isFetching } = useSearchSkills({ q, label: selectedLabel || undefined, @@ -79,7 +125,6 @@ export function SearchPage() { isLoading: isLoadingStarred, isFetching: isFetchingStarred, } = useMyStars(starredOnly && isAuthenticated) - useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without // triggering a navigation on every keystroke. @@ -117,6 +162,7 @@ export function SearchPage() { } const handlePageChange = (newPage: number) => { + blurActiveElement() navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) } @@ -267,7 +313,7 @@ export function SearchPage() { description={ starredOnly ? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills')) - : (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword')) + : (q ? t('search.noResultsFor', { q }) : undefined) } /> )} diff --git a/web/src/pages/settings/profile.test.ts b/web/src/pages/settings/profile.test.ts index ceb412d4..9e6e56a8 100644 --- a/web/src/pages/settings/profile.test.ts +++ b/web/src/pages/settings/profile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import React from 'react' vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') @@ -10,6 +11,10 @@ vi.mock('react-i18next', async () => { } }) +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: null }), useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), @@ -53,10 +58,16 @@ vi.mock('@/shared/ui/input', () => ({ Input: () => null, })) +import { renderToStaticMarkup } from 'react-dom/server' import { ProfileSettingsPage } from './profile' describe('ProfileSettingsPage', () => { it('exports a named component function', () => { expect(typeof ProfileSettingsPage).toBe('function') }) + + it('renders reset-password entry action', () => { + const html = renderToStaticMarkup(React.createElement(ProfileSettingsPage)) + expect(html).toContain('profile.resetPassword') + }) }) diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx index 8fb07f7f..dfb4a09c 100644 --- a/web/src/pages/settings/profile.tsx +++ b/web/src/pages/settings/profile.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, profileApi } from '@/api/client' @@ -38,6 +39,7 @@ function getFieldValue( export function ProfileSettingsPage() { const { t } = useTranslation() const { user } = useAuth() + const navigate = useNavigate() const queryClient = useQueryClient() const [isEditing, setIsEditing] = useState(false) @@ -180,10 +182,17 @@ export function ProfileSettingsPage() { {t('profile.title')} {t('profile.subtitle')} - {!isEditing && hasEditableFields ? ( - + {!isEditing ? ( +
+ + {hasEditableFields ? ( + + ) : null} +
) : null} diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index deed65d3..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', () => ({ @@ -112,6 +116,8 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useRereleaseSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), useUnarchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useWithdrawSkillReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSubmitForReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useConfirmPublish: () => ({ mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/shared/hooks/use-label-queries', () => ({ @@ -163,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, @@ -206,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 990396b4..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' @@ -53,6 +54,8 @@ import { useRereleaseSkillVersion, useUnarchiveSkill, useWithdrawSkillReview, + useSubmitForReview, + useConfirmPublish, } from '@/shared/hooks/use-skill-queries' import { useSubmitPromotion } from '@/shared/hooks/use-user-queries' @@ -114,7 +117,11 @@ 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) const [diffCompareVersion, setDiffCompareVersion] = useState(null) const [isOverviewExpanded, setIsOverviewExpanded] = useState(false) const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false) @@ -260,6 +267,8 @@ export function SkillDetailPage() { const rereleaseVersionMutation = useRereleaseSkillVersion() const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) + const submitForReviewMutation = useSubmitForReview() + const confirmPublishMutation = useConfirmPublish() const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') @@ -380,6 +389,7 @@ export function SkillDetailPage() { DRAFT: t('skillDetail.versionStatusDraft'), SCANNING: t('skillDetail.versionStatusScanning'), SCAN_FAILED: t('skillDetail.versionStatusScanFailed'), + UPLOADED: t('skillDetail.versionStatusUploaded'), PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'), PUBLISHED: t('skillDetail.versionStatusPublished'), REJECTED: t('skillDetail.versionStatusRejected'), @@ -388,7 +398,7 @@ export function SkillDetailPage() { return status ? (map[status] ?? status) : '' } - const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' + const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED' const isLastVersion = versions?.length === 1 const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' @@ -529,12 +539,46 @@ export function SkillDetailPage() { } } + const handleConfirmPublish = async () => { + if (!confirmPublishTarget) { + return + } + try { + await confirmPublishMutation.mutateAsync({ namespace, slug, version: confirmPublishTarget }) + toast.success( + t('skillDetail.confirmPublishSuccessTitle'), + t('skillDetail.confirmPublishSuccessDescription', { version: confirmPublishTarget }), + ) + setConfirmPublishTarget(null) + } catch (error) { + toast.error(t('skillDetail.confirmPublishErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + + const handleSubmitForReview = async () => { + if (!submitReviewTarget) { + return + } + try { + await submitForReviewMutation.mutateAsync({ namespace, slug, version: submitReviewTarget, targetVisibility: 'PUBLIC' }) + toast.success( + t('skillDetail.submitReviewSuccessTitle'), + t('skillDetail.submitReviewSuccessDescription', { version: submitReviewTarget }), + ) + setSubmitReviewTarget(null) + } catch (error) { + toast.error(t('skillDetail.submitReviewErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + const handleOpenRerelease = (version: string) => { setRereleaseTarget(version) setTargetVersionInput(suggestNextVersion(version)) } - const handleRereleaseVersion = async () => { + const handleRereleaseVersion = async (confirmWarnings = false) => { if (!rereleaseTarget || !targetVersionInput.trim()) { return } @@ -544,6 +588,7 @@ export function SkillDetailPage() { slug, version: rereleaseTarget, targetVersion: targetVersionInput.trim(), + confirmWarnings, }) toast.success( t('skillDetail.rereleaseSuccessTitle'), @@ -551,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 } @@ -669,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')} @@ -884,6 +958,24 @@ export function SkillDetailPage() { {t('skillDetail.withdrawReview')} )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )}
{version.changelog && ( @@ -898,7 +990,7 @@ export function SkillDetailPage() { ))} ) : ( -
{t('skillDetail.noVersions')}
+ {t('skillDetail.noVersions')} )} @@ -1337,6 +1429,8 @@ export function SkillDetailPage() { if (!open) { setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } }} > @@ -1363,13 +1457,60 @@ export function SkillDetailPage() { - + +

{t('skillDetail.rereleaseWarningDescription')}

+
    + {rereleaseWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ + } + confirmText={t('skillDetail.rereleaseWarningConfirm')} + onConfirm={() => { + setRereleaseWarningDialogOpen(false) + handleRereleaseVersion(true) + }} + /> + + { + if (!open) { + setConfirmPublishTarget(null) + } + }} + title={t('skillDetail.confirmPublishDialogTitle')} + description={confirmPublishTarget ? t('skillDetail.confirmPublishDialogDescription', { version: confirmPublishTarget }) : ''} + confirmText={t('skillDetail.confirmPublish')} + onConfirm={handleConfirmPublish} + /> + + { + if (!open) { + setSubmitReviewTarget(null) + } + }} + title={t('skillDetail.submitReviewDialogTitle')} + description={submitReviewTarget ? t('skillDetail.submitReviewDialogDescription', { version: submitReviewTarget }) : ''} + confirmText={t('skillDetail.submitReview')} + onConfirm={handleSubmitForReview} + /> + { 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 925333dd..cf19d18c 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -37,11 +37,12 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`) } -async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise { +async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise { const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace const formData = new FormData() formData.append('file', params.file) formData.append('visibility', params.visibility) + formData.append('confirmWarnings', String(params.confirmWarnings === true)) return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, { method: 'POST', @@ -205,8 +206,49 @@ 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] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} + +/** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ +export function useSubmitForReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version, targetVisibility }: { namespace: string; slug: string; version: string; targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY' }) => + skillLifecycleApi.submitForReview(namespace, slug, version, targetVisibility), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} + +/** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ +export function useConfirmPublish() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) => + skillLifecycleApi.confirmPublish(namespace, slug, version), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] })