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/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/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/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/README.md b/README.md index cf803028..9a8f6c39 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iflytek/skillhub) [![Docs](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![Build](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) [![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) @@ -95,7 +96,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan **For users in China (Aliyun mirror):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` If deployment runs into problems, clear the existing runtime home and retry. @@ -195,7 +196,7 @@ Published images target both `linux/amd64` and `linux/arm64`. curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # Aliyun mirror (recommended for users in China) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +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:** @@ -222,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 @@ -396,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)** @@ -434,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 edb58fc8..72c7eb73 100644 --- a/README_zh.md +++ b/README_zh.md @@ -7,6 +7,7 @@
[![文档](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS) [![许可证](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![构建](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) [![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) @@ -67,7 +68,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u **国内用户(阿里云镜像):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` 如果部署遇到问题,请清除现有的运行时目录并重试。 @@ -177,7 +178,7 @@ skillhub/ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # 阿里云镜像(国内推荐) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` ### 配置参数说明 @@ -331,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)** @@ -367,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/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/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/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 051e9566..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 @@ -28,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; /** @@ -37,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; @@ -94,7 +97,8 @@ public class ClawHubCompatAppService { String hash, String userId, Map userNsRoles) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + Map roles = normalizeRoles(userNsRoles); SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( coord.namespace(), @@ -103,7 +107,7 @@ public class ClawHubCompatAppService { "latest".equals(version) ? "latest" : null, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + roles ); return toResolveResponse(resolved); } @@ -132,23 +136,37 @@ public class ClawHubCompatAppService { : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - public String downloadLocationByQuery(String slug, String version) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + public String downloadLocationByQuery(String slug, + String version, + String userId, + Map userNsRoles) { + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); return "latest".equals(version) ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; } - private SkillCoordinate resolveQueryCoordinate(String slug) { + private SkillCoordinate resolveQueryCoordinate(String slug, + String userId, + Map userNsRoles) { if (slug != null && slug.contains("--")) { return mapper.fromCanonical(slug); } + CompatSkillLookupService.CompatSkillContext context; try { - CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug); - return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug()); + context = compatSkillLookupService.findByLegacySlug(slug); } catch (DomainNotFoundException ex) { return mapper.fromCanonical(slug); } + Map roles = normalizeRoles(userNsRoles); + if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug()); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); } public ClawHubSkillListResponse listSkills(int page, @@ -182,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); @@ -263,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() + "\"}"); @@ -282,6 +309,7 @@ public class ClawHubCompatAppService { public ClawHubPublishResponse publish(MultipartFile file, String namespace, + boolean confirmWarnings, PlatformPrincipal principal, String clientIp, String userAgent) throws IOException { @@ -290,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 + "\"}"); @@ -367,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 a47c1e29..0cc32ec4 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -80,6 +80,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: @@ -89,6 +100,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: @@ -187,10 +202,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 @@ -199,4 +217,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 421fa1d8..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,32 +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 @@ -49,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)) @@ -124,6 +151,9 @@ class ClawHubCompatControllerTest { @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")); @@ -135,6 +165,7 @@ class ClawHubCompatControllerTest { .andExpect(jsonPath("$.match.version").value("latest")) .andExpect(jsonPath("$.latestVersion.version").value("latest")); + verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap()); verify(skillQueryService).resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()); } @@ -149,11 +180,16 @@ class ClawHubCompatControllerTest { @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 @@ -192,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 9d378e25..df0b1867 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 @@ -230,7 +230,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 498d2010..8235032c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -71,10 +71,10 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"), - RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"), RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**") ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index b566b922..6b9f4344 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 @@ -238,4 +238,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 f3259729..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. } @@ -188,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 @@ -205,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 { @@ -239,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)}`) }) @@ -272,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() }) @@ -282,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/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.ts b/web/src/api/client.ts index 928036a0..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, @@ -354,6 +356,26 @@ export const authApi = { }) }, + async requestPasswordReset(request: PasswordResetRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/request', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async logout(): Promise { const response = await fetch('/api/v1/auth/logout', { method: 'POST', @@ -466,14 +488,44 @@ export const skillLifecycleApi = { }) }, - async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ targetVersion }), + body: JSON.stringify({ targetVersion, confirmWarnings }), + }) + }, + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ + async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version, targetVisibility }), + }) + }, + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ + async confirmPublish(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version }), }) }, } @@ -1063,6 +1115,13 @@ export const adminApi = { }) }, + async triggerPasswordReset(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/password-reset`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + async getAuditLogs(params: { action?: string userId?: string diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 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/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/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 e0faae42..c96495f9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -209,6 +209,7 @@ "hidePassword": "Hide password", "submitting": "Logging in...", "submit": "Login", + "forgotPassword": "Forgot password?", "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.", @@ -232,13 +233,47 @@ "email": "Email", "password": "Password", "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", - "emailPlaceholder": "Optional, for account identification", + "emailPlaceholder": "Enter your email", + "emailRequired": "Email is required", "passwordPlaceholder": "At least 8 characters with 3 character types", "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", @@ -521,6 +556,7 @@ "approveUser": "Approve", "disable": "Disable", "enable": "Enable", + "resetPassword": "Reset Password", "totalRecords": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -535,7 +571,8 @@ "roleSuperAdmin": "Super Admin", "confirmAction": "Confirm Action", "confirmDisable": "Are you sure you want to disable user {{username}}?", - "confirmEnable": "Are you sure you want to enable user {{username}}?" + "confirmEnable": "Are you sure you want to enable user {{username}}?", + "confirmResetPassword": "Send password reset verification code to user {{username}}?" }, "adminLabels": { "title": "Label Management", @@ -642,6 +679,7 @@ "subtitle": "Manage your display name and personal information.", "displayName": "Display Name", "email": "Email", + "resetPassword": "Reset Password", "edit": "Edit", "save": "Save", "saving": "Saving...", @@ -769,6 +807,7 @@ "versionStatusDraft": "Draft", "versionStatusScanning": "Scanning", "versionStatusScanFailed": "Scan Failed", + "versionStatusUploaded": "Uploaded", "versionStatusPendingReview": "Pending Review", "versionStatusPublished": "Published", "versionStatusRejected": "Rejected", @@ -817,6 +856,18 @@ "withdrawReviewSuccessTitle": "Review withdrawn", "withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.", "withdrawReviewErrorTitle": "Failed to withdraw review", + "confirmPublish": "Confirm Publish", + "confirmPublishDialogTitle": "Confirm publish", + "confirmPublishDialogDescription": "Publish version {{version}} as a private skill? It will be available for you to download and install, but not visible on the marketplace.", + "confirmPublishSuccessTitle": "Version published", + "confirmPublishSuccessDescription": "Version {{version}} has been published as a private skill.", + "confirmPublishErrorTitle": "Failed to confirm publish", + "submitReview": "Submit for Review", + "submitReviewDialogTitle": "Submit for review", + "submitReviewDialogDescription": "Submit version {{version}} for public review? Once approved, it will be visible on the marketplace.", + "submitReviewSuccessTitle": "Submitted for review", + "submitReviewSuccessDescription": "Version {{version}} has been submitted for review.", + "submitReviewErrorTitle": "Failed to submit for review", "deleteVersion": "Delete Version", "deleteVersionConfirmTitle": "Delete version", "deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?", @@ -860,6 +911,9 @@ "rereleaseSuccessTitle": "Version re-released", "rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.", "rereleaseErrorTitle": "Failed to re-release version", + "rereleaseWarningTitle": "Pre-publish warning", + "rereleaseWarningDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue re-releasing.", + "rereleaseWarningConfirm": "Continue re-releasing", "yankVersion": "Yank Current Version", "promoteToGlobal": "Promote to Global", "promotionSectionTitle": "Promote to Global", @@ -983,6 +1037,8 @@ "savingRole": "Saving...", "changeRole": "Change role", "colUserId": "User ID", + "colUsername": "Username", + "colEmail": "Email", "colRole": "Role", "colJoinedAt": "Joined At", "colActions": "Actions", @@ -1025,6 +1081,7 @@ "sortLabel": "Time Order", "sortNewest": "Newest first", "sortOldest": "Oldest first", + "openReview": "Open review", "pageSummary": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -1171,6 +1228,10 @@ "versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.", "precheckFailedTitle": "Pre-publish check failed", "precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.", + "warningConfirmTitle": "Pre-publish warning", + "warningConfirmDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue publishing.", + "warningConfirmContinue": "Continue publishing", + "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", "selectRequired": "Please select namespace and file" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 39be514b..12219285 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -209,6 +209,7 @@ "hidePassword": "隐藏密码", "submitting": "登录中...", "submit": "登录", + "forgotPassword": "忘记密码?", "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。", @@ -232,13 +233,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": "设备授权", @@ -521,6 +556,7 @@ "approveUser": "审批通过", "disable": "禁用", "enable": "启用", + "resetPassword": "重置密码", "totalRecords": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -535,7 +571,8 @@ "roleSuperAdmin": "超级管理员", "confirmAction": "确认操作", "confirmDisable": "确定要禁用用户 {{username}} 吗?", - "confirmEnable": "确定要启用用户 {{username}} 吗?" + "confirmEnable": "确定要启用用户 {{username}} 吗?", + "confirmResetPassword": "确定给用户 {{username}} 发送密码重置验证码吗?" }, "adminLabels": { "title": "标签管理", @@ -642,6 +679,7 @@ "subtitle": "管理你的昵称和个人信息。", "displayName": "昵称", "email": "邮箱", + "resetPassword": "重置密码", "edit": "编辑", "save": "保存", "saving": "保存中...", @@ -769,6 +807,7 @@ "versionStatusDraft": "草稿", "versionStatusScanning": "安全扫描中", "versionStatusScanFailed": "扫描失败", + "versionStatusUploaded": "已上传", "versionStatusPendingReview": "审核中", "versionStatusPublished": "已发布", "versionStatusRejected": "已拒绝", @@ -817,6 +856,19 @@ "withdrawReviewSuccessTitle": "已撤销审核", "withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。", "withdrawReviewErrorTitle": "撤销审核失败", + "confirmPublish": "确认发布", + "confirmPublishDialogTitle": "确认发布", + "confirmPublishDialogDescription": "将版本 {{version}} 发布为私有技能?发布后您可以下载和安装,但不会在市场展示。", + "confirmPublishSuccessTitle": "版本已发布", + "confirmPublishSuccessDescription": "版本 {{version}} 已发布为私有技能。", + "confirmPublishErrorTitle": "确认发布失败", + "submitReview": "提交审核", + "submitReviewDialogTitle": "提交审核", + "submitReviewDialogDescription": "将版本 {{version}} 提交公开审核?审核通过后将在市场展示。", + "submitReviewSuccessTitle": "已提交审核", + "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。", + "submitReviewErrorTitle": "提交审核失败", + "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", @@ -860,6 +912,9 @@ "rereleaseSuccessTitle": "版本已重新发布", "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", "rereleaseErrorTitle": "重新发布版本失败", + "rereleaseWarningTitle": "发布前风险提醒", + "rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。", + "rereleaseWarningConfirm": "继续重新发布", "yankVersion": "撤回当前版本", "promoteToGlobal": "申请提升到全局", "promotionSectionTitle": "提升到全局", @@ -983,6 +1038,8 @@ "savingRole": "保存中...", "changeRole": "修改角色", "colUserId": "用户 ID", + "colUsername": "用户名", + "colEmail": "邮箱", "colRole": "角色", "colJoinedAt": "加入时间", "colActions": "操作", @@ -1025,6 +1082,7 @@ "sortLabel": "时间排序", "sortNewest": "最新优先", "sortOldest": "最早优先", + "openReview": "进入审核详情", "pageSummary": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -1171,6 +1229,10 @@ "versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。", "precheckFailedTitle": "发布前校验未通过", "precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。", + "warningConfirmTitle": "发布前风险提醒", + "warningConfirmDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续发布。", + "warningConfirmContinue": "继续发布", + "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", "selectRequired": "请选择命名空间和文件" diff --git a/web/src/pages/admin/users.test.tsx b/web/src/pages/admin/users.test.tsx index 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 8e031088..735307ca 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: { children: unknown; to: string }) => createElement('a', { href: to }, children as string), useParams: () => ({ slug: 'test-ns' }), })) @@ -127,6 +128,8 @@ describe('NamespaceReviewsPage', () => { const html = renderToStaticMarkup(createElement(NamespaceReviewsPage)) expect(html).toContain('nsReviews.pageSummary') + expect(html).toContain('/dashboard/namespaces/test-ns/reviews/1') + expect(html).toContain('nsReviews.openReview') expect(paginationProps).toHaveLength(1) expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(2) @@ -154,4 +157,18 @@ describe('NamespaceReviewsPage', () => { expect(paginationProps).toHaveLength(0) }) + + it('does not enable review queries before namespace detail resolves', () => { + useNamespaceDetailMock.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + renderToStaticMarkup(createElement(NamespaceReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + for (const call of useReviewListMock.mock.calls) { + expect(call[5]).toBe(false) + } + }) }) diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 009d5f4f..fb995e8a 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { useParams } from '@tanstack/react-router' +import { Link, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths' import { formatLocalDateTime } from '@/shared/lib/date-time' import { Card } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' @@ -15,8 +16,9 @@ type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' type TimeSortDirection = 'ASC' | 'DESC' const PAGE_SIZE = 10 -function ReviewListSection({ namespaceId }: { namespaceId?: number }) { +function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug: string }) { const { t, i18n } = useTranslation() + const reviewsEnabled = typeof namespaceId === 'number' && namespaceId > 0 const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -24,9 +26,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { }) const [activeStatus, setActiveStatus] = useState('PENDING') const [sortDirection, setSortDirection] = useState('DESC') - const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING') - const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED') - const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED') + const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'PENDING') + const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'APPROVED') + const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'REJECTED') const changePage = (status: ReviewStatus, nextPage: number) => { setPages((current) => ({ ...current, [status]: nextPage })) @@ -90,6 +92,14 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { {review.reviewComment ? (

{review.reviewComment}

) : null} +
+ + {t('nsReviews.openReview')} + +
))} {query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null} @@ -151,7 +161,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} - + ) } diff --git a/web/src/pages/dashboard/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 992c102b..694a0dce 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -67,8 +67,14 @@ vi.mock('@/features/review/use-review-list', () => ({ })) const hasRoleMock = vi.fn() +const userMock = { platformRoles: ['SKILL_ADMIN'] } vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: hasRoleMock }), + useAuth: () => ({ hasRole: hasRoleMock, user: userMock }), +})) + +const useMyNamespacesMock = vi.fn() +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => useMyNamespacesMock(), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -106,7 +112,13 @@ describe('ReviewsPage', () => { paginationProps.length = 0 hasRoleMock.mockReset() useReviewListMock.mockReset() + useMyNamespacesMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN') + userMock.platformRoles = ['SKILL_ADMIN'] + useMyNamespacesMock.mockReturnValue({ + data: [], + isLoading: false, + }) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 426da9b8..4ead6c84 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,10 +1,16 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, + getPreferredNamespaceReviewEntry, +} from '@/features/review/review-paths' import { Table, TableBody, @@ -32,7 +38,8 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() - const { hasRole } = useAuth() + const { hasRole, user } = useAuth() + const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -43,14 +50,29 @@ export function ReviewsPage() { const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') + const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) + const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces) const showTypeTabs = isSkillAdmin && isUserAdmin // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED') + useEffect(() => { + if (hasGlobalReviewAccess || isLoadingNamespaces) { + return + } + + if (namespaceReviewEntry) { + void navigate({ to: buildNamespaceReviewsPath(namespaceReviewEntry.slug), replace: true }) + return + } + + void navigate({ to: '/dashboard', replace: true }) + }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) + + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -211,6 +233,17 @@ export function ReviewsPage() { ) } + if (!hasGlobalReviewAccess) { + return ( +
+ + + Loading... + +
+ ) + } + return (
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index a23d0e3e..1aab99c4 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -160,6 +160,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] })