diff --git a/.env.release.draft b/.env.release.draft index 40058266..c8f0872b 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST= SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub + +# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes diff --git a/.env.release.example b/.env.release.example index cdbddf06..2d30c7bc 100644 --- a/.env.release.example +++ b/.env.release.example @@ -104,6 +104,10 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub # Security scanner is enabled by default. Set to false to disable scanning. SKILLHUB_SECURITY_SCANNER_ENABLED=true +# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +# runtime.sh generates and persists one automatically when this placeholder is still present. +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes + # Scanner LLM configuration (optional, for AI-powered scanning features) SKILL_SCANNER_LLM_API_KEY= SKILL_SCANNER_LLM_BASE_URL= diff --git a/.github/workflows/pr-cli.yml b/.github/workflows/pr-cli.yml index 7de2a31e..65c0ca6a 100644 --- a/.github/workflows/pr-cli.yml +++ b/.github/workflows/pr-cli.yml @@ -7,6 +7,9 @@ on: - 'Makefile' - '.github/workflows/pr-cli.yml' +permissions: + contents: read + jobs: cli: strategy: @@ -16,6 +19,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.13 diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml index f25d8072..f94ffdc2 100644 --- a/.github/workflows/pr-e2e.yml +++ b/.github/workflows/pr-e2e.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index eb7809d1..e521eb9e 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -4,16 +4,34 @@ on: pull_request: paths: - 'scripts/**' + - '.env.release.example' + - '.env.release.draft' + - 'compose.release.yml' + - 'Makefile' + - '.github/workflows/pr-cli.yml' + - '.github/workflows/pr-e2e.yml' + - '.github/workflows/pr-tests.yml' + - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' + - '**/*.py' + +permissions: + contents: read jobs: - publish-cli-test: + scripts-tests: + name: Script Regression Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '21' - run: bash scripts/tests/publish-cli-test.sh + - run: bash scripts/tests/runtime-secret-test.sh + - run: bash scripts/tests/validate-release-config-test.sh + - run: bash scripts/tests/dev-web-host-test.sh + - run: bash scripts/tests/workflow-security-test.sh diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index da4d7622..4e957ec3 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up pnpm uses: pnpm/action-setup@v4 @@ -52,6 +54,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Java uses: actions/setup-java@v4 @@ -74,6 +78,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Detect docs changes id: changed diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..9382d933 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,85 @@ +name: Security + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency Review + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + + codeql: + name: CodeQL (${{ matrix.language }}) + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: manual + - language: javascript-typescript + build-mode: none + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Java + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Ensure Maven wrapper is executable + if: matrix.language == 'java-kotlin' + run: chmod +x server/mvnw + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Build Java for CodeQL + if: matrix.language == 'java-kotlin' + run: cd server && ./mvnw -q -DskipTests package + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/Makefile b/Makefile index 05637d63..c45bb9ee 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid DEV_SERVER_LOG := $(DEV_DIR)/server.log DEV_WEB_LOG := $(DEV_DIR)/web.log DEV_WEB_URL := http://localhost:3000 +DEV_WEB_HOST ?= 127.0.0.1 DEV_API_URL := http://localhost:8080 DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 @@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \ fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ @@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' + cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' dev-server-restart: ## 重启后端开发服务器 @mkdir -p $(DEV_DIR) @@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖 cd web && CI=true pnpm install --frozen-lockfile dev-web: ## 启动前端开发服务器 - cd web && pnpm run dev + cd web && pnpm exec vite --host $(DEV_WEB_HOST) build-frontend: web-deps ## 构建前端 cd web && pnpm run build diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 3d983727..2290ee09 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -160,6 +160,7 @@ function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] { async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise { const prompts = await import('prompts') + let highlightedIndex = 0 const { selected } = await prompts.default({ type: 'multiselect', name: 'selected', @@ -167,7 +168,13 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise choices: candidates.map(c => ({ title: `${c.agent} (${c.rootDir})`, value: c - })) + })), + onRender: function (this: { cursor?: number }) { + highlightedIndex = this.cursor ?? highlightedIndex + }, + format: (selectedTargets: AgentCandidate[]) => ( + selectedTargets.length > 0 ? selectedTargets : [candidates[highlightedIndex] ?? candidates[0]!] + ) }) if (!selected || selected.length === 0) { throw new CliError('installation cancelled', EXIT.usage) diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index 1e420567..f549185d 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -1,6 +1,14 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { zipSync, unzipSync } from 'fflate' +import { MAX_PACKAGE_BYTES } from './download' + +const MAX_ZIP_ENTRIES = 500 +const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024 +const EOCD_SIGNATURE = 0x06054b50 +const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50 +const ZIP64_MARKER_16 = 0xffff +const ZIP64_MARKER_32 = 0xffffffff /** * Extract a zip archive buffer into the target directory. @@ -8,9 +16,15 @@ import { zipSync, unzipSync } from 'fflate' */ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise { await mkdir(targetDir, { recursive: true }) - const files = unzipSync(new Uint8Array(buffer)) - for (const [name, data] of Object.entries(files)) { - const filePath = safeJoin(targetDir, name) + const archive = new Uint8Array(buffer) + validateZipCentralDirectory(archive) + const files = unzipSync(archive) + const entries = Object.entries(files).map(([name, data]) => ({ + name, + data, + filePath: safeJoin(targetDir, name), + })) + for (const { name, data, filePath } of entries) { if (name.endsWith('/')) { await mkdir(filePath, { recursive: true }) continue @@ -20,6 +34,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis } } +function validateZipCentralDirectory(archive: Uint8Array): void { + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + const eocdOffset = findEndOfCentralDirectory(view) + if (eocdOffset < 0) { + throw new Error('invalid zip central directory') + } + + const diskNumber = view.getUint16(eocdOffset + 4, true) + const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true) + const entriesOnDisk = view.getUint16(eocdOffset + 8, true) + const totalEntries = view.getUint16(eocdOffset + 10, true) + const centralDirectorySize = view.getUint32(eocdOffset + 12, true) + const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true) + + if ( + entriesOnDisk === ZIP64_MARKER_16 || + totalEntries === ZIP64_MARKER_16 || + centralDirectorySize === ZIP64_MARKER_32 || + centralDirectoryOffset === ZIP64_MARKER_32 + ) { + throw new Error('zip64 archives are not supported') + } + if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) { + throw new Error('multi-disk zip archives are not supported') + } + if (totalEntries > MAX_ZIP_ENTRIES) { + throw new Error('zip entry count exceeds limit') + } + if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) { + throw new Error('invalid zip central directory') + } + + let offset = centralDirectoryOffset + let totalUncompressedSize = 0 + const decoder = new TextDecoder() + for (let i = 0; i < totalEntries; i++) { + if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) { + throw new Error('invalid zip central directory') + } + const uncompressedSize = view.getUint32(offset + 24, true) + const nameLength = view.getUint16(offset + 28, true) + const extraLength = view.getUint16(offset + 30, true) + const commentLength = view.getUint16(offset + 32, true) + const nameStart = offset + 46 + const nameEnd = nameStart + nameLength + const nextOffset = nameEnd + extraLength + commentLength + if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) { + throw new Error('invalid zip central directory') + } + + const entryName = decoder.decode(archive.subarray(nameStart, nameEnd)) + if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) { + throw new Error('zip entry size exceeds limit') + } + totalUncompressedSize += uncompressedSize + if (totalUncompressedSize > MAX_PACKAGE_BYTES) { + throw new Error('zip total uncompressed size exceeds limit') + } + offset = nextOffset + } +} + +function findEndOfCentralDirectory(view: DataView): number { + const minOffset = Math.max(0, view.byteLength - 0xffff - 22) + for (let offset = view.byteLength - 22; offset >= minOffset; offset--) { + if (view.getUint32(offset, true) === EOCD_SIGNATURE) { + return offset + } + } + return -1 +} + /** * Create a zip archive from a directory. * Returns the archive as a Blob. diff --git a/cli/src/platform/download.ts b/cli/src/platform/download.ts new file mode 100644 index 00000000..30490731 --- /dev/null +++ b/cli/src/platform/download.ts @@ -0,0 +1,56 @@ +import { EXIT } from '../shared/constants' +import { CliError } from '../shared/errors' + +export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024 + +export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise { + const contentLength = response.headers.get('content-length') + if (contentLength !== null) { + const declaredLength = Number(contentLength) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new CliError('download exceeds maximum package size', EXIT.network, { + contentLength: declaredLength, + maxBytes + }) + } + } + + if (!response.body) { + const buffer = await response.arrayBuffer() + if (buffer.byteLength > maxBytes) { + throw new CliError('download exceeds maximum package size', EXIT.network, { + receivedBytes: buffer.byteLength, + maxBytes + }) + } + return buffer + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let receivedBytes = 0 + + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + receivedBytes += value.byteLength + if (receivedBytes > maxBytes) { + await reader.cancel() + throw new CliError('download exceeds maximum package size', EXIT.network, { + receivedBytes, + maxBytes + }) + } + chunks.push(value) + } + + const result = new Uint8Array(receivedBytes) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result.buffer +} diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index b989ce58..4293ff6e 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,10 +1,11 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' import { InventoryStore } from '../stores/inventory-store' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { extractZip } from '../platform/archive' +import { readBoundedResponseBody } from '../platform/download' import { pathExists } from '../platform/paths' import type { AgentCandidate } from '../agents/types' @@ -23,7 +24,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed const client = new SkillHubClient(options.registry, options.token) const resolved = await client.resolve(options.namespace, options.slug, options.version) const response = await client.download(options.namespace, options.slug, resolved.version) - const buffer = await response.arrayBuffer() + const buffer = await readBoundedResponseBody(response) const installed: Array<{ agent: string; dir: string }> = [] const store = new InventoryStore(options.home) @@ -38,35 +39,62 @@ export async function installSkill(options: InstallOptions): Promise<{ installed }) } - if (await pathExists(skillDir) && options.force) { - await store.removeTargetsByInstallDir(skillDir) - await rm(skillDir, { recursive: true, force: true }) + await mkdir(target.rootDir, { recursive: true }) + const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) + let movedIntoPlace = false + + try { + await extractZip(buffer, tempDir) + + const installedAt = new Date().toISOString() + const metaDir = join(tempDir, '.skillhub') + await mkdir(metaDir, { recursive: true }) + await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ + registry: options.registry, + namespace: options.namespace, + slug: options.slug, + version: resolved.version, + agent: target.agent, + installedAt + }, null, 2)) + + if (await pathExists(skillDir) && !options.force) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + + if (await pathExists(skillDir) && options.force) { + await store.removeTargetsByInstallDir(skillDir) + await rm(skillDir, { recursive: true, force: true }) + } + + try { + await rename(tempDir, skillDir) + } catch (error) { + if (!options.force && await pathExists(skillDir)) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + throw error + } + movedIntoPlace = true + + await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, { + agent: target.agent, + rootDir: target.rootDir, + installDir: skillDir, + installedAt + }) + } finally { + if (!movedIntoPlace) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } } - // Create skill directory and extract into a clean skill-specific directory. - await mkdir(skillDir, { recursive: true }) - await extractZip(buffer, skillDir) - - // Write .skillhub/metadata.json - const metaDir = join(skillDir, '.skillhub') - await mkdir(metaDir, { recursive: true }) - await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ - registry: options.registry, - namespace: options.namespace, - slug: options.slug, - version: resolved.version, - agent: target.agent, - installedAt: new Date().toISOString() - }, null, 2)) - - // Update inventory - await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, { - agent: target.agent, - rootDir: target.rootDir, - installDir: skillDir, - installedAt: new Date().toISOString() - }) - installed.push({ agent: target.agent, dir: skillDir }) } diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts new file mode 100644 index 00000000..55bc99ac --- /dev/null +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { AgentCandidate } from '../../../src/agents/types' + +interface PromptOptions { + onRender?: (this: { cursor?: number }) => void + format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[] +} + +mock.module('prompts', () => ({ + default: (options: PromptOptions) => { + options.onRender?.call({ cursor: 1 }) + return { selected: options.format?.([]) ?? [] } + } +})) + +const { resolveInstallTargets } = await import('../../../src/agents/resolver') + +describe('resolveInstallTargets interactive prompt', () => { + test('uses the highlighted target when Enter submits an empty multiselect', async () => { + const detected: AgentCandidate[] = [ + { agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }, + { agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' } + ] + const highlighted = detected[1]! + + const targets = await resolveInstallTargets({ + cwd: '/repo', + agents: [], + json: false, + interactive: true, + detected + }) + + expect(targets).toEqual([highlighted]) + }) +}) diff --git a/cli/test/unit/platform/archive.test.ts b/cli/test/unit/platform/archive.test.ts index a39c3c03..b175e279 100644 --- a/cli/test/unit/platform/archive.test.ts +++ b/cli/test/unit/platform/archive.test.ts @@ -32,6 +32,17 @@ describe('archive helpers', () => { await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path') }) + test('rejects unsafe zip before writing earlier safe entries', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-partial-')) + const unsafe = zipSync({ + 'SKILL.md': new TextEncoder().encode('# Partial'), + '../escape.txt': new TextEncoder().encode('bad'), + }) + + await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path') + await expect(readFile(join(target, 'SKILL.md'), 'utf-8')).rejects.toThrow() + }) + test('rejects zip entries with absolute paths', async () => { const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-abs-')) const unsafe = zipSync({ '/etc/passwd': new TextEncoder().encode('bad') }) @@ -56,4 +67,32 @@ describe('archive helpers', () => { const entries = await readdir(target) expect(entries).toEqual([]) }) + + test('rejects zip archives with more than 500 entries before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-')) + const manyEntries = Object.fromEntries( + Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)]) + ) + const archive = zipSync(manyEntries) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit') + }) + + test('rejects zip entries larger than 10 MiB before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-')) + const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) }) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit') + }) + + test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => { + const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-')) + const oneMiB = new Uint8Array(1024 * 1024) + const entries = Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB]) + ) + const archive = zipSync(entries) + + await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit') + }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 2ea2735d..fd08e1b6 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -21,6 +21,13 @@ function installFetch(zipEntries: Record): typeof fetch { Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)]) )) + return installFetchWithDownloadResponse(new Response( + archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, + { status: 200 } + )) +} + +function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch { const fakeFetch = async (input: URL | RequestInfo) => { const path = new URL(String(input)).pathname if (path.endsWith('/resolve')) { @@ -37,8 +44,7 @@ function installFetch(zipEntries: Record): typeof fetch { }) } if (path.endsWith('/download')) { - const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer - return new Response(body, { status: 200 }) + return downloadResponse.clone() } return Response.json({ code: 404 }, { status: 404 }) } @@ -125,4 +131,60 @@ describe('installSkill', () => { expect(inventory.items[0].targets).toHaveLength(1) expect(inventory.items[0].targets[0].installDir).toBe(skillDir) }) + + test('force keeps old installation and inventory when replacement extraction fails', async () => { + globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 })) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Old') + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(inventoryPath, JSON.stringify({ + items: [{ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + version: '0.1.0', + targets: [{ + agent: 'codex', + rootDir, + installDir: skillDir, + installedAt: '2026-04-20T00:00:00.000Z' + }] + }] + }, null, 2)) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home + })).rejects.toThrow('invalid zip central directory') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old') + const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(inventory.items).toHaveLength(1) + expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' }) + expect(inventory.items[0].targets[0].installDir).toBe(skillDir) + }) + + test('rejects downloads whose content-length exceeds the package limit', async () => { + globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), { + status: 200, + headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) } + })) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false + })).rejects.toThrow('download exceeds maximum package size') + }) }) diff --git a/compose.release.yml b/compose.release.yml index 1b878731..5ed7086e 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -58,6 +58,7 @@ services: SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required} SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3} STORAGE_BASE_PATH: /var/lib/skillhub/storage SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-} @@ -99,6 +100,8 @@ services: condition: service_healthy redis: condition: service_healthy + skill-scanner: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 10s diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index a6c5e2ff..48d1fd1a 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -42,11 +42,17 @@ services: BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local OAUTH2_GITHUB_CLIENT_ID: local-placeholder OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes + SKILLHUB_SECURITY_SCANNER_ENABLED: "true" + SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000 + SKILLHUB_SECURITY_SCANNER_MODE: upload depends_on: postgres: condition: service_healthy redis: condition: service_healthy + skill-scanner: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 10s diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index e0719939..3cd6a7cb 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -112,7 +112,7 @@ | skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 | | namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 | | SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 | -| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;review 场景下还能审自己的提交 | 可 | 可 | +| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 | ### 对象存储写入策略 diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 6e9db3cc..4ee36a08 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -66,7 +66,7 @@ my-skill/ ``` 校验规则: -- 根目录必须包含 `SKILL.md` +- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md`、`Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md` - 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` - 单文件大小限制:1MB(可配置) - 总包大小限制:10MB(可配置) diff --git a/docs/20-cloud-url-builtin-skills-setup.md b/docs/20-cloud-url-builtin-skills-setup.md new file mode 100644 index 00000000..7de92d63 --- /dev/null +++ b/docs/20-cloud-url-builtin-skills-setup.md @@ -0,0 +1,269 @@ +# 云存储链接内置 Skills 配置指南 + +本文说明如何通过仓库内 manifest 配置 SkillHub 内置 Skills,以及应用启动时这些 Skills 如何从云存储同步到 `@global` 空间。 + +适用场景: + +- 希望 SkillHub 新部署实例默认带有一批官方内置 Skills。 +- 不希望把完整 Skill 包目录长期放在代码仓库和镜像中。 +- 内置 Skill 包已经上传到官方可控的云存储域名。 + +## 1. 方案概览 + +内置 Skills 不再以本地目录包的形式直接随仓库维护。当前方案只在仓库中维护一个 manifest 文件,应用启动时根据 manifest 中的云存储 URL 下载 zip 包,并通过 SkillHub 现有发布链路发布到 `@global`。 + +流程: + +```text +维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用 ready -> 后台读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见 +``` + +核心文件: + +```text +server/skillhub-app/src/main/resources/builtin-skills/manifest.json +``` + +首版 manifest 只需要维护三个字段: + +- `slug`:Skill 在 `@global` 下的 slug。 +- `version`:期望同步的 Skill 版本。 +- `url`:Skill zip 包的云存储 HTTPS 链接。 + +## 2. Manifest 配置 + +manifest 文件格式如下: + +```json +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.0.0.zip" + } + ] +} +``` + +可以配置多个 Skills,也可以为同一个 `slug` 配置多个版本: + +```json +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.0.0.zip" + }, + { + "slug": "skillhub-hello", + "version": "1.1.0", + "url": "https://bjcdn.openstorage.cn//skillhub-hello-1.1.0.zip" + }, + { + "slug": "skillhub-guide", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn//skillhub-guide-1.0.0.zip" + } + ] +} +``` + +配置要求: + +- `skills` 必须是数组。 +- 每一项必须同时填写 `slug`、`version`、`url`。 +- `slug` 必须符合 SkillHub slug 规则。 +- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。 +- manifest 最多处理前 100 条 entries。 +- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。 + +## 3. Skill 包要求 + +manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议: + +- zip 可以在根目录直接包含 `SKILL.md`,也可以包含一个单独的顶层 Skill 目录,并在该目录下包含 `SKILL.md`。 +- `SKILL.md` frontmatter 中必须包含合法的 `name`、`description`、`version` 等元数据。 +- `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`。 +- `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`。 +- 包内容仍会经过 SkillHub 现有发布校验,包括文件数量、文件大小、扩展名、文件类型等规则。 + +示例: + +```text +skillhub-hello-1.0.0.zip +├── SKILL.md +├── README.md +└── scripts/ + └── check.js +``` + +同样支持标准单目录 Skill 包: + +```text +skillhub-hello-1.0.0.zip +└── skillhub-hello/ + ├── SKILL.md + └── README.md +``` + +如果 zip 中存在多个顶层目录,或在多个目录中同时出现 `SKILL.md`,同步器会跳过该项并记录错误,避免误选入口。 + +## 4. URL 安全限制 + +内置 Skill 同步由后端在启动时主动下载远程文件,因此 URL 有严格限制。 + +首版只允许: + +- `https://` 协议。 +- host 为 `bjcdn.openstorage.cn`。 +- host 为 `bjcdn.openstorage.cn` 的子域名,例如 `assets.bjcdn.openstorage.cn`。 +- 默认 HTTPS 端口,或显式 `:443`。 + +以下 URL 会被跳过: + +- `http://...` +- 非 `bjcdn.openstorage.cn` 及其子域名。 +- 带 userinfo 的 URL,例如 `https://user:pass@bjcdn.openstorage.cn/file.zip`。 +- 非 443 端口,例如 `https://bjcdn.openstorage.cn:8443/file.zip`。 +- `localhost`、IP 地址、IPv6 literal 等 host。 +- 需要 HTTP redirect 才能拿到文件的链接。 + +如果某一项 URL 不符合规则,SkillHub 会记录日志并跳过该项,不会阻塞应用启动。 + +## 5. 启动同步流程 + +应用 ready 后同步器会在后台执行一次,不阻塞应用 ready。 + +详细流程: + +1. 检查 `skillhub.builtin-skills.enabled` 是否开启。 +2. 读取 `classpath:builtin-skills/manifest.json`。 +3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。 +4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。 +5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。 +6. 如果系统发布者还不是 `@global` 成员,则创建 `OWNER` 成员记录;已有成员记录不会自动改角色。 +7. 按 manifest 顺序处理每一个 item。 +8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。 +9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。 +10. 解包并校验 Skill 入口 `SKILL.md`。 +11. 校验 manifest 中的 `slug`、`version` 与包内元数据一致。 +12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。 +13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`。 +14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`。 + +同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务,因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。 + +## 6. 幂等与冲突处理 + +内置 Skill 同步支持重复启动和多次部署。 + +幂等键: + +```text +@global/{slug} + version +``` + +行为说明: + +| 场景 | 行为 | +|---|---| +| `@global/{slug}` 不存在 | 发布 manifest 中的 Skill | +| `@global/{slug}` 已存在,owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 | +| 同版本已存在且已发布 | 下载前跳过 | +| 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 | +| `@global/{slug}` 已被其他 owner 创建或发布 | 下载前跳过并记录 warning | + +这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill;即使该 Skill 仍处于待审、未发布或已拒绝状态,也会跳过对应 manifest item。 +同版本已存在时,同步器不会重新下载远端 zip,也不会验证远端对象内容是否发生漂移。 + +如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。 + +## 7. 开关配置 + +内置 Skill 同步默认开启。 + +Spring 配置项: + +```yaml +skillhub: + builtin-skills: + enabled: true +``` + +环境变量: + +```dotenv +SKILLHUB_BUILTIN_SKILLS_ENABLED=true +``` + +如需禁用启动同步: + +```dotenv +SKILLHUB_BUILTIN_SKILLS_ENABLED=false +``` + +禁用后,应用 ready 后不会读取 manifest,也不会下载或发布任何内置 Skill。 + +## 8. 维护流程 + +新增一个内置 Skill 的推荐步骤: + +1. 准备 Skill 包,并确认 zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。 +2. 检查 `SKILL.md` 中的 `name` 和 `version`。 +3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。 +4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。 +5. 确保 manifest 中的 `slug` 等于 `SKILL.md name` 归一化后的 slug。 +6. 确保 manifest 中的 `version` 等于 `SKILL.md version`。 +7. 本地或测试环境启动 SkillHub,查看后端日志确认同步结果。 +8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。 + +更新一个已有内置 Skill 的推荐步骤: + +1. 不要覆盖已经发布过的旧版本 zip 内容。 +2. 在 `SKILL.md` 中提升 `version`。 +3. 重新打包并上传新的 zip 文件。 +4. 在 manifest 中新增一条同 `slug`、新 `version` 的记录。 +5. 保留旧版本记录,除非产品明确不再需要该旧版本在新实例中预置。 + +不推荐: + +- 修改旧版本 zip 内容但保持同一个 `version`。 +- 把 URL 指向会发生内容变化的临时对象。 +- 使用需要登录、签名跳转或重定向的下载链接。 + +## 9. 日志与排查 + +启动时可以通过后端日志观察同步结果。 + +常见日志含义: + +| 日志含义 | 处理建议 | +|---|---| +| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath | +| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 | +| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 | +| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 | +| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 | +| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 | +| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 | +| manifest version does not match package version | 检查 manifest `version` 和 `SKILL.md version` 是否一致 | +| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 | +| published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 | + +如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。 + +## 10. 验收检查 + +配置或新增内置 Skill 后,建议至少完成以下检查: + +- manifest JSON 格式合法。 +- 每个 item 都包含 `slug`、`version`、`url`。 +- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。 +- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`。 +- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。 +- `SKILL.md version` 与 manifest `version` 一致。 +- 启动日志没有该 item 的 warning 或 error。 +- Web UI 中可以看到 `@global/{slug}`。 +- Skill 可被匿名或登录用户按公开 Skill 规则发现。 diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index c99092f4..cdacab91 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -7,6 +7,11 @@ export default defineConfig({ ignoreDeadLinks: [/^http:\/\/localhost/], head: [], + vite: { + build: { + target: 'es2020', + }, + }, // Define root locale for redirect locales: { @@ -124,4 +129,4 @@ export default defineConfig({ }, }, }, -}) \ No newline at end of file +}) diff --git a/docs/skillhub/package-lock.json b/docs/skillhub/package-lock.json index e9bf813d..4bdf62a4 100644 --- a/docs/skillhub/package-lock.json +++ b/docs/skillhub/package-lock.json @@ -369,9 +369,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -386,9 +386,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -403,9 +403,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -420,9 +420,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -437,9 +437,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -454,9 +454,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -505,9 +505,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -522,9 +522,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -539,9 +539,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -556,9 +556,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -573,9 +573,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -590,9 +590,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -607,9 +607,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -624,9 +624,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -641,9 +641,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -658,9 +658,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -692,9 +692,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -726,9 +726,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -794,9 +794,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1757,9 +1757,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1770,32 +1770,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -2475,9 +2475,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/skillhub/package.json b/docs/skillhub/package.json index 4b119b04..a044c51f 100644 --- a/docs/skillhub/package.json +++ b/docs/skillhub/package.json @@ -11,8 +11,8 @@ "vitepress": "^1.6.3" }, "overrides": { - "vite": "^6.4.2", + "vite": "^6.4.3", "postcss": "^8.5.10", - "esbuild": "^0.25.0" + "esbuild": "^0.28.1" } } diff --git a/document/docs/02-administration/security/authorization.md b/document/docs/02-administration/security/authorization.md index 36d7fef3..75ff506e 100644 --- a/document/docs/02-administration/security/authorization.md +++ b/document/docs/02-administration/security/authorization.md @@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制(RBAC)系统。 | 角色 | 代码 | 实际能力 | |------|------|----------| -| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion,且普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | +| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion;普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | | 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本(yank)、处理技能举报;可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report;不能分配平台角色、不能看审计日志、不能管理用户。 | | 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 | | 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs` 和 `/actuator/prometheus`;治理工作台中只能看 activity,不能处理 review/promotion/report,也不能管理用户或技能。 | diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md index 4c0359be..ee411ca4 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md @@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles: | Role | Code | Effective behavior | |------|------|--------------------| -| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. | +| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. | | Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. | | User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. | | Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. | diff --git a/scanner/docs/configuration.md b/scanner/docs/configuration.md index 1de8606a..2544aed2 100644 --- a/scanner/docs/configuration.md +++ b/scanner/docs/configuration.md @@ -18,7 +18,7 @@ skillhub: security: scanner: # 基础配置 - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local} @@ -48,20 +48,20 @@ skillhub: #### `enabled` - **类型**:Boolean -- **默认值**:`false` +- **默认值**:`true` - **环境变量**:`SKILLHUB_SECURITY_SCANNER_ENABLED` - **说明**:是否启用安全扫描功能 - **影响**: - `true`:技能包发布时会触发安全扫描 - - `false`:跳过安全扫描,直接进入审核流程 + - `false`:仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner **示例**: ```yaml -# 开发环境:禁用扫描 +# 仅本地私有技能调试:禁用扫描 enabled: false -# 生产环境:启用扫描 +# 公共或命名空间可见发布:启用扫描 enabled: true ``` @@ -103,8 +103,8 @@ base-url: https://scanner.example.com **示例**: ```yaml -# Docker Compose 环境(共享卷) -mode: local +# Docker Compose 环境(Scanner 独立容器,无共享发布目录) +mode: upload # Kubernetes 环境(独立 Pod) mode: upload @@ -298,7 +298,7 @@ services: environment: - SKILLHUB_SECURITY_SCANNER_ENABLED=true - SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000 - - SKILLHUB_SECURITY_SCANNER_MODE=local + - SKILLHUB_SECURITY_SCANNER_MODE=upload - SKILLHUB_SCANNER_USE_BEHAVIORAL=false - SKILLHUB_SCANNER_USE_LLM=false - SKILLHUB_SCANNER_USE_META=true @@ -348,9 +348,9 @@ stringData: skillhub: security: scanner: - enabled: false # 开发时禁用扫描,加快迭代速度 + enabled: true # 默认启用;PUBLIC/NAMESPACE_ONLY 发布依赖扫描 base-url: http://localhost:8000 - mode: local + mode: upload analyzers: meta: true # 只启用元数据分析 policy: @@ -366,7 +366,7 @@ skillhub: scanner: enabled: true # 测试环境启用扫描 base-url: http://skill-scanner:8000 - mode: local + mode: upload analyzers: behavioral: true meta: true diff --git a/scripts/runtime.sh b/scripts/runtime.sh index f5b89550..fec6375e 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -159,13 +159,34 @@ set_env_value() { fi tmp="$ENV_FILE.tmp" - if grep -q "^$key=" "$ENV_FILE"; then - sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp" - else - cat "$ENV_FILE" >"$tmp" - printf '%s=%s\n' "$key" "$value" >>"$tmp" - fi + found=false + old_umask="$(umask)" + umask 077 + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "$key="*) + printf '%s=%s\n' "$key" "$value" + found=true + ;; + *) + printf '%s\n' "$line" + ;; + esac + done <"$ENV_FILE" + if [ "$found" = "false" ]; then + printf '%s=%s\n' "$key" "$value" + fi + } >"$tmp" + umask "$old_umask" mv "$tmp" "$ENV_FILE" + secure_env_file +} + +secure_env_file() { + if [ -f "$ENV_FILE" ]; then + chmod 600 "$ENV_FILE" + fi } get_env_value() { @@ -180,6 +201,42 @@ get_env_value() { fi } +generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + + if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then + dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' + return 0 + fi + + echo "Unable to generate SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET. Install openssl or configure it manually." >&2 + exit 1 +} + +is_placeholder_secret() { + case "$1" in + ""|change-me-in-production|replace-me|replace-with-random-download-secret-32-bytes|TODO*|todo*|replace*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_anonymous_download_secret() { + secret="$(get_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "")" + if ! is_placeholder_secret "$secret" && [ "${#secret}" -ge 32 ]; then + return 0 + fi + + set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)" + echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE" +} + wait_for_postgres_ready() { postgres_user="$1" postgres_db="$2" @@ -199,6 +256,23 @@ wait_for_postgres_ready() { exit 1 } +wait_for_redis_ready() { + attempt=1 + + while [ "$attempt" -le 60 ]; do + if run_compose exec -T redis redis-cli ping >/dev/null 2>&1; then + return 0 + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + echo "Redis did not become ready in time." >&2 + run_compose logs redis >&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")" @@ -231,8 +305,12 @@ prepare_runtime_files() { download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE" if [ ! -f "$ENV_FILE" ]; then + old_umask="$(umask)" + umask 077 cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" + umask "$old_umask" fi + secure_env_file if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}" @@ -280,6 +358,12 @@ prepare_runtime_files() { if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE" fi + + if [ "$DISABLE_SCANNER" = "true" ]; then + set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false" + fi + + ensure_anonymous_download_secret } run_compose() { @@ -295,7 +379,9 @@ case "$COMMAND" in 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 + run_compose up -d redis + wait_for_redis_ready + SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --no-deps --scale skill-scanner=0 server web else run_compose up -d fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 35389ece..e1e46289 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -36,8 +36,8 @@ echo "Target: $BASE_URL" echo check "Health endpoint" "$BASE_URL/actuator/health" "200" -check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" -check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" +check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401" +check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null @@ -67,6 +67,15 @@ else FAIL=$((FAIL + 1)) fi +NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)" +if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then + echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)" + FAIL=$((FAIL + 1)) +fi + CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ -X POST "$BASE_URL/api/v1/auth/local/change-password" \ -b "$COOKIE_JAR" \ diff --git a/scripts/tests/dev-web-host-test.sh b/scripts/tests/dev-web-host-test.sh new file mode 100755 index 00000000..10c22a07 --- /dev/null +++ b/scripts/tests/dev-web-host-test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MAKEFILE="$REPO_ROOT/Makefile" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \ + || fail "Makefile must default DEV_WEB_HOST to 127.0.0.1" + +grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \ + || fail "dev web startup must pass DEV_WEB_HOST to vite" + +grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \ + || fail "dev-server must inject scanner upload environment" + +if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then + fail "dev web startup must not bind Vite to 0.0.0.0 by default" +fi + +echo "dev-web-host-test passed" diff --git a/scripts/tests/runtime-secret-test.sh b/scripts/tests/runtime-secret-test.sh new file mode 100755 index 00000000..506b58fd --- /dev/null +++ b/scripts/tests/runtime-secret-test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/runtime.sh" + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +install_fake_tools() { + local bin_dir="$1" + mkdir -p "$bin_dir" + cat >"$bin_dir/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${DOCKER_LOG:?DOCKER_LOG is required}" +if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then + exit 0 +fi +exit 0 +EOF + chmod +x "$bin_dir/docker" + + cat >"$bin_dir/openssl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "rand" && "${2:-}" == "-hex" && "${3:-}" == "32" ]]; then + printf '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' + exit 0 +fi +echo "unsupported openssl args: $*" >&2 +exit 1 +EOF + chmod +x "$bin_dir/openssl" +} + +run_runtime() { + local home="$1" + local bin_dir="$2" + local stdout="$3" + shift 3 + DOCKER_LOG="$home/docker.log" \ + SKILLHUB_HOME="$home" \ + SKILLHUB_RAW_BASE="file://$REPO_ROOT" \ + PATH="$bin_dir:$PATH" \ + sh "$SCRIPT" up --version sha-test --public-url http://localhost "$@" >"$stdout" +} + +file_mode() { + local file="$1" + if stat -c %a "$file" >/dev/null 2>&1; then + stat -c %a "$file" + else + stat -f %Lp "$file" + fi +} + +tmp="$(new_tmp)" +bin_dir="$tmp/bin" +install_fake_tools "$bin_dir" + +home_generated="$tmp/generated" +stdout_generated="$tmp/generated.out" +mkdir -p "$home_generated" +run_runtime "$home_generated" "$bin_dir" "$stdout_generated" + +generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)" +[[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \ + || fail "runtime should generate a persisted anonymous download secret" +[[ "$(file_mode "$home_generated/.env.release")" == "600" ]] \ + || fail "runtime env file must be readable only by the owner" +grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \ + || fail "runtime should explain that it generated the secret" +if grep -Fq "$generated_secret" "$stdout_generated"; then + fail "runtime must not print the generated secret value" +fi + +home_preserved="$tmp/preserved" +stdout_preserved="$tmp/preserved.out" +mkdir -p "$home_preserved" +cat >"$home_preserved/.env.release" <<'EOF' +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=already-valid-runtime-secret-32-bytes +EOF +run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved" + +preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)" +[[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \ + || fail "runtime must preserve an existing valid anonymous download secret" +[[ "$(file_mode "$home_preserved/.env.release")" == "600" ]] \ + || fail "runtime env file must remain owner-readable only when an existing secret is preserved" +if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then + fail "runtime must not regenerate an existing valid secret" +fi + +home_no_scanner="$tmp/no-scanner" +stdout_no_scanner="$tmp/no-scanner.out" +mkdir -p "$home_no_scanner" +run_runtime "$home_no_scanner" "$bin_dir" "$stdout_no_scanner" --no-scanner + +grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.release" \ + || fail "runtime should persist scanner disabled state for --no-scanner" +grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \ + || fail "runtime --no-scanner should start server/web without waiting on scanner dependencies" + +echo "runtime-secret-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh new file mode 100755 index 00000000..fd76ddfc --- /dev/null +++ b/scripts/tests/validate-release-config-test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh" + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_env() { + local file="$1" + local secret="${2:-}" + local include_secret="${3:-yes}" + cat >"$file" <>"$file" + fi +} + +expect_fail() { + local file="$1" + local expected="$2" + local output + if output="$("$SCRIPT" "$file" 2>&1)"; then + fail "expected validation to fail for $file" + fi + if [[ "$output" != *"$expected"* ]]; then + fail "expected output to contain '$expected', got: $output" + fi +} + +tmp="$(new_tmp)" + +valid_env="$tmp/valid.env" +write_env "$valid_env" "release-download-secret-32-bytes-minimum" +"$SCRIPT" "$valid_env" >/dev/null + +missing_env="$tmp/missing.env" +write_env "$missing_env" "" no +expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required" + +placeholder_env="$tmp/placeholder.env" +write_env "$placeholder_env" "change-me-in-production" +expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value" + +short_env="$tmp/short.env" +write_env "$short_env" "too-short" +expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" + +draft_env="$tmp/draft.env" +while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=*) + printf '%s\n' "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum" + ;; + *) + printf '%s\n' "$line" + ;; + esac +done <"$REPO_ROOT/.env.release.draft" >"$draft_env" +expect_fail "$draft_env" "POSTGRES_PASSWORD" + +echo "validate-release-config-test passed" diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh new file mode 100755 index 00000000..9a1688ef --- /dev/null +++ b/scripts/tests/workflow-security-test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml" +PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_pr_workflow_hardened() { + local workflow="$1" + grep -Eq '^permissions:[[:space:]]*$' "$workflow" \ + || fail "$workflow must declare top-level permissions" + grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$workflow" \ + || fail "$workflow GITHUB_TOKEN permissions must include contents: read" + grep -Fq 'persist-credentials: false' "$workflow" \ + || fail "$workflow checkout steps must not persist credentials" +} + +[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required" + +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml" +assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW" +assert_pr_workflow_hardened "$SECURITY_WORKFLOW" + +grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \ + || fail "security workflow must run dependency review" +grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \ + || fail "security workflow must initialize CodeQL" +grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \ + || fail "security workflow must build Java with the server Maven wrapper" +grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \ + || fail "security workflow must grant SARIF upload permission" + +python_source="$(find "$REPO_ROOT" \ + \( -path "$REPO_ROOT/.git" -o -path '*/node_modules' -o -path '*/.venv' \) -prune -o \ + -type f -name '*.py' -print -quit)" +if [[ -n "$python_source" ]]; then + grep -Fq 'language: python' "$SECURITY_WORKFLOW" \ + || fail "security workflow must run Python CodeQL when Python source exists" +else + ! grep -Fq 'language: python' "$SECURITY_WORKFLOW" \ + || fail "security workflow must not run Python CodeQL without Python source" +fi + +grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when security workflow changes" +grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR CLI workflow changes" +grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR E2E workflow changes" +grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when Python source changes" +grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release env example changes" +grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release env draft changes" +grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when release compose changes" +grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run runtime-secret-test" +grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run dev-web-host-test" +grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run workflow-security-test" + +echo "workflow-security-test passed" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 1d4f9285..03e42bd8 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -52,6 +52,23 @@ reject_values() { done } +reject_patterns() { + var_name="$1" + shift + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + for pattern in "$@"; do + case "$var_value" in + $pattern) + error "$var_name still uses placeholder/default pattern: $var_value" + return 0 + ;; + esac + done +} + validate_url() { var_name="$1" eval "var_value=\${$var_name:-}" @@ -97,17 +114,40 @@ validate_port() { esac } +validate_min_length() { + var_name="$1" + min_length="$2" + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + return 0 + fi + if [ "${#var_value}" -lt "$min_length" ]; then + error "$var_name must be at least $min_length characters" + fi +} + require_non_empty SKILLHUB_PUBLIC_BASE_URL validate_url SKILLHUB_PUBLIC_BASE_URL validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL +require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET +reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes" +reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*" +validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32 + reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" +reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*" reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" +reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*" if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then require_non_empty BOOTSTRAP_ADMIN_PASSWORD fi reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me" reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me" +reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*" +reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*" +reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*" +reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*" validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index bef19708..71ee7de8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub; +import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties; import com.iflytek.skillhub.config.ProfileFieldPolicyProperties; import com.iflytek.skillhub.config.ProfileModerationProperties; import org.springframework.boot.SpringApplication; @@ -10,7 +11,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties * Main Spring Boot entry point for the SkillHub backend application. */ @SpringBootApplication -@EnableConfigurationProperties({ProfileModerationProperties.class, ProfileFieldPolicyProperties.class}) +@EnableConfigurationProperties({ + BuiltinSkillProperties.class, + ProfileModerationProperties.class, + ProfileFieldPolicyProperties.class +}) public class SkillhubApplication { public static void main(String[] args) { SpringApplication.run(SkillhubApplication.class, args); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java new file mode 100644 index 00000000..7ed8c00d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java @@ -0,0 +1,439 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Best-effort startup synchronizer for remotely hosted built-in skill packages. + */ +@Component +public class BuiltinSkillInitializer { + + static final String GLOBAL_NAMESPACE = "global"; + static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher"; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class); + private static final Set SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN"); + private static final boolean CONFIRM_BUILTIN_PUBLISH_WARNINGS = true; + + private final BuiltinSkillProperties properties; + private final BuiltinSkillManifestLoader manifestLoader; + private final BuiltinSkillRemotePackageDownloader downloader; + private final BuiltinSkillPackageExtractor extractor; + private final SkillMetadataParser metadataParser; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final SkillPublishService skillPublishService; + + public BuiltinSkillInitializer( + BuiltinSkillProperties properties, + BuiltinSkillManifestLoader manifestLoader, + BuiltinSkillRemotePackageDownloader downloader, + BuiltinSkillPackageExtractor extractor, + SkillMetadataParser metadataParser, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillFileRepository skillFileRepository, + SkillPublishService skillPublishService) { + this.properties = properties; + this.manifestLoader = manifestLoader; + this.downloader = downloader; + this.extractor = extractor; + this.metadataParser = metadataParser; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.skillPublishService = skillPublishService; + } + + @EventListener(ApplicationReadyEvent.class) + @Async("skillhubEventExecutor") + public void synchronizeAfterApplicationReady() { + synchronize(); + } + + void synchronize() { + if (!properties.isEnabled()) { + log.info("Built-in skill startup synchronization is disabled"); + return; + } + + Optional namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE); + if (namespace.isEmpty()) { + log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization", + GLOBAL_NAMESPACE); + return; + } + + List items = manifestLoader.load(); + if (items.isEmpty()) { + log.info("No built-in skill manifest items to synchronize"); + return; + } + + try { + if (!ensureSystemPublisher(namespace.get())) { + return; + } + } catch (RuntimeException exception) { + log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}", + exception.getMessage(), exception); + return; + } + + int published = 0; + int idempotentSkipped = 0; + int conflictSkipped = 0; + int failed = 0; + for (ManifestItem item : items) { + try { + SyncOutcome outcome = syncItem(namespace.get(), item); + switch (outcome) { + case PUBLISHED -> published++; + case IDEMPOTENT_SKIPPED -> idempotentSkipped++; + case CONFLICT_SKIPPED -> conflictSkipped++; + case FAILED -> failed++; + } + } catch (Exception exception) { + failed++; + log.error( + "Failed to synchronize built-in skill slug={} version={}: {}", + item.slug(), + item.version(), + exception.getMessage(), + exception + ); + } + } + log.info( + "Built-in skill synchronization finished: total={}, published={}, idempotentSkipped={}, conflictSkipped={}, failed={}", + items.size(), + published, + idempotentSkipped, + conflictSkipped, + failed + ); + } + + private boolean ensureSystemPublisher(Namespace namespace) { + Optional existingPublisher = userAccountRepository.findById(SYSTEM_PUBLISHER_ID); + UserAccount publisher; + if (existingPublisher.isPresent()) { + publisher = existingPublisher.get(); + } else { + publisher = UserAccount.systemAccount( + SYSTEM_PUBLISHER_ID, + "Built-in Skill Publisher", + null, + null + ); + userAccountRepository.save(publisher); + } + if (!publisher.isSystemAccount()) { + log.error("Built-in skill publisher account id '{}' already exists but is not a system account; " + + "skipping built-in skill synchronization", SYSTEM_PUBLISHER_ID); + return false; + } + + if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember( + namespace.getId(), + SYSTEM_PUBLISHER_ID, + NamespaceRole.OWNER + )); + } + return true; + } + + private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exception { + Optional skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item); + if (skipBeforeDownload.isPresent()) { + return skipBeforeDownload.get(); + } + + Optional packageUri = parsePackageUri(item); + if (packageUri.isEmpty()) { + return SyncOutcome.FAILED; + } + + Optional packageBytes = downloader.download(packageUri.get()); + if (packageBytes.isEmpty()) { + log.warn("Skipping built-in skill slug={} version={} because package download failed", + item.slug(), item.version()); + return SyncOutcome.FAILED; + } + + SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get()); + List entries = extractionResult.entries(); + SkillMetadata metadata = parseSkillMetadata(entries); + String packageSlug = SlugValidator.slugify(metadata.name()); + if (!item.slug().equals(packageSlug)) { + log.warn( + "Skipping built-in skill manifest slug={} version={} because package slug is {}", + item.slug(), + item.version(), + packageSlug + ); + return SyncOutcome.FAILED; + } + if (!item.version().equals(metadata.version())) { + log.warn( + "Skipping built-in skill slug={} because manifest version {} does not match package version {}", + item.slug(), + item.version(), + metadata.version() + ); + return SyncOutcome.FAILED; + } + + Optional skipExisting = shouldSkipExisting(namespace.getId(), item, entries); + if (skipExisting.isPresent()) { + return skipExisting.get(); + } + + try { + skillPublishService.publishFromEntries( + GLOBAL_NAMESPACE, + entries, + SYSTEM_PUBLISHER_ID, + SkillVisibility.PUBLIC, + SYSTEM_PUBLISHER_ROLES, + CONFIRM_BUILTIN_PUBLISH_WARNINGS + ); + log.info("Published built-in skill slug={} version={} to @{}", + item.slug(), item.version(), GLOBAL_NAMESPACE); + return SyncOutcome.PUBLISHED; + } catch (RuntimeException exception) { + if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) { + log.info("Built-in skill slug={} version={} was published concurrently, skipping", + item.slug(), item.version()); + return SyncOutcome.IDEMPOTENT_SKIPPED; + } + log.error("Failed to publish built-in skill slug={} version={}: {}", + item.slug(), item.version(), exception.getMessage(), exception); + return SyncOutcome.FAILED; + } + } + + private Optional parsePackageUri(ManifestItem item) { + try { + return Optional.of(URI.create(item.url())); + } catch (IllegalArgumentException exception) { + log.warn("Skipping built-in skill slug={} version={} because URL is not allowed: {}", + item.slug(), item.version(), exception.getMessage()); + return Optional.empty(); + } + } + + private SkillMetadata parseSkillMetadata(List entries) { + PackageEntry skillMd = entries.stream() + .filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH)); + return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8)); + } + + private Optional shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + if (hasOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user", + item.slug()); + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); + } + + Optional builtinSkill = existingSkills.stream() + .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .findFirst(); + if (builtinSkill.isEmpty()) { + return Optional.empty(); + } + + Optional existingVersion = skillVersionRepository + .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); + if (existingVersion.isEmpty()) { + return Optional.empty(); + } + + SkillVersion version = existingVersion.get(); + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + log.info("Skipping built-in skill slug={} version={} before download because it is already published", + item.slug(), item.version()); + } else { + log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}", + item.slug(), item.version(), version.getStatus()); + } + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); + } + + private Optional shouldSkipExisting(Long namespaceId, ManifestItem item, List entries) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + if (hasOtherOwnerConflict(existingSkills)) { + log.warn("Skipping built-in skill slug={} because the slug already belongs to another user", + item.slug()); + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); + } + + Optional builtinSkill = existingSkills.stream() + .filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) + .findFirst(); + if (builtinSkill.isEmpty()) { + return Optional.empty(); + } + + Optional existingVersion = skillVersionRepository + .findBySkillIdAndVersion(builtinSkill.get().getId(), item.version()); + if (existingVersion.isEmpty()) { + return Optional.empty(); + } + + SkillVersion version = existingVersion.get(); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + log.info("Skipping built-in skill slug={} version={} because existing version status is {}", + item.slug(), item.version(), version.getStatus()); + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); + } + + String packageFingerprint = computeFingerprint(entries); + String existingFingerprint = computeFingerprint(version); + if (packageFingerprint.equals(existingFingerprint)) { + log.info("Skipping built-in skill slug={} version={} because it is already published", + item.slug(), item.version()); + return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED); + } else { + log.warn( + "Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}", + item.slug(), + item.version(), + existingFingerprint, + packageFingerprint + ); + return Optional.of(SyncOutcome.CONFLICT_SKIPPED); + } + } + + private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List entries) { + List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug()); + for (Skill skill : existingSkills) { + if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) { + continue; + } + Optional version = skillVersionRepository + .findBySkillIdAndVersion(skill.getId(), item.version()); + if (version.isPresent() && version.get().getStatus() == SkillVersionStatus.PUBLISHED) { + String packageFingerprint = computeFingerprint(entries); + String existingFingerprint = computeFingerprint(version.get()); + if (packageFingerprint.equals(existingFingerprint)) { + return true; + } + log.warn( + "Built-in skill slug={} version={} was published concurrently with different content: existing={}, package={}", + item.slug(), + item.version(), + existingFingerprint, + packageFingerprint + ); + return false; + } + } + return false; + } + + private boolean hasOtherOwnerConflict(List existingSkills) { + return existingSkills.stream() + .anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())); + } + + private String computeFingerprint(SkillVersion version) { + List files = skillFileRepository.findByVersionId(version.getId()).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + return computeFingerprintFromFileDigests(files.stream() + .map(file -> new FileDigest(file.getFilePath(), file.getSha256())) + .toList()); + } + + private String computeFingerprint(List entries) { + return computeFingerprintFromFileDigests(entries.stream() + .map(entry -> new FileDigest(entry.path(), sha256(entry.content()))) + .toList()); + } + + private String computeFingerprintFromFileDigests(List files) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (FileDigest file : files.stream().sorted(Comparator.comparing(FileDigest::path)).toList()) { + String line = file.path() + ":" + file.sha256() + "\n"; + digest.update(line.getBytes(StandardCharsets.UTF_8)); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute built-in skill fingerprint", exception); + } + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException("Failed to compute built-in skill file digest", exception); + } + } + + private record FileDigest(String path, String sha256) { + } + + private enum SyncOutcome { + PUBLISHED, + IDEMPOTENT_SKIPPED, + CONFLICT_SKIPPED, + FAILED + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java new file mode 100644 index 00000000..487179a4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoader.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class BuiltinSkillManifestLoader { + + static final String MANIFEST_LOCATION = "classpath:builtin-skills/manifest.json"; + static final int MAX_ITEMS = 100; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class); + + private final ObjectMapper objectMapper; + private final ResourceLoader resourceLoader; + + public BuiltinSkillManifestLoader(ObjectMapper objectMapper, ResourceLoader resourceLoader) { + this.objectMapper = objectMapper; + this.resourceLoader = resourceLoader; + } + + public List load() { + Resource resource = resourceLoader.getResource(MANIFEST_LOCATION); + if (!resource.exists()) { + log.warn("Built-in skill manifest not found at {}", MANIFEST_LOCATION); + return List.of(); + } + + JsonNode root; + try (InputStream inputStream = resource.getInputStream()) { + root = objectMapper.readTree(inputStream); + } catch (IOException | RuntimeException ex) { + log.warn("Failed to read built-in skill manifest at {}: {}", MANIFEST_LOCATION, ex.getMessage()); + return List.of(); + } + + if (root == null || root.isNull()) { + log.warn("Built-in skill manifest at {} is empty", MANIFEST_LOCATION); + return List.of(); + } + + JsonNode skillsNode = root.path("skills"); + if (!skillsNode.isArray()) { + log.warn("Built-in skill manifest at {} does not contain an array field 'skills'", MANIFEST_LOCATION); + return List.of(); + } + + List items = new ArrayList<>(); + Set seenSlugVersions = new HashSet<>(); + int totalEntries = skillsNode.size(); + if (totalEntries > MAX_ITEMS) { + log.warn("Built-in skill manifest has {} entries, only the first {} entries will be processed", + totalEntries, MAX_ITEMS); + } + int limit = Math.min(totalEntries, MAX_ITEMS); + for (int index = 0; index < limit; index++) { + JsonNode itemNode = skillsNode.get(index); + String slug = text(itemNode, "slug"); + String version = text(itemNode, "version"); + String url = text(itemNode, "url"); + if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) { + log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index); + continue; + } + try { + SlugValidator.validate(slug); + } catch (RuntimeException ex) { + log.warn("Skipping built-in skill manifest item {} because slug is invalid [slug={}]: {}", + index, slug, ex.getMessage()); + continue; + } + + String key = slug + "\n" + version; + if (!seenSlugVersions.add(key)) { + log.warn("Skipping duplicate built-in skill manifest item for slug={} version={}", slug, version); + continue; + } + + items.add(new ManifestItem(slug, version, url)); + } + return List.copyOf(items); + } + + private static String text(JsonNode node, String fieldName) { + JsonNode value = node.get(fieldName); + if (value == null || !value.isTextual()) { + return ""; + } + return value.asText().trim(); + } + + public record ManifestItem(String slug, String version, String url) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java new file mode 100644 index 00000000..973dd5b2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractor.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +@Component +public class BuiltinSkillPackageExtractor { + + private final SkillPackageArchiveExtractor archiveExtractor; + + public BuiltinSkillPackageExtractor(SkillPackageArchiveExtractor archiveExtractor) { + this.archiveExtractor = archiveExtractor; + } + + public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException { + SkillPackageArchiveExtractor.ExtractionResult result = + archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes)); + if (!result.warnings().isEmpty()) { + throw new IllegalArgumentException("Built-in skill package has warnings: " + + String.join("; ", result.warnings())); + } + boolean hasSkillMd = result.entries().stream() + .anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path())); + if (!hasSkillMd) { + throw new IllegalArgumentException("Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH); + } + return result; + } + + private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile { + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return "builtin-skill.zip"; + } + + @Override + public String getContentType() { + return "application/zip"; + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public byte[] getBytes() { + return bytes.clone(); + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(java.io.File dest) throws IOException { + throw new UnsupportedOperationException("Built-in skill zip adapter is read-only"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java new file mode 100644 index 00000000..6fd3626d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.bootstrap; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "skillhub.builtin-skills") +public class BuiltinSkillProperties { + + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java new file mode 100644 index 00000000..2e507f73 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloader.java @@ -0,0 +1,198 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Pattern; + +@Component +public class BuiltinSkillRemotePackageDownloader { + + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + static final String ALLOWED_HOST = "bjcdn.openstorage.cn"; + + private static final Logger log = LoggerFactory.getLogger(BuiltinSkillRemotePackageDownloader.class); + private static final Pattern IPV4_LITERAL = Pattern.compile("\\d{1,3}(\\.\\d{1,3}){3}"); + + private final long maxPackageSize; + private final HttpClient httpClient; + private final Duration requestTimeout; + + @Autowired + public BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties) { + this( + properties, + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + REQUEST_TIMEOUT + ); + } + + BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties, HttpClient httpClient) { + this(properties, httpClient, REQUEST_TIMEOUT); + } + + BuiltinSkillRemotePackageDownloader( + SkillPublishProperties properties, + HttpClient httpClient, + Duration requestTimeout) { + this.maxPackageSize = properties.getMaxPackageSize(); + this.httpClient = httpClient; + this.requestTimeout = requestTimeout; + } + + public Optional download(URI uri) { + if (!isAllowedUrl(uri)) { + log.warn("Skipping built-in skill package download because URL is not allowed: {}", safeUrl(uri)); + return Optional.empty(); + } + + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(requestTimeout) + .GET() + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = response.body()) { + if (response.statusCode() != 200) { + log.warn("Failed to download built-in skill package from {}: HTTP {}", + safeUrl(uri), + response.statusCode()); + return Optional.empty(); + } + return readBoundedWithTimeout(body, uri); + } + } catch (IOException ex) { + log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); + return Optional.empty(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while downloading built-in skill package from {}", safeUrl(uri)); + return Optional.empty(); + } catch (RuntimeException ex) { + log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage()); + return Optional.empty(); + } + } + + HttpClient httpClient() { + return httpClient; + } + + static boolean isAllowedUrl(URI uri) { + if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (uri.getRawUserInfo() != null) { + return false; + } + int port = uri.getPort(); + if (port != -1 && port != 443) { + return false; + } + String host = uri.getHost(); + if (host == null) { + return false; + } + String normalizedHost = host.toLowerCase(Locale.ROOT); + if (isDisallowedHostLiteral(normalizedHost)) { + return false; + } + return normalizedHost.equals(ALLOWED_HOST) || normalizedHost.endsWith("." + ALLOWED_HOST); + } + + private Optional readBounded(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long totalRead = 0; + int read; + while ((read = inputStream.read(buffer)) != -1) { + totalRead += read; + if (totalRead > maxPackageSize) { + log.warn("Built-in skill package download exceeded max package size: {} bytes (max: {})", + totalRead, + maxPackageSize); + return Optional.empty(); + } + outputStream.write(buffer, 0, read); + } + return Optional.of(outputStream.toByteArray()); + } + + private Optional readBoundedWithTimeout(InputStream inputStream, URI uri) throws IOException { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Future> future = executor.submit(() -> readBounded(inputStream)); + try { + return future.get(Math.max(1, requestTimeout.toMillis()), TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + closeQuietly(inputStream); + future.cancel(true); + log.warn("Timed out while downloading built-in skill package body from {} after {}", + safeUrl(uri), + requestTimeout); + return Optional.empty(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + closeQuietly(inputStream); + future.cancel(true); + log.warn("Interrupted while reading built-in skill package body from {}", safeUrl(uri)); + return Optional.empty(); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof IOException ioException) { + throw ioException; + } + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Failed to read built-in skill package body", cause); + } finally { + executor.shutdownNow(); + } + } + + private static void closeQuietly(InputStream inputStream) { + try { + inputStream.close(); + } catch (IOException ignored) { + // Best-effort cleanup after timeout/interruption. + } + } + + private static boolean isDisallowedHostLiteral(String host) { + return "localhost".equals(host) + || IPV4_LITERAL.matcher(host).matches() + || host.contains(":"); + } + + private static String safeUrl(URI uri) { + if (uri == null) { + return ""; + } + String host = uri.getHost(); + String path = uri.getRawPath(); + return (host == null ? "" : host) + (path == null ? "" : path); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java index 3d922e90..2b1930fa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DownloadRateLimitProperties.java @@ -10,7 +10,7 @@ public class DownloadRateLimitProperties { private String anonymousCookieName = "skillhub_anon_dl"; private Duration anonymousCookieMaxAge = Duration.ofDays(30); - private String anonymousCookieSecret = "change-me-in-production"; + private String anonymousCookieSecret; public String getAnonymousCookieName() { return anonymousCookieName; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java index 7ab3b55a..bb1f0f87 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "skillhub.security.scanner") public class SkillScannerProperties { - private boolean enabled = false; + private boolean enabled = true; private String baseUrl = "http://localhost:8000"; private String healthPath = "/health"; private String scanPath = "/scan-upload"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 69be5fa3..c0c34567 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import java.util.Set; /** * Namespace portal endpoints for discovery, membership management, and @@ -155,9 +156,13 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces/{slug}/members") public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable, - @RequestAttribute("userId") String userId) { + @RequestAttribute("userId") String userId, + @AuthenticationPrincipal PlatformPrincipal principal) { + Set platformRoles = principal != null && principal.platformRoles() != null + ? principal.platformRoles() + : Set.of(); return ok("response.success.read", - namespacePortalQueryAppService.listMembers(slug, pageable, userId)); + namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } @GetMapping("/namespaces/{slug}/member-candidates") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index 6a1f1c68..ec62ebb9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController { return ok("response.success.deleted", null); } - @GetMapping("/sse") + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sse(@RequestAttribute("userId") String userId) { return sseEmitterManager.register(userId); } @@ -113,6 +114,9 @@ public class NotificationController extends BaseApiController { if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } + if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile"); + } if ("PROMOTION_SUBMITTED".equals(eventType)) { return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 0a9fc793..e1a0a57c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -110,7 +111,7 @@ public class MultipartPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return path; + return SkillPackagePolicy.canonicalizeSkillMdPath(path); } private String determineContentType(String filename) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec70..a23e8cc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -112,7 +113,7 @@ public class ZipPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return normalizedPath; + return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); } catch (InvalidPathException ex) { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Invalid package path: " + path); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index cda766ff..d46a1e49 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -8,6 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; @@ -30,12 +32,20 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_PREFIXES = Set.of( "/actuator", "/favicon.ico", "/assets/" ); + private static final Set SKIP_SUFFIXES = Set.of( + "/sse" + ); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String uri = request.getRequestURI(); + if (isNotificationSse(uri)) { + prepareSseResponse(response); + filterChain.doFilter(request, response); + return; + } if (shouldSkip(uri)) { filterChain.doFilter(request, response); return; @@ -89,9 +99,24 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return true; } } + for (String suffix : SKIP_SUFFIXES) { + if (uri.endsWith(suffix)) { + return true; + } + } return false; } + private boolean isNotificationSse(String uri) { + return uri != null && uri.endsWith("/notifications/sse"); + } + + private void prepareSseResponse(HttpServletResponse response) { + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform"); + response.setHeader("X-Accel-Buffering", "no"); + } + private String getRequestBody(ContentCachingRequestWrapper request) { byte[] buf = request.getContentAsByteArray(); if (buf.length > 0) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 74ad10d8..c20498a2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -19,6 +19,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; @Component public class NotificationEventListener { @@ -53,7 +54,7 @@ public class NotificationEventListener { @TransactionalEventListener public void onSkillPublished(SkillPublishedEvent event) { skillRepository.findById(event.skillId()).ifPresent(skill -> { - if (!event.publisherId().equals(skill.getCreatedBy())) { + if (!Objects.equals(event.publisherId(), skill.getOwnerId())) { return; } String title = "Skill published: " + skillDisplayName(skill); @@ -127,6 +128,22 @@ public class NotificationEventListener { }); } + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) { + String title = "Profile review submitted"; + Map body = new LinkedHashMap<>(); + body.put("profileReviewId", event.profileReviewId()); + body.put("submitterId", event.submitterId()); + body.put("fields", event.fields()); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformUserAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId()); + } + } + @Async("skillhubEventExecutor") @TransactionalEventListener public void onReviewApproved(ReviewApprovedEvent event) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java index 90e076d7..0ea087d3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java @@ -39,4 +39,14 @@ public class RecipientResolver { List::copyOf )); } + + public List resolvePlatformUserAdmins() { + return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")) + .stream() + .map(binding -> binding.getUserId()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java index f6ff6dff..a166c666 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.ratelimit; import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -11,6 +12,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.Arrays; import java.util.Base64; +import java.util.Set; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.springframework.http.ResponseCookie; @@ -24,6 +26,12 @@ import org.springframework.stereotype.Component; public class AnonymousDownloadIdentityService { private static final String COOKIE_VERSION = "v1"; + private static final int MIN_SECRET_LENGTH = 32; + private static final Set DISALLOWED_SECRET_VALUES = Set.of( + "change-me-in-production", + "replace-me", + "replace-with-random-download-secret-32-bytes" + ); private static final SecureRandom RANDOM = new SecureRandom(); private final DownloadRateLimitProperties properties; @@ -35,6 +43,21 @@ public class AnonymousDownloadIdentityService { this.clientIpResolver = clientIpResolver; } + @PostConstruct + void validateAnonymousCookieSecret() { + String secret = properties.getAnonymousCookieSecret(); + if (secret == null || secret.isBlank()) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"); + } + String trimmedSecret = secret.trim(); + if (DISALLOWED_SECRET_VALUES.contains(trimmedSecret)) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder"); + } + if (trimmedSecret.length() < MIN_SECRET_LENGTH) { + throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"); + } + } + public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) { String ip = clientIpResolver.resolve(request); String cookieId = extractValidCookieId(request); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 656cefa0..b684c744 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -81,6 +81,7 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserRole(String userId, String roleCode, Set actorPlatformRoles) { UserAccount user = loadUser(userId); + rejectSystemAccountMutation(user); String normalizedRoleCode = normalizeRoleCode(roleCode); if ("SUPER_ADMIN".equals(normalizedRoleCode) @@ -102,6 +103,7 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserStatus(String userId, String status) { UserAccount user = loadUser(userId); + rejectSystemAccountMutation(user); UserStatus nextStatus = parseManageableStatus(status); user.setStatus(nextStatus); userAccountRepository.save(user); @@ -164,4 +166,10 @@ public class AdminUserAppService { return userAccountRepository.findById(userId) .orElseThrow(() -> new DomainNotFoundException("error.admin.user.notFound", userId)); } + + private void rejectSystemAccountMutation(UserAccount user) { + if (user.isSystemAccount()) { + throw new DomainForbiddenException("error.admin.user.systemAccount.immutable"); + } + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 82b38610..e8df0ad9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -18,6 +19,7 @@ import com.iflytek.skillhub.dto.PageResponse; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -111,9 +113,16 @@ public class NamespacePortalQueryAppService { } @Transactional(readOnly = true) - public PageResponse listMembers(String slug, Pageable pageable, String userId) { + public PageResponse listMembers(String slug, Pageable pageable, String userId, Set platformRoles) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.assertMember(namespace.getId(), userId); + if (namespace.getType() == NamespaceType.GLOBAL) { + Set roles = platformRoles != null ? platformRoles : Set.of(); + if (!roles.contains("SUPER_ADMIN") && !roles.contains("USER_ADMIN")) { + throw new DomainForbiddenException("error.namespace.global.members.platformAdmin.required"); + } + } else { + namespaceService.assertMember(namespace.getId(), userId); + } Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); List memberUserIds = members.getContent().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index 16a1e95b..ef4b1888 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -75,7 +75,8 @@ public class PromotionPortalAppService { comment, platformRoles(userId) ); - recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, detailWithComment(comment)); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, + detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -89,7 +90,8 @@ public class PromotionPortalAppService { comment, platformRoles(userId) ); - recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, detailWithComment(comment)); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, + detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -159,10 +161,26 @@ public class PromotionPortalAppService { ); } - private String detailWithComment(String comment) { - if (comment == null || comment.isBlank()) { + private String detailWithComment(String comment, boolean selfReview) { + boolean hasComment = comment != null && !comment.isBlank(); + if (!hasComment && !selfReview) { return null; } - return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + StringBuilder detail = new StringBuilder("{"); + if (hasComment) { + detail.append("\"comment\":\"").append(escapeJson(comment)).append("\""); + } + if (selfReview) { + if (hasComment) { + detail.append(","); + } + detail.append("\"selfReview\":true"); + } + detail.append("}"); + return detail.toString(); + } + + private String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); } } diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index ba5bcd76..3c6ac3ae 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -30,13 +30,18 @@ skillhub: auth: mock: enabled: true + ratelimit: + download: + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:local-dev-download-secret-32-bytes} notification: cleanup: read-retention-days: 30 unread-retention-days: 90 security: scanner: - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} + base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} + mode: ${SKILLHUB_SECURITY_SCANNER_MODE:upload} stream: reclaim-enabled: ${SKILLHUB_SCAN_STREAM_RECLAIM_ENABLED:true} reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT2M} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 3d7c7efe..78a7fdb5 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -98,6 +98,8 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + builtin-skills: + enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} @@ -147,7 +149,7 @@ skillhub: download: anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl} anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D} - anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production} + anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:} publish: max-file-count: 100 max-single-file-size: 10485760 # 10MB @@ -169,7 +171,7 @@ skillhub: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} security: scanner: - enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} + enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} health-path: /health scan-path: /scan-upload diff --git a/server/skillhub-app/src/main/resources/builtin-skills/manifest.json b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json new file mode 100644 index 00000000..a9fc8cac --- /dev/null +++ b/server/skillhub-app/src/main/resources/builtin-skills/manifest.json @@ -0,0 +1,14 @@ +{ + "skills": [ + { + "slug": "skillhub-hello", + "version": "1.0.0", + "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-11/f8a59af3-30d4-4031-80f6-ebff74b05195.zip" + }, + { + "slug": "agentguard", + "version": "1.1", + "url": "https://bjcdn.openstorage.cn/aicontest/2026-06-12/9d063bc7-223a-4762-adeb-305c268aa29e.zip" + } + ] +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql new file mode 100644 index 00000000..af7456ef --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V43__user_account_system_account.sql @@ -0,0 +1,83 @@ +ALTER TABLE user_account + ADD COLUMN system_account BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE user_account +SET system_account = TRUE +WHERE id = 'builtin-skill-publisher' + AND display_name = 'Built-in Skill Publisher' + AND email IS NULL + AND avatar_url IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM local_credential + WHERE local_credential.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_binding + WHERE identity_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM api_token + WHERE api_token.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM user_role_binding + WHERE user_role_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM namespace_member + WHERE namespace_member.user_id = user_account.id + ); + +UPDATE user_account +SET system_account = TRUE, + display_name = 'Built-in Skill Publisher', + email = NULL, + avatar_url = NULL +WHERE id = 'builtin-skill-publisher' + AND display_name = 'SkillHub Built-in Publisher' + AND email = 'builtin-skill-publisher@example.invalid' + AND avatar_url IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM local_credential + WHERE local_credential.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM identity_binding + WHERE identity_binding.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM api_token + WHERE api_token.user_id = user_account.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM user_role_binding + WHERE user_role_binding.user_id = user_account.id + ) + AND EXISTS ( + SELECT 1 + FROM namespace_member legacy_member + JOIN namespace legacy_namespace ON legacy_namespace.id = legacy_member.namespace_id + WHERE legacy_member.user_id = user_account.id + AND legacy_namespace.slug = 'global' + AND legacy_member.role = 'OWNER' + ) + AND NOT EXISTS ( + SELECT 1 + FROM namespace_member bad_member + LEFT JOIN namespace bad_namespace ON bad_namespace.id = bad_member.namespace_id + WHERE bad_member.user_id = user_account.id + AND ( + bad_namespace.slug IS NULL + OR bad_namespace.slug <> 'global' + OR bad_member.role <> 'OWNER' + ) + ); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index be3e2ebe..8f7cf1df 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -68,6 +68,7 @@ error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} error.namespace.membership.required=Namespace membership required +error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required error.namespace.owner.required=Namespace owner role required error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces @@ -93,6 +94,7 @@ error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills error.skill.publish.archived=Archived skill must be restored before publishing: {0} review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} review.withdraw.not_submitter=Only the submitter can withdraw this review @@ -134,6 +136,7 @@ error.deviceAuth.deviceCode.used=Device code has already been used error.admin.user.notFound=User not found: {0} error.admin.user.role.invalid=Invalid role: {0} error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role +error.admin.user.systemAccount.immutable=System accounts cannot be modified from user management error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index cef09563..e608d247 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -68,6 +68,7 @@ error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} error.namespace.membership.required=需要先加入该命名空间 +error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 error.namespace.owner.required=需要命名空间所有者权限 error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 @@ -93,6 +94,7 @@ error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0} error.skill.publish.precheck.failed=预发布校验失败:{0} +error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器 error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 @@ -134,6 +136,7 @@ error.deviceAuth.deviceCode.used=设备验证码已被使用 error.admin.user.notFound=用户不存在:{0} error.admin.user.role.invalid=无效的角色:{0} error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.systemAccount.immutable=系统账号不能在用户管理中修改 error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java new file mode 100644 index 00000000..bc5602a0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java @@ -0,0 +1,427 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.lang.reflect.Method; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@ExtendWith({MockitoExtension.class, OutputCaptureExtension.class}) +class BuiltinSkillInitializerTest { + + private static final String GLOBAL = "global"; + private static final String PUBLISHER = "builtin-skill-publisher"; + private static final ManifestItem ITEM = new ManifestItem( + "skillhub-hello", + "1.0.0", + "https://bjcdn.openstorage.cn/skills/skillhub-hello.zip" + ); + + @Mock private BuiltinSkillManifestLoader manifestLoader; + @Mock private BuiltinSkillRemotePackageDownloader downloader; + @Mock private BuiltinSkillPackageExtractor extractor; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private UserAccountRepository userAccountRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SkillFileRepository skillFileRepository; + @Mock private SkillPublishService skillPublishService; + + private BuiltinSkillProperties properties; + private BuiltinSkillInitializer initializer; + private Namespace globalNamespace; + + @BeforeEach + void setUp() { + properties = new BuiltinSkillProperties(); + initializer = new BuiltinSkillInitializer( + properties, + manifestLoader, + downloader, + extractor, + new SkillMetadataParser(), + namespaceRepository, + namespaceMemberRepository, + userAccountRepository, + skillRepository, + skillVersionRepository, + skillFileRepository, + skillPublishService + ); + globalNamespace = new Namespace(GLOBAL, "Global", "system"); + ReflectionTestUtils.setField(globalNamespace, "id", 1L); + } + + @Test + void skipsWhenDisabled() { + properties.setEnabled(false); + + runInitializer(); + + verify(manifestLoader, never()).load(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsAllItemsWhenGlobalNamespaceDoesNotExist() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.empty()); + + runInitializer(); + + verify(manifestLoader, never()).load(); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void synchronizesAfterApplicationReadyWithoutBlockingApplicationRunner() throws Exception { + assertThat(ApplicationRunner.class.isAssignableFrom(BuiltinSkillInitializer.class)).isFalse(); + + Method method = BuiltinSkillInitializer.class.getDeclaredMethod("synchronizeAfterApplicationReady"); + EventListener eventListener = method.getAnnotation(EventListener.class); + Async async = method.getAnnotation(Async.class); + + assertThat(eventListener).isNotNull(); + assertThat(eventListener.value()).containsExactly(ApplicationReadyEvent.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("skillhubEventExecutor"); + } + + @Test + void skipsSynchronizationWhenPublisherIdIsOccupiedByNonSystemAccount() { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)) + .thenReturn(Optional.of(new UserAccount(PUBLISHER, "Human User", "human@example.com", null))); + + runInitializer(); + + verify(namespaceMemberRepository, never()).save(any()); + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsPublishedSameVersionBeforeDownloadingPackage() { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + + runInitializer(); + + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsExistingSameVersionWhenNotPublishedBeforeDownloadingPackage() { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); + SkillVersion uploaded = version(200L, 100L, "1.0.0", SkillVersionStatus.UPLOADED); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(builtinSkill)); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(uploaded)); + + runInitializer(); + + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsSkillOwnedByAnotherUserBeforeDownloadingPackage() { + Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + givenManifestAndSystemPublisher(); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill)); + + runInitializer(); + + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsSkillOwnedByAnotherUserAfterDownloadingPackage() throws Exception { + Skill otherSkill = skill(100L, "skillhub-hello", "someone-else"); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "same")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) + .thenReturn(List.of(otherSkill)); + + runInitializer(); + + verify(downloader).download(URI.create(ITEM.url())); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsMalformedUrlWithoutSynchronizationFailureLog(CapturedOutput output) { + ManifestItem malformed = new ManifestItem( + "skillhub-hello", + "1.0.0", + "https://bjcdn.openstorage.cn/skills/%zz.zip" + ); + givenManifestAndSystemPublisher(List.of(malformed)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + + runInitializer(); + + verify(downloader, never()).download(any()); + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + assertThat(output).doesNotContain("Failed to synchronize built-in skill slug=skillhub-hello"); + } + + @Test + void skipsWhenManifestSlugDoesNotMatchPackageMetadata() throws Exception { + givenExtractedPackage(packageEntries("other-skill", "1.0.0", "same")); + + runInitializer(); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void skipsWhenManifestVersionDoesNotMatchPackageMetadata() throws Exception { + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.1", "same")); + + runInitializer(); + + verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + void publishesNewVersionToGlobalAsPublicWithSystemPublisher() throws Exception { + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + + runInitializer(); + + ArgumentCaptor> entriesCaptor = ArgumentCaptor.captor(); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), + entriesCaptor.capture(), + eq(PUBLISHER), + eq(SkillVisibility.PUBLIC), + eq(Set.of("SUPER_ADMIN")), + eq(true) + ); + assertThat(entriesCaptor.getValue()).isEqualTo(entries); + } + + @Test + void createsSystemPublisherAndGlobalMembershipBeforePublishing() throws Exception { + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); + givenExtractedPackage(entries); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.empty()); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of()); + + runInitializer(); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER); + assertThat(userCaptor.getValue().isSystemAccount()).isTrue(); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo(PUBLISHER); + assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.OWNER); + } + + @Test + void treatsConcurrentDuplicatePublishedVersionAsCompleted() throws Exception { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + List entries = packageEntries("skillhub-hello", "1.0.0", "same"); + givenExtractedPackage(entries); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) + .thenReturn(List.of()) + .thenReturn(List.of(builtinSkill)); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true))) + .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(skillFilesFor(entries, 200L)); + + runInitializer(); + + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true)); + } + + @Test + void doesNotTreatConcurrentDuplicateWithDifferentFingerprintAsCompleted(CapturedOutput output) throws Exception { + Skill builtinSkill = skill(100L, "skillhub-hello", PUBLISHER); + SkillVersion published = version(200L, 100L, "1.0.0", SkillVersionStatus.PUBLISHED); + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "new-content")); + when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")) + .thenReturn(List.of()) + .thenReturn(List.of()) + .thenReturn(List.of(builtinSkill)); + when(skillPublishService.publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true))) + .thenThrow(new DomainBadRequestException("error.skill.version.exists", "1.0.0")); + when(skillVersionRepository.findBySkillIdAndVersion(100L, "1.0.0")).thenReturn(Optional.of(published)); + when(skillFileRepository.findByVersionId(200L)).thenReturn(List.of( + new SkillFile(200L, "SKILL.md", 7L, "text/markdown", sha256("old-content"), "storage-key") + )); + + runInitializer(); + + verify(skillFileRepository).findByVersionId(200L); + verify(skillPublishService).publishFromEntries( + eq(GLOBAL), any(), eq(PUBLISHER), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), eq(true)); + assertThat(output).contains("Failed to publish built-in skill slug=skillhub-hello version=1.0.0"); + assertThat(output).doesNotContain("was published concurrently, skipping"); + } + + private void givenExtractedPackage() throws Exception { + givenExtractedPackage(packageEntries("skillhub-hello", "1.0.0", "same")); + } + + private void givenExtractedPackage(List entries) throws Exception { + byte[] bytes = "zip".getBytes(StandardCharsets.UTF_8); + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(List.of(ITEM)); + lenient().when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + lenient().when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + when(downloader.download(URI.create(ITEM.url()))).thenReturn(Optional.of(bytes)); + when(extractor.extract(bytes)).thenReturn(new SkillPackageArchiveExtractor.ExtractionResult(entries, List.of())); + } + + private void givenManifestAndSystemPublisher() { + givenManifestAndSystemPublisher(List.of(ITEM)); + } + + private void givenManifestAndSystemPublisher(List items) { + when(namespaceRepository.findBySlug(GLOBAL)).thenReturn(Optional.of(globalNamespace)); + when(manifestLoader.load()).thenReturn(items); + when(userAccountRepository.findById(PUBLISHER)).thenReturn(Optional.of(systemPublisher())); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER)) + .thenReturn(Optional.of(new NamespaceMember(1L, PUBLISHER, NamespaceRole.OWNER))); + } + + private void runInitializer() { + initializer.synchronize(); + } + + private static UserAccount systemPublisher() { + return UserAccount.systemAccount(PUBLISHER, "Built-in Skill Publisher", null, null); + } + + private static Skill skill(Long id, String slug, String ownerId) { + Skill skill = new Skill(1L, slug, ownerId, SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", id); + return skill; + } + + private static SkillVersion version(Long id, Long skillId, String version, SkillVersionStatus status) { + SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER); + ReflectionTestUtils.setField(skillVersion, "id", id); + skillVersion.setStatus(status); + return skillVersion; + } + + private static List packageEntries(String name, String version, String readme) { + byte[] skillMd = (""" + --- + name: %s + description: Built-in guardrails + version: %s + --- + # %s + """).formatted(name, version, name).getBytes(StandardCharsets.UTF_8); + byte[] readmeBytes = readme.getBytes(StandardCharsets.UTF_8); + return List.of( + new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown"), + new PackageEntry("README.md", readmeBytes, readmeBytes.length, "text/markdown") + ); + } + + private static List skillFilesFor(List entries, Long versionId) { + return entries.stream() + .map(entry -> new SkillFile( + versionId, + entry.path(), + entry.size(), + entry.contentType(), + sha256(entry.content()), + "storage-key/" + entry.path() + )) + .toList(); + } + + private static String sha256(String content) { + return sha256(content.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java new file mode 100644 index 00000000..66bc128f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillManifestLoaderTest.java @@ -0,0 +1,157 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ResourceLoader; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +class BuiltinSkillManifestLoaderTest { + + @Test + void loadsManifestItemsInOrder() { + BuiltinSkillManifestLoader loader = loaderWith(""" + { + "skills": [ + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/skillhub-hello.zip"}, + {"slug": "skillhub-hello", "version": "1.1.0", "url": "https://cdn.bjcdn.openstorage.cn/skillhub-hello.zip"} + ] + } + """); + + List items = loader.load(); + + assertThat(items) + .extracting(BuiltinSkillManifestLoader.ManifestItem::version) + .containsExactly("1.0.0", "1.1.0"); + } + + @Test + void returnsEmptyListWhenManifestIsMissing() { + BuiltinSkillManifestLoader loader = new BuiltinSkillManifestLoader( + new ObjectMapper(), + new ResourceLoader() { + @Override + public org.springframework.core.io.Resource getResource(String location) { + return new MissingResource(); + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + } + ); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void returnsEmptyListWhenManifestIsMalformed() { + BuiltinSkillManifestLoader loader = loaderWith("{not-json"); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void returnsEmptyListWhenManifestIsEmpty() { + BuiltinSkillManifestLoader loader = loaderWith(""); + + assertThat(loader.load()).isEmpty(); + } + + @Test + void skipsItemsWithMissingHumanFieldsAndDuplicateSlugVersion() { + BuiltinSkillManifestLoader loader = loaderWith(""" + { + "skills": [ + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/first.zip"}, + {"slug": "skillhub-hello", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/second.zip"}, + {"slug": "InvalidUppercase", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/invalid.zip"}, + {"slug": "", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/blank.zip"}, + {"slug": "missing-version", "url": "https://bjcdn.openstorage.cn/missing-version.zip"}, + {"slug": "missing-url", "version": "1.0.0"}, + {"slug": "valid-after-invalid", "version": "1.0.0", "url": "https://bjcdn.openstorage.cn/valid.zip"} + ] + } + """); + + List items = loader.load(); + + assertThat(items) + .extracting(BuiltinSkillManifestLoader.ManifestItem::url) + .containsExactly( + "https://bjcdn.openstorage.cn/first.zip", + "https://bjcdn.openstorage.cn/valid.zip" + ); + } + + @Test + void capsManifestEntriesAtOneHundredRawEntries() { + StringBuilder json = new StringBuilder("{\"skills\":["); + for (int i = 0; i < 101; i++) { + if (i > 0) { + json.append(','); + } + if (i == 0) { + json.append("{\"slug\":\"\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/blank.zip\"}"); + } else { + json.append("{\"slug\":\"skill-").append(i) + .append("\",\"version\":\"1.0.0\",\"url\":\"https://bjcdn.openstorage.cn/skill-") + .append(i) + .append(".zip\"}"); + } + } + json.append("]}"); + + BuiltinSkillManifestLoader loader = loaderWith(json.toString()); + + assertThat(loader.load()).hasSize(99); + } + + private BuiltinSkillManifestLoader loaderWith(String content) { + ResourceLoader resourceLoader = new ResourceLoader() { + @Override + public org.springframework.core.io.Resource getResource(String location) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public boolean exists() { + return true; + } + + @Override + public String getDescription() { + return "test manifest"; + } + }; + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + }; + return new BuiltinSkillManifestLoader(new ObjectMapper(), resourceLoader); + } + + static class MissingResource extends ByteArrayResource { + + MissingResource() { + super(new byte[0]); + } + + @Override + public boolean exists() { + return false; + } + + @Override + public String getDescription() { + return "missing manifest"; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java new file mode 100644 index 00000000..8f1a5397 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageExtractorTest.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +class BuiltinSkillPackageExtractorTest { + + private final BuiltinSkillPackageExtractor extractor = new BuiltinSkillPackageExtractor( + new SkillPackageArchiveExtractor(new SkillPublishProperties()) + ); + + @Test + void extractsZipBytesThroughArchiveExtractor() throws Exception { + byte[] zip = zip( + entry("SKILL.md", """ + --- + name: skillhub-hello + version: 1.0.0 + --- + # SkillHub Hello + """), + entry("README.md", "# Readme") + ); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extract(zip); + + assertThat(result.entries()) + .extracting(entry -> entry.path()) + .containsExactly("SKILL.md", "README.md"); + } + + @Test + void rejectsZipWithoutRootSkillMd() throws Exception { + byte[] zip = zip(entry("README.md", "# Readme")); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SkillPackagePolicy.SKILL_MD_PATH); + } + + @Test + void acceptsZipWithSingleTopLevelSkillDirectory() throws Exception { + byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ + --- + name: skillhub-hello + version: 1.0.0 + --- + # SkillHub Hello + """), entry("skillhub-hello/README.md", "# Readme")); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extract(zip); + + assertThat(result.entries()) + .extracting(entry -> entry.path()) + .containsExactly("SKILL.md", "README.md"); + } + + @Test + void rejectsZipWhenSkillDirectoryPromotionWouldIgnoreOutsideFiles() throws Exception { + byte[] zip = zip(entry("skillhub-hello/SKILL.md", """ + --- + name: skillhub-hello + version: 1.0.0 + --- + # SkillHub Hello + """), entry("LICENSE", "Apache-2.0")); + + assertThatThrownBy(() -> extractor.extract(zip)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Ignored file outside skill directory: LICENSE"); + } + + private static ZipSource entry(String path, String content) { + return new ZipSource(path, content.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] zip(ZipSource... sources) throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (ZipSource source : sources) { + zipOutputStream.putNextEntry(new ZipEntry(source.path())); + zipOutputStream.write(source.content()); + zipOutputStream.closeEntry(); + } + } + return outputStream.toByteArray(); + } + + record ZipSource(String path, byte[] content) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java new file mode 100644 index 00000000..174b8347 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +import java.util.Map; + +class BuiltinSkillPropertiesBindingTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfig.class); + + @Test + void enabledDefaultsToTrue() { + contextRunner.run((context) -> { + BuiltinSkillProperties properties = context.getBean(BuiltinSkillProperties.class); + + assertThat(properties.isEnabled()).isTrue(); + }); + } + + @Test + void bindsEnabledFromEnvironmentStyleProperty() { + contextRunner + .withInitializer((context) -> context.getEnvironment().getPropertySources().addFirst( + new SystemEnvironmentPropertySource( + "test-env", + Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false") + ) + )) + .run((context) -> { + BuiltinSkillProperties properties = context.getBean(BuiltinSkillProperties.class); + + assertThat(properties.isEnabled()).isFalse(); + }); + } + + @Configuration + @EnableConfigurationProperties(BuiltinSkillProperties.class) + static class TestConfig { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java new file mode 100644 index 00000000..dc935f5d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillRemotePackageDownloaderTest.java @@ -0,0 +1,321 @@ +package com.iflytek.skillhub.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +class BuiltinSkillRemotePackageDownloaderTest { + + @Test + void acceptsAllowedHttpsCdnHostsOnly() { + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://bjcdn.openstorage.cn/a.zip"))) + .isTrue(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://assets.bjcdn.openstorage.cn/a.zip"))) + .isTrue(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("http://bjcdn.openstorage.cn/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://evil.com/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://user:pass@bjcdn.openstorage.cn/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://bjcdn.openstorage.cn:8443/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://127.0.0.1/a.zip"))) + .isFalse(); + assertThat(BuiltinSkillRemotePackageDownloader.isAllowedUrl(URI.create("https://localhost/a.zip"))) + .isFalse(); + } + + @Test + void defaultHttpClientDoesNotFollowRedirects() { + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader(new SkillPublishProperties()); + + assertThat(downloader.httpClient().followRedirects()).isEqualTo(HttpClient.Redirect.NEVER); + assertThat(downloader.httpClient().connectTimeout()).contains(Duration.ofSeconds(5)); + } + + @Test + void downloadsAllowedUrlWithThirtySecondRequestTimeout() { + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1, 2, 3}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).contains(new byte[] {1, 2, 3}); + assertThat(client.lastRequest.timeout()).contains(Duration.ofSeconds(30)); + } + + @Test + void rejectsRedirectResponsesWithoutReadingLocation() { + FakeHttpClient client = new FakeHttpClient(302, new byte[] {1}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(client.sendCalls).isEqualTo(1); + } + + @Test + void closesNonSuccessResponseBody() { + CloseAwareInputStream body = new CloseAwareInputStream(new byte[] {1}); + FakeHttpClient client = new FakeHttpClient(500, body); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(body.closed()).isTrue(); + } + + @Test + void rejectedUrlDoesNotSendHttpRequest() { + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client + ); + + Optional bytes = downloader.download(URI.create("https://example.com/package.zip")); + + assertThat(bytes).isEmpty(); + assertThat(client.sendCalls).isZero(); + } + + @Test + void stopsReadingWhenResponseExceedsMaxPackageSize() { + SkillPublishProperties properties = new SkillPublishProperties(); + properties.setMaxPackageSize(2); + FakeHttpClient client = new FakeHttpClient(200, new byte[] {1, 2, 3}); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader(properties, client); + + assertThat(downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))).isEmpty(); + } + + @Test + void returnsEmptyWhenResponseBodyStopsBeforeCompletion() throws Exception { + BlockingInputStream body = new BlockingInputStream(); + FakeHttpClient client = new FakeHttpClient(200, body); + BuiltinSkillRemotePackageDownloader downloader = new BuiltinSkillRemotePackageDownloader( + new SkillPublishProperties(), + client, + Duration.ofMillis(50) + ); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future> result = executor.submit( + () -> downloader.download(URI.create("https://bjcdn.openstorage.cn/package.zip"))); + + try { + assertThat(result.get(1, TimeUnit.SECONDS)).isEmpty(); + assertThat(body.closed()).isTrue(); + } finally { + body.close(); + executor.shutdownNow(); + } + } + + static class FakeHttpClient extends HttpClient { + + private final int statusCode; + private final InputStream body; + private HttpRequest lastRequest; + private int sendCalls; + + FakeHttpClient(int statusCode, byte[] body) { + this(statusCode, new ByteArrayInputStream(body)); + } + + FakeHttpClient(int statusCode, InputStream body) { + this.statusCode = statusCode; + this.body = body; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.of(Duration.ofSeconds(5)); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return null; + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException { + lastRequest = request; + sendCalls++; + @SuppressWarnings("unchecked") + T responseBody = (T) body; + return new FakeResponse<>(request, statusCode, responseBody); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler + ) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler + ) { + throw new UnsupportedOperationException(); + } + } + + static final class CloseAwareInputStream extends ByteArrayInputStream { + + private boolean closed; + + private CloseAwareInputStream(byte[] bytes) { + super(bytes); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + + boolean closed() { + return closed; + } + } + + static final class BlockingInputStream extends InputStream { + + private final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public int read() { + waitUntilClosed(); + return -1; + } + + @Override + public int read(byte[] bytes, int offset, int length) { + waitUntilClosed(); + return -1; + } + + @Override + public void close() { + closed.set(true); + } + + boolean closed() { + return closed.get(); + } + + private void waitUntilClosed() { + while (!closed.get()) { + try { + Thread.sleep(10); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + } + + record FakeResponse(HttpRequest request, int statusCode, T body) implements HttpResponse { + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(java.util.Map.of(), (name, value) -> true); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java new file mode 100644 index 00000000..56f8994d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/DownloadRateLimitPropertiesTest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DownloadRateLimitPropertiesTest { + + @Test + void anonymousCookieSecretDoesNotDefaultToProductionPlaceholder() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + + assertThat(properties.getAnonymousCookieSecret()) + .isNull(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java index 63efeb8b..42e25294 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SkillScannerPropertiesBindingTest.java @@ -14,22 +14,34 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class SkillScannerPropertiesBindingTest { @Test - void defaultConfig_disablesScannerByDefault() throws IOException { + void defaultConfig_enablesScannerByDefault() throws IOException { SkillScannerProperties properties = bindProperties( List.of("application.yml"), Map.of() ); - assertFalse(properties.isEnabled()); + assertTrue(properties.isEnabled()); assertEquals("local", properties.getMode()); assertEquals("http://localhost:8000", properties.getBaseUrl()); } + @Test + void localConfig_usesUploadScannerModeByDefault() throws IOException { + SkillScannerProperties properties = bindProperties( + List.of("application-local.yml", "application.yml"), + Map.of() + ); + + assertTrue(properties.isEnabled()); + assertEquals("upload", properties.getMode()); + assertEquals("http://localhost:8000", properties.getBaseUrl()); + } + @Test void environmentVariables_overrideScannerDefaults() throws IOException { SkillScannerProperties properties = bindProperties( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java new file mode 100644 index 00000000..b2c8a460 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.device.DeviceCodeResponse; +import com.iflytek.skillhub.auth.device.DeviceTokenResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DeviceAuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void requestDeviceCode_withoutCsrfIsAllowedForCliFlow() throws Exception { + given(deviceAuthService.generateDeviceCode()) + .willReturn(new DeviceCodeResponse("device-1", "USER-CODE", "http://localhost/device", 600, 5)); + + mockMvc.perform(post("/api/v1/auth/device/code")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.deviceCode").value("device-1")); + } + + @Test + void pollToken_withoutCsrfIsAllowedForCliFlow() throws Exception { + given(deviceAuthService.pollToken("device-1")) + .willReturn(DeviceTokenResponse.success("token-1")); + + mockMvc.perform(post("/api/v1/auth/device/token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"deviceCode\":\"device-1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.accessToken").value("token-1")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 7158cdfd..440fe4bf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import jakarta.servlet.http.Cookie; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -197,6 +198,61 @@ class LocalAuthControllerTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + void changePassword_withAuthentication_withInvalidCsrf_returnsForbidden() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .with(csrf().useInvalidToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isForbidden()); + } + + @Test + void changePassword_withSessionCookieAndBearerHeaderWithoutCsrf_isRejected() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_3", + "carol", + "carol@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + mockMvc.perform(post("/api/v1/auth/local/change-password") + .with(authentication(auth)) + .header("Authorization", "Bearer invalid-token") + .cookie(new Cookie("SESSION", "browser-session")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"currentPassword":"old","newPassword":"Newpass123!"} + """)) + .andExpect(status().isUnauthorized()); + + verify(localAuthService, never()).changePassword("usr_3", "old", "Newpass123!"); + } + @Test void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception { mockMvc.perform(post("/api/v1/auth/local/password-reset/request") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 699cb740..566d3ef0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -174,6 +174,41 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void listMembers_globalNamespaceRejectsRegularUsers() throws Exception { + Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace); + given(namespaceMemberService.listMembers(eq(1L), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(), org.springframework.data.domain.PageRequest.of(0, 20), 0)); + + mockMvc.perform(get("/api/v1/namespaces/global/members") + .with(auth("regular-1")) + .requestAttr("userId", "regular-1")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void listMembers_globalNamespaceAllowsUserAdminWithoutMembership() throws Exception { + Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.MEMBER); + UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); + given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace); + doThrow(new DomainForbiddenException("error.namespace.membership.required")) + .when(namespaceService).assertMember(1L, "user-admin-1"); + given(namespaceMemberService.listMembers(eq(1L), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(member), org.springframework.data.domain.PageRequest.of(0, 20), 1)); + given(userAccountRepository.findByIdIn(List.of("user-2"))).willReturn(List.of(user)); + + mockMvc.perform(get("/api/v1/namespaces/global/members") + .with(auth("user-admin-1", Set.of("USER_ADMIN"))) + .requestAttr("userId", "user-admin-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].userId").value("user-2")) + .andExpect(jsonPath("$.data.items[0].email").value("alice@example.com")); + } + @Test void searchMemberCandidates_returnsCandidates() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index 72b5d26d..b72551ed 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -95,7 +95,7 @@ class NamespaceWorkflowContractTest { .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1")) .willReturn(adminMemberResponse); - given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"))) + given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"), eq(Set.of()))) .willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20)); given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1"))) .willReturn(adminMemberResponse); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 111d1173..a605b7cc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -136,7 +136,7 @@ class SkillStarControllerTest { } @Test - void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception { + void apiWebStarSkillWithCsrfShouldAllowSessionAuth() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -152,7 +152,8 @@ class SkillStarControllerTest { ); mockMvc.perform(put("/api/web/skills/10/star") - .with(authentication(auth))) + .with(authentication(auth)) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java index f2224de0..d82aa32d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java @@ -55,6 +55,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)) @@ -75,6 +76,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(false)) @@ -95,6 +97,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes())) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)); @@ -108,6 +111,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes())) + .header("Authorization", "Bearer test-token") .with(authentication(auth()))) .andExpect(status().isBadRequest()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index d7edf81d..b2421c89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -118,6 +118,7 @@ class CliSkillControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .delete("/api/cli/v1/skills/global/demo") + .header("Authorization", "Bearer test-token") .with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.ok").value(true)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 2536180e..2f344f2c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -70,6 +70,28 @@ class NotificationControllerTest { verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)); } + @Test + void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { + Notification notification = notification( + 15L, + NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", + "{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}", + "PROFILE_REVIEW", + 77L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("admin-1", "REVIEW", 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("PROFILE_REVIEW"); + assertThat(item.targetId()).isEqualTo(77L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile"); + }); + } + @Test void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index 44d39712..a2268a67 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -38,6 +38,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -53,6 +56,8 @@ class PromotionApprovalFlowIntegrationTest { private static final String SUBMITTER_ID = "promotion-owner"; private static final String REVIEWER_ID = "docker-admin"; + private static final String SELF_SUPER_ADMIN_ID = "self-super-admin"; + private static final String SELF_SKILL_ADMIN_ID = "self-skill-admin"; @Autowired private MockMvc mockMvc; @@ -127,31 +132,86 @@ class PromotionApprovalFlowIntegrationTest { assertThat(targetVersions.get(0).getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); } + @Test + void approvePromotion_allowsSuperAdminToApproveOwnPromotionThroughV1Route() throws Exception { + when(rbacService.getUserRoleCodes(SELF_SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + PromotionGraph graph = createPromotionGraph(SELF_SUPER_ADMIN_ID); + + mockMvc.perform(post("/api/v1/promotions/" + graph.request().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"self approve\"}") + .with(authentication(portalAuth(SELF_SUPER_ADMIN_ID, "SUPER_ADMIN"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(graph.request().getId())) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.reviewedBy").value(SELF_SUPER_ADMIN_ID)) + .andExpect(jsonPath("$.data.submittedBy").value(SELF_SUPER_ADMIN_ID)); + + PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId())) + .stream() + .findFirst() + .orElseThrow(); + assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); + assertThat(savedRequest.getReviewedBy()).isEqualTo(SELF_SUPER_ADMIN_ID); + assertThat(savedRequest.getTargetSkillId()).isNotNull(); + verify(governanceNotificationService).notifyUser( + eq(SELF_SUPER_ADMIN_ID), + eq("PROMOTION"), + eq("PROMOTION_REQUEST"), + eq(graph.request().getId()), + eq("Promotion approved"), + any() + ); + } + + @Test + void approvePromotion_rejectsSkillAdminSelfApprovalThroughWebRoute() throws Exception { + when(rbacService.getUserRoleCodes(SELF_SKILL_ADMIN_ID)).thenReturn(Set.of("SKILL_ADMIN")); + PromotionGraph graph = createPromotionGraph(SELF_SKILL_ADMIN_ID); + + mockMvc.perform(post("/api/web/promotions/" + graph.request().getId() + "/approve") + .contentType("application/json") + .content("{\"comment\":\"self approve\"}") + .with(authentication(portalAuth(SELF_SKILL_ADMIN_ID, "SKILL_ADMIN"))) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId())) + .stream() + .findFirst() + .orElseThrow(); + assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + assertThat(savedRequest.getTargetSkillId()).isNull(); + } + private PromotionGraph createPromotionGraph() { + return createPromotionGraph(SUBMITTER_ID); + } + + private PromotionGraph createPromotionGraph(String submitterId) { String suffix = UUID.randomUUID().toString().substring(0, 8); - userAccountRepository.saveAndFlush( - new UserAccount(SUBMITTER_ID, "Promotion Owner", "owner-" + suffix + "@example.com", null) - ); - userAccountRepository.saveAndFlush( - new UserAccount(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com", null) - ); + saveUserIfAbsent(submitterId, "Promotion Owner", "owner-" + suffix + "@example.com"); + saveUserIfAbsent(REVIEWER_ID, "Admin", "admin-" + suffix + "@example.com"); Namespace globalNamespace = new Namespace("global-" + suffix, "Global " + suffix, REVIEWER_ID); globalNamespace.setType(NamespaceType.GLOBAL); globalNamespace = namespaceRepository.saveAndFlush(globalNamespace); - Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, SUBMITTER_ID); + Namespace teamNamespace = new Namespace("team-" + suffix, "Team " + suffix, submitterId); teamNamespace = namespaceRepository.saveAndFlush(teamNamespace); - Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, SUBMITTER_ID, SkillVisibility.PUBLIC); + Skill sourceSkill = new Skill(teamNamespace.getId(), "promote-skill-" + suffix, submitterId, SkillVisibility.PUBLIC); sourceSkill.setDisplayName("Promote Skill " + suffix); sourceSkill.setSummary("Used to verify promotion approval flow."); - sourceSkill.setCreatedBy(SUBMITTER_ID); - sourceSkill.setUpdatedBy(SUBMITTER_ID); + sourceSkill.setCreatedBy(submitterId); + sourceSkill.setUpdatedBy(submitterId); sourceSkill = skillRepository.saveAndFlush(sourceSkill); - SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", SUBMITTER_ID); + SkillVersion sourceVersion = new SkillVersion(sourceSkill.getId(), "1.0.0", submitterId); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); sourceVersion.setPublishedAt(Instant.now()); sourceVersion.setRequestedVisibility(SkillVisibility.PUBLIC); @@ -160,16 +220,22 @@ class PromotionApprovalFlowIntegrationTest { sourceVersion = skillVersionRepository.saveAndFlush(sourceVersion); sourceSkill.setLatestVersionId(sourceVersion.getId()); - sourceSkill.setUpdatedBy(SUBMITTER_ID); + sourceSkill.setUpdatedBy(submitterId); sourceSkill = skillRepository.saveAndFlush(sourceSkill); PromotionRequest request = promotionRequestRepository.saveAndFlush( - new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), SUBMITTER_ID) + new PromotionRequest(sourceSkill.getId(), sourceVersion.getId(), globalNamespace.getId(), submitterId) ); return new PromotionGraph(globalNamespace, sourceSkill, sourceVersion, request); } + private void saveUserIfAbsent(String userId, String displayName, String email) { + if (!userAccountRepository.existsById(userId)) { + userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null)); + } + } + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java index 809c22a9..4ebf9424 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java @@ -59,7 +59,8 @@ class SkillSubscriptionControllerTest { @Test void subscribe_skill_returns_envelope() throws Exception { mockMvc.perform(put("/api/web/skills/10/subscription") - .with(authentication(authenticatedUser()))) + .with(authentication(authenticatedUser())) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.timestamp").isNotEmpty()) @@ -80,7 +81,8 @@ class SkillSubscriptionControllerTest { @Test void unsubscribe_skill_returns_envelope() throws Exception { mockMvc.perform(delete("/api/web/skills/10/subscription") - .with(authentication(authenticatedUser()))) + .with(authentication(authenticatedUser())) + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.timestamp").isNotEmpty()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java new file mode 100644 index 00000000..f3be9120 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MultipartPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + MultipartPackageExtractor extractor = new MultipartPackageExtractor( + new SkillPublishProperties(), + new ObjectMapper() + ); + MockMultipartFile skillMd = new MockMultipartFile( + "files", + "skill.md", + "text/markdown", + "---\nname: test\n---\n".getBytes() + ); + + MultipartPackageExtractor.ExtractedPackage extracted = extractor.extract( + new MockMultipartFile[] {skillMd}, + "{\"namespace\":\"global\",\"slug\":\"test\"}" + ); + + assertEquals(1, extracted.entries().size()); + assertTrue(extracted.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(extracted.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index ba5cc2f7..c849fc4c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -85,6 +85,21 @@ class SkillPackageArchiveExtractorTest { assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); } + @Test + void canonicalizesCaseInsensitiveSkillMdAtRoot() throws Exception { + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + assertTrue(result.warnings().isEmpty()); + } + @Test void doesNotStripWhenMultipleRootEntries() throws Exception { byte[] zipBytes = createZip(Map.of( @@ -144,6 +159,23 @@ class SkillPackageArchiveExtractorTest { assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); } + @Test + void promotesCaseInsensitiveSkillMdFromSubdirectory() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/skill.md", "---\nname: test\n---\n".getBytes(), + "my-skill/README.md", "# readme".getBytes(), + "other.txt", "stray file".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("README.md"))); + assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); + } + @Test void rejectsAmbiguousMultipleSkillMdInSubdirectories() throws Exception { byte[] zipBytes = createZip(Map.of( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java new file mode 100644 index 00000000..02f26214 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZipPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties()); + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + List entries = extractor.extract(file); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md"))); + } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index ee23aee6..21369016 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -72,6 +72,36 @@ class FlywayMigrationGuardrailTest { assertThat(invalidFiles).isEmpty(); } + @Test + void systemAccountMigration_mustNotPromoteUsersWithApiTokens() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("FROM api_token"); + assertThat(migration).contains("api_token.user_id = user_account.id"); + } + + @Test + void systemAccountMigration_mustNotPromoteUsersWithRolesOrNamespaceMemberships() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("FROM user_role_binding"); + assertThat(migration).contains("user_role_binding.user_id = user_account.id"); + assertThat(migration).contains("FROM namespace_member"); + assertThat(migration).contains("namespace_member.user_id = user_account.id"); + } + + @Test + void systemAccountMigration_mustPromoteLegacyBuiltinPublisherSafely() throws IOException { + String migration = Files.readString(migrationPath("V43__user_account_system_account.sql")); + + assertThat(migration).contains("SkillHub Built-in Publisher"); + assertThat(migration).contains("builtin-skill-publisher@example.invalid"); + assertThat(migration).contains("legacy_namespace.slug = 'global'"); + assertThat(migration).contains("legacy_member.role = 'OWNER'"); + assertThat(migration).contains("bad_member.user_id = user_account.id"); + assertThat(migration).contains("bad_namespace.slug <> 'global'"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") @@ -92,4 +122,12 @@ class FlywayMigrationGuardrailTest { private String relativeToRepo(Path file) { return repoRoot().relativize(file).toString(); } + + private Path migrationPath(String fileName) { + return repoRoot() + .resolve("server") + .resolve("skillhub-app") + .resolve("src/main/resources/db/migration") + .resolve(fileName); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index c79708a3..11ec0aec 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -1,22 +1,26 @@ package com.iflytek.skillhub.filter; +import static org.assertj.core.api.Assertions.assertThat; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - +import jakarta.servlet.ServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.util.ContentCachingResponseWrapper; class RequestLoggingFilterTest { @@ -78,6 +82,31 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health")); } + @Test + void doFilterInternal_skipsOtherSseEndpointsWithoutWrappingResponse() + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + attachAppender(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/scan/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> { + assertThat(res).isSameAs(response); + res.setContentType("text/event-stream"); + res.getWriter().write("event:connected\n"); + res.getWriter().flush(); + }; + + filter.doFilter(request, response, filterChain); + + assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getHeader("X-Accel-Buffering")).isNull(); + assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull(); + assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/scan/sse")); + } + @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { @@ -101,6 +130,44 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {")); } + @Test + void doFilterInternal_shouldBypassCachingWrapperForNotificationSse() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference responseSeenByChain = new AtomicReference<>(); + FilterChain chain = (servletRequest, servletResponse) -> { + responseSeenByChain.set(servletResponse); + servletResponse.getWriter().write("event: connected\n"); + servletResponse.flushBuffer(); + }; + + filter.doFilter(request, response, chain); + + assertThat(responseSeenByChain.get()).isSameAs(response); + assertThat(response.getHeader("X-Accel-Buffering")).isEqualTo("no"); + assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache, no-transform"); + assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getContentAsString()).contains("event: connected"); + } + + @Test + void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/unread-count"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference responseSeenByChain = new AtomicReference<>(); + FilterChain chain = (servletRequest, servletResponse) -> { + responseSeenByChain.set(servletResponse); + servletResponse.getWriter().write("{\"count\":1}"); + }; + + filter.doFilter(request, response, chain); + + assertThat(responseSeenByChain.get()).isInstanceOf(ContentCachingResponseWrapper.class); + assertThat(response.getContentAsString()).isEqualTo("{\"count\":1}"); + } + private void attachAppender() { logger.setLevel(Level.INFO); appender = new ListAppender<>(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..6321f004 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.notification.domain.NotificationCategory; import com.iflytek.skillhub.notification.service.NotificationDispatcher; @@ -43,6 +44,24 @@ class NotificationEventListenerTest { return skill; } + private Skill skill(Long id, String ownerId, String createdBy) { + Skill skill = new Skill(5L, "test-skill", ownerId, SkillVisibility.PUBLIC); + skill.setCreatedBy(createdBy); + skill.setDisplayName("Test Skill"); + setId(skill, id); + return skill; + } + + private void setId(Skill skill, Long id) { + try { + java.lang.reflect.Field field = Skill.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(skill, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + private void mockNamespace() { Namespace namespace = mock(Namespace.class); when(namespace.getSlug()).thenReturn("demo"); @@ -51,8 +70,7 @@ class NotificationEventListenerTest { @Test void onSkillPublished_shouldDispatchToPublisher() throws Exception { - Skill skill = mockSkill(1L); - when(skill.getCreatedBy()).thenReturn("publisher-1"); + Skill skill = skill(1L, "publisher-1", "publisher-1"); when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); mockNamespace(); when(objectMapper.writeValueAsString(any())).thenReturn("{}"); @@ -64,9 +82,19 @@ class NotificationEventListenerTest { } @Test - void onSkillPublished_shouldSkipWhenPublisherIsNotSkillCreator() throws Exception { - Skill skill = mock(Skill.class); - when(skill.getCreatedBy()).thenReturn("submitter-1"); + void onSkillPublished_shouldSkipWhenPublisherIsNotSkillOwner() throws Exception { + Skill skill = skill(1L, "submitter-1", "submitter-1"); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + + listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); + + verifyNoInteractions(dispatcher); + } + + @Test + void onSkillPublished_shouldSkipPromotedSkillCopyCreatedByReviewer() throws Exception { + Skill skill = skill(1L, "submitter-1", "reviewer-1"); + skill.setSourceSkillId(99L); when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); @@ -99,6 +127,21 @@ class NotificationEventListenerTest { verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); } + @Test + void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception { + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformUserAdmins()) + .thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1")); + + listener.onProfileReviewSubmitted( + new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName"))); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L)); + verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + } + @Test void onReviewApproved_shouldDispatchToSubmitter() throws Exception { Skill skill = mockSkill(1L); @@ -144,6 +187,32 @@ class NotificationEventListenerTest { eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); } + @Test + void onPromotionApproved_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onPromotionApproved(new PromotionApprovedEvent(200L, 1L, "self-admin", "self-admin")); + + verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + + @Test + void onPromotionRejected_shouldDispatchToSubmitterWhenReviewerIsSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onPromotionRejected(new PromotionRejectedEvent(200L, 1L, "self-admin", "self-admin", "not ready")); + + verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_REJECTED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + @Test void onReportResolved_shouldDispatchToReporter() throws Exception { Skill skill = mockSkill(1L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java index 20325b9d..c056a1ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java @@ -80,4 +80,20 @@ class RecipientResolverTest { assertThat(result).containsExactly("skill-admin", "super-admin"); } + + @Test + void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() { + UserRoleBinding userAdmin = mock(UserRoleBinding.class); + UserRoleBinding superAdmin = mock(UserRoleBinding.class); + UserRoleBinding duplicate = mock(UserRoleBinding.class); + when(userAdmin.getUserId()).thenReturn("user-admin"); + when(superAdmin.getUserId()).thenReturn("super-admin"); + when(duplicate.getUserId()).thenReturn("user-admin"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(userAdmin, superAdmin, duplicate)); + + List result = resolver.resolvePlatformUserAdmins(); + + assertThat(result).containsExactly("user-admin", "super-admin"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java new file mode 100644 index 00000000..39603ebd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.ratelimit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.config.DownloadRateLimitProperties; +import org.junit.jupiter.api.Test; + +class AnonymousDownloadIdentityServiceTest { + + @Test + void validateAnonymousCookieSecretRejectsReleaseExamplePlaceholder() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + properties.setAnonymousCookieSecret("replace-with-random-download-secret-32-bytes"); + AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver()); + + assertThatThrownBy(service::validateAnonymousCookieSecret) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must not use the default placeholder"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 4dd22e9c..7f2cfc22 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -89,6 +89,18 @@ class AdminUserAppServiceTest { () -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN"))); } + @Test + void updateUserRole_rejectsSystemAccount() { + when(userAccountRepository.findById("builtin-skill-publisher")) + .thenReturn(Optional.of(systemUser())); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserRole("builtin-skill-publisher", "AUDITOR", Set.of("SUPER_ADMIN"))); + + verify(userRoleBindingRepository, never()).deleteByUserId(any()); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + } + @Test void updateUserRole_replacesExistingBindings() { when(userAccountRepository.findById("user-1")) @@ -137,6 +149,17 @@ class AdminUserAppServiceTest { assertThat(response.status()).isEqualTo("DISABLED"); } + @Test + void updateUserStatus_rejectsSystemAccount() { + when(userAccountRepository.findById("builtin-skill-publisher")) + .thenReturn(Optional.of(systemUser())); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserStatus("builtin-skill-publisher", "DISABLED")); + + verify(userAccountRepository, never()).save(any(UserAccount.class)); + } + @Test void updateUserStatus_withUnknownUser_throwsNotFound() { when(userAccountRepository.findById("missing")).thenReturn(Optional.empty()); @@ -152,6 +175,18 @@ class AdminUserAppServiceTest { return user; } + private UserAccount systemUser() { + UserAccount user = UserAccount.systemAccount( + "builtin-skill-publisher", + "Built-in Skill Publisher", + null, + null + ); + ReflectionTestUtils.setField(user, "createdAt", Instant.parse("2026-03-13T09:00:00Z")); + ReflectionTestUtils.setField(user, "updatedAt", Instant.parse("2026-03-13T09:00:00Z")); + return user; + } + private Role role(String code) { Role role = new Role(); ReflectionTestUtils.setField(role, "code", code); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 10e58718..05a9fd3c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -29,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import java.util.Map; +import java.util.Set; class NamespacePortalQueryAppServiceTest { @@ -131,7 +132,7 @@ class NamespacePortalQueryAppServiceTest { when(userAccountRepository.findByIdIn(List.of("user-2"))) .thenReturn(List.of(user)); - PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of()); assertThat(result.items()).hasSize(1); MemberResponse mr = result.items().get(0); @@ -153,7 +154,7 @@ class NamespacePortalQueryAppServiceTest { when(userAccountRepository.findByIdIn(List.of("ghost-user"))) .thenReturn(List.of()); - PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1"); + PageResponse result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of()); assertThat(result.items()).hasSize(1); MemberResponse mr = result.items().get(0); @@ -161,4 +162,17 @@ class NamespacePortalQueryAppServiceTest { assertThat(mr.displayName()).isNull(); assertThat(mr.email()).isNull(); } + + @Test + void listMembers_globalNamespaceRejectsRegularUsersEvenWhenTheyAreGlobalMembers() { + Namespace ns = namespace(1L, "global"); + ns.setType(NamespaceType.GLOBAL); + when(namespaceService.getNamespaceBySlug("global")).thenReturn(ns); + when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + assertThatThrownBy(() -> service.listMembers("global", PageRequest.of(0, 20), "user-1", Set.of())) + .isInstanceOf(DomainForbiddenException.class) + .hasMessageContaining("error.namespace.global.members.platformAdmin.required"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java new file mode 100644 index 00000000..abede8fc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -0,0 +1,170 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.PromotionService; +import com.iflytek.skillhub.dto.PromotionResponseDto; +import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.lang.reflect.Field; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PromotionPortalAppServiceTest { + + private static final Long PROMOTION_ID = 1L; + private static final String SUPER_ADMIN_ID = "super-admin"; + private static final String REVIEWER_ID = "reviewer"; + private static final String SUBMITTER_ID = "submitter"; + + @Mock + private PromotionService promotionService; + @Mock + private PromotionRequestRepository promotionRequestRepository; + @Mock + private GovernanceQueryRepository governanceQueryRepository; + @Mock + private RbacService rbacService; + @Mock + private AuditLogService auditLogService; + + private PromotionPortalAppService service; + + @BeforeEach + void setUp() { + service = new PromotionPortalAppService( + promotionService, + promotionRequestRepository, + governanceQueryRepository, + rbacService, + auditLogService + ); + } + + @Test + void approvePromotion_recordsSelfReviewAuditDetailForSuperAdminSelfApproval() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + when(promotionService.approvePromotion(PROMOTION_ID, SUPER_ADMIN_ID, "ship", Set.of("SUPER_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.approvePromotion( + PROMOTION_ID, + "ship", + SUPER_ADMIN_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(SUPER_ADMIN_ID), + eq("PROMOTION_APPROVE"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"comment\":\"ship\",\"selfReview\":true}") + ); + } + + @Test + void rejectPromotion_recordsSelfReviewAuditDetailWithoutComment() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); + when(promotionService.rejectPromotion(PROMOTION_ID, SUPER_ADMIN_ID, null, Set.of("SUPER_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.rejectPromotion( + PROMOTION_ID, + null, + SUPER_ADMIN_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(SUPER_ADMIN_ID), + eq("PROMOTION_REJECT"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"selfReview\":true}") + ); + } + + @Test + void approvePromotion_keepsExistingAuditDetailForReviewerApprovingOthersPromotion() { + PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUBMITTER_ID); + when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SKILL_ADMIN")); + when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ship", Set.of("SKILL_ADMIN"))) + .thenReturn(promotion); + when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); + + service.approvePromotion( + PROMOTION_ID, + "ship", + REVIEWER_ID, + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + verify(auditLogService).record( + eq(REVIEWER_ID), + eq("PROMOTION_APPROVE"), + eq("PROMOTION_REQUEST"), + eq(PROMOTION_ID), + eq(null), + eq("127.0.0.1"), + eq("JUnit"), + eq("{\"comment\":\"ship\"}") + ); + } + + private PromotionResponseDto response(PromotionRequest request) { + return new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + "team-a", + "skill-a", + "1.0.0", + "global", + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + "Submitter", + request.getReviewedBy(), + null, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + ); + } + + private PromotionRequest promotionRequest(Long id, String submittedBy) { + PromotionRequest request = new PromotionRequest(10L, 20L, 30L, submittedBy); + setId(request, id); + return request; + } + + private void setId(Object entity, Long id) { + try { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index e7a6ea69..8749a355 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -41,6 +41,9 @@ skillhub: enforce-active-user-check: false access-policy: mode: OPEN + ratelimit: + download: + anonymous-cookie-secret: test-download-secret-32-bytes-long security: scanner: enabled: false diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 05b87025..8c2ff2dc 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; @@ -103,7 +105,7 @@ public class SecurityConfig { RequestMatcher csrfIgnoreMatcher = request -> { String path = request.getRequestURI(); String authorization = request.getHeader("Authorization"); - return routeSecurityPolicyRegistry.shouldIgnoreCsrf(path, authorization); + return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request)); }; http @@ -183,4 +185,20 @@ public class SecurityConfig { } } } + + static boolean hasSessionCookie(HttpServletRequest request) { + if (request.getRequestedSessionId() != null) { + return true; + } + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return false; + } + for (Cookie cookie : cookies) { + if ("SESSION".equals(cookie.getName()) || "JSESSIONID".equals(cookie.getName())) { + return true; + } + } + return false; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java index 60b52acc..74653ead 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -178,6 +178,9 @@ public class PasswordResetService { } private boolean isEligibleForReset(UserAccount user) { + if (user.isSystemAccount()) { + return false; + } if (user.getStatus() != UserStatus.ACTIVE) { return false; } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 5ac6c1d1..c4c52fff 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -150,14 +150,22 @@ public class RouteSecurityPolicyRegistry { return ApiTokenAuthorizationDecision.unsupported(path); } - public boolean shouldIgnoreCsrf(String path, String authorizationHeader) { - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader) { + return shouldIgnoreCsrf(method, path, authorizationHeader, false); + } + + public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader, boolean hasSessionCookie) { + if (!hasSessionCookie && authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { return true; } if (path == null) { return false; } - return path.startsWith("/api/"); + if (!"POST".equalsIgnoreCase(method)) { + return false; + } + return "/api/v1/auth/device/code".equals(path) + || "/api/v1/auth/device/token".equals(path); } public boolean shouldProjectRequestContext(String path) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java new file mode 100644 index 00000000..2865f3fb --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/config/SecurityConfigTest.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +class SecurityConfigTest { + + @Test + void hasSessionCookieDetectsSpringSessionCookie() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("SESSION", "session-id")); + + assertTrue(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieDetectsRequestedSessionIdFromServletContainer() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestedSessionId("custom-session-id"); + + assertTrue(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieIgnoresServerSideSessionWithoutClientSessionId() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); + + assertFalse(SecurityConfig.hasSessionCookie(request)); + } + + @Test + void hasSessionCookieIgnoresNonSessionCookies() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("XSRF-TOKEN", "csrf-token")); + + assertFalse(SecurityConfig.hasSessionCookie(request)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java index aa78c1fb..251b7d54 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.atLeastOnce; @@ -215,4 +216,31 @@ class PasswordResetServiceTest { .extracting("status") .isEqualTo(HttpStatus.BAD_REQUEST); } + + @Test + void adminTriggerPasswordReset_forSystemAccount_throwsBadRequest() { + UserAccount user = UserAccount.systemAccount( + "builtin-skill-publisher", + "Built-in Skill Publisher", + "builtin@example.com", + null + ); + given(userAccountRepository.findById("builtin-skill-publisher")).willReturn(Optional.of(user)); + lenient().when(credentialRepository.findByUserId("builtin-skill-publisher")).thenReturn( + Optional.of(new LocalCredential("builtin-skill-publisher", "builtin", "encoded")) + ); + lenient().when(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + anyString(), any(Instant.class)) + ).thenReturn(List.of()); + lenient().when(passwordEncoder.encode(anyString())).thenReturn("encoded-value"); + + assertThatThrownBy(() -> service.adminTriggerPasswordReset("builtin-skill-publisher", "admin_1")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(credentialRepository, never()).findByUserId("builtin-skill-publisher"); + verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class)); + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8499ee4d..efb5b0bd 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -113,10 +113,16 @@ class RouteSecurityPolicyRegistryTest { } @Test - void shouldIgnoreCsrf_forBearerAndApiPaths() { - assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); - assertTrue(registry.shouldIgnoreCsrf("/not-api", "Bearer token")); - assertFalse(registry.shouldIgnoreCsrf("/ui/settings", null)); + void shouldIgnoreCsrf_onlyForBearerTokensAndDeviceTokenFlow() { + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/admin/users", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/local/change-password", null, false)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", true)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/code", null, false)); + assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/token", null, false)); + assertFalse(registry.shouldIgnoreCsrf("GET", "/api/v1/auth/device/code", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/authorize", null, false)); + assertFalse(registry.shouldIgnoreCsrf("POST", "/ui/settings", null, false)); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java new file mode 100644 index 00000000..f0c250d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.event; + +import java.util.List; + +public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List fields) { + + public ProfileReviewSubmittedEvent { + fields = List.copyOf(fields); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index c3c125b1..df86ec75 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -108,7 +108,7 @@ public class ReviewPermissionChecker { String userId, Set platformRoles) { if (request.getSubmittedBy().equals(userId)) { - return false; + return platformRoles.contains("SUPER_ADMIN"); } return hasPlatformReviewRole(platformRoles); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java index 36fc1ffc..c44a4516 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java @@ -50,6 +50,9 @@ public class SkillMetadataParser { String name = extractRequiredField(frontmatter, "name"); String description = extractRequiredField(frontmatter, "description"); String version = extractOptionalField(frontmatter, "version"); + if (version == null) { + version = extractNestedOptionalField(frontmatter, "metadata", "version"); + } return new SkillMetadata(name, description, version, body, frontmatter); } @@ -122,4 +125,13 @@ public class SkillMetadataParser { Object value = frontmatter.get(fieldName); return value == null ? null : value.toString(); } + + private String extractNestedOptionalField(Map frontmatter, String objectFieldName, String fieldName) { + Object nestedValue = frontmatter.get(objectFieldName); + if (!(nestedValue instanceof Map nestedMap)) { + return null; + } + Object value = nestedMap.get(fieldName); + return value == null ? null : value.toString(); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index b53a6c63..56b69ddd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -102,7 +102,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(skill.getLatestVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.latest.notFound")); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -123,7 +123,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -150,7 +150,7 @@ public class SkillDownloadService { SkillVersion version = skillVersionRepository.findById(tag.getVersionId()) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.version.notFound", tagName)); - return downloadVersion(skill, version); + return downloadVersion(skill, version, currentUserId, userNsRoles); } /** @@ -161,9 +161,12 @@ public class SkillDownloadService { return buildDownloadResult(skill, version); } - private DownloadResult downloadVersion(Skill skill, SkillVersion version) { + private DownloadResult downloadVersion(Skill skill, + SkillVersion version, + String currentUserId, + Map userNsRoles) { assertPublishedAccessible(skill); - assertDownloadableVersion(skill, version); + assertDownloadableVersion(skill, version, currentUserId, userNsRoles); DownloadResult result = buildDownloadResult(skill, version); // Only increment download count for PUBLISHED versions @@ -304,18 +307,33 @@ public class SkillDownloadService { /** * Asserts that the version can be downloaded. * - PUBLISHED: anyone with skill access can download - * - UPLOADED/PENDING_REVIEW: only skill owner can download + * - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download */ - private void assertDownloadableVersion(Skill skill, SkillVersion version) { + private void assertDownloadableVersion(Skill skill, + SkillVersion version, + String currentUserId, + Map userNsRoles) { switch (version.getStatus()) { case PUBLISHED -> { // Anyone with skill access can download published versions } case UPLOADED, PENDING_REVIEW -> { - // Only owner can download UPLOADED/PENDING_REVIEW versions - // Note: This check is already done in assertCanDownload via visibilityChecker + if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } } default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } + + private boolean canManageSkillDraft(Skill skill, String currentUserId, Map userNsRoles) { + if (currentUserId == null) { + return false; + } + if (skill.getOwnerId().equals(currentUserId)) { + return true; + } + NamespaceRole role = userNsRoles == null ? null : userNsRoles.get(skill.getNamespaceId()); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index d4271bf8..c204306a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -170,6 +170,9 @@ public class SkillPublishService { errors.add("Publisher is not a member of namespace: " + namespaceSlug); } } + if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { + errors.add("error.security.scanner.required"); + } // 3. Package validation ValidationResult packageValidation = skillPackageValidator.validate(entries); @@ -380,6 +383,9 @@ public class SkillPublishService { "error.skill.publish.precheck.confirmRequired", formatValidationMessages(publishWarnings)); } + if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) { + throw new DomainBadRequestException("error.security.scanner.required"); + } // 6. Find or create Skill record (with owner isolation) List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); @@ -564,8 +570,7 @@ public class SkillPublishService { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } - // FK 约束 fk_skill_latest_version 阻止删除 skill_version 当 skill.latest_version_id 还指向它。 - // 必须先解开引用并 flush,让 PG 在 delete 时看不到引用。 + // PostgreSQL prevents deleting a skill_version while skill.latest_version_id still references it. if (version.getId().equals(skill.getLatestVersionId())) { skill.setLatestVersionId(null); skillRepository.save(skill); @@ -589,6 +594,10 @@ public class SkillPublishService { skillVersionRepository.flush(); } + private boolean requiresSecurityScanner(SkillVisibility visibility) { + return visibility == SkillVisibility.PUBLIC || visibility == SkillVisibility.NAMESPACE_ONLY; + } + private String resolveNamespaceSlug(Long namespaceId) { return namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId)) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index eab8d2e2..ee3880ae 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -64,7 +64,19 @@ public final class SkillPackagePolicy { throw new IllegalArgumentException("Package entry path must be normalized: " + rawPath); } - return canonical; + return canonicalizeSkillMdPath(canonical); + } + + public static String canonicalizeSkillMdPath(String normalizedPath) { + int slashIndex = normalizedPath.lastIndexOf('/'); + String fileName = slashIndex >= 0 ? normalizedPath.substring(slashIndex + 1) : normalizedPath; + if (!SKILL_MD_PATH.equalsIgnoreCase(fileName)) { + return normalizedPath; + } + if (slashIndex < 0) { + return SKILL_MD_PATH; + } + return normalizedPath.substring(0, slashIndex + 1) + SKILL_MD_PATH; } public static boolean hasAllowedExtension(String path) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java index 0ef2c569..19e6961f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -27,6 +27,9 @@ public class UserAccount { @Column(name = "merged_to_user_id") private String mergedToUserId; + @Column(name = "system_account", nullable = false) + private boolean systemAccount = false; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -43,6 +46,12 @@ public class UserAccount { this.status = UserStatus.ACTIVE; } + public static UserAccount systemAccount(String id, String displayName, String email, String avatarUrl) { + UserAccount user = new UserAccount(id, displayName, email, avatarUrl); + user.systemAccount = true; + return user; + } + @PrePersist void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); @@ -65,6 +74,7 @@ public class UserAccount { public void setStatus(UserStatus status) { this.status = status; } public String getMergedToUserId() { return mergedToUserId; } public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; } + public boolean isSystemAccount() { return systemAccount; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } public boolean isActive() { return this.status == UserStatus.ACTIVE; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java index 68b3951b..c6f037c3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java @@ -3,10 +3,13 @@ package com.iflytek.skillhub.domain.user; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,19 +32,22 @@ public class UserProfileService { private final ProfileModerationConfig moderationConfig; private final ProfileFieldPolicyConfig fieldPolicyConfig; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; public UserProfileService(UserAccountRepository userAccountRepository, ProfileChangeRequestRepository changeRequestRepository, ProfileModerationService moderationService, ProfileModerationConfig moderationConfig, ProfileFieldPolicyConfig fieldPolicyConfig, - AuditLogService auditLogService) { + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher) { this.userAccountRepository = userAccountRepository; this.changeRequestRepository = changeRequestRepository; this.moderationService = moderationService; this.moderationConfig = moderationConfig; this.fieldPolicyConfig = fieldPolicyConfig; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; } /** @@ -112,8 +118,10 @@ public class UserProfileService { // 5. Queue review changes if (!reviewChanges.isEmpty()) { cancelPendingRequests(userId); - saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING, - machineTag, null); + ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues, + ProfileChangeStatus.PENDING, machineTag, null); + eventPublisher.publishEvent(new ProfileReviewSubmittedEvent( + pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet()))); } // 6. Return appropriate result @@ -166,9 +174,9 @@ public class UserProfileService { /** * Persist a change request record for audit and review purposes. */ - private void saveChangeRequest(String userId, Map changes, - Map oldValues, ProfileChangeStatus status, - String machineResult, String machineReason) { + private ProfileChangeRequest saveChangeRequest(String userId, Map changes, + Map oldValues, ProfileChangeStatus status, + String machineResult, String machineReason) { ProfileChangeRequest request = new ProfileChangeRequest( userId, toJson(changes), @@ -177,7 +185,7 @@ public class UserProfileService { machineResult, machineReason ); - changeRequestRepository.save(request); + return changeRequestRepository.save(request); } private String toJson(Object obj) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 19b1887f..1b10f394 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -401,6 +401,51 @@ class PromotionServiceTest { verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); } + + @Test + void shouldNotifySubmitterWhenSuperAdminApprovesOwnPromotion() { + PromotionRequest request = createPendingPromotion(); + PromotionRequest approvedRequest = approvedPromotion(request, "self approve"); + approvedRequest.setReviewedBy(USER_ID); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", USER_ID, SkillVisibility.PUBLIC); + setField(newSkill, "id", NEW_SKILL_ID); + SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), USER_ID); + setField(newVersion, "id", NEW_VERSION_ID); + + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(request), Optional.of(approvedRequest)); + when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, USER_ID, "self approve", null, request.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest); + + promotionService.approvePromotion(PROMOTION_ID, USER_ID, "self approve", Set.of("SUPER_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); + } + + @Test + void shouldNotifySubmitterWhenSuperAdminRejectsOwnPromotion() { + PromotionRequest request = createPendingPromotion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, USER_ID, Set.of("SUPER_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.REJECTED, USER_ID, "self reject", null, request.getVersion())) + .thenReturn(1); + + promotionService.rejectPromotion(PROMOTION_ID, USER_ID, "self reject", Set.of("SUPER_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); + } } @Nested diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 25ae2f70..faf94008 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -234,10 +234,26 @@ class ReviewPermissionCheckerTest { } @Test - void cannotReviewOwnPromotion() { + void skillAdminCannotReviewOwnPromotion() { String userId = "user-2"; PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); assertFalse(checker.canReviewPromotion(req, userId, Set.of("SKILL_ADMIN"))); } + + @Test + void regularUserCannotReviewOwnPromotion() { + String userId = "user-2"; + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); + assertFalse(checker.canReviewPromotion(req, userId, + Set.of())); + } + + @Test + void superAdminCanReviewOwnPromotion() { + String userId = "user-2"; + PromotionRequest req = new PromotionRequest(1L, 1L, 1L, userId); + assertTrue(checker.canReviewPromotion(req, userId, + Set.of("SUPER_ADMIN"))); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java index 15969023..aececcd2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java @@ -123,6 +123,46 @@ class SkillMetadataParserTest { assertNull(metadata.version()); } + @Test + void testUsesMetadataVersionWhenTopLevelVersionIsMissing() { + String content = """ + --- + name: agentguard + description: Agent security guard + metadata: + author: GoPlusSecurity + version: "1.1" + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("agentguard", metadata.name()); + assertEquals("Agent security guard", metadata.description()); + assertEquals("1.1", metadata.version()); + } + + @Test + void testTopLevelVersionTakesPrecedenceOverMetadataVersion() { + String content = """ + --- + name: versioned-skill + description: Prefer top-level version + version: 2.0.0 + metadata: + version: "1.1" + --- + Body + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("versioned-skill", metadata.name()); + assertEquals("Prefer top-level version", metadata.description()); + assertEquals("2.0.0", metadata.version()); + } + @Test void testFallsBackToLooseFrontmatterParsingWhenYamlSyntaxIsNotStrict() { String content = """ diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 4bc20d34..ce84509d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; @@ -351,6 +352,33 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_RejectsAnonymousPendingReviewPublicSkill() throws Exception { + Namespace namespace = new Namespace("global", "Global", "system"); + setId(namespace, 1L); + namespace.setType(NamespaceType.GLOBAL); + + Skill skill = new Skill(1L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.1.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.1.0")).thenReturn(Optional.of(version)); + + assertThrows(DomainForbiddenException.class, () -> + service.downloadVersion("global", "demo-skill", "1.1.0", null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 77264b23..73f118fd 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -101,7 +101,7 @@ class SkillPublishServiceTest { eventPublisher, CLOCK ); - lenient().when(securityScanService.isEnabled()).thenReturn(false); + lenient().when(securityScanService.isEnabled()).thenReturn(true); lenient().when(skillVersionRepository.findBySkillIdAndStatus(anyLong(), eq(SkillVersionStatus.PENDING_REVIEW))) .thenReturn(List.of()); lenient().when(reviewTaskRepository.save(any(ReviewTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -455,6 +455,7 @@ class SkillPublishServiceTest { Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setLatestVersionId(8L); SkillVersion draftVersion = new SkillVersion(1L, "1.0.0-beta", publisherId); draftVersion.setStatus(SkillVersionStatus.DRAFT); setId(draftVersion, 8L); @@ -485,10 +486,14 @@ class SkillPublishServiceTest { Set.of() ); - InOrder inOrder = inOrder(skillVersionRepository); + assertNull(skill.getLatestVersionId()); + InOrder inOrder = inOrder(skillRepository, skillVersionRepository); + inOrder.verify(skillRepository).save(skill); + inOrder.verify(skillRepository).flush(); inOrder.verify(skillVersionRepository).delete(draftVersion); inOrder.verify(skillVersionRepository).flush(); inOrder.verify(skillVersionRepository, times(2)).save(any(SkillVersion.class)); + inOrder.verify(skillRepository).save(skill); } @Test @@ -1279,6 +1284,120 @@ class SkillPublishServiceTest { assertEquals(SkillVersionStatus.SCAN_FAILED, SkillVersionStatus.valueOf("SCAN_FAILED")); } + @Test + void testPublishFromEntries_PublicWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "test-skill", "test-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + verify(skillRepository, never()).save(any(Skill.class)); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_NamespaceOnlyWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "team-skill", "team-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.NAMESPACE_ONLY, + Set.of() + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + verify(skillRepository, never()).save(any(Skill.class)); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_PrivateWhenScannerDisabled_ShouldAllowUploadWithoutScan() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "private-skill", "private-skill", "1.0.0", true); + when(securityScanService.isEnabled()).thenReturn(false); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PRIVATE, + Set.of() + ); + + assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus()); + verify(securityScanService, never()).triggerScan(anyLong(), anyList(), anyString()); + verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); + } + + @Test + void testPublishFromEntries_SuperAdminPublicWhenScannerDisabled_ShouldStillRejectBeforeAutoPublish() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "admin-user"; + PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "admin-skill", "admin-skill", "1.0.0", false); + when(securityScanService.isEnabled()).thenReturn(false); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + fixture.entries(), + publisherId, + SkillVisibility.PUBLIC, + Set.of("SUPER_ADMIN") + )); + + assertEquals("error.security.scanner.required", exception.messageCode()); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); + } + + @Test + void testValidateOnly_PublicWhenScannerDisabled_ShouldReturnScannerRequiredError() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + List entries = skillEntries("test-skill", "1.0.0"); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + when(securityScanService.isEnabled()).thenReturn(false); + 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(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + + SkillPublishService.DryRunResult result = service.validateOnly( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); + + assertFalse(result.valid()); + assertTrue(result.errors().contains("error.security.scanner.required")); + } + @Test void testPublishFromEntries_WhenScannerEnabled_ShouldCreateReviewTaskAndTriggerScan() throws Exception { String namespaceSlug = "test-ns"; @@ -1372,6 +1491,56 @@ class SkillPublishServiceTest { verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); } + private record PublishFixture(List entries) { + } + + private PublishFixture stubValidPublishInputs( + String namespaceSlug, + String publisherId, + String skillName, + String skillSlug, + String version, + boolean stubMembership) throws Exception { + List entries = skillEntries(skillName, version); + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + SkillMetadata metadata = new SkillMetadata(skillName, "Test", version, "Body", Map.of()); + Skill skill = new Skill(namespace.getId(), skillSlug, publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + if (stubMembership) { + NamespaceMember member = mock(NamespaceMember.class); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + } + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + lenient().when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)).thenReturn(List.of(skill)); + lenient().when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)).thenReturn(Optional.of(skill)); + lenient().when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)).thenReturn(Optional.empty()); + lenient().when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + lenient().when(skillRepository.save(any(Skill.class))).thenReturn(skill); + return new PublishFixture(entries); + } + + private List skillEntries(String skillName, String version) { + String skillMdContent = "---\nname: " + skillName + "\ndescription: Test\nversion: " + version + "\n---\nBody"; + PackageEntry skillMd = new PackageEntry( + "SKILL.md", + skillMdContent.getBytes(StandardCharsets.UTF_8), + skillMdContent.getBytes(StandardCharsets.UTF_8).length, + "text/markdown"); + PackageEntry readme = new PackageEntry("README.md", "content".getBytes(StandardCharsets.UTF_8), 7, "text/markdown"); + return List.of(skillMd, readme); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index d473a361..c2b6b63c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -40,6 +40,35 @@ class SkillPackageValidatorTest { assertTrue(result.errors().isEmpty()); } + @Test + void normalizesSkillMdFilenameCase() { + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("skill.md")); + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("Skill.MD")); + assertEquals("nested/SKILL.md", SkillPackagePolicy.normalizeEntryPath("nested/skill.md")); + } + + @Test + void acceptsSkillMdFilenameWithDifferentCase() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + """; + + List entries = List.of( + new PackageEntry("skill.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("README.md", "readme".getBytes(), 6, "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertTrue(result.passed()); + assertTrue(result.errors().isEmpty()); + } + @Test void testMissingSkillMd() { List entries = List.of( diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java index d6af56f6..1346e6a2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java @@ -1,16 +1,21 @@ package com.iflytek.skillhub.domain.user; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -41,9 +46,24 @@ class UserProfileServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UserProfileService userProfileService; + @BeforeEach + void setUp() { + AtomicLong ids = new AtomicLong(1L); + lenient().when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + if (request.getId() == null) { + setField(request, "id", ids.getAndIncrement()); + } + return request; + }); + } + // -- Helper -- private UserAccount testUser() { @@ -136,6 +156,32 @@ class UserProfileServiceTest { verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() { + var user = testUser(); + when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(moderationConfig.machineReview()).thenReturn(false); + when(moderationConfig.humanReview()).thenReturn(true); + stubFieldPolicies(true); + when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING)) + .thenReturn(List.of()); + when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + setField(request, "id", 77L); + return request; + }); + + userProfileService.updateProfile( + "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent"); + + var eventCaptor = ArgumentCaptor.forClass(ProfileReviewSubmittedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ProfileReviewSubmittedEvent event = eventCaptor.getValue(); + assertEquals(77L, event.profileReviewId()); + assertEquals("user-1", event.submitterId()); + assertEquals(List.of("displayName"), event.fields()); + } + // ===== AC-P-005: Overwrite existing PENDING request ===== @Test @@ -221,4 +267,14 @@ class UserProfileServiceTest { userProfileService.updateProfile( "nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent")); } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } } diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java index 82c502c5..43646817 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.IOException; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; class SseEmitterManagerTest { @@ -30,13 +34,19 @@ class SseEmitterManagerTest { @Test void register_shouldReturnEmitter() { - emitters.add(new TestEmitter()); + TestEmitter testEmitter = new TestEmitter(); + emitters.add(testEmitter); SseEmitter emitter = manager.register("user-1"); assertNotNull(emitter); assertEquals(1, manager.totalEmitters()); assertEquals(1, manager.emittersForUser("user-1")); + assertEquals(1, testEmitter.sentEventCount()); + assertTrue(testEmitter.sentEventData(0).stream() + .anyMatch(value -> value.toString().contains("event:connected"))); + assertTrue(testEmitter.sentEventData(0).contains("ok")); + assertTrue(testEmitter.isOpen()); } @Test @@ -93,6 +103,27 @@ class SseEmitterManagerTest { assertEquals(1, manager.emittersForUser("user-1")); } + @Test + void push_shouldSendNotificationEventToRegisteredOpenEmitter() { + TestEmitter emitter = new TestEmitter(); + emitters.add(emitter); + manager.register("user-1"); + + Map payload = Map.of( + "id", 42L, + "eventType", "PROFILE_REVIEW_SUBMITTED" + ); + manager.push("user-1", payload); + + assertEquals(2, emitter.sentEventCount()); + assertTrue(emitter.sentEventData(1).stream() + .anyMatch(value -> value.toString().contains("event:notification"))); + assertTrue(emitter.sentEventData(1).contains(payload)); + assertTrue(emitter.isOpen()); + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + @Test void heartbeat_shouldRemoveEmitterWhenSendFails() { TestEmitter healthy = new TestEmitter(); @@ -152,6 +183,8 @@ class SseEmitterManagerTest { private boolean failAfterConnected; private boolean throwOnComplete; private int sendCount; + private boolean completed; + private final List> sentEvents = new ArrayList<>(); private TestEmitter() { super(60_000L); @@ -173,6 +206,18 @@ class SseEmitterManagerTest { errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); } + boolean isOpen() { + return !completed; + } + + int sentEventCount() { + return sentEvents.size(); + } + + List sentEventData(int index) { + return sentEvents.get(index); + } + @Override public synchronized void onCompletion(Runnable callback) { this.completionCallback = callback; @@ -193,6 +238,7 @@ class SseEmitterManagerTest { if (throwOnComplete) { throw new IllegalStateException("already complete"); } + completed = true; completionCallback.run(); } @@ -202,6 +248,9 @@ class SseEmitterManagerTest { if (failAfterConnected && sendCount > 1) { throw new IOException("send failed"); } + sentEvents.add(builder.build().stream() + .map(ResponseBodyEmitter.DataWithMediaType::getData) + .toList()); } } } diff --git a/web/e2e/helpers/csrf.ts b/web/e2e/helpers/csrf.ts new file mode 100644 index 00000000..410b0837 --- /dev/null +++ b/web/e2e/helpers/csrf.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test' + +const csrfCookieName = 'XSRF-TOKEN' +const csrfHeaderName = 'X-XSRF-TOKEN' +const requestTimeoutMs = process.env.CI ? 12_000 : 8_000 + +async function readCsrfToken(page: Page): Promise { + const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName) + return cookie?.value?.trim() || null +} + +export async function csrfHeaders(page: Page, headers?: Record): Promise> { + let token = await readCsrfToken(page) + if (!token) { + await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs }) + token = await readCsrfToken(page) + } + + if (!token) { + throw new Error('Missing XSRF-TOKEN cookie after auth provider warm-up') + } + + return { + ...headers, + [csrfHeaderName]: token, + } +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 763ef1c3..91554c62 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -1,4 +1,5 @@ import { expect, type Page, type TestInfo } from '@playwright/test' +import { csrfHeaders } from './csrf' const password = 'Passw0rd!123' const cachedUserByWorker = new Map() @@ -50,15 +51,17 @@ function isRetryableStatus(status: number): boolean { } async function loginWithRetry( - request: Page['request'], + page: Page, username: string, currentPassword = password, retries = process.env.CI ? 10 : 6, ): Promise { + const request = page.context().request for (let i = 0; i < retries; i += 1) { try { const login = await request.post('/api/v1/auth/local/login', { data: { username, password: currentPassword }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -187,13 +190,13 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re } // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. - if (cached && await loginWithRetry(request, cached)) { + if (cached && await loginWithRetry(page, cached)) { await cacheSession(page, worker, cached) return { username: cached, password } } // Support environments where a deterministic worker account already exists. - if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) { + if (!cached && await loginWithRetry(page, username, password, process.env.CI ? 4 : 3)) { cachedUserByWorker.set(worker, username) await cacheSession(page, worker, username) return { username, password } @@ -206,6 +209,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${username}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -215,7 +219,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re return { username, password } } - if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) { + if (register.status() === 409 && await loginWithRetry(page, username, password, process.env.CI ? 8 : 6)) { cachedUserByWorker.set(worker, username) await cacheSession(page, worker, username) return { username, password } @@ -236,6 +240,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re password, email: `${uniqueUsername}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -269,7 +274,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re // Final fallback for environments where registration is temporarily unavailable. const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) for (const candidate of fallbackCandidates) { - if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) { + if (await loginWithRetry(page, candidate, password, process.env.CI ? 12 : 8)) { cachedUserByWorker.set(worker, candidate) await cacheSession(page, worker, candidate) return { username: candidate, password } @@ -295,6 +300,7 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) { password, email: `${uniqueUsername}@example.test`, }, + headers: await csrfHeaders(page), timeout: requestTimeoutMs, }) @@ -358,8 +364,6 @@ export async function createFreshSession(page: Page, testInfo?: TestInfo) { } export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) { - const request = page.context().request - await primeAuthProviders(page) const restored = await restoreCachedSessionForAccount(page, credentials.username) @@ -368,7 +372,7 @@ export async function loginWithCredentials(page: Page, credentials: TestCredenti } const loggedIn = await loginWithRetry( - request, + page, credentials.username, credentials.password, process.env.CI ? 12 : 8, diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index a6b77adc..f255ea4a 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' import type { APIRequestContext, Page, TestInfo } from '@playwright/test' +import { csrfHeaders } from './csrf' type CleanupTask = () => Promise @@ -237,12 +238,14 @@ export class E2eTestDataBuilder { displayName, description: `E2E namespace ${slug}`, }, + headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, { data: { reason: 'e2e cleanup' }, + headers: await csrfHeaders(this.page), }) }) @@ -270,13 +273,17 @@ export class E2eTestDataBuilder { if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { return parseEnvelope( - await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`), + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`, { + headers: await csrfHeaders(this.page), + }), ) } if (namespace.status === 'ARCHIVED' && namespace.canRestore) { return parseEnvelope( - await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`), + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`, { + headers: await csrfHeaders(this.page), + }), ) } @@ -483,11 +490,12 @@ export class E2eTestDataBuilder { async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { let lastError: unknown - for (let attempt = 0; attempt < 30; attempt += 1) { + for (let attempt = 0; attempt < 60; attempt += 1) { try { await parseEnvelope( await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { data: { comment }, + headers: await csrfHeaders(this.page), }), ) return @@ -498,7 +506,7 @@ export class E2eTestDataBuilder { if (!isScanInProgress) { throw error } - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 1_000)) } } throw lastError instanceof Error ? lastError : new Error('approveReview timed out') @@ -515,6 +523,7 @@ export class E2eTestDataBuilder { await parseEnvelope<{ userId: string; role: string }>( await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, { data: { userId, role }, + headers: await csrfHeaders(this.page), }), ) } @@ -533,11 +542,14 @@ export class E2eTestDataBuilder { }, visibility: 'PUBLIC', }, + headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { - await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) + await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`, { + headers: await csrfHeaders(this.page), + }) }) return result diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts index 720c2796..f5834812 100644 --- a/web/e2e/public-skill-detail-anonymous.spec.ts +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -18,6 +18,8 @@ function escapeRegExp(value: string) { let seeded: PreparedSearchSeed | undefined test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeAll(async ({ browser }, testInfo) => { seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) }) diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts index f9906a35..83626e4d 100644 --- a/web/e2e/reviews-pagination.spec.ts +++ b/web/e2e/reviews-pagination.spec.ts @@ -81,4 +81,12 @@ test.describe('Review Management Pagination (Real API)', () => { } } }) + + test('opens the profile review queue from the review type search param', async ({ page }) => { + await page.goto('/dashboard/reviews?type=profile') + + await expect(page).toHaveURL(/\/dashboard\/reviews\?type=profile$/) + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Profile Review Queue' })).toBeVisible() + }) }) diff --git a/web/e2e/skill-subscription.spec.ts b/web/e2e/skill-subscription.spec.ts index c6d74d24..22003201 100644 --- a/web/e2e/skill-subscription.spec.ts +++ b/web/e2e/skill-subscription.spec.ts @@ -16,6 +16,8 @@ function adminCredentials() { } test.describe('Skill Subscription (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) await registerSession(page, testInfo) diff --git a/web/e2e/skill-version-compare.spec.ts b/web/e2e/skill-version-compare.spec.ts index c24da0a7..274197ac 100644 --- a/web/e2e/skill-version-compare.spec.ts +++ b/web/e2e/skill-version-compare.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' import { loginWithCredentials, registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' @@ -16,6 +17,8 @@ function adminCredentials() { } test.describe('Skill Version Compare (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) await registerSession(page, testInfo) @@ -50,6 +53,7 @@ test.describe('Skill Version Compare (Real API)', () => { targetVersion: '1.1.0', confirmWarnings: true, }, + headers: await csrfHeaders(page), } ) expect(rereleaseResponse.ok()).toBe(true) diff --git a/web/package.json b/web/package.json index 8dc05dfb..4b969147 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,10 @@ "esbuild" ], "overrides": { - "vite@<6.4.2": "^6.4.2", + "vite@<6.4.3": "^6.4.3", + "esbuild@<0.28.1": "^0.28.1", + "js-yaml@<4.2.0": "^4.2.0", + "@babel/core@<7.29.6": "^7.29.6", "postcss@<8.5.10": "^8.5.10", "picomatch@<2.3.2": "^2.3.2", "picomatch@>=4.0.0 <4.0.4": "^4.0.4", @@ -76,7 +79,7 @@ "postcss": "^8.5.10", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.4.2", + "vite": "^6.4.3", "vitest": "^4.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b12a2c88..389b1abb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -5,7 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - vite@<6.4.2: ^6.4.2 + vite@<6.4.3: ^6.4.3 + esbuild@<0.28.1: ^0.28.1 + js-yaml@<4.2.0: ^4.2.0 + '@babel/core@<7.29.6': ^7.29.6 postcss@<8.5.10: ^8.5.10 picomatch@<2.3.2: ^2.3.2 picomatch@>=4.0.0 <4.0.4: ^4.0.4 @@ -116,7 +119,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@6.4.2(jiti@1.21.7)) + version: 4.7.0(vite@6.4.3(jiti@1.21.7)) autoprefixer: specifier: ^10.4.0 version: 10.4.27(postcss@8.5.15) @@ -145,11 +148,11 @@ importers: specifier: ^5.7.0 version: 5.9.3 vite: - specifier: ^6.4.2 - version: 6.4.2(jiti@1.21.7) + specifier: ^6.4.3 + version: 6.4.3(jiti@1.21.7) vitest: specifier: ^4.1.0 - version: 4.1.8(jsdom@29.1.1)(vite@6.4.2(jiti@1.21.7)) + version: 4.1.8(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) packages: @@ -176,35 +179,51 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.29.6 '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} @@ -214,16 +233,24 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.0': @@ -231,17 +258,22 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^7.29.6 '@babel/plugin-transform-react-jsx-source@7.27.1': resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^7.29.6 '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} @@ -251,14 +283,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -343,158 +387,158 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1217,7 +1261,7 @@ packages: resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^6.4.2 + vite: ^6.4.3 '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -1226,7 +1270,7 @@ packages: resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: msw: optional: true @@ -1523,8 +1567,8 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1851,8 +1895,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsdom@29.1.1: @@ -2712,8 +2756,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -2768,7 +2812,7 @@ packages: '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' - vite: ^6.4.2 + vite: ^6.4.3 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -2906,19 +2950,25 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -2936,16 +2986,26 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/generator@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -2953,12 +3013,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -2966,27 +3033,35 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helpers@7.28.6': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/parser@7.29.7': dependencies: - '@babel/core': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.28.6': {} @@ -2997,6 +3072,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3009,11 +3090,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -3116,82 +3214,82 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': @@ -3209,7 +3307,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3585,7 +3683,7 @@ snapshots: colorette: 1.4.0 https-proxy-agent: 7.0.6(supports-color@10.2.2) js-levenshtein: 1.1.6 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 5.1.9 pluralize: 8.0.0 yaml-ast-parser: 0.0.43 @@ -3878,15 +3976,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) transitivePeerDependencies: - supports-color @@ -3899,13 +3997,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@6.4.2(jiti@1.21.7))': + '@vitest/mocker@4.1.8(vite@6.4.3(jiti@1.21.7))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) '@vitest/pretty-format@4.1.8': dependencies: @@ -4172,34 +4270,34 @@ snapshots: es-module-lexer@2.1.0: {} - esbuild@0.25.12: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -4253,7 +4351,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.1 + js-yaml: 4.2.0 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -4543,7 +4641,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -5206,7 +5304,7 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) classnames: 2.5.1 diff: 8.0.4 - js-yaml: 4.1.1 + js-yaml: 4.2.0 memoize-one: 6.0.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -5665,9 +5763,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.2(jiti@1.21.7): + vite@6.4.3(jiti@1.21.7): dependencies: - esbuild: 0.25.12 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 @@ -5677,10 +5775,10 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.8(jsdom@29.1.1)(vite@6.4.2(jiti@1.21.7)): + vitest@4.1.8(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@6.4.2(jiti@1.21.7)) + '@vitest/mocker': 4.1.8(vite@6.4.3(jiti@1.21.7)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -5697,7 +5795,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.2(jiti@1.21.7) + vite: 6.4.3(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: jsdom: 29.1.1 diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..a141fb20 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9717,7 +9717,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9737,7 +9737,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d654e026..c9b3bac8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', beforeLoad: requireAuth, + validateSearch: (search: Record): { type?: 'skill' | 'profile' } => ({ + type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined, + }), component: ReviewsPage, }) diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88..b8ed8bcf 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -138,7 +138,8 @@ If a request fails with `403`, check: ## Skill Package Contract -SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. +SkillHub expects OpenSkills-style packages with canonical `SKILL.md` as the entry point. Uploads +accept filename case variants such as `skill.md` and normalize them to `SKILL.md`. ## Publishing Guidance diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 694a0dce..186c15eb 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' +const useSearchMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), + useSearch: () => useSearchMock(), })) vi.mock('lucide-react', () => ({ @@ -119,6 +121,7 @@ describe('ReviewsPage', () => { data: [], isLoading: false, }) + useSearchMock.mockReturnValue({}) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } @@ -180,4 +183,15 @@ describe('ReviewsPage', () => { expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(1) }) + + it('uses the profile review tab when the review type search param requests it', () => { + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN') + userMock.platformRoles = ['SUPER_ADMIN'] + useSearchMock.mockReturnValue({ type: 'profile' }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 4ead6c84..94c0ded8 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' @@ -38,6 +38,7 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() + const search = useSearch({ from: '/dashboard/reviews' }) const { hasRole, user } = useAuth() const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ @@ -56,6 +57,9 @@ export function ReviewsPage() { // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' + const requestedType = search.type === 'skill' || search.type === 'profile' ? search.type : undefined + const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType + const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { if (hasGlobalReviewAccess || isLoadingNamespaces) { @@ -70,9 +74,9 @@ export function ReviewsPage() { 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 pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -93,6 +97,14 @@ export function ReviewsPage() { }) } + function handleTypeChange(value: string) { + void navigate({ + to: '/dashboard/reviews', + search: { type: value === 'profile' ? 'profile' : 'skill' }, + replace: true, + }) + } + function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { const currentPage = pages[status] return ( @@ -249,7 +261,7 @@ export function ReviewsPage() { {showTypeTabs ? ( - +