mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-15 23:31:10 +00:00
Merge remote-tracking branch 'upstream/main' into feat/skillhub-cli-v2
This commit is contained in:
commit
b332d0ba97
95 changed files with 3178 additions and 263 deletions
|
|
@ -56,6 +56,13 @@ DEVICE_AUTH_VERIFICATION_URI=
|
|||
OAUTH2_GITHUB_CLIENT_ID=
|
||||
OAUTH2_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Optional: configure real GitLab OAuth before exposing the stack to other users.
|
||||
# Set OAUTH2_GITLAB_BASE_URI to your self-hosted GitLab URL when applicable.
|
||||
OAUTH2_GITLAB_CLIENT_ID=
|
||||
OAUTH2_GITLAB_CLIENT_SECRET=
|
||||
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
|
||||
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
|
||||
|
||||
# SMTP configuration for password reset verification emails.
|
||||
SPRING_MAIL_HOST=
|
||||
SPRING_MAIL_PORT=587
|
||||
|
|
|
|||
42
.github/release-template.md
vendored
Normal file
42
.github/release-template.md
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# SkillHub {{version}}
|
||||
|
||||
{{One-line summary of the key changes in this release}}
|
||||
|
||||
## 🌟 Highlights
|
||||
|
||||
- {{Highlight 1}}
|
||||
- {{Highlight 2}}
|
||||
- {{Highlight 3}}
|
||||
|
||||
## 🚨 Breaking Changes
|
||||
|
||||
⚠️ {{If any, describe impact and migration guide}}
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- {{Feature description}} by @author in #PR
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- {{Fix description}} by @author in #PR
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- {{Performance improvement}} by @author in #PR
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- {{Documentation changes}} by @author in #PR
|
||||
|
||||
## 🔧 Chore
|
||||
|
||||
- {{Maintenance work}} by @author in #PR
|
||||
|
||||
## 📖 Documentation
|
||||
- Docs site: https://iflytek.github.io/skillhub/
|
||||
|
||||
## 👥 New Contributors
|
||||
{{Keep as-is}}
|
||||
|
||||
**Full Changelog**: https://github.com/iflytek/skillhub/compare/{{prev_tag}}...{{tag}}
|
||||
|
||||
41
.github/scripts/github.ts
vendored
41
.github/scripts/github.ts
vendored
|
|
@ -138,6 +138,47 @@ export class GitHubClient {
|
|||
);
|
||||
}
|
||||
|
||||
async listCommitPulls(sha: string): Promise<Array<{ number: number; title: string }>> {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/repos/${this.owner}/${this.repo}/commits/${sha}/pulls`,
|
||||
);
|
||||
}
|
||||
|
||||
async createDraftRelease(
|
||||
tag: string,
|
||||
name: string,
|
||||
body: string,
|
||||
): Promise<{ id: number; upload_url: string }> {
|
||||
return this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, {
|
||||
tag_name: tag,
|
||||
name,
|
||||
body,
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadReleaseAsset(
|
||||
releaseId: number,
|
||||
filename: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const uploadUrl = `https://uploads.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`;
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.headers(),
|
||||
"Content-Type": "text/markdown",
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await GitHubApiError.fromResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
private async paginate<T>(path: string): Promise<T[]> {
|
||||
const collected: T[] = [];
|
||||
let nextPath: string | null = path;
|
||||
|
|
|
|||
80
.github/scripts/issue-llm-provider.ts
vendored
80
.github/scripts/issue-llm-provider.ts
vendored
|
|
@ -37,6 +37,35 @@ export async function requestOpenAiCompatibleJson(
|
|||
throw lastError ?? new Error("LLM request failed for an unknown reason.");
|
||||
}
|
||||
|
||||
export async function requestOpenAiCompatibleMarkdown(
|
||||
config: IssueLlmConfig,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
): Promise<string> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await requestOnceMarkdown(config, systemPrompt, userPrompt);
|
||||
} catch (error) {
|
||||
const normalized = normalizeRequestError(
|
||||
error,
|
||||
attempt,
|
||||
config.maxAttempts,
|
||||
);
|
||||
lastError = normalized;
|
||||
|
||||
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
|
||||
throw normalized;
|
||||
}
|
||||
|
||||
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("LLM request failed for an unknown reason.");
|
||||
}
|
||||
|
||||
async function requestOnce(
|
||||
config: IssueLlmConfig,
|
||||
systemPrompt: string,
|
||||
|
|
@ -88,6 +117,57 @@ async function requestOnce(
|
|||
}
|
||||
}
|
||||
|
||||
async function requestOnceMarkdown(
|
||||
config: IssueLlmConfig,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
temperature: config.temperature,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
`LLM request failed with status ${response.status}: ${await response
|
||||
.text()}`;
|
||||
throw new RetryableHttpError(
|
||||
message,
|
||||
response.status,
|
||||
response.headers.get("retry-after"),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as OpenAiCompatibleResponse;
|
||||
const content = payload.choices?.[0]?.message?.content;
|
||||
const text = normalizeMessageContent(content);
|
||||
|
||||
if (!text) {
|
||||
throw new Error("LLM response did not include message content.");
|
||||
}
|
||||
|
||||
return text;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
class RetryableHttpError extends Error {
|
||||
status: number;
|
||||
retryAfterSeconds: number | null;
|
||||
|
|
|
|||
53
.github/scripts/release-notes-config.ts
vendored
Normal file
53
.github/scripts/release-notes-config.ts
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { IssueLlmConfig } from "./issue-llm-types.ts";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 2;
|
||||
const DEFAULT_RETRY_BACKOFF_MS = 1500;
|
||||
const DEFAULT_TEMPERATURE = 0.2;
|
||||
|
||||
export function readReleaseNotesLlmConfig(): IssueLlmConfig | null {
|
||||
const baseUrl = normalizeUrl(
|
||||
Deno.env.get("RELEASE_NOTES_LLM_BASE_URL") ||
|
||||
Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL"),
|
||||
);
|
||||
const apiKey = (
|
||||
Deno.env.get("RELEASE_NOTES_LLM_API_KEY") ||
|
||||
Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY")
|
||||
)?.trim() ?? "";
|
||||
const model = (
|
||||
Deno.env.get("RELEASE_NOTES_LLM_MODEL") ||
|
||||
Deno.env.get("ISSUE_TRIAGE_LLM_MODEL")
|
||||
)?.trim() ?? "";
|
||||
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
console.warn(
|
||||
"LLM config missing, will use fallback mode (conventional commit grouping)",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "assist",
|
||||
provider: "openai-compatible",
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
maxAttempts: DEFAULT_MAX_ATTEMPTS,
|
||||
retryBackoffMs: DEFAULT_RETRY_BACKOFF_MS,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
maxComments: 0,
|
||||
maxCommentChars: 0,
|
||||
maxBodyChars: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string | undefined | null) {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
||||
}
|
||||
271
.github/scripts/release-notes-generator.ts
vendored
Normal file
271
.github/scripts/release-notes-generator.ts
vendored
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { GitHubClient } from "./github.ts";
|
||||
import { IssueLlmConfig } from "./issue-llm-types.ts";
|
||||
import { requestOpenAiCompatibleMarkdown } from "./issue-llm-provider.ts";
|
||||
|
||||
interface RawCommit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: string;
|
||||
prNumber?: number;
|
||||
prTitle?: string;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
interface ChangeEntry {
|
||||
type: string;
|
||||
scope: string;
|
||||
description: string;
|
||||
prNumber?: number;
|
||||
authors: string[];
|
||||
commits: string[];
|
||||
}
|
||||
|
||||
export async function generateReleaseNotes(
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
prevTag: string,
|
||||
llmConfig: IssueLlmConfig | null,
|
||||
dryRun: boolean,
|
||||
): Promise<string> {
|
||||
const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo);
|
||||
|
||||
console.log(`Collecting changes from ${prevTag} to ${tag}...`);
|
||||
const changes = await collectChanges(github, prevTag, tag);
|
||||
console.log(`Found ${changes.length} changes after deduplication`);
|
||||
|
||||
if (llmConfig) {
|
||||
console.log("Generating release notes with LLM...");
|
||||
try {
|
||||
const markdown = await generateMarkdownWithLLM(
|
||||
llmConfig,
|
||||
changes,
|
||||
tag,
|
||||
prevTag,
|
||||
owner,
|
||||
repo,
|
||||
);
|
||||
return markdown;
|
||||
} catch (error) {
|
||||
console.error("LLM generation failed:", error);
|
||||
console.log("Falling back to conventional commit grouping...");
|
||||
return generateFallback(changes, tag, prevTag, owner, repo);
|
||||
}
|
||||
} else {
|
||||
console.log("Using fallback mode (conventional commit grouping)...");
|
||||
return generateFallback(changes, tag, prevTag, owner, repo);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectChanges(
|
||||
github: GitHubClient,
|
||||
prevTag: string,
|
||||
tag: string,
|
||||
): Promise<ChangeEntry[]> {
|
||||
const gitLogCmd = new Deno.Command("git", {
|
||||
args: ["log", `${prevTag}..${tag}`, "--format=%H|%s|%an"],
|
||||
stdout: "piped",
|
||||
});
|
||||
const gitLogOutput = await gitLogCmd.output();
|
||||
const gitLogText = new TextDecoder().decode(gitLogOutput.stdout);
|
||||
|
||||
const rawCommits: RawCommit[] = [];
|
||||
for (const line of gitLogText.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const [sha, message, author] = line.split("|");
|
||||
rawCommits.push({
|
||||
sha,
|
||||
message,
|
||||
author,
|
||||
excluded: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const commit of rawCommits) {
|
||||
if (/^Revert "(.+)"$/.test(commit.message)) {
|
||||
commit.excluded = true;
|
||||
const revertedMsg = commit.message.match(/^Revert "(.+)"$/)?.[1];
|
||||
if (revertedMsg) {
|
||||
const reverted = rawCommits.find((c) => c.message === revertedMsg);
|
||||
if (reverted) reverted.excluded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^Merge (pull request|branch|remote-tracking)/.test(commit.message)) {
|
||||
commit.excluded = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const commit of rawCommits.filter((c) => !c.excluded)) {
|
||||
try {
|
||||
const pulls = await github.listCommitPulls(commit.sha);
|
||||
if (pulls.length > 0) {
|
||||
commit.prNumber = pulls[0].number;
|
||||
commit.prTitle = pulls[0].title;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch PR for commit ${commit.sha}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const prMap = new Map<number, ChangeEntry>();
|
||||
const standaloneCommits: ChangeEntry[] = [];
|
||||
|
||||
for (const commit of rawCommits.filter((c) => !c.excluded)) {
|
||||
const parsed = parseConventionalCommit(commit.message);
|
||||
const description = commit.prTitle || parsed.description;
|
||||
|
||||
if (commit.prNumber) {
|
||||
if (!prMap.has(commit.prNumber)) {
|
||||
prMap.set(commit.prNumber, {
|
||||
type: parsed.type,
|
||||
scope: parsed.scope,
|
||||
description,
|
||||
prNumber: commit.prNumber,
|
||||
authors: [commit.author],
|
||||
commits: [commit.sha],
|
||||
});
|
||||
} else {
|
||||
const entry = prMap.get(commit.prNumber)!;
|
||||
if (!entry.authors.includes(commit.author)) {
|
||||
entry.authors.push(commit.author);
|
||||
}
|
||||
entry.commits.push(commit.sha);
|
||||
}
|
||||
} else {
|
||||
standaloneCommits.push({
|
||||
type: parsed.type,
|
||||
scope: parsed.scope,
|
||||
description,
|
||||
authors: [commit.author],
|
||||
commits: [commit.sha],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...prMap.values(), ...standaloneCommits];
|
||||
}
|
||||
|
||||
function parseConventionalCommit(message: string): {
|
||||
type: string;
|
||||
scope: string;
|
||||
description: string;
|
||||
} {
|
||||
const match = message.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
|
||||
if (match) {
|
||||
return {
|
||||
type: match[1],
|
||||
scope: match[2] || "",
|
||||
description: match[3],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "other",
|
||||
scope: "",
|
||||
description: message,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateMarkdownWithLLM(
|
||||
config: IssueLlmConfig,
|
||||
changes: ChangeEntry[],
|
||||
tag: string,
|
||||
prevTag: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<string> {
|
||||
const template = await Deno.readTextFile(".github/release-template.md");
|
||||
|
||||
const systemPrompt = `You are a senior product manager and technical documentation expert. Rewrite the following technical change list into user-friendly Release Notes.
|
||||
|
||||
Requirements:
|
||||
1. Strictly follow the Markdown structure and heading levels of the template below
|
||||
2. Remove any section entirely (including its heading) if there are no items for it
|
||||
3. Rewrite technical jargon into language that end-users can understand
|
||||
4. For Breaking Changes, add an upgrade / migration guide
|
||||
5. Highlights must contain 2-4 items, distilled from the most important changes
|
||||
6. PR number format: #123
|
||||
7. Contributor format: @username
|
||||
8. Replace {{version}}, {{prev_tag}}, {{tag}} with actual values
|
||||
9. Output ONLY the Markdown content — no extra commentary, no code fences
|
||||
|
||||
Template:
|
||||
---
|
||||
${template}
|
||||
---`;
|
||||
|
||||
const changesList = changes.map((c) => {
|
||||
const pr = c.prNumber ? ` (#${c.prNumber})` : "";
|
||||
const authors = c.authors.map((a) => `@${a}`).join(", ");
|
||||
return `- ${c.type}(${c.scope}): ${c.description}${pr} by ${authors}`;
|
||||
}).join("\n");
|
||||
|
||||
const userPrompt = `Version: ${tag}
|
||||
Previous version: ${prevTag}
|
||||
Repository: ${owner}/${repo}
|
||||
|
||||
Change list:
|
||||
${changesList}`;
|
||||
|
||||
const markdown = await requestOpenAiCompatibleMarkdown(
|
||||
config,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
);
|
||||
|
||||
return markdown
|
||||
.replace(/\{\{version\}\}/g, tag)
|
||||
.replace(/\{\{prev_tag\}\}/g, prevTag)
|
||||
.replace(/\{\{tag\}\}/g, tag);
|
||||
}
|
||||
|
||||
function generateFallback(
|
||||
changes: ChangeEntry[],
|
||||
tag: string,
|
||||
prevTag: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): string {
|
||||
const grouped = new Map<string, ChangeEntry[]>();
|
||||
for (const change of changes) {
|
||||
const type = change.type;
|
||||
if (!grouped.has(type)) {
|
||||
grouped.set(type, []);
|
||||
}
|
||||
grouped.get(type)!.push(change);
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
feat: "## ✨ Features",
|
||||
fix: "## 🐛 Bug Fixes",
|
||||
docs: "## 📚 Documentation",
|
||||
perf: "## ⚡ Performance",
|
||||
refactor: "## 🔧 Improvements",
|
||||
test: "## 🧪 Tests",
|
||||
chore: "## 🔧 Chore",
|
||||
};
|
||||
|
||||
let md = `# SkillHub ${tag}\n\n`;
|
||||
md += `> [Auto-generated - LLM unavailable]\n\n`;
|
||||
|
||||
for (const [type, items] of grouped.entries()) {
|
||||
const label = typeLabels[type] || `## ${type}`;
|
||||
md += `${label}\n\n`;
|
||||
for (const item of items) {
|
||||
const pr = item.prNumber ? ` in #${item.prNumber}` : "";
|
||||
const authors = item.authors.map((a) => `@${a}`).join(", ");
|
||||
md += `- ${item.description}${pr} by ${authors}\n`;
|
||||
}
|
||||
md += "\n";
|
||||
}
|
||||
|
||||
const contributors = [
|
||||
...new Set(changes.flatMap((c) => c.authors)),
|
||||
];
|
||||
md += `## 👥 Contributors\n\n`;
|
||||
md += contributors.map((a) => `@${a}`).join(", ") + "\n\n";
|
||||
|
||||
md += `**Full Changelog**: https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}\n`;
|
||||
|
||||
return md;
|
||||
}
|
||||
79
.github/scripts/release-notes.ts
vendored
Normal file
79
.github/scripts/release-notes.ts
vendored
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { GitHubClient } from "./github.ts";
|
||||
import { readReleaseNotesLlmConfig } from "./release-notes-config.ts";
|
||||
import { generateReleaseNotes } from "./release-notes-generator.ts";
|
||||
|
||||
function readFlag(name: string): string | null {
|
||||
const index = Deno.args.indexOf(name);
|
||||
if (index === -1 || index === Deno.args.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return Deno.args[index + 1];
|
||||
}
|
||||
|
||||
function hasFlag(name: string): boolean {
|
||||
return Deno.args.includes(name);
|
||||
}
|
||||
|
||||
async function detectPrevTag(tag: string): Promise<string> {
|
||||
const cmd = new Deno.Command("git", {
|
||||
args: ["tag", "--sort=-v:refname"],
|
||||
stdout: "piped",
|
||||
});
|
||||
const output = await cmd.output();
|
||||
const tags = new TextDecoder().decode(output.stdout).trim().split("\n");
|
||||
|
||||
const currentIndex = tags.indexOf(tag);
|
||||
if (currentIndex === -1 || currentIndex === tags.length - 1) {
|
||||
throw new Error(`Cannot find previous tag for ${tag}`);
|
||||
}
|
||||
|
||||
return tags[currentIndex + 1];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const owner = readFlag("--owner");
|
||||
const repo = readFlag("--repo");
|
||||
const tag = readFlag("--tag");
|
||||
const prevTagArg = readFlag("--prev-tag");
|
||||
const dryRun = hasFlag("--dry-run");
|
||||
const skipLlm = hasFlag("--skip-llm");
|
||||
|
||||
if (!owner || !repo || !tag) {
|
||||
console.error("Usage: release-notes.ts --owner <owner> --repo <repo> --tag <tag> [--prev-tag <prev-tag>] [--dry-run] [--skip-llm]");
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const prevTag = prevTagArg || await detectPrevTag(tag);
|
||||
console.log(`Generating release notes for ${tag} (previous: ${prevTag})`);
|
||||
|
||||
const llmConfig = skipLlm ? null : readReleaseNotesLlmConfig();
|
||||
|
||||
const markdown = await generateReleaseNotes(
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
prevTag,
|
||||
llmConfig,
|
||||
dryRun,
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n=== DRY RUN MODE ===\n");
|
||||
console.log(markdown);
|
||||
console.log("\n=== END DRY RUN ===");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Creating draft release...");
|
||||
const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo);
|
||||
const release = await github.createDraftRelease(tag, tag, markdown);
|
||||
console.log(`Draft release created: ${release.id}`);
|
||||
|
||||
console.log(`\nDraft release created successfully!`);
|
||||
console.log(`View at: https://github.com/${owner}/${repo}/releases/tag/${tag}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Error:", error);
|
||||
Deno.exit(1);
|
||||
});
|
||||
46
.github/workflows/release-notes.yml
vendored
Normal file
46
.github/workflows/release-notes.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: AI Release Notes
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to generate release notes for (e.g. v0.3.0)"
|
||||
required: true
|
||||
prev_tag:
|
||||
description: "Previous tag (auto-detect if empty)"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_NOTES_LLM_BASE_URL: ${{ vars.RELEASE_NOTES_LLM_BASE_URL || 'https://models.inference.ai.azure.com' }}
|
||||
RELEASE_NOTES_LLM_API_KEY: ${{ secrets.RELEASE_NOTES_LLM_API_KEY || github.token }}
|
||||
RELEASE_NOTES_LLM_MODEL: ${{ vars.RELEASE_NOTES_LLM_MODEL || 'gpt-4o-mini' }}
|
||||
ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }}
|
||||
ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }}
|
||||
ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PREV="${{ github.event.inputs.prev_tag }}"
|
||||
ARGS="--owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }} --tag $TAG"
|
||||
[ -n "$PREV" ] && ARGS="$ARGS --prev-tag $PREV"
|
||||
|
||||
deno run --allow-env --allow-net --allow-run --allow-read \
|
||||
.github/scripts/release-notes.ts $ARGS
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -62,6 +62,7 @@ package-lock.json
|
|||
**/.playwright/
|
||||
**/playwright-report/
|
||||
**/test-results/
|
||||
.playwright-mcp/
|
||||
|
||||
# Python / temporary files
|
||||
*.py[cod]
|
||||
|
|
@ -82,3 +83,6 @@ docs/superpowers/
|
|||
# Local workspace metadata
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Local config file
|
||||
.mcp.json
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
[](https://deepwiki.com/iflytek/skillhub)
|
||||
[](https://zread.ai/iflytek/skillhub)
|
||||
[](https://discord.gg/qHYvtDNPHS)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
|
||||
[](https://ghcr.io/iflytek/skillhub)
|
||||
|
|
@ -95,7 +96,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan
|
|||
**For users in China (Aliyun mirror):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
If deployment runs into problems, clear the existing runtime home and retry.
|
||||
|
|
@ -195,7 +196,7 @@ Published images target both `linux/amd64` and `linux/arm64`.
|
|||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
|
||||
|
||||
# Aliyun mirror (recommended for users in China)
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
**Deployment parameters:**
|
||||
|
|
@ -441,6 +442,7 @@ what you'd like to change.
|
|||
|
||||
- 💬 **Community Discussion**: [GitHub Discussions](https://github.com/iflytek/skillhub/discussions)
|
||||
- 🐛 **Bug Reports**: [Issues](https://github.com/iflytek/skillhub/issues)
|
||||
- 👾 **Discord**: [Join our Server](https://discord.gg/qHYvtDNPHS)
|
||||
- 👥 **WeChat Work Group**:
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<div align="center">
|
||||
|
||||
[](https://zread.ai/iflytek/skillhub)
|
||||
[](https://discord.gg/qHYvtDNPHS)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
|
||||
[](https://ghcr.io/iflytek/skillhub)
|
||||
|
|
@ -67,7 +68,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
|
|||
**国内用户(阿里云镜像):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
如果部署遇到问题,请清除现有的运行时目录并重试。
|
||||
|
|
@ -177,7 +178,7 @@ skillhub/
|
|||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
|
||||
|
||||
# 阿里云镜像(国内推荐)
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
|
||||
```
|
||||
|
||||
### 配置参数说明
|
||||
|
|
@ -373,6 +374,7 @@ namespace `my-space` 和 skill slug `my-skill`。
|
|||
|
||||
- 💬 **社区讨论**:[GitHub Discussions](https://github.com/iflytek/skillhub/discussions)
|
||||
- 🐛 **Bug 报告**:[Issues](https://github.com/iflytek/skillhub/issues)
|
||||
- 👾 **Discord**:[加入我们的服务器](https://discord.gg/qHYvtDNPHS)
|
||||
- 👥 **企业微信群**:
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -589,13 +589,13 @@ private boolean canDownload(SkillVersion version, Skill skill, String currentUse
|
|||
|
||||
| 问题 | 严重程度 | 状态 |
|
||||
|------|---------|------|
|
||||
| 新增 UPLOADED 状态 | 高 | 待实现 |
|
||||
| PRIVATE skill 发布逻辑改动 | 高 | 待实现 |
|
||||
| 提交审核接口 | 高 | 待实现 |
|
||||
| 撤回审核后进入 UPLOADED | 中 | 待实现 |
|
||||
| 同名冲突检查补全 | 中 | 待实现 |
|
||||
| 管理员可见 UPLOADED skill | 低 | 待实现 |
|
||||
| package_name 唯一性检查 | 低 | 可选 |
|
||||
| 新增 UPLOADED 状态 | 高 | 已完成 |
|
||||
| PRIVATE skill 发布逻辑改动 | 高 | 已完成 |
|
||||
| 提交审核接口 | 高 | 已完成 |
|
||||
| 撤回审核后进入 UPLOADED | 中 | 已完成 |
|
||||
| 同名冲突检查补全 | 中 | 已完成 |
|
||||
| 管理员可见 UPLOADED skill | 低 | 已完成 |
|
||||
| package_name 唯一性检查 | 低 | 可选(SaaS Adapter 职责) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,63 @@ set_env_value() {
|
|||
mv "$tmp" "$ENV_FILE"
|
||||
}
|
||||
|
||||
get_env_value() {
|
||||
key="$1"
|
||||
default_value="${2:-}"
|
||||
value="$(grep "^$key=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true)"
|
||||
|
||||
if [ -n "$value" ]; then
|
||||
printf '%s' "$value"
|
||||
else
|
||||
printf '%s' "$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_postgres_ready() {
|
||||
postgres_user="$1"
|
||||
postgres_db="$2"
|
||||
attempt=1
|
||||
|
||||
while [ "$attempt" -le 60 ]; do
|
||||
if run_compose exec -T postgres pg_isready -U "$postgres_user" -d "$postgres_db" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "PostgreSQL did not become ready in time." >&2
|
||||
run_compose logs postgres >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_postgres_password_matches_env() {
|
||||
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
|
||||
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
|
||||
postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")"
|
||||
|
||||
if [ -z "$postgres_password" ]; then
|
||||
echo "POSTGRES_PASSWORD must not be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait_for_postgres_ready "$postgres_user" "$postgres_db"
|
||||
|
||||
run_compose exec -T postgres \
|
||||
psql -U "$postgres_user" -d "$postgres_db" \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-v password="$postgres_password" <<'SQL' >/dev/null
|
||||
SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password');
|
||||
\gexec
|
||||
SQL
|
||||
|
||||
run_compose exec -T -e "PGPASSWORD=$postgres_password" postgres \
|
||||
psql -h 127.0.0.1 -U "$postgres_user" -d "$postgres_db" \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-c 'select current_user;' >/dev/null
|
||||
}
|
||||
|
||||
prepare_runtime_files() {
|
||||
mkdir -p "$SKILLHUB_HOME"
|
||||
download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE"
|
||||
|
|
@ -235,6 +292,8 @@ prepare_runtime_files
|
|||
|
||||
case "$COMMAND" in
|
||||
up)
|
||||
run_compose up -d postgres
|
||||
ensure_postgres_password_matches_env
|
||||
if [ "$DISABLE_SCANNER" = "true" ]; then
|
||||
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0
|
||||
else
|
||||
|
|
|
|||
|
|
@ -104,6 +104,63 @@ set_env_value() {
|
|||
mv "${tmp}" .env.release
|
||||
}
|
||||
|
||||
get_env_value() {
|
||||
key="$1"
|
||||
default_value="${2:-}"
|
||||
value="$(grep -E "^${key}=" .env.release | tail -n 1 | cut -d= -f2- || true)"
|
||||
|
||||
if [[ -n "${value}" ]]; then
|
||||
printf '%s' "${value}"
|
||||
else
|
||||
printf '%s' "${default_value}"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_postgres_ready() {
|
||||
postgres_user="$1"
|
||||
postgres_db="$2"
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if docker compose --env-file .env.release -f compose.release.yml exec -T postgres \
|
||||
pg_isready -U "${postgres_user}" -d "${postgres_db}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "PostgreSQL did not become ready in time" >&2
|
||||
docker compose --env-file .env.release -f compose.release.yml logs postgres >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_postgres_password_matches_env() {
|
||||
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
|
||||
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
|
||||
postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")"
|
||||
|
||||
if [[ -z "${postgres_password}" ]]; then
|
||||
echo "POSTGRES_PASSWORD must not be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait_for_postgres_ready "${postgres_user}" "${postgres_db}"
|
||||
|
||||
docker compose --env-file .env.release -f compose.release.yml exec -T postgres \
|
||||
psql -U "${postgres_user}" -d "${postgres_db}" \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-v password="${postgres_password}" <<'SQL' >/dev/null
|
||||
SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password');
|
||||
\gexec
|
||||
SQL
|
||||
|
||||
docker compose --env-file .env.release -f compose.release.yml exec -T \
|
||||
-e PGPASSWORD="${postgres_password}" postgres \
|
||||
psql -h 127.0.0.1 -U "${postgres_user}" -d "${postgres_db}" \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-c 'select current_user;' >/dev/null
|
||||
}
|
||||
|
||||
cd "${runtime_dir}"
|
||||
|
||||
test -f .env.release
|
||||
|
|
@ -123,6 +180,8 @@ run_url=${run_url}
|
|||
METADATA
|
||||
|
||||
docker compose --env-file .env.release -f compose.release.yml pull
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d postgres
|
||||
ensure_postgres_password_matches_env
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d
|
||||
docker compose --env-file .env.release -f compose.release.yml ps
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ public class ClawHubCompatAppService {
|
|||
String hash,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug);
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
|
||||
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
|
||||
|
||||
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
|
||||
coord.namespace(),
|
||||
|
|
@ -106,7 +107,7 @@ public class ClawHubCompatAppService {
|
|||
"latest".equals(version) ? "latest" : null,
|
||||
hash,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
roles
|
||||
);
|
||||
return toResolveResponse(resolved);
|
||||
}
|
||||
|
|
@ -135,23 +136,37 @@ public class ClawHubCompatAppService {
|
|||
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
|
||||
}
|
||||
|
||||
public String downloadLocationByQuery(String slug, String version) {
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug);
|
||||
public String downloadLocationByQuery(String slug,
|
||||
String version,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
|
||||
return "latest".equals(version)
|
||||
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
|
||||
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
|
||||
}
|
||||
|
||||
private SkillCoordinate resolveQueryCoordinate(String slug) {
|
||||
private SkillCoordinate resolveQueryCoordinate(String slug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (slug != null && slug.contains("--")) {
|
||||
return mapper.fromCanonical(slug);
|
||||
}
|
||||
CompatSkillLookupService.CompatSkillContext context;
|
||||
try {
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
|
||||
context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
} catch (DomainNotFoundException ex) {
|
||||
return mapper.fromCanonical(slug);
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
|
||||
if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", slug);
|
||||
}
|
||||
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
|
||||
}
|
||||
|
||||
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
|
||||
return userNsRoles != null ? userNsRoles : Map.of();
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
|
|
@ -185,11 +200,18 @@ public class ClawHubCompatAppService {
|
|||
}
|
||||
|
||||
public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) {
|
||||
return getSkill(canonicalSlug, userId, Map.of());
|
||||
}
|
||||
|
||||
public ClawHubSkillResponse getSkill(String canonicalSlug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
|
||||
coord.namespace(),
|
||||
coord.slug(),
|
||||
userId
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
SkillVersion latestVersionEntity = context.latestVersion().orElse(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -82,8 +82,10 @@ public class ClawHubCompatController {
|
|||
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/download")
|
||||
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
|
||||
@RequestParam(defaultValue = "latest") String version) {
|
||||
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version));
|
||||
@RequestParam(defaultValue = "latest") String version,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles));
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
@ -99,8 +101,9 @@ public class ClawHubCompatController {
|
|||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/skills/{canonicalSlug}")
|
||||
public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId) {
|
||||
return clawHubCompatAppService.getSkill(canonicalSlug, userId);
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ public class ClawHubRegistryFacade {
|
|||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
|
||||
coordinate.namespace(),
|
||||
coordinate.slug(),
|
||||
userId
|
||||
userId,
|
||||
normalizeRoles(userNsRoles)
|
||||
);
|
||||
Skill skill = context.skill();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
|
@ -22,15 +25,18 @@ public class CompatSkillLookupService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
|
||||
public CompatSkillLookupService(SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
VisibilityChecker visibilityChecker) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
}
|
||||
|
||||
public CompatSkillContext findByLegacySlug(String slug) {
|
||||
|
|
@ -41,10 +47,28 @@ public class CompatSkillLookupService {
|
|||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (skill == null) {
|
||||
return false;
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
return visibilityChecker.canAccess(skill, currentUserId, roles);
|
||||
}
|
||||
|
||||
public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of());
|
||||
}
|
||||
|
||||
public CompatSkillContext resolveVisible(String namespaceSlug,
|
||||
String skillSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
if (!canAccess(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", skillSlug);
|
||||
}
|
||||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/namespaces")
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
|
||||
Pageable pageable,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces")
|
||||
|
|
@ -68,7 +70,7 @@ public class NamespaceController extends BaseApiController {
|
|||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController {
|
|||
return true;
|
||||
}
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
NamespaceRole namespaceRole = namespaceRoles.get(skill.getNamespaceId());
|
||||
if (namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER) {
|
||||
return true;
|
||||
}
|
||||
return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||
|
||||
public record SkillVersionRereleaseRequest(
|
||||
@NotBlank(message = "{validation.required}")
|
||||
String targetVersion
|
||||
String targetVersion,
|
||||
boolean confirmWarnings
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ public class AuthContextFilter extends OncePerRequestFilter {
|
|||
return;
|
||||
}
|
||||
request.setAttribute("userId", principal.userId());
|
||||
request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of());
|
||||
Map<Long, NamespaceRole> userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream()
|
||||
.collect(Collectors.toMap(
|
||||
NamespaceMember::getNamespaceId,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
|
|
@ -20,6 +21,8 @@ import java.util.Map;
|
|||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -50,9 +53,31 @@ public class NamespacePortalQueryAppService {
|
|||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable) {
|
||||
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
|
||||
return PageResponse.from(namespaces.map(NamespaceResponse::from));
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
Page<NamespaceResponse> empty = new PageImpl<>(
|
||||
List.of(),
|
||||
PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()),
|
||||
0
|
||||
);
|
||||
return PageResponse.from(empty);
|
||||
}
|
||||
|
||||
List<Namespace> scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
|
||||
.filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE)
|
||||
.sorted(Comparator.comparing(Namespace::getSlug))
|
||||
.toList();
|
||||
int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size());
|
||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size());
|
||||
Page<NamespaceResponse> page = new PageImpl<>(
|
||||
scopedNamespaces.subList(fromIndex, toIndex).stream()
|
||||
.map(NamespaceResponse::from)
|
||||
.toList(),
|
||||
pageable,
|
||||
scopedNamespaces.size()
|
||||
);
|
||||
return PageResponse.from(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
|
@ -73,10 +98,14 @@ public class NamespacePortalQueryAppService {
|
|||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
|
||||
slug,
|
||||
userId,
|
||||
userNamespaceRoles != null ? userNamespaceRoles : Map.of());
|
||||
namespaceRoles);
|
||||
if (!namespaceRoles.containsKey(namespace.getId())) {
|
||||
throw new DomainForbiddenException("error.namespace.membership.required");
|
||||
}
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@ public class SkillLifecycleAppService {
|
|||
skillVersion.getVersion(),
|
||||
targetVersion,
|
||||
userId,
|
||||
normalizeRoles(userNamespaceRoles)
|
||||
normalizeRoles(userNamespaceRoles),
|
||||
request.confirmWarnings()
|
||||
);
|
||||
auditLogService.record(
|
||||
userId,
|
||||
|
|
|
|||
|
|
@ -122,10 +122,8 @@ public class SkillSearchAppService {
|
|||
}
|
||||
|
||||
private boolean hasPlatformWideReadAccess(Set<String> platformRoles) {
|
||||
if (platformRoles == null || platformRoles.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return platformRoles.contains("SUPER_ADMIN");
|
||||
// Super admins should use a dedicated admin interface, not the public portal
|
||||
return false;
|
||||
}
|
||||
|
||||
private SearchResponse searchVisibleSkills(
|
||||
|
|
|
|||
|
|
@ -53,10 +53,29 @@ spring:
|
|||
github:
|
||||
client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder}
|
||||
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder}
|
||||
scope: read:user,user:email
|
||||
scope:
|
||||
- read:user
|
||||
- user:email
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
client-name: GitHub
|
||||
authorization-grant-type: authorization_code
|
||||
gitlab:
|
||||
client-id: ${OAUTH2_GITLAB_CLIENT_ID:placeholder}
|
||||
client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET:placeholder}
|
||||
scope:
|
||||
- read_user
|
||||
- email
|
||||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
|
||||
provider:
|
||||
github:
|
||||
user-info-uri: https://api.github.com/user
|
||||
gitlab:
|
||||
authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize
|
||||
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
|
||||
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
|
||||
user-name-attribute: username
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
|
|
@ -189,7 +208,7 @@ management:
|
|||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
|
@ -198,4 +217,4 @@ management:
|
|||
application: skillhub
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can m
|
|||
error.skill.version.exists=Version already exists: {0}
|
||||
error.skill.version.notFound=Version not found: {0}
|
||||
error.skill.version.notPublished=Version is not published: {0}
|
||||
error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0}
|
||||
error.skill.version.delete.unsupported=Only DRAFT, UPLOADED, REJECTED, or SCAN_FAILED versions can be deleted: {0}
|
||||
error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0}
|
||||
error.skill.report.reason.required=Please provide a report reason
|
||||
error.skill.report.unavailable=This skill cannot be reported right now: {0}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员
|
|||
error.skill.version.exists=版本已存在:{0}
|
||||
error.skill.version.notFound=未找到版本:{0}
|
||||
error.skill.version.notPublished=版本未发布:{0}
|
||||
error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0}
|
||||
error.skill.version.delete.unsupported=只有 DRAFT、UPLOADED、REJECTED 或 SCAN_FAILED 版本可以删除:{0}
|
||||
error.skill.version.delete.lastVersion=无法删除最后一个版本:{0}
|
||||
error.skill.report.reason.required=请填写举报原因
|
||||
error.skill.report.unavailable=当前无法举报该技能:{0}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,9 @@ import java.util.Set;
|
|||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
|
|
@ -150,6 +153,7 @@ class ClawHubCompatControllerTest {
|
|||
void resolve_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
|
||||
when(compatSkillLookupService.findByLegacySlug("my-skill"))
|
||||
.thenReturn(legacyCompatContext("global", "my-skill"));
|
||||
when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true);
|
||||
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
|
||||
|
|
@ -161,6 +165,7 @@ class ClawHubCompatControllerTest {
|
|||
.andExpect(jsonPath("$.match.version").value("latest"))
|
||||
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
|
||||
|
||||
verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap());
|
||||
verify(skillQueryService).resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of());
|
||||
}
|
||||
|
||||
|
|
@ -177,11 +182,14 @@ class ClawHubCompatControllerTest {
|
|||
void download_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
|
||||
when(compatSkillLookupService.findByLegacySlug("my-skill"))
|
||||
.thenReturn(legacyCompatContext("global", "my-skill"));
|
||||
when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true);
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "my-skill")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "/api/v1/skills/global/my-skill/download"));
|
||||
|
||||
verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -142,11 +142,12 @@ class AuthControllerTest {
|
|||
mockMvc.perform(get("/api/v1/auth/providers"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee")))
|
||||
.andExpect(jsonPath("$.data.length()").value(3))
|
||||
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab")))
|
||||
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
|
||||
"/oauth2/authorization/github",
|
||||
"/oauth2/authorization/gitee"
|
||||
"/oauth2/authorization/gitee",
|
||||
"/oauth2/authorization/gitlab"
|
||||
)))
|
||||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
|
|
|
|||
|
|
@ -94,18 +94,9 @@ class NamespacePortalControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow(
|
||||
new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException(
|
||||
"error.namespace.slug.notFound",
|
||||
"team-a"
|
||||
)
|
||||
);
|
||||
|
||||
void getNamespace_requiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/namespaces/team-a"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class NamespaceWorkflowContractTest {
|
|||
Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM);
|
||||
Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
|
||||
NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN);
|
||||
UserAccount adminUser = new UserAccount("user-admin", "Admin", "admin@example.com", null);
|
||||
setMemberId(adminMember, 11L);
|
||||
NamespaceResponse namespaceResponse = NamespaceResponse.from(namespace);
|
||||
NamespaceResponse frozenResponse = NamespaceResponse.from(frozen);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
import com.iflytek.skillhub.auth.rbac.RbacService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
|
|
@ -128,6 +129,37 @@ class SkillApprovalVisibilityFlowIntegrationTest {
|
|||
assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAdminCanApproveOwnTeamReview() throws Exception {
|
||||
PendingSkillGraph graph = createPendingTeamSkill("team-admin");
|
||||
when(namespaceMemberRepository.findByUserId("team-admin"))
|
||||
.thenReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(
|
||||
graph.namespace().getId(),
|
||||
"team-admin",
|
||||
NamespaceRole.ADMIN
|
||||
)));
|
||||
when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of());
|
||||
|
||||
mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve")
|
||||
.contentType("application/json")
|
||||
.content("{\"comment\":\"approved by namespace admin\"}")
|
||||
.with(authentication(apiAuth("team-admin")))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId()))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||
.andExpect(jsonPath("$.data.reviewedBy").value("team-admin"))
|
||||
.andExpect(jsonPath("$.data.reviewComment").value("approved by namespace admin"));
|
||||
|
||||
Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow();
|
||||
SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow();
|
||||
|
||||
assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId());
|
||||
assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
|
||||
assertThat(savedVersion.getPublishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
private PendingSkillGraph createPendingGlobalSkill(String ownerId) {
|
||||
String suffix = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
|
|
@ -154,6 +186,31 @@ class SkillApprovalVisibilityFlowIntegrationTest {
|
|||
return new PendingSkillGraph(namespace, skill, version, reviewTask);
|
||||
}
|
||||
|
||||
private PendingSkillGraph createPendingTeamSkill(String ownerId) {
|
||||
String suffix = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Namespace namespace = new Namespace("team-approval-" + suffix, "Team Approval " + suffix, ownerId);
|
||||
namespace = namespaceRepository.save(namespace);
|
||||
|
||||
Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
|
||||
skill.setDisplayName("Approval Skill " + suffix);
|
||||
skill.setSummary("Team namespace self-review should be allowed for namespace admins.");
|
||||
skill.setCreatedBy(ownerId);
|
||||
skill.setUpdatedBy(ownerId);
|
||||
skill = skillRepository.save(skill);
|
||||
skillRepository.flush();
|
||||
|
||||
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId);
|
||||
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
version.setRequestedVisibility(SkillVisibility.PUBLIC);
|
||||
version = skillVersionRepository.save(version);
|
||||
skillVersionRepository.flush();
|
||||
|
||||
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
|
||||
|
||||
return new PendingSkillGraph(namespace, skill, version, reviewTask);
|
||||
}
|
||||
|
||||
private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException {
|
||||
Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
|
||||
Optional<SkillSearchDocumentEntity> indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList;
|
|||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
|
|
@ -69,7 +72,7 @@ class SkillPublishControllerTest {
|
|||
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("global"),
|
||||
anyList(),
|
||||
ArgumentMatchers.<List<PackageEntry>>any(),
|
||||
eq("usr_1"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
|
|
@ -120,7 +123,7 @@ class SkillPublishControllerTest {
|
|||
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("global"),
|
||||
anyList(),
|
||||
ArgumentMatchers.<List<PackageEntry>>any(),
|
||||
eq("usr_1"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
|
@ -16,6 +17,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
|
|
@ -71,6 +73,39 @@ class NamespacePortalQueryAppServiceTest {
|
|||
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() {
|
||||
Namespace teamA = namespace(1L, "team-a");
|
||||
Namespace teamB = namespace(2L, "team-b");
|
||||
Namespace archived = namespace(3L, "archived");
|
||||
archived.setStatus(NamespaceStatus.ARCHIVED);
|
||||
|
||||
when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA));
|
||||
|
||||
var response = service.listNamespaces(
|
||||
PageRequest.of(0, 10),
|
||||
Map.of(
|
||||
1L, NamespaceRole.MEMBER,
|
||||
2L, NamespaceRole.ADMIN,
|
||||
3L, NamespaceRole.OWNER
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(response.items()).hasSize(2);
|
||||
assertThat(response.items().get(0).slug()).isEqualTo("team-a");
|
||||
assertThat(response.items().get(1).slug()).isEqualTo("team-b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() {
|
||||
Namespace namespace = namespace(1L, "team-a");
|
||||
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
|
||||
.thenReturn(namespace);
|
||||
|
||||
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, slug, "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ class SkillSearchAppServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void search_shouldGrantPlatformWideAccessToSuperAdmin() {
|
||||
void search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal() {
|
||||
when(searchQueryService.search(any()))
|
||||
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
|
||||
when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER"));
|
||||
|
|
@ -228,7 +228,7 @@ class SkillSearchAppServiceTest {
|
|||
|
||||
SearchVisibilityScope scope = captor.getValue().visibilityScope();
|
||||
assertEquals("admin-1", scope.userId());
|
||||
assertEquals(true, scope.platformWideAccess());
|
||||
assertEquals(false, scope.platformWideAccess());
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
package com.iflytek.skillhub.auth.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
@Entity
|
||||
@Table(name = "identity_binding",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"}))
|
||||
|
|
@ -24,6 +36,7 @@ public class IdentityBinding {
|
|||
@Column(name = "login_name", length = 128)
|
||||
private String loginName;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "extra_json", columnDefinition = "jsonb")
|
||||
private String extraJson;
|
||||
|
||||
|
|
|
|||
|
|
@ -31,12 +31,14 @@ public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequ
|
|||
PlatformPrincipal principal = context.principal();
|
||||
var attrs = new HashMap<>(context.upstreamUser().getAttributes());
|
||||
attrs.put("platformPrincipal", principal);
|
||||
// Store providerLogin under a fixed key so DefaultOAuth2User can find it
|
||||
attrs.put("providerLogin", principal.userId());
|
||||
|
||||
var authorities = new LinkedHashSet<GrantedAuthority>(context.upstreamUser().getAuthorities());
|
||||
principal.platformRoles().stream()
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||
.forEach(authorities::add);
|
||||
|
||||
return new DefaultOAuth2User(authorities, attrs, "login");
|
||||
return new DefaultOAuth2User(authorities, attrs, "providerLogin");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Provider-specific claims extractor that enriches GitLab OAuth users with their
|
||||
* verified email information.
|
||||
*
|
||||
* <p>GitLab OAuth2 user info endpoint returns user profile data. This extractor
|
||||
* fetches additional email information from GitLab API when needed.
|
||||
*/
|
||||
@Component
|
||||
public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GitLabClaimsExtractor.class);
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) {
|
||||
this.restClient = restClientBuilder
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return "gitlab";
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
|
||||
Map<String, Object> attrs = oAuth2User.getAttributes();
|
||||
log.debug("Extracting GitLab OAuth claims for user attributes: {}", attrs.keySet());
|
||||
|
||||
// GitLab returns email directly in user info
|
||||
String email = (String) attrs.get("email");
|
||||
|
||||
boolean emailVerified = isConfirmed(attrs.get("confirmed_at"));
|
||||
|
||||
log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified);
|
||||
|
||||
// If email is not verified or not present, try to fetch from emails API
|
||||
if (email == null || !emailVerified) {
|
||||
log.debug("Email not verified or missing, attempting to fetch from GitLab emails API");
|
||||
GitLabEmail primaryEmail = loadPrimaryEmail(request);
|
||||
if (primaryEmail != null) {
|
||||
email = primaryEmail.email();
|
||||
emailVerified = true;
|
||||
log.debug("Found verified email from GitLab API: {}", email);
|
||||
} else {
|
||||
log.debug("No verified email found from GitLab emails API");
|
||||
}
|
||||
}
|
||||
|
||||
// GitLab uses "username" for login name
|
||||
String username = (String) attrs.get("username");
|
||||
if (username == null) {
|
||||
username = (String) attrs.get("login");
|
||||
}
|
||||
|
||||
String subject = String.valueOf(attrs.get("id"));
|
||||
log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}",
|
||||
subject, username, email, emailVerified);
|
||||
|
||||
return new OAuthClaims(
|
||||
"gitlab",
|
||||
subject,
|
||||
email,
|
||||
emailVerified,
|
||||
username,
|
||||
attrs
|
||||
);
|
||||
}
|
||||
|
||||
private GitLabEmail loadPrimaryEmail(OAuth2UserRequest request) {
|
||||
String baseUrl = getGitLabApiBaseUrl(request);
|
||||
log.debug("Loading primary email from GitLab API base URL: {}", baseUrl);
|
||||
|
||||
try {
|
||||
List<GitLabEmail> emails = restClient.get()
|
||||
.uri(baseUrl + "/user/emails")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
|
||||
.retrieve()
|
||||
.body(new org.springframework.core.ParameterizedTypeReference<List<GitLabEmail>>() {});
|
||||
|
||||
if (emails == null || emails.isEmpty()) {
|
||||
log.debug("No emails returned from GitLab emails API");
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("Retrieved {} emails from GitLab API", emails.size());
|
||||
|
||||
// Return the primary verified email
|
||||
return emails.stream()
|
||||
.filter(GitLabEmail::confirmed)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the GitLab API base URL from the provider configuration.
|
||||
* The user-info-uri is configured as ${OAUTH2_GITLAB_BASE_URI}/api/v4/user,
|
||||
* so we simply remove the /user suffix to get the API base URL.
|
||||
*/
|
||||
private String getGitLabApiBaseUrl(OAuth2UserRequest request) {
|
||||
String userInfoUri = request.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri();
|
||||
log.debug("GitLab user info URI: {}", userInfoUri);
|
||||
// user-info-uri format: ${OAUTH2_GITLAB_BASE_URI}/api/v4/user
|
||||
// Remove /user suffix to get API base URL
|
||||
String baseUrl = userInfoUri.substring(0, userInfoUri.length() - "/user".length());
|
||||
log.debug("GitLab API base URL: {}", baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitLab marks a confirmed email by populating confirmed_at.
|
||||
*/
|
||||
private record GitLabEmail(String email, @JsonProperty("confirmed_at") String confirmedAt) {
|
||||
boolean confirmed() {
|
||||
return confirmedAt != null && !confirmedAt.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConfirmed(Object confirmedAt) {
|
||||
return confirmedAt instanceof String value && !value.isBlank();
|
||||
}
|
||||
}
|
||||
|
|
@ -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/**")
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package com.iflytek.skillhub.auth.session;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
|
@ -10,6 +8,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
|||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Synchronizes {@link PlatformPrincipal} snapshots with Spring Security's
|
||||
* session-backed authentication context.
|
||||
|
|
@ -53,7 +55,13 @@ public class PlatformSessionService {
|
|||
Authentication authentication,
|
||||
HttpServletRequest request,
|
||||
boolean rotateSessionId) {
|
||||
persist(principal, authentication, request, rotateSessionId);
|
||||
// Create a new authentication with PlatformPrincipal as the principal
|
||||
// instead of using the OAuth2 authentication which has OAuth2User as principal
|
||||
var authorities = principal.platformRoles().stream()
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||
.toList();
|
||||
Authentication platformAuth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
|
||||
persist(principal, platformAuth, request, rotateSessionId);
|
||||
}
|
||||
|
||||
private void persist(PlatformPrincipal principal,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class GitLabClaimsExtractorTest {
|
||||
|
||||
@Test
|
||||
void extract_marksProfileEmailVerifiedWhenConfirmedAtPresent() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
|
||||
|
||||
OAuthClaims claims = extractor.extract(
|
||||
userRequest(),
|
||||
new DefaultOAuth2User(
|
||||
java.util.List.of(),
|
||||
Map.of(
|
||||
"id", 42,
|
||||
"username", "alice",
|
||||
"email", "alice@gitlab.example",
|
||||
"confirmed_at", "2026-04-16T08:00:00Z"
|
||||
),
|
||||
"username"
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(claims.email()).isEqualTo("alice@gitlab.example");
|
||||
assertThat(claims.emailVerified()).isTrue();
|
||||
assertThat(claims.providerLogin()).isEqualTo("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_loadsConfirmedEmailFromEmailListWhenProfileEmailIsUnconfirmed() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
server.expect(requestTo("https://gitlab.example.com/api/v4/user/emails"))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
[
|
||||
{"email":"alice@gitlab.example","confirmed_at":"2026-04-16T08:00:00Z"},
|
||||
{"email":"alice+pending@gitlab.example","confirmed_at":null}
|
||||
]
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
|
||||
|
||||
OAuthClaims claims = extractor.extract(
|
||||
userRequest(),
|
||||
new DefaultOAuth2User(
|
||||
java.util.List.of(),
|
||||
Map.of(
|
||||
"id", 42,
|
||||
"username", "alice",
|
||||
"email", "alice+pending@gitlab.example"
|
||||
),
|
||||
"username"
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(claims.email()).isEqualTo("alice@gitlab.example");
|
||||
assertThat(claims.emailVerified()).isTrue();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("read_user", "email")
|
||||
.authorizationUri("https://gitlab.example.com/oauth/authorize")
|
||||
.tokenUri("https://gitlab.example.com/oauth/token")
|
||||
.userInfoUri("https://gitlab.example.com/api/v4/user")
|
||||
.userNameAttributeName("username")
|
||||
.clientName("GitLab")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ class OAuth2LoginHandlersTest {
|
|||
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
|
||||
assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal);
|
||||
assertThat(securityContext).isNotNull();
|
||||
assertThat(securityContext.getAuthentication()).isSameAs(authentication);
|
||||
assertThat(securityContext.getAuthentication().getPrincipal()).isEqualTo(principal);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ public class ReviewPermissionChecker {
|
|||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
if (task.getSubmittedBy().equals(userId)) {
|
||||
return platformRoles.contains("SUPER_ADMIN");
|
||||
return platformRoles.contains("SUPER_ADMIN")
|
||||
|| canSelfReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles);
|
||||
}
|
||||
return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);
|
||||
}
|
||||
|
|
@ -135,4 +136,15 @@ public class ReviewPermissionChecker {
|
|||
return platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
|
||||
private boolean canSelfReviewNamespace(Long namespaceId,
|
||||
NamespaceType namespaceType,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
if (namespaceType == NamespaceType.GLOBAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
NamespaceRole role = userNamespaceRoles.get(namespaceId);
|
||||
return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Evaluates whether a caller may read a skill based on publication state, visibility, ownership,
|
||||
|
|
@ -11,6 +12,13 @@ import java.util.Map;
|
|||
public class VisibilityChecker {
|
||||
|
||||
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
return canAccess(skill, currentUserId, userNamespaceRoles, Set.of());
|
||||
}
|
||||
|
||||
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles, Set<String> platformRoles) {
|
||||
if (isSuperAdmin(platformRoles)) {
|
||||
return true;
|
||||
}
|
||||
if (skill.isHidden()) {
|
||||
return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId()));
|
||||
}
|
||||
|
|
@ -31,4 +39,8 @@ public class VisibilityChecker {
|
|||
private boolean isAdminOrAbove(NamespaceRole role) {
|
||||
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(Set<String> platformRoles) {
|
||||
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,8 @@ public class SkillPublishService {
|
|||
String sourceVersion,
|
||||
String targetVersion,
|
||||
String publisherId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
boolean confirmWarnings) {
|
||||
Skill skill = skillRepository.findById(skillId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
|
||||
assertCanManageLifecycle(skill, publisherId, userNamespaceRoles);
|
||||
|
|
@ -181,7 +182,7 @@ public class SkillPublishService {
|
|||
publisherId,
|
||||
skill.getVisibility(),
|
||||
Set.of(),
|
||||
false, // confirmWarnings=false: no warnings to confirm for rerelease
|
||||
confirmWarnings, // confirmWarnings: honour caller's choice for rerelease
|
||||
false, // forceAutoPublish=false: respect visibility rules
|
||||
true
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
|
|
@ -149,11 +150,14 @@ public class SkillQueryService {
|
|||
String skillSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
|
||||
// Visibility check
|
||||
if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED
|
||||
&& !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) {
|
||||
throw new DomainForbiddenException("error.namespace.archived", namespaceSlug);
|
||||
}
|
||||
|
||||
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
|
||||
}
|
||||
|
|
@ -198,6 +202,15 @@ public class SkillQueryService {
|
|||
);
|
||||
}
|
||||
|
||||
public SkillDetailDTO getSkillDetail(
|
||||
String namespaceSlug,
|
||||
String skillSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles,
|
||||
Set<String> platformRoles) {
|
||||
return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists skills within a namespace after filtering out records the caller is
|
||||
* not allowed to discover.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -483,6 +483,46 @@ class ReviewServiceTest {
|
|||
assertEquals(USER_ID, skill.getUpdatedBy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAdminCanApproveOwnSubmission() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
Skill skill = createSkill();
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
when(permissionChecker.canReview(
|
||||
eq(task),
|
||||
eq(USER_ID),
|
||||
eq(ns.getType()),
|
||||
eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
|
||||
eq(Set.of())))
|
||||
.thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(
|
||||
REVIEW_TASK_ID,
|
||||
ReviewTaskStatus.APPROVED,
|
||||
USER_ID,
|
||||
"self approved as namespace admin",
|
||||
task.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
REVIEW_TASK_ID,
|
||||
USER_ID,
|
||||
"self approved as namespace admin",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
|
||||
Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
|
||||
assertEquals(USER_ID, skill.getUpdatedBy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowOnConcurrentModification() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
|
|
@ -602,6 +642,43 @@ class ReviewServiceTest {
|
|||
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAdminCanRejectOwnSubmission() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
when(permissionChecker.canReview(
|
||||
eq(task),
|
||||
eq(USER_ID),
|
||||
eq(ns.getType()),
|
||||
eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
|
||||
eq(Set.of())))
|
||||
.thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(
|
||||
REVIEW_TASK_ID,
|
||||
ReviewTaskStatus.REJECTED,
|
||||
USER_ID,
|
||||
"self rejected as namespace admin",
|
||||
task.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill()));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.rejectReview(
|
||||
REVIEW_TASK_ID,
|
||||
USER_ID,
|
||||
"self rejected as namespace admin",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
|
||||
Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowOnConcurrentModification() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -855,7 +855,8 @@ class SkillPublishServiceTest {
|
|||
"1.2.3",
|
||||
"1.2.4",
|
||||
publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
|
||||
false
|
||||
);
|
||||
|
||||
assertEquals("1.2.4", result.version().getVersion());
|
||||
|
|
@ -892,7 +893,8 @@ class SkillPublishServiceTest {
|
|||
"1.2.3",
|
||||
"1.2.4",
|
||||
publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -953,7 +955,8 @@ class SkillPublishServiceTest {
|
|||
"1.2.3",
|
||||
"1.2.4",
|
||||
publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
|
||||
false
|
||||
);
|
||||
|
||||
assertEquals("1.2.4", result.version().getVersion());
|
||||
|
|
@ -966,6 +969,100 @@ class SkillPublishServiceTest {
|
|||
assertEquals(30L, skill.getLatestVersionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRereleasePublishedVersion_ShouldRequireConfirmationWhenWarningsExist() throws Exception {
|
||||
String publisherId = "user-100";
|
||||
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 11L);
|
||||
skill.setDisplayName("Demo Skill");
|
||||
skill.setSummary("Original summary");
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setId(namespace, 1L);
|
||||
|
||||
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
|
||||
setId(sourceVersion, 21L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
|
||||
|
||||
String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world";
|
||||
SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
|
||||
SkillMetadata rereleaseMetadata = new SkillMetadata(
|
||||
"Demo Skill", "Original summary", "1.2.4", "Hello world",
|
||||
Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
|
||||
|
||||
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
|
||||
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
|
||||
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
|
||||
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
|
||||
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
|
||||
"SKILL.md line 5 contains a value that looks like a secret or token.")));
|
||||
|
||||
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion(
|
||||
skill.getId(), "1.2.3", "1.2.4", publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
|
||||
false
|
||||
));
|
||||
|
||||
assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode());
|
||||
assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token"));
|
||||
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRereleasePublishedVersion_ShouldSucceedWhenWarningsConfirmed() throws Exception {
|
||||
String publisherId = "user-100";
|
||||
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 11L);
|
||||
skill.setDisplayName("Demo Skill");
|
||||
skill.setSummary("Original summary");
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setId(namespace, 1L);
|
||||
|
||||
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
|
||||
setId(sourceVersion, 21L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
|
||||
|
||||
String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world";
|
||||
SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
|
||||
SkillMetadata rereleaseMetadata = new SkillMetadata(
|
||||
"Demo Skill", "Original summary", "1.2.4", "Hello world",
|
||||
Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
|
||||
|
||||
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
|
||||
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
|
||||
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
|
||||
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
|
||||
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
|
||||
"SKILL.md line 5 contains a value that looks like a secret or token.")));
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) { setId(saved, 30L); }
|
||||
return saved;
|
||||
});
|
||||
when(skillRepository.save(any())).thenReturn(skill);
|
||||
|
||||
SkillPublishService.PublishResult result = service.rereleasePublishedVersion(
|
||||
skill.getId(), "1.2.3", "1.2.4", publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
|
||||
true // confirmWarnings = true → should bypass warning and succeed
|
||||
);
|
||||
|
||||
assertEquals("1.2.4", result.version().getVersion());
|
||||
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
|
||||
verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
@ -999,6 +1096,39 @@ class SkillPublishServiceTest {
|
|||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldRejectWithPrivateConflictWhenOtherOwnerHasPrivatePublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String publisherId = "user-200";
|
||||
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
|
||||
|
||||
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
|
||||
List<PackageEntry> entries = List.of(skillMd);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
NamespaceMember member = mock(NamespaceMember.class);
|
||||
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
|
||||
|
||||
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE);
|
||||
setId(existingSkill, 1L);
|
||||
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
|
||||
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
|
||||
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
|
||||
|
||||
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
|
||||
namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of()
|
||||
));
|
||||
assertEquals("error.skill.publish.nameConflict.private", ex.messageCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import java.lang.reflect.Field;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
|
@ -50,7 +51,6 @@ class SkillQueryServiceTest {
|
|||
private SkillTagRepository skillTagRepository;
|
||||
@Mock
|
||||
private ObjectStorageService objectStorageService;
|
||||
@Mock
|
||||
private VisibilityChecker visibilityChecker;
|
||||
@Mock
|
||||
private PromotionRequestRepository promotionRequestRepository;
|
||||
|
|
@ -65,6 +65,7 @@ class SkillQueryServiceTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
visibilityChecker = new VisibilityChecker();
|
||||
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
|
||||
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
|
||||
service = new SkillQueryService(
|
||||
|
|
@ -105,7 +106,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
|
||||
when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null)));
|
||||
|
||||
|
|
@ -148,7 +148,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
|
||||
when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
|
@ -176,7 +175,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(DomainForbiddenException.class, () ->
|
||||
|
|
@ -215,13 +213,11 @@ class SkillQueryServiceTest {
|
|||
setId(namespace, 1L);
|
||||
Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC);
|
||||
setId(skill1, 1L);
|
||||
Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE);
|
||||
Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE);
|
||||
setId(skill2, 2L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2));
|
||||
when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true);
|
||||
when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false);
|
||||
|
||||
// Act
|
||||
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
|
||||
|
|
@ -267,8 +263,6 @@ class SkillQueryServiceTest {
|
|||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
|
||||
.thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill));
|
||||
when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true);
|
||||
when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false);
|
||||
|
||||
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
|
||||
|
||||
|
|
@ -296,8 +290,6 @@ class SkillQueryServiceTest {
|
|||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
|
||||
.thenReturn(List.of(visibleSkill, hiddenSkill));
|
||||
when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true);
|
||||
when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false);
|
||||
|
||||
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
|
||||
|
||||
|
|
@ -325,7 +317,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
|
||||
when(objectStorageService.exists("key1")).thenReturn(true);
|
||||
|
|
@ -358,7 +349,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
|
|
@ -385,7 +375,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
|
||||
when(objectStorageService.exists(file.getStorageKey())).thenReturn(true);
|
||||
|
|
@ -419,7 +408,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile));
|
||||
when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true);
|
||||
|
|
@ -482,7 +470,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
|
||||
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
|
||||
|
|
@ -516,7 +503,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
|
||||
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
|
||||
when(objectStorageService.exists("storage-key")).thenReturn(true);
|
||||
|
|
@ -555,7 +541,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20));
|
||||
|
|
@ -589,7 +574,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of(version100, version110));
|
||||
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110));
|
||||
|
|
@ -631,7 +615,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
|
||||
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
|
||||
|
|
@ -664,7 +647,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
||||
|
|
@ -691,7 +673,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty());
|
||||
|
|
@ -723,7 +704,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING))
|
||||
.thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class)));
|
||||
|
|
@ -753,7 +733,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
|
||||
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED))
|
||||
|
|
@ -784,7 +763,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
|
||||
|
|
@ -793,6 +771,56 @@ class SkillQueryServiceTest {
|
|||
assertFalse(result.canSubmitPromotion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldNotGrantLifecyclePermissionToSuperAdminInPortal() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "super-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(
|
||||
namespaceSlug, skillSlug, userId, userNsRoles, Set.of("SUPER_ADMIN"));
|
||||
|
||||
assertFalse(result.canManageLifecycle());
|
||||
assertFalse(result.canSubmitPromotion());
|
||||
assertEquals("PUBLISHED", result.resolutionMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldNotGrantPrivateVisibilityToSuperAdminInPortal() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String userId = "super-1";
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PRIVATE);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
|
||||
assertThrows(DomainForbiddenException.class, () ->
|
||||
service.getSkillDetail(namespaceSlug, skillSlug, userId, Map.of(), Set.of("SUPER_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
@ -813,7 +841,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
|
||||
.thenReturn(List.of());
|
||||
|
|
@ -853,7 +880,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
|
||||
|
|
@ -889,7 +915,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected));
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected));
|
||||
|
|
@ -925,7 +950,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
|
||||
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
|
||||
|
|
@ -960,7 +984,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
|
||||
when(objectStorageService.exists("storage-key")).thenReturn(true);
|
||||
|
|
@ -992,7 +1015,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
|
|
@ -1024,7 +1046,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(
|
||||
|
|
@ -1059,7 +1080,6 @@ class SkillQueryServiceTest {
|
|||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
|
||||
|
||||
Page<SkillVersion> result = service.listVersions(
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
Set<Long> adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty()
|
||||
? Set.of(-1L)
|
||||
: query.visibilityScope().adminNamespaceIds();
|
||||
boolean platformWideAccess = query.visibilityScope().platformWideAccess();
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT d.skill_id ");
|
||||
|
|
@ -118,9 +117,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
sql.append("AND (d.visibility = 'PUBLIC' ");
|
||||
if (query.visibilityScope().userId() != null) {
|
||||
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) ");
|
||||
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE) ");
|
||||
sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) ");
|
||||
sql.append("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE) ");
|
||||
}
|
||||
sql.append(") ");
|
||||
|
||||
|
|
@ -131,7 +127,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
sql.append("AND (n.status <> 'ARCHIVED' ");
|
||||
if (query.visibilityScope().userId() != null) {
|
||||
sql.append("OR d.namespace_id IN :memberNamespaceIds ");
|
||||
sql.append("OR :platformWideAccess = TRUE ");
|
||||
}
|
||||
sql.append(") ");
|
||||
|
||||
|
|
@ -196,8 +191,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
if (query.visibilityScope().userId() != null) {
|
||||
nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
|
||||
nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
|
||||
nativeQuery.setParameter("platformWideAccess", platformWideAccess);
|
||||
nativeQuery.setParameter("userId", query.visibilityScope().userId());
|
||||
}
|
||||
|
||||
if (query.namespaceId() != null) {
|
||||
|
|
@ -243,8 +236,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
if (query.visibilityScope().userId() != null) {
|
||||
countQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
|
||||
countQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
|
||||
countQuery.setParameter("platformWideAccess", platformWideAccess);
|
||||
countQuery.setParameter("userId", query.visibilityScope().userId());
|
||||
}
|
||||
|
||||
if (query.namespaceId() != null) {
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ class PostgresFullTextQueryServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() {
|
||||
void platformWideAccessShouldNotBypassVisibilityInPortalSearch() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
|
|
@ -418,12 +418,12 @@ class PostgresFullTextQueryServiceTest {
|
|||
|
||||
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
// Portal search should not include platformWideAccess bypass logic
|
||||
assertThat(sqlCaptor.getAllValues().getFirst())
|
||||
.contains("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE)")
|
||||
.contains("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE)")
|
||||
.contains("OR :platformWideAccess = TRUE");
|
||||
verify(nativeQuery).setParameter("platformWideAccess", true);
|
||||
verify(countQuery).setParameter("platformWideAccess", true);
|
||||
.doesNotContain("platformWideAccess")
|
||||
.doesNotContain("PRIVATE");
|
||||
verify(nativeQuery, never()).setParameter("platformWideAccess", true);
|
||||
verify(countQuery, never()).setParameter("platformWideAccess", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
64
web/e2e/helpers/review-seed.ts
Normal file
64
web/e2e/helpers/review-seed.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Browser, Page, TestInfo } from '@playwright/test'
|
||||
import { loginWithCredentials, registerSession } from './session'
|
||||
import { E2eTestDataBuilder, type SeededReviewData } from './test-data-builder'
|
||||
|
||||
function getOptionalEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim()
|
||||
return value ? value : undefined
|
||||
}
|
||||
|
||||
function adminCredentials() {
|
||||
return {
|
||||
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
|
||||
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
|
||||
}
|
||||
}
|
||||
|
||||
function matchCandidateUsername(
|
||||
candidate: { userId: string; displayName: string; email?: string },
|
||||
username: string,
|
||||
) {
|
||||
return candidate.userId === username
|
||||
|| candidate.displayName === username
|
||||
|| candidate.email === `${username}@example.test`
|
||||
}
|
||||
|
||||
export async function createNamespaceReviewData(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
): Promise<SeededReviewData & { reviewTaskId: number; cleanup: () => Promise<void> }> {
|
||||
const credentials = await registerSession(page, testInfo, { allowMockSession: false })
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
const adminPage = await adminContext.newPage()
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
|
||||
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
|
||||
await adminBuilder.init()
|
||||
|
||||
const namespace = await adminBuilder.createNamespace('e2e-team')
|
||||
const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username)
|
||||
const matchedCandidate = candidates.find((candidate) => matchCandidateUsername(candidate, credentials.username)) ?? candidates[0]
|
||||
|
||||
if (!matchedCandidate) {
|
||||
throw new Error(`No namespace member candidate found for review actor ${credentials.username}`)
|
||||
}
|
||||
|
||||
await adminBuilder.addNamespaceMember(namespace.slug, matchedCandidate.userId, 'ADMIN')
|
||||
const skill = await builder.publishSkill(namespace.slug)
|
||||
const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version)
|
||||
|
||||
return {
|
||||
namespace,
|
||||
skill,
|
||||
reviewTaskId,
|
||||
cleanup: async () => {
|
||||
await builder.cleanup()
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ export interface TestCredentials {
|
|||
username: string
|
||||
}
|
||||
|
||||
interface RegisterSessionOptions {
|
||||
allowMockSession?: boolean
|
||||
}
|
||||
|
||||
interface SessionSnapshot {
|
||||
username: string
|
||||
cookies: Array<{
|
||||
|
|
@ -160,7 +164,7 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us
|
|||
return { username: 'local-user', password }
|
||||
}
|
||||
|
||||
async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
||||
async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
|
||||
const worker = testInfo?.parallelIndex ?? 0
|
||||
const cached = cachedUserByWorker.get(worker)
|
||||
const username = usernameForWorker(testInfo)
|
||||
|
|
@ -175,9 +179,11 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
return { username: restored.username, password }
|
||||
}
|
||||
|
||||
const mockSession = await tryBootstrapMockSession(page, worker)
|
||||
if (mockSession) {
|
||||
return mockSession
|
||||
if (options?.allowMockSession !== false) {
|
||||
const mockSession = await tryBootstrapMockSession(page, worker)
|
||||
if (mockSession) {
|
||||
return mockSession
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
|
||||
|
|
@ -317,12 +323,12 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
throw new Error(`Failed to create fresh e2e session for worker ${worker}`)
|
||||
}
|
||||
|
||||
export async function registerSession(page: Page, testInfo?: TestInfo) {
|
||||
export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
return await registerSessionOnce(page, testInfo)
|
||||
return await registerSessionOnce(page, testInfo, options)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 2) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ export interface SeededNamespace {
|
|||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
status?: string
|
||||
type?: string
|
||||
currentUserRole?: string
|
||||
canUnfreeze?: boolean
|
||||
canRestore?: boolean
|
||||
}
|
||||
|
||||
export interface SeededSkill {
|
||||
|
|
@ -34,6 +39,13 @@ interface ReviewTaskSummary {
|
|||
version: string
|
||||
}
|
||||
|
||||
interface NamespaceCandidate {
|
||||
userId: string
|
||||
displayName: string
|
||||
email?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number
|
||||
msg: string
|
||||
|
|
@ -45,6 +57,8 @@ interface ApiFailure extends Error {
|
|||
code?: number
|
||||
}
|
||||
|
||||
const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000
|
||||
|
||||
export interface SeedSkillOptions {
|
||||
name?: string
|
||||
description?: string
|
||||
|
|
@ -65,6 +79,24 @@ function uniqueSuffix(testInfo?: TestInfo): string {
|
|||
return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
async function runCleanupTaskWithTimeout(task: CleanupTask): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`))
|
||||
}, cleanupTimeoutMs)
|
||||
|
||||
void task()
|
||||
.then(() => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
|
||||
const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48)
|
||||
const description = options?.description || 'E2E generated skill for real-request tests'
|
||||
|
|
@ -166,7 +198,7 @@ export class E2eTestDataBuilder {
|
|||
async cleanup(): Promise<void> {
|
||||
for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
await this.cleanupTasks[i]()
|
||||
await runCleanupTaskWithTimeout(this.cleanupTasks[i])
|
||||
} catch {
|
||||
// Best-effort cleanup for E2E environments.
|
||||
}
|
||||
|
|
@ -207,6 +239,34 @@ export class E2eTestDataBuilder {
|
|||
)
|
||||
}
|
||||
|
||||
private isTeamNamespace(namespace: SeededNamespace): boolean {
|
||||
return namespace.type === 'TEAM' || namespace.slug !== 'global'
|
||||
}
|
||||
|
||||
private isActiveNamespace(namespace: SeededNamespace): boolean {
|
||||
return namespace.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
private async activateNamespace(namespace: SeededNamespace): Promise<SeededNamespace | null> {
|
||||
if (!this.isTeamNamespace(namespace)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
|
||||
return parseEnvelope<SeededNamespace>(
|
||||
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`),
|
||||
)
|
||||
}
|
||||
|
||||
if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
|
||||
return parseEnvelope<SeededNamespace>(
|
||||
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async ensureWritableNamespace(): Promise<SeededNamespace> {
|
||||
if (this.ensuredNamespace) {
|
||||
return this.ensuredNamespace
|
||||
|
|
@ -224,12 +284,75 @@ export class E2eTestDataBuilder {
|
|||
}
|
||||
|
||||
const namespaces = await this.listMyNamespaces()
|
||||
const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0]
|
||||
if (!writable) {
|
||||
throw new Error('No namespace available for e2e data seeding')
|
||||
const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
|
||||
if (activeTeam) {
|
||||
this.ensuredNamespace = activeTeam
|
||||
return activeTeam
|
||||
}
|
||||
this.ensuredNamespace = writable
|
||||
return writable
|
||||
|
||||
const activeFallback = namespaces.find((item) => this.isActiveNamespace(item))
|
||||
if (activeFallback) {
|
||||
this.ensuredNamespace = activeFallback
|
||||
return activeFallback
|
||||
}
|
||||
|
||||
const activatable = namespaces.find((item) =>
|
||||
this.isTeamNamespace(item)
|
||||
&& ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
|
||||
)
|
||||
if (activatable) {
|
||||
const activated = await this.activateNamespace(activatable)
|
||||
if (activated) {
|
||||
this.ensuredNamespace = activated
|
||||
return activated
|
||||
}
|
||||
}
|
||||
|
||||
const summary = namespaces
|
||||
.map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
|
||||
.join(', ')
|
||||
throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`)
|
||||
}
|
||||
|
||||
async ensureReviewableNamespace(): Promise<SeededNamespace> {
|
||||
if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(this.ensuredNamespace)) {
|
||||
return this.ensuredNamespace
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await this.createNamespace('e2e-team')
|
||||
this.ensuredNamespace = created
|
||||
return created
|
||||
} catch (error) {
|
||||
const failure = error as ApiFailure
|
||||
if (failure.status !== 403) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const namespaces = await this.listMyNamespaces()
|
||||
const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
|
||||
if (activeTeam) {
|
||||
this.ensuredNamespace = activeTeam
|
||||
return activeTeam
|
||||
}
|
||||
|
||||
const activatable = namespaces.find((item) =>
|
||||
this.isTeamNamespace(item)
|
||||
&& ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
|
||||
)
|
||||
if (activatable) {
|
||||
const activated = await this.activateNamespace(activatable)
|
||||
if (activated) {
|
||||
this.ensuredNamespace = activated
|
||||
return activated
|
||||
}
|
||||
}
|
||||
|
||||
const summary = namespaces
|
||||
.map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
|
||||
.join(', ')
|
||||
throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`)
|
||||
}
|
||||
|
||||
private async getMySkillInNamespace(namespaceSlug: string): Promise<SeededSkill | null> {
|
||||
|
|
@ -350,6 +473,21 @@ export class E2eTestDataBuilder {
|
|||
)
|
||||
}
|
||||
|
||||
async searchNamespaceMemberCandidates(slug: string, search: string): Promise<NamespaceCandidate[]> {
|
||||
const query = new URLSearchParams({ search })
|
||||
return parseEnvelope<NamespaceCandidate[]>(
|
||||
await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`),
|
||||
)
|
||||
}
|
||||
|
||||
async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise<void> {
|
||||
await parseEnvelope<{ userId: string; role: string }>(
|
||||
await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, {
|
||||
data: { userId, role },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise<SeededSkill> {
|
||||
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
|
||||
|
|
@ -384,7 +522,7 @@ export class E2eTestDataBuilder {
|
|||
}
|
||||
|
||||
async createReviewData(): Promise<SeededReviewData> {
|
||||
const namespace = await this.ensureWritableNamespace()
|
||||
const namespace = await this.ensureReviewableNamespace()
|
||||
const skill = await this.publishSkill(namespace.slug)
|
||||
return { namespace, skill }
|
||||
}
|
||||
|
|
|
|||
59
web/e2e/namespace-review-detail-access.spec.ts
Normal file
59
web/e2e/namespace-review-detail-access.spec.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { createNamespaceReviewData } from './helpers/review-seed'
|
||||
|
||||
test.describe('Namespace Review Detail Access (Real API)', () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('opens namespace review detail from the namespace review list', async ({ browser, page }, testInfo) => {
|
||||
let seeded: Awaited<ReturnType<typeof createNamespaceReviewData>> | undefined
|
||||
try {
|
||||
seeded = await createNamespaceReviewData(browser, page, testInfo)
|
||||
|
||||
await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible()
|
||||
await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`)).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Open review' }).first().click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/\\d+$`))
|
||||
await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible()
|
||||
await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`).first()).toBeVisible()
|
||||
} finally {
|
||||
await seeded?.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ browser, page }, testInfo) => {
|
||||
let seeded: Awaited<ReturnType<typeof createNamespaceReviewData>> | undefined
|
||||
try {
|
||||
seeded = await createNamespaceReviewData(browser, page, testInfo)
|
||||
|
||||
await page.goto('/dashboard/reviews')
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/)
|
||||
await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible()
|
||||
} finally {
|
||||
await seeded?.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('redirects namespace review detail opened through the global detail route', async ({ browser, page }, testInfo) => {
|
||||
let seeded: Awaited<ReturnType<typeof createNamespaceReviewData>> | undefined
|
||||
try {
|
||||
seeded = await createNamespaceReviewData(browser, page, testInfo)
|
||||
|
||||
await page.goto(`/dashboard/reviews/${seeded.reviewTaskId}`)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${seeded.reviewTaskId}$`))
|
||||
await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible()
|
||||
} finally {
|
||||
await seeded?.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,26 +1,24 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { registerSession } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
import { createNamespaceReviewData } from './helpers/review-seed'
|
||||
|
||||
test.describe('Namespace Reviews Data (Real API)', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('opens namespace reviews page with seeded review data context', async ({ page }, testInfo) => {
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
test('opens namespace reviews page with seeded review data context', async ({ browser, page }, testInfo) => {
|
||||
let seeded: Awaited<ReturnType<typeof createNamespaceReviewData>> | undefined
|
||||
try {
|
||||
const seeded = await builder.createReviewData()
|
||||
seeded = await createNamespaceReviewData(browser, page, testInfo)
|
||||
await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible()
|
||||
await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible()
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
await seeded?.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
46
web/e2e/public-skill-detail-anonymous.spec.ts
Normal file
46
web/e2e/public-skill-detail-anonymous.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { getSearchCard, prepareSearchSeed, type PreparedSearchSeed } from './helpers/search-seed'
|
||||
|
||||
const SEARCH_URL = (q: string) => `/search?q=${encodeURIComponent(q)}&sort=relevance&page=0&starredOnly=false`
|
||||
|
||||
function latestSeed(seed: PreparedSearchSeed) {
|
||||
return {
|
||||
skill: seed.skills[seed.skills.length - 1],
|
||||
skillName: seed.skillNames[seed.skillNames.length - 1],
|
||||
}
|
||||
}
|
||||
|
||||
let seeded: PreparedSearchSeed | undefined
|
||||
|
||||
test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
seeded = await prepareSearchSeed(browser, testInfo, { count: 1 })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await seeded?.dispose()
|
||||
seeded = undefined
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('allows anonymous users to open a public skill detail and view install content', async ({ page }) => {
|
||||
const current = latestSeed(seeded!)
|
||||
|
||||
await page.goto(SEARCH_URL(seeded!.keyword))
|
||||
const card = getSearchCard(page, current.skillName)
|
||||
await expect(card).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await card.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`))
|
||||
await expect(page).not.toHaveURL(/\/login\?returnTo=/)
|
||||
await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Install', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,38 +30,61 @@ async function waitForCards(page: Page) {
|
|||
|
||||
const keyword = basicSeed?.keyword
|
||||
const encodedKeyword = keyword ? encodeURIComponent(keyword) : null
|
||||
let reloaded = false
|
||||
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await page.waitForLoadState('networkidle')
|
||||
const waitForMatchingResponse = async () => {
|
||||
if (!encodedKeyword) {
|
||||
return
|
||||
}
|
||||
|
||||
await page.waitForResponse(async (response) => {
|
||||
if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) {
|
||||
return false
|
||||
}
|
||||
if (response.status() !== 200) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await response.json() as { data?: { items?: Array<unknown> } }
|
||||
return Array.isArray(payload.data?.items) && payload.data.items.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, { timeout: 15_000 }).catch(() => null)
|
||||
}
|
||||
|
||||
const waitForCardCount = async () => {
|
||||
await expect.poll(
|
||||
async () => cards.count(),
|
||||
{
|
||||
timeout: 20_000,
|
||||
intervals: [250, 500, 1_000, 2_000],
|
||||
},
|
||||
).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
if (await cards.count() > 0) {
|
||||
return cards
|
||||
}
|
||||
|
||||
await waitForMatchingResponse()
|
||||
|
||||
try {
|
||||
await waitForCardCount()
|
||||
} catch {
|
||||
if (reloaded) {
|
||||
throw new Error('Timed out waiting for search cards after one reload fallback')
|
||||
}
|
||||
|
||||
reloaded = true
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
if (await cards.count() > 0) {
|
||||
return cards
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
const responsePromise = encodedKeyword
|
||||
? page.waitForResponse(async (response) => {
|
||||
if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) {
|
||||
return false
|
||||
}
|
||||
if (response.status() !== 200) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await response.json() as { data?: { items?: Array<unknown> } }
|
||||
return Array.isArray(payload.data?.items) && payload.data.items.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, { timeout: 12_000 }).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
await page.waitForTimeout(750 * (attempt + 1))
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await responsePromise
|
||||
}
|
||||
await waitForMatchingResponse()
|
||||
await waitForCardCount()
|
||||
}
|
||||
|
||||
return cards
|
||||
|
|
|
|||
|
|
@ -39,12 +39,11 @@ test.describe('Search Input (Real API)', () => {
|
|||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_003 P0 - empty search guidance
|
||||
test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => {
|
||||
// TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list
|
||||
test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => {
|
||||
await page.goto(searchUrl(''))
|
||||
await expect(page).toHaveURL(/\/search/)
|
||||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible()
|
||||
await expect(page.getByText('Please enter a search keyword')).toBeVisible()
|
||||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// TC_SEARCH_INPUT_004 P0 - Enter key triggers search
|
||||
|
|
|
|||
2
web/public/github-logo.svg
Normal file
2
web/public/github-logo.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 -0.5 25 25" xmlns="http://www.w3.org/2000/svg"><path d="m12.301 0h.093c2.242 0 4.34.613 6.137 1.68l-.055-.031c1.871 1.094 3.386 2.609 4.449 4.422l.031.058c1.04 1.769 1.654 3.896 1.654 6.166 0 5.406-3.483 10-8.327 11.658l-.087.026c-.063.02-.135.031-.209.031-.162 0-.312-.054-.433-.144l.002.001c-.128-.115-.208-.281-.208-.466 0-.005 0-.01 0-.014v.001q0-.048.008-1.226t.008-2.154c.007-.075.011-.161.011-.249 0-.792-.323-1.508-.844-2.025.618-.061 1.176-.163 1.718-.305l-.076.017c.573-.16 1.073-.373 1.537-.642l-.031.017c.508-.28.938-.636 1.292-1.058l.006-.007c.372-.476.663-1.036.84-1.645l.009-.035c.209-.683.329-1.468.329-2.281 0-.045 0-.091-.001-.136v.007c0-.022.001-.047.001-.072 0-1.248-.482-2.383-1.269-3.23l.003.003c.168-.44.265-.948.265-1.479 0-.649-.145-1.263-.404-1.814l.011.026c-.115-.022-.246-.035-.381-.035-.334 0-.649.078-.929.216l.012-.005c-.568.21-1.054.448-1.512.726l.038-.022-.609.384c-.922-.264-1.981-.416-3.075-.416s-2.153.152-3.157.436l.081-.02q-.256-.176-.681-.433c-.373-.214-.814-.421-1.272-.595l-.066-.022c-.293-.154-.64-.244-1.009-.244-.124 0-.246.01-.364.03l.013-.002c-.248.524-.393 1.139-.393 1.788 0 .531.097 1.04.275 1.509l-.01-.029c-.785.844-1.266 1.979-1.266 3.227 0 .025 0 .051.001.076v-.004c-.001.039-.001.084-.001.13 0 .809.12 1.591.344 2.327l-.015-.057c.189.643.476 1.202.85 1.693l-.009-.013c.354.435.782.793 1.267 1.062l.022.011c.432.252.933.465 1.46.614l.046.011c.466.125 1.024.227 1.595.284l.046.004c-.431.428-.718 1-.784 1.638l-.001.012c-.207.101-.448.183-.699.236l-.021.004c-.256.051-.549.08-.85.08-.022 0-.044 0-.066 0h.003c-.394-.008-.756-.136-1.055-.348l.006.004c-.371-.259-.671-.595-.881-.986l-.007-.015c-.198-.336-.459-.614-.768-.827l-.009-.006c-.225-.169-.49-.301-.776-.38l-.016-.004-.32-.048c-.023-.002-.05-.003-.077-.003-.14 0-.273.028-.394.077l.007-.003q-.128.072-.08.184c.039.086.087.16.145.225l-.001-.001c.061.072.13.135.205.19l.003.002.112.08c.283.148.516.354.693.603l.004.006c.191.237.359.505.494.792l.01.024.16.368c.135.402.38.738.7.981l.005.004c.3.234.662.402 1.057.478l.016.002c.33.064.714.104 1.106.112h.007c.045.002.097.002.15.002.261 0 .517-.021.767-.062l-.027.004.368-.064q0 .609.008 1.418t.008.873v.014c0 .185-.08.351-.208.466h-.001c-.119.089-.268.143-.431.143-.075 0-.147-.011-.214-.032l.005.001c-4.929-1.689-8.409-6.283-8.409-11.69 0-2.268.612-4.393 1.681-6.219l-.032.058c1.094-1.871 2.609-3.386 4.422-4.449l.058-.031c1.739-1.034 3.835-1.645 6.073-1.645h.098-.005zm-7.64 17.666q.048-.112-.112-.192-.16-.048-.208.032-.048.112.112.192.144.096.208-.032zm.497.545q.112-.08-.032-.256-.16-.144-.256-.048-.112.08.032.256.159.157.256.047zm.48.72q.144-.112 0-.304-.128-.208-.272-.096-.144.08 0 .288t.272.112zm.672.673q.128-.128-.064-.304-.192-.192-.32-.048-.144.128.064.304.192.192.32.044zm.913.4q.048-.176-.208-.256-.24-.064-.304.112t.208.24q.24.097.304-.096zm1.009.08q0-.208-.272-.176-.256 0-.256.176 0 .208.272.176.256.001.256-.175zm.929-.16q-.032-.176-.288-.144-.256.048-.224.24t.288.128.225-.224z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
2
web/public/gitlab-logo.svg
Normal file
2
web/public/gitlab-logo.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#FC6D26" d="M14.975 8.904L14.19 6.55l-1.552-4.67a.268.268 0 00-.255-.18.268.268 0 00-.254.18l-1.552 4.667H5.422L3.87 1.879a.267.267 0 00-.254-.179.267.267 0 00-.254.18l-1.55 4.667-.784 2.357a.515.515 0 00.193.583l6.78 4.812 6.778-4.812a.516.516 0 00.196-.583z"/><path fill="#E24329" d="M8 14.296l2.578-7.75H5.423L8 14.296z"/><path fill="#FC6D26" d="M8 14.296l-2.579-7.75H1.813L8 14.296z"/><path fill="#FCA326" d="M1.81 6.549l-.784 2.354a.515.515 0 00.193.583L8 14.3 1.81 6.55z"/><path fill="#E24329" d="M1.812 6.549h3.612L3.87 1.882a.268.268 0 00-.254-.18.268.268 0 00-.255.18L1.812 6.549z"/><path fill="#FC6D26" d="M8 14.296l2.578-7.75h3.614L8 14.296z"/><path fill="#FCA326" d="M14.19 6.549l.783 2.354a.514.514 0 01-.193.583L8 14.296l6.188-7.747h.001z"/><path fill="#E24329" d="M14.19 6.549H10.58l1.551-4.667a.267.267 0 01.255-.18c.115 0 .217.073.254.18l1.552 4.667z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -488,14 +488,14 @@ export const skillLifecycleApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise<void> {
|
||||
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ targetVersion }),
|
||||
body: JSON.stringify({ targetVersion, confirmWarnings }),
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
1
web/src/api/generated/schema.d.ts
vendored
1
web/src/api/generated/schema.d.ts
vendored
|
|
@ -3237,6 +3237,7 @@ export interface components {
|
|||
};
|
||||
SkillVersionRereleaseRequest: {
|
||||
targetVersion: string;
|
||||
confirmWarnings?: boolean;
|
||||
};
|
||||
SkillReportSubmitRequest: {
|
||||
reason?: string;
|
||||
|
|
|
|||
|
|
@ -86,22 +86,18 @@ const NamespaceReviewsPage = createLazyRouteComponent(
|
|||
() => import('@/pages/dashboard/namespace-reviews'),
|
||||
'NamespaceReviewsPage',
|
||||
)
|
||||
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
|
||||
const ReviewsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/reviews'),
|
||||
'ReviewsPage',
|
||||
['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'],
|
||||
const NamespaceReviewDetailPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/review-detail'),
|
||||
'NamespaceReviewDetailPage',
|
||||
)
|
||||
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
|
||||
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
|
||||
const ReportsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/reports'),
|
||||
'ReportsPage',
|
||||
['SKILL_ADMIN', 'SUPER_ADMIN'],
|
||||
)
|
||||
const ReviewDetailPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/review-detail'),
|
||||
'ReviewDetailPage',
|
||||
['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'],
|
||||
)
|
||||
const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage')
|
||||
const PromotionsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/promotions'),
|
||||
'PromotionsPage',
|
||||
|
|
@ -228,7 +224,6 @@ const namespaceRoute = createRoute({
|
|||
const skillDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/space/$namespace/$slug',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: (search: Record<string, unknown>): { returnTo?: string } => ({
|
||||
returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined,
|
||||
}),
|
||||
|
|
@ -305,6 +300,13 @@ const dashboardReviewDetailRoute = createRoute({
|
|||
component: ReviewDetailPage,
|
||||
})
|
||||
|
||||
const dashboardNamespaceReviewDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/namespaces/$slug/reviews/$id',
|
||||
beforeLoad: requireAuth,
|
||||
component: NamespaceReviewDetailPage,
|
||||
})
|
||||
|
||||
const dashboardPromotionsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/promotions',
|
||||
|
|
@ -416,6 +418,7 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardNamespacesRoute,
|
||||
dashboardNamespaceMembersRoute,
|
||||
dashboardNamespaceReviewsRoute,
|
||||
dashboardNamespaceReviewDetailRoute,
|
||||
dashboardGovernanceRoute,
|
||||
dashboardReviewsRoute,
|
||||
dashboardReportsRoute,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ interface LoginButtonProps {
|
|||
returnTo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate icon for a given OAuth provider.
|
||||
*/
|
||||
function OAuthIcon({ provider }: { provider: string }) {
|
||||
const normalizedProvider = provider.toLowerCase()
|
||||
return (
|
||||
<img
|
||||
src={`/${normalizedProvider}-logo.svg`}
|
||||
alt={provider}
|
||||
className="w-5 h-5 mr-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders OAuth login buttons from the auth-method catalog returned by the backend.
|
||||
*/
|
||||
|
|
@ -37,12 +51,11 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
|
|||
window.location.href = provider.actionUrl
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
<OAuthIcon provider={provider.provider} />
|
||||
{t('loginButton.loginWith', { name: provider.displayName })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
107
web/src/features/review/review-paths.test.ts
Normal file
107
web/src/features/review/review-paths.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildGlobalReviewsPath,
|
||||
buildNamespaceReviewDetailPath,
|
||||
buildNamespaceReviewsPath,
|
||||
canAccessGlobalReviewCenter,
|
||||
canAccessReviewCenter,
|
||||
canManageNamespaceReviews,
|
||||
getPreferredNamespaceReviewEntry,
|
||||
} from './review-paths'
|
||||
|
||||
describe('review-paths', () => {
|
||||
it('builds the global reviews path', () => {
|
||||
expect(buildGlobalReviewsPath()).toBe('/dashboard/reviews')
|
||||
})
|
||||
|
||||
it('builds namespace review paths', () => {
|
||||
expect(buildNamespaceReviewsPath('team alpha')).toBe('/dashboard/namespaces/team%20alpha/reviews')
|
||||
expect(buildNamespaceReviewDetailPath('team alpha', 12)).toBe('/dashboard/namespaces/team%20alpha/reviews/12')
|
||||
})
|
||||
|
||||
it('detects global review access from platform roles', () => {
|
||||
expect(canAccessGlobalReviewCenter(['SKILL_ADMIN'])).toBe(true)
|
||||
expect(canAccessGlobalReviewCenter(['USER_ADMIN'])).toBe(true)
|
||||
expect(canAccessGlobalReviewCenter(['SUPER_ADMIN'])).toBe(true)
|
||||
expect(canAccessGlobalReviewCenter(['USER'])).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes namespace review managers', () => {
|
||||
expect(canManageNamespaceReviews('OWNER')).toBe(true)
|
||||
expect(canManageNamespaceReviews('ADMIN')).toBe(true)
|
||||
expect(canManageNamespaceReviews('MEMBER')).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers active team namespaces for namespace review entry', () => {
|
||||
expect(getPreferredNamespaceReviewEntry([
|
||||
{
|
||||
id: 1,
|
||||
slug: 'archived-team',
|
||||
displayName: 'Archived Team',
|
||||
type: 'TEAM',
|
||||
status: 'ARCHIVED',
|
||||
immutable: false,
|
||||
canFreeze: false,
|
||||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
currentUserRole: 'ADMIN',
|
||||
createdAt: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
slug: 'active-team',
|
||||
displayName: 'Active Team',
|
||||
type: 'TEAM',
|
||||
status: 'ACTIVE',
|
||||
immutable: false,
|
||||
canFreeze: false,
|
||||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
currentUserRole: 'OWNER',
|
||||
createdAt: '',
|
||||
},
|
||||
])?.slug).toBe('active-team')
|
||||
})
|
||||
|
||||
it('returns null when no manageable namespace exists', () => {
|
||||
expect(getPreferredNamespaceReviewEntry([
|
||||
{
|
||||
id: 1,
|
||||
slug: 'member-team',
|
||||
displayName: 'Member Team',
|
||||
type: 'TEAM',
|
||||
status: 'ACTIVE',
|
||||
immutable: false,
|
||||
canFreeze: false,
|
||||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
currentUserRole: 'MEMBER',
|
||||
createdAt: '',
|
||||
},
|
||||
])).toBeNull()
|
||||
})
|
||||
|
||||
it('detects review center access from either platform roles or namespace roles', () => {
|
||||
expect(canAccessReviewCenter(['SKILL_ADMIN'], [])).toBe(true)
|
||||
expect(canAccessReviewCenter([], [
|
||||
{
|
||||
id: 2,
|
||||
slug: 'team-admin',
|
||||
displayName: 'Team Admin',
|
||||
type: 'TEAM',
|
||||
status: 'ACTIVE',
|
||||
immutable: false,
|
||||
canFreeze: false,
|
||||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
currentUserRole: 'ADMIN',
|
||||
createdAt: '',
|
||||
},
|
||||
])).toBe(true)
|
||||
expect(canAccessReviewCenter([], [])).toBe(false)
|
||||
})
|
||||
})
|
||||
48
web/src/features/review/review-paths.ts
Normal file
48
web/src/features/review/review-paths.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { ManagedNamespace, NamespaceRole } from '@/api/types'
|
||||
|
||||
const GLOBAL_REVIEW_PLATFORM_ROLES = ['SKILL_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'] as const
|
||||
|
||||
export function buildGlobalReviewsPath() {
|
||||
return '/dashboard/reviews'
|
||||
}
|
||||
|
||||
export function buildNamespaceReviewsPath(slug: string) {
|
||||
return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews`
|
||||
}
|
||||
|
||||
export function buildNamespaceReviewDetailPath(slug: string, reviewId: number) {
|
||||
return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews/${reviewId}`
|
||||
}
|
||||
|
||||
export function canAccessGlobalReviewCenter(platformRoles?: readonly string[]) {
|
||||
return GLOBAL_REVIEW_PLATFORM_ROLES.some((role) => platformRoles?.includes(role))
|
||||
}
|
||||
|
||||
export function canManageNamespaceReviews(role?: NamespaceRole) {
|
||||
return role === 'OWNER' || role === 'ADMIN'
|
||||
}
|
||||
|
||||
export function getPreferredNamespaceReviewEntry(
|
||||
namespaces?: readonly ManagedNamespace[],
|
||||
) {
|
||||
if (!namespaces?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const manageableNamespaces = namespaces.filter((namespace) =>
|
||||
namespace.type === 'TEAM' && canManageNamespaceReviews(namespace.currentUserRole),
|
||||
)
|
||||
if (manageableNamespaces.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeNamespace = manageableNamespaces.find((namespace) => namespace.status === 'ACTIVE')
|
||||
return activeNamespace ?? manageableNamespaces[0]
|
||||
}
|
||||
|
||||
export function canAccessReviewCenter(
|
||||
platformRoles?: readonly string[],
|
||||
namespaces?: readonly ManagedNamespace[],
|
||||
) {
|
||||
return canAccessGlobalReviewCenter(platformRoles) || getPreferredNamespaceReviewEntry(namespaces) !== null
|
||||
}
|
||||
92
web/src/features/skill/version-status-badge.tsx
Normal file
92
web/src/features/skill/version-status-badge.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
type VersionStatus =
|
||||
| 'DRAFT'
|
||||
| 'SCANNING'
|
||||
| 'SCAN_FAILED'
|
||||
| 'UPLOADED'
|
||||
| 'PENDING_REVIEW'
|
||||
| 'PUBLISHED'
|
||||
| 'REJECTED'
|
||||
| 'YANKED'
|
||||
|
||||
const statusStyles: Record<VersionStatus, string> = {
|
||||
PUBLISHED:
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
|
||||
UPLOADED:
|
||||
'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400',
|
||||
PENDING_REVIEW:
|
||||
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
||||
REJECTED:
|
||||
'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
|
||||
SCANNING:
|
||||
'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400',
|
||||
SCAN_FAILED:
|
||||
'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
|
||||
YANKED:
|
||||
'border-border/60 bg-secondary/40 text-muted-foreground',
|
||||
DRAFT:
|
||||
'border-border/60 bg-secondary/40 text-muted-foreground',
|
||||
}
|
||||
|
||||
const i18nKeys: Record<VersionStatus, string> = {
|
||||
DRAFT: 'skillDetail.versionStatusDraft',
|
||||
SCANNING: 'skillDetail.versionStatusScanning',
|
||||
SCAN_FAILED: 'skillDetail.versionStatusScanFailed',
|
||||
UPLOADED: 'skillDetail.versionStatusUploaded',
|
||||
PENDING_REVIEW: 'skillDetail.versionStatusPendingReview',
|
||||
PUBLISHED: 'skillDetail.versionStatusPublished',
|
||||
REJECTED: 'skillDetail.versionStatusRejected',
|
||||
YANKED: 'skillDetail.versionStatusYanked',
|
||||
}
|
||||
|
||||
/** Color-coded row styles (left-border + subtle background) for version cards. */
|
||||
export const versionRowStyles: Record<VersionStatus, string> = {
|
||||
UPLOADED:
|
||||
'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]',
|
||||
PENDING_REVIEW:
|
||||
'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]',
|
||||
REJECTED:
|
||||
'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]',
|
||||
SCANNING:
|
||||
'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]',
|
||||
SCAN_FAILED:
|
||||
'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]',
|
||||
PUBLISHED: '',
|
||||
YANKED: '',
|
||||
DRAFT: '',
|
||||
}
|
||||
|
||||
export function getVersionRowStyle(status?: string): string {
|
||||
if (!status) return ''
|
||||
return versionRowStyles[status as VersionStatus] ?? ''
|
||||
}
|
||||
|
||||
export function VersionStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status?: string
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!status) return null
|
||||
|
||||
const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT
|
||||
const label = i18nKeys[status as VersionStatus]
|
||||
? t(i18nKeys[status as VersionStatus])
|
||||
: status
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
|
||||
style,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -212,7 +212,7 @@
|
|||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"register": "Sign up now",
|
||||
"oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.",
|
||||
"oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.",
|
||||
"passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.",
|
||||
"enterpriseSsoTitle": "Enterprise SSO",
|
||||
"enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.",
|
||||
|
|
@ -911,6 +911,9 @@
|
|||
"rereleaseSuccessTitle": "Version re-released",
|
||||
"rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.",
|
||||
"rereleaseErrorTitle": "Failed to re-release version",
|
||||
"rereleaseWarningTitle": "Pre-publish warning",
|
||||
"rereleaseWarningDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue re-releasing.",
|
||||
"rereleaseWarningConfirm": "Continue re-releasing",
|
||||
"yankVersion": "Yank Current Version",
|
||||
"promoteToGlobal": "Promote to Global",
|
||||
"promotionSectionTitle": "Promote to Global",
|
||||
|
|
@ -1078,6 +1081,7 @@
|
|||
"sortLabel": "Time Order",
|
||||
"sortNewest": "Newest first",
|
||||
"sortOldest": "Oldest first",
|
||||
"openReview": "Open review",
|
||||
"pageSummary": "Total {{total}} records, page {{page}}",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@
|
|||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "还没有账号?",
|
||||
"register": "立即注册",
|
||||
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
|
||||
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
|
||||
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
|
||||
"enterpriseSsoTitle": "企业单点登录",
|
||||
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",
|
||||
|
|
@ -912,6 +912,9 @@
|
|||
"rereleaseSuccessTitle": "版本已重新发布",
|
||||
"rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。",
|
||||
"rereleaseErrorTitle": "重新发布版本失败",
|
||||
"rereleaseWarningTitle": "发布前风险提醒",
|
||||
"rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。",
|
||||
"rereleaseWarningConfirm": "继续重新发布",
|
||||
"yankVersion": "撤回当前版本",
|
||||
"promoteToGlobal": "申请提升到全局",
|
||||
"promotionSectionTitle": "提升到全局",
|
||||
|
|
@ -1079,6 +1082,7 @@
|
|||
"sortLabel": "时间排序",
|
||||
"sortNewest": "最新优先",
|
||||
"sortOldest": "最早优先",
|
||||
"openReview": "进入审核详情",
|
||||
"pageSummary": "共 {{total}} 条记录,第 {{page}} 页",
|
||||
"prevPage": "上一页",
|
||||
"nextPage": "下一页",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
|
|
@ -15,8 +16,9 @@ type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
|||
type TimeSortDirection = 'ASC' | 'DESC'
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
|
||||
function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug: string }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const reviewsEnabled = typeof namespaceId === 'number' && namespaceId > 0
|
||||
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
|
||||
PENDING: 0,
|
||||
APPROVED: 0,
|
||||
|
|
@ -24,9 +26,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
|
|||
})
|
||||
const [activeStatus, setActiveStatus] = useState<ReviewStatus>('PENDING')
|
||||
const [sortDirection, setSortDirection] = useState<TimeSortDirection>('DESC')
|
||||
const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING')
|
||||
const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED')
|
||||
const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED')
|
||||
const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'PENDING')
|
||||
const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'APPROVED')
|
||||
const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'REJECTED')
|
||||
|
||||
const changePage = (status: ReviewStatus, nextPage: number) => {
|
||||
setPages((current) => ({ ...current, [status]: nextPage }))
|
||||
|
|
@ -90,6 +92,14 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
|
|||
{review.reviewComment ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{review.reviewComment}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Link
|
||||
to={buildNamespaceReviewDetailPath(slug, review.id)}
|
||||
className="inline-flex items-center rounded-md border border-border/60 px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t('nsReviews.openReview')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null}
|
||||
|
|
@ -151,7 +161,7 @@ export function NamespaceReviewsPage() {
|
|||
{readOnlyMessage}
|
||||
</Card>
|
||||
) : null}
|
||||
<ReviewListSection namespaceId={namespace?.id} />
|
||||
<ReviewListSection namespaceId={namespace?.id} slug={slug} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ const navigateMock = vi.fn()
|
|||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useParams: () => ({ id: '13' }),
|
||||
useParams: (options?: { from?: string }) => (
|
||||
options?.from === '/dashboard/namespaces/$slug/reviews/$id'
|
||||
? { id: '13', slug: 'team-alpha' }
|
||||
: { id: '13' }
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
|
|
@ -112,6 +116,11 @@ vi.mock('@/features/review/use-review-detail', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
const userMock = { platformRoles: ['SKILL_ADMIN'] as string[] }
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ user: userMock }),
|
||||
}))
|
||||
|
||||
// Mock hooks used directly by the review-detail page for file browser sidebar
|
||||
vi.mock('@/features/review/use-review-file', () => ({
|
||||
useReviewFile: () => ({ data: null, isLoading: false, error: null }),
|
||||
|
|
@ -122,11 +131,12 @@ vi.mock('@/api/client', () => ({
|
|||
WEB_API_PREFIX: '/api/web',
|
||||
}))
|
||||
|
||||
import { ReviewDetailPage } from './review-detail'
|
||||
import { NamespaceReviewDetailPage, ReviewDetailPage } from './review-detail'
|
||||
|
||||
describe('ReviewDetailPage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
userMock.platformRoles = ['SKILL_ADMIN']
|
||||
useReviewDetailMock.mockReset()
|
||||
useReviewSkillDetailMock.mockReset()
|
||||
useReviewDetailMock.mockReturnValue({
|
||||
|
|
@ -206,6 +216,81 @@ describe('ReviewDetailPage', () => {
|
|||
expect(html).toContain('review.notFound')
|
||||
})
|
||||
|
||||
it('renders namespace review detail through the namespace route wrapper', () => {
|
||||
useReviewDetailMock.mockReturnValue({
|
||||
data: {
|
||||
id: 13,
|
||||
namespace: 'team-alpha',
|
||||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<NamespaceReviewDetailPage />)
|
||||
|
||||
expect(html).toContain('review.detail')
|
||||
expect(html).toContain('demo-skill')
|
||||
})
|
||||
|
||||
it('redirects namespace reviews opened through the global route for namespace operators', () => {
|
||||
userMock.platformRoles = []
|
||||
useReviewDetailMock.mockReturnValue({
|
||||
data: {
|
||||
id: 13,
|
||||
namespace: 'team-alpha',
|
||||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<ReviewDetailPage />)
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('shows not-found state when the namespace route slug does not match the review namespace', () => {
|
||||
useReviewDetailMock.mockReturnValue({
|
||||
data: {
|
||||
id: 13,
|
||||
namespace: 'other-team',
|
||||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<NamespaceReviewDetailPage />)
|
||||
|
||||
expect(html).toContain('review.notFound')
|
||||
expect(html).toContain('review.backToList')
|
||||
})
|
||||
|
||||
it('disables approval and shows a scanning hint while the active review version is scanning', () => {
|
||||
useReviewSkillDetailMock.mockReturnValue({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, Folder } from 'lucide-react'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import {
|
||||
buildGlobalReviewsPath,
|
||||
buildNamespaceReviewDetailPath,
|
||||
buildNamespaceReviewsPath,
|
||||
canAccessGlobalReviewCenter,
|
||||
} from '@/features/review/review-paths'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
|
|
@ -25,11 +32,18 @@ import { useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectRevie
|
|||
* interaction state because both actions depend on route-local confirmation
|
||||
* dialogs, comment input, and redirect behavior after completion.
|
||||
*/
|
||||
export function ReviewDetailPage() {
|
||||
const { id } = useParams({ from: '/dashboard/reviews/$id' })
|
||||
function ReviewDetailScreen({
|
||||
taskId,
|
||||
backTo,
|
||||
namespaceSlug,
|
||||
}: {
|
||||
taskId: number
|
||||
backTo: string
|
||||
namespaceSlug?: string
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { t, i18n } = useTranslation()
|
||||
const taskId = Number(id)
|
||||
const { user } = useAuth()
|
||||
|
||||
const { data: review, isLoading } = useReviewDetail(taskId)
|
||||
const {
|
||||
|
|
@ -40,7 +54,7 @@ export function ReviewDetailPage() {
|
|||
const approveMutation = useApproveReview({
|
||||
onSuccess: () => {
|
||||
toast.success(t('review.approveSuccess'))
|
||||
navigate({ to: '/dashboard/reviews' })
|
||||
navigate({ to: backTo })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error))
|
||||
|
|
@ -49,7 +63,7 @@ export function ReviewDetailPage() {
|
|||
const rejectMutation = useRejectReview({
|
||||
onSuccess: () => {
|
||||
toast.success(t('review.rejectSuccess'))
|
||||
navigate({ to: '/dashboard/reviews' })
|
||||
navigate({ to: backTo })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error))
|
||||
|
|
@ -64,6 +78,12 @@ export function ReviewDetailPage() {
|
|||
const [fileBrowserOpen, setFileBrowserOpen] = useState(true)
|
||||
const [previewNode, setPreviewNode] = useState<FileTreeNode | null>(null)
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
|
||||
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
|
||||
const shouldRedirectToNamespaceRoute =
|
||||
!namespaceSlug &&
|
||||
!!review &&
|
||||
review.namespace !== 'global' &&
|
||||
!hasGlobalReviewAccess
|
||||
|
||||
// File content for preview — uses the review-bound version via review file API
|
||||
const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useReviewFile(
|
||||
|
|
@ -92,6 +112,17 @@ export function ReviewDetailPage() {
|
|||
return formatLocalDateTime(dateString, i18n.language)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRedirectToNamespaceRoute || !review) {
|
||||
return
|
||||
}
|
||||
|
||||
void navigate({
|
||||
to: buildNamespaceReviewDetailPath(review.namespace, review.id),
|
||||
replace: true,
|
||||
})
|
||||
}, [navigate, review, shouldRedirectToNamespaceRoute])
|
||||
|
||||
const handleApprove = async () => {
|
||||
approveMutation.mutate({ taskId, comment: comment || undefined })
|
||||
}
|
||||
|
|
@ -113,6 +144,10 @@ export function ReviewDetailPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (shouldRedirectToNamespaceRoute) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-up">
|
||||
|
|
@ -121,6 +156,23 @@ export function ReviewDetailPage() {
|
|||
)
|
||||
}
|
||||
|
||||
const hasNamespaceMismatch = Boolean(namespaceSlug && review.namespace !== namespaceSlug)
|
||||
|
||||
if (hasNamespaceMismatch) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl animate-fade-up">
|
||||
<div className="text-center py-20">
|
||||
<h2 className="text-2xl font-bold font-heading mb-2">{t('review.notFound')}</h2>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={() => navigate({ to: backTo })}>
|
||||
{t('review.backToList')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const reviewFiles = reviewSkillDetail?.files
|
||||
const activeReviewVersion = reviewSkillDetail?.versions?.find(
|
||||
(version) => version.version === reviewSkillDetail.activeVersion
|
||||
|
|
@ -136,7 +188,7 @@ export function ReviewDetailPage() {
|
|||
<h1 className="text-4xl font-bold font-heading mb-2">{t('review.detail')}</h1>
|
||||
<p className="text-muted-foreground">{t('review.id')}: {review.id}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate({ to: '/dashboard/reviews' })}>
|
||||
<Button variant="outline" onClick={() => navigate({ to: backTo })}>
|
||||
{t('review.backToList')}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -363,3 +415,26 @@ export function ReviewDetailPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReviewDetailPage() {
|
||||
const { id } = useParams({ from: '/dashboard/reviews/$id' })
|
||||
|
||||
return (
|
||||
<ReviewDetailScreen
|
||||
taskId={Number(id)}
|
||||
backTo={buildGlobalReviewsPath()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NamespaceReviewDetailPage() {
|
||||
const { id, slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews/$id' })
|
||||
|
||||
return (
|
||||
<ReviewDetailScreen
|
||||
taskId={Number(id)}
|
||||
backTo={buildNamespaceReviewsPath(slug)}
|
||||
namespaceSlug={slug}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { FileCheck2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import {
|
||||
buildNamespaceReviewsPath,
|
||||
canAccessGlobalReviewCenter,
|
||||
getPreferredNamespaceReviewEntry,
|
||||
} from '@/features/review/review-paths'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -32,7 +38,8 @@ const PAGE_SIZE = 20
|
|||
export function ReviewsPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { hasRole } = useAuth()
|
||||
const { hasRole, user } = useAuth()
|
||||
const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
|
||||
PENDING: 0,
|
||||
APPROVED: 0,
|
||||
|
|
@ -43,14 +50,29 @@ export function ReviewsPage() {
|
|||
|
||||
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
|
||||
const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces)
|
||||
const showTypeTabs = isSkillAdmin && isUserAdmin
|
||||
|
||||
// Determine default top-level tab
|
||||
const defaultType = isSkillAdmin ? 'skill' : 'profile'
|
||||
|
||||
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING')
|
||||
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED')
|
||||
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED')
|
||||
useEffect(() => {
|
||||
if (hasGlobalReviewAccess || isLoadingNamespaces) {
|
||||
return
|
||||
}
|
||||
|
||||
if (namespaceReviewEntry) {
|
||||
void navigate({ to: buildNamespaceReviewsPath(namespaceReviewEntry.slug), replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
void navigate({ to: '/dashboard', replace: true })
|
||||
}, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate])
|
||||
|
||||
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING')
|
||||
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED')
|
||||
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED')
|
||||
|
||||
const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language)
|
||||
|
||||
|
|
@ -211,6 +233,17 @@ export function ReviewsPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!hasGlobalReviewAccess) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
|
||||
|
|
|
|||
|
|
@ -46,7 +46,13 @@ vi.mock('@/shared/components/skeleton-loader', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/shared/components/empty-state', () => ({
|
||||
EmptyState: () => <div>empty-state</div>,
|
||||
EmptyState: ({ title, description }: { title: string; description?: string }) => (
|
||||
<div>
|
||||
empty-state
|
||||
<span>{title}</span>
|
||||
{description ? <span>{description}</span> : null}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
|
|
@ -202,4 +208,55 @@ describe('SearchPage', () => {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the default skill list when the empty query still returns items', () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
q: '',
|
||||
label: '',
|
||||
sort: 'newest',
|
||||
page: 0,
|
||||
starredOnly: false,
|
||||
})
|
||||
useSearchSkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', downloadCount: 1, starCount: 1, ratingCount: 0, updatedAt: '2026-03-20T00:00:00Z', canSubmitPromotion: false }],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 12,
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
expect(html).toContain('skill-card')
|
||||
expect(html).not.toContain('empty-state')
|
||||
})
|
||||
|
||||
it('shows a generic empty state when the default discovery list is empty', () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
q: '',
|
||||
label: '',
|
||||
sort: 'newest',
|
||||
page: 0,
|
||||
starredOnly: false,
|
||||
})
|
||||
useSearchSkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 0,
|
||||
size: 12,
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
expect(html).toContain('empty-state')
|
||||
expect(html).toContain('search.noResults')
|
||||
expect(html).not.toContain('search.enterKeyword')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -125,8 +125,6 @@ export function SearchPage() {
|
|||
isLoading: isLoadingStarred,
|
||||
isFetching: isFetchingStarred,
|
||||
} = useMyStars(starredOnly && isAuthenticated)
|
||||
const shouldShowGuidance = !starredOnly && !q && !selectedLabel
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce URL updates while the user is typing so query state stays shareable without
|
||||
// triggering a navigation on every keystroke.
|
||||
|
|
@ -202,10 +200,10 @@ export function SearchPage() {
|
|||
: data
|
||||
? Math.ceil(data.total / data.size)
|
||||
: 0
|
||||
const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? []))
|
||||
const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading)
|
||||
const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading)
|
||||
const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0))
|
||||
const displayItems = starredOnly ? starredPageItems : (data?.items ?? [])
|
||||
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
|
||||
const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading
|
||||
const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)
|
||||
|
||||
return (
|
||||
<div className={APP_SHELL_PAGE_CLASS_NAME}>
|
||||
|
|
@ -313,11 +311,9 @@ export function SearchPage() {
|
|||
<EmptyState
|
||||
title={starredOnly ? t('search.noStarredResults') : t('search.noResults')}
|
||||
description={
|
||||
shouldShowGuidance
|
||||
? t('search.enterKeyword')
|
||||
: starredOnly
|
||||
starredOnly
|
||||
? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills'))
|
||||
: (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword'))
|
||||
: (q ? t('search.noResultsFor', { q }) : undefined)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const hasRoleMock = vi.fn((role: string) => role === 'USER')
|
||||
const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER')
|
||||
const useSkillDetailMock = vi.fn()
|
||||
const useSkillLabelsMock = vi.fn()
|
||||
const useSkillVersionsMock = vi.fn()
|
||||
let authState: {
|
||||
user: { userId: string; platformRoles: string[] } | null
|
||||
hasRole: (role: string) => boolean
|
||||
} = {
|
||||
user: { userId: 'owner-1', platformRoles: ['USER'] },
|
||||
hasRole: hasRoleMock,
|
||||
}
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
|
|
@ -32,10 +39,7 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { userId: 'owner-1', platformRoles: ['USER'] },
|
||||
hasRole: hasRoleMock,
|
||||
}),
|
||||
useAuth: () => authState,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/report/use-skill-reports', () => ({
|
||||
|
|
@ -165,6 +169,10 @@ describe('SkillDetailPage', () => {
|
|||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
hasRoleMock.mockImplementation((role: string) => role === 'USER')
|
||||
authState = {
|
||||
user: { userId: 'owner-1', platformRoles: ['USER'] },
|
||||
hasRole: hasRoleMock,
|
||||
}
|
||||
useSkillDetailMock.mockReturnValue({
|
||||
data: createSkill(),
|
||||
isLoading: false,
|
||||
|
|
@ -208,6 +216,31 @@ describe('SkillDetailPage', () => {
|
|||
expect(html).not.toContain('skillDetail.deleteSkill')
|
||||
})
|
||||
|
||||
it('renders public skill details for an anonymous viewer', () => {
|
||||
authState = {
|
||||
user: null,
|
||||
hasRole: vi.fn(() => false),
|
||||
}
|
||||
|
||||
useSkillDetailMock.mockReturnValue({
|
||||
data: createSkill({
|
||||
canManageLifecycle: false,
|
||||
canInteract: true,
|
||||
visibility: 'PUBLIC',
|
||||
}),
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<SkillDetailPage />)
|
||||
|
||||
expect(html).toContain('Demo Skill')
|
||||
expect(html).toContain('install')
|
||||
expect(html).not.toContain('skillDetail.loginRequired')
|
||||
expect(html).not.toContain('skillDetail.deleteSkill')
|
||||
})
|
||||
|
||||
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
|
||||
useSkillDetailMock.mockReturnValue({
|
||||
data: createSkill({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, RefreshCw, ShieldCheck, Terminal, User } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { FileTree } from '@/features/skill/file-tree'
|
||||
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
shouldCollapseOverview,
|
||||
} from '@/features/skill/overview-collapse'
|
||||
import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error'
|
||||
import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils'
|
||||
import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow'
|
||||
import { isSkillDetailQueriesEnabled } from './skill-detail-query'
|
||||
import { RatingInput } from '@/features/social/rating-input'
|
||||
|
|
@ -116,6 +117,8 @@ export function SkillDetailPage() {
|
|||
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
|
||||
const [rereleaseTarget, setRereleaseTarget] = useState<string | null>(null)
|
||||
const [targetVersionInput, setTargetVersionInput] = useState('')
|
||||
const [rereleaseWarnings, setRereleaseWarnings] = useState<string[]>([])
|
||||
const [rereleaseWarningDialogOpen, setRereleaseWarningDialogOpen] = useState(false)
|
||||
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
|
||||
const [confirmPublishTarget, setConfirmPublishTarget] = useState<string | null>(null)
|
||||
const [submitReviewTarget, setSubmitReviewTarget] = useState<string | null>(null)
|
||||
|
|
@ -575,7 +578,7 @@ export function SkillDetailPage() {
|
|||
setTargetVersionInput(suggestNextVersion(version))
|
||||
}
|
||||
|
||||
const handleRereleaseVersion = async () => {
|
||||
const handleRereleaseVersion = async (confirmWarnings = false) => {
|
||||
if (!rereleaseTarget || !targetVersionInput.trim()) {
|
||||
return
|
||||
}
|
||||
|
|
@ -585,6 +588,7 @@ export function SkillDetailPage() {
|
|||
slug,
|
||||
version: rereleaseTarget,
|
||||
targetVersion: targetVersionInput.trim(),
|
||||
confirmWarnings,
|
||||
})
|
||||
toast.success(
|
||||
t('skillDetail.rereleaseSuccessTitle'),
|
||||
|
|
@ -592,7 +596,15 @@ export function SkillDetailPage() {
|
|||
)
|
||||
setRereleaseTarget(null)
|
||||
setTargetVersionInput('')
|
||||
setRereleaseWarnings([])
|
||||
setRereleaseWarningDialogOpen(false)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage)) {
|
||||
const warnings = extractPrecheckWarnings(error.serverMessage)
|
||||
setRereleaseWarnings(warnings)
|
||||
setRereleaseWarningDialogOpen(true)
|
||||
return
|
||||
}
|
||||
toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
|
|
@ -710,10 +722,31 @@ export function SkillDetailPage() {
|
|||
<div className="flex items-center gap-3 mb-1">
|
||||
<NamespaceBadge type="GLOBAL" name={namespace} />
|
||||
{skill.status && (
|
||||
<span className="badge-soft badge-soft-blue">
|
||||
<span className={cn(
|
||||
'badge-soft',
|
||||
skill.status === 'ACTIVE' && 'badge-soft-green',
|
||||
skill.status === 'ARCHIVED' && 'bg-secondary text-muted-foreground',
|
||||
skill.status === 'HIDDEN' && 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
!['ACTIVE', 'ARCHIVED', 'HIDDEN'].includes(skill.status) && 'badge-soft-blue',
|
||||
)}>
|
||||
{resolveSkillStatusLabel(skill.status)}
|
||||
</span>
|
||||
)}
|
||||
{skill.visibility && (
|
||||
<span className={cn(
|
||||
'badge-soft inline-flex items-center gap-1',
|
||||
skill.visibility === 'PUBLIC' && 'badge-soft-green',
|
||||
skill.visibility === 'PRIVATE' && 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
skill.visibility === 'NAMESPACE_ONLY' && 'badge-soft-blue',
|
||||
)}>
|
||||
{skill.visibility === 'PUBLIC' && <Globe className="h-3 w-3" />}
|
||||
{skill.visibility === 'PRIVATE' && <Lock className="h-3 w-3" />}
|
||||
{skill.visibility === 'NAMESPACE_ONLY' && <Users className="h-3 w-3" />}
|
||||
{skill.visibility === 'PUBLIC' && t('publish.visibilityOptions.public')}
|
||||
{skill.visibility === 'PRIVATE' && t('publish.visibilityOptions.private')}
|
||||
{skill.visibility === 'NAMESPACE_ONLY' && t('publish.visibilityOptions.namespaceOnly')}
|
||||
</span>
|
||||
)}
|
||||
{isReviewFlowPending && (
|
||||
<span className="badge-soft" style={{ background: '#fef3c7', color: '#92400e' }}>
|
||||
{t('skillDetail.versionStatusPendingReview')}
|
||||
|
|
@ -957,7 +990,7 @@ export function SkillDetailPage() {
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-center py-8">{t('skillDetail.noVersions')}</div>
|
||||
<Card className="p-8 text-muted-foreground text-center">{t('skillDetail.noVersions')}</Card>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
@ -1396,6 +1429,8 @@ export function SkillDetailPage() {
|
|||
if (!open) {
|
||||
setRereleaseTarget(null)
|
||||
setTargetVersionInput('')
|
||||
setRereleaseWarnings([])
|
||||
setRereleaseWarningDialogOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -1422,13 +1457,34 @@ export function SkillDetailPage() {
|
|||
<Button variant="outline" onClick={() => setRereleaseTarget(null)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleRereleaseVersion} disabled={rereleaseVersionMutation.isPending || !targetVersionInput.trim()}>
|
||||
<Button onClick={() => handleRereleaseVersion()} disabled={rereleaseVersionMutation.isPending || !targetVersionInput.trim()}>
|
||||
{rereleaseVersionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.rereleaseVersion')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={rereleaseWarningDialogOpen}
|
||||
onOpenChange={setRereleaseWarningDialogOpen}
|
||||
title={t('skillDetail.rereleaseWarningTitle')}
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<p>{t('skillDetail.rereleaseWarningDescription')}</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm">
|
||||
{rereleaseWarnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('skillDetail.rereleaseWarningConfirm')}
|
||||
onConfirm={() => {
|
||||
setRereleaseWarningDialogOpen(false)
|
||||
handleRereleaseVersion(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmPublishTarget}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths'
|
||||
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
|
||||
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
|
@ -22,19 +24,19 @@ interface UserMenuProps {
|
|||
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: myNamespaces } = useMyNamespaces()
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isClickOpen, setIsClickOpen] = useState(false)
|
||||
|
||||
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
|
||||
const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const canSeeGovernance = canViewGovernanceCenter(user.platformRoles)
|
||||
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
|
||||
const isSuperAdmin = hasRole('SUPER_ADMIN')
|
||||
const canAccessReviewCenter = isReviewer || isUserAdmin
|
||||
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
|
||||
const isLocalAccount = !user.oauthProvider
|
||||
const open = isHovered || isClickOpen
|
||||
|
||||
|
|
@ -157,8 +159,8 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
<Link to="/dashboard/stars" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.stars')}
|
||||
</Link>
|
||||
{canAccessReviewCenter ? (
|
||||
<Link to="/dashboard/reviews" className={menuItemClassName} onClick={closeMenu}>
|
||||
{reviewCenterVisible ? (
|
||||
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviews')}
|
||||
</Link>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -206,8 +206,11 @@ export function useRereleaseSkillVersion() {
|
|||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ namespace, slug, version, targetVersion }: { namespace: string; slug: string; version: string; targetVersion: string }) =>
|
||||
skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion),
|
||||
mutationFn: ({ namespace, slug, version, targetVersion, confirmWarnings }: { namespace: string; slug: string; version: string; targetVersion: string; confirmWarnings?: boolean }) =>
|
||||
skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion, confirmWarnings),
|
||||
meta: {
|
||||
skipGlobalErrorHandler: true,
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue