mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
Merge branch 'iflytek:main' into main
This commit is contained in:
commit
cb950c2d21
124 changed files with 5405 additions and 467 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
5
.github/workflows/pr-cli.yml
vendored
5
.github/workflows/pr-cli.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/pr-e2e.yml
vendored
2
.github/workflows/pr-e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
20
.github/workflows/pr-scripts.yml
vendored
20
.github/workflows/pr-scripts.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/pr-tests.yml
vendored
6
.github/workflows/pr-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
85
.github/workflows/security.yml
vendored
Normal file
85
.github/workflows/security.yml
vendored
Normal file
|
|
@ -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 }}
|
||||
7
Makefile
7
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
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] {
|
|||
|
||||
async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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.
|
||||
|
|
|
|||
56
cli/src/platform/download.ts
Normal file
56
cli/src/platform/download.ts
Normal file
|
|
@ -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<ArrayBuffer> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
36
cli/test/unit/agents/resolver-interactive.test.ts
Normal file
36
cli/test/unit/agents/resolver-interactive.test.ts
Normal file
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ function installFetch(zipEntries: Record<string, string>): 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<string, string>): 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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 场景下还能审自己的提交 | 可 | 可 |
|
||||
|
||||
### 对象存储写入策略
|
||||
|
||||
|
|
|
|||
|
|
@ -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(可配置)
|
||||
|
|
|
|||
269
docs/20-cloud-url-builtin-skills-setup.md
Normal file
269
docs/20-cloud-url-builtin-skills-setup.md
Normal file
|
|
@ -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/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
可以配置多个 Skills,也可以为同一个 `slug` 配置多个版本:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-hello",
|
||||
"version": "1.1.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.1.0.zip"
|
||||
},
|
||||
{
|
||||
"slug": "skillhub-guide",
|
||||
"version": "1.0.0",
|
||||
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/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 规则发现。
|
||||
|
|
@ -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({
|
|||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
220
docs/skillhub/package-lock.json
generated
220
docs/skillhub/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,也不能管理用户或技能。 |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
25
scripts/tests/dev-web-host-test.sh
Executable file
25
scripts/tests/dev-web-host-test.sh
Executable file
|
|
@ -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"
|
||||
123
scripts/tests/runtime-secret-test.sh
Executable file
123
scripts/tests/runtime-secret-test.sh
Executable file
|
|
@ -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"
|
||||
96
scripts/tests/validate-release-config-test.sh
Executable file
96
scripts/tests/validate-release-config-test.sh
Executable file
|
|
@ -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" <<EOF
|
||||
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
|
||||
POSTGRES_DB=skillhub
|
||||
POSTGRES_USER=skillhub
|
||||
POSTGRES_PASSWORD=strong-postgres-password
|
||||
SESSION_COOKIE_SECURE=true
|
||||
BOOTSTRAP_ADMIN_ENABLED=false
|
||||
SKILLHUB_STORAGE_PROVIDER=s3
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
|
||||
SKILLHUB_STORAGE_S3_BUCKET=skillhub
|
||||
SKILLHUB_STORAGE_S3_ACCESS_KEY=release-access-key
|
||||
SKILLHUB_STORAGE_S3_SECRET_KEY=release-secret-key
|
||||
SKILLHUB_STORAGE_S3_REGION=us-east-1
|
||||
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
|
||||
EOF
|
||||
if [[ "$include_secret" == "yes" ]]; then
|
||||
printf 'SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=%s\n' "$secret" >>"$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"
|
||||
76
scripts/tests/workflow-security-test.sh
Executable file
76
scripts/tests/workflow-security-test.sh
Executable file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> 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> namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE);
|
||||
if (namespace.isEmpty()) {
|
||||
log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization",
|
||||
GLOBAL_NAMESPACE);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ManifestItem> 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<UserAccount> 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<SyncOutcome> skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item);
|
||||
if (skipBeforeDownload.isPresent()) {
|
||||
return skipBeforeDownload.get();
|
||||
}
|
||||
|
||||
Optional<URI> packageUri = parsePackageUri(item);
|
||||
if (packageUri.isEmpty()) {
|
||||
return SyncOutcome.FAILED;
|
||||
}
|
||||
|
||||
Optional<byte[]> 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<PackageEntry> 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<SyncOutcome> 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<URI> 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<PackageEntry> 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<SyncOutcome> shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) {
|
||||
List<Skill> 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<Skill> builtinSkill = existingSkills.stream()
|
||||
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
|
||||
.findFirst();
|
||||
if (builtinSkill.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<SkillVersion> 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<SyncOutcome> shouldSkipExisting(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
|
||||
List<Skill> 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<Skill> builtinSkill = existingSkills.stream()
|
||||
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
|
||||
.findFirst();
|
||||
if (builtinSkill.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<SkillVersion> 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<PackageEntry> entries) {
|
||||
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
|
||||
for (Skill skill : existingSkills) {
|
||||
if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) {
|
||||
continue;
|
||||
}
|
||||
Optional<SkillVersion> 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<Skill> existingSkills) {
|
||||
return existingSkills.stream()
|
||||
.anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()));
|
||||
}
|
||||
|
||||
private String computeFingerprint(SkillVersion version) {
|
||||
List<SkillFile> 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<PackageEntry> entries) {
|
||||
return computeFingerprintFromFileDigests(entries.stream()
|
||||
.map(entry -> new FileDigest(entry.path(), sha256(entry.content())))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private String computeFingerprintFromFileDigests(List<FileDigest> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ManifestItem> 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<ManifestItem> items = new ArrayList<>();
|
||||
Set<String> 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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<byte[]> 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<InputStream> 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<byte[]> 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<byte[]> readBoundedWithTimeout(InputStream inputStream, URI uri) throws IOException {
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
Future<Optional<byte[]>> 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 "<null>";
|
||||
}
|
||||
String host = uri.getHost();
|
||||
String path = uri.getRawPath();
|
||||
return (host == null ? "<unknown-host>" : host) + (path == null ? "" : path);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
|
||||
Pageable pageable,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
@RequestAttribute("userId") String userId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
Set<String> 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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> SKIP_PREFIXES = Set.of(
|
||||
"/actuator", "/favicon.ico", "/assets/"
|
||||
);
|
||||
private static final Set<String> 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) {
|
||||
|
|
|
|||
|
|
@ -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<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("profileReviewId", event.profileReviewId());
|
||||
body.put("submitterId", event.submitterId());
|
||||
body.put("fields", event.fields());
|
||||
String json = toJson(body);
|
||||
List<String> 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) {
|
||||
|
|
|
|||
|
|
@ -39,4 +39,14 @@ public class RecipientResolver {
|
|||
List::copyOf
|
||||
));
|
||||
}
|
||||
|
||||
public List<String> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ public class AdminUserAppService {
|
|||
@Transactional
|
||||
public AdminUserMutationResponse updateUserRole(String userId, String roleCode, Set<String> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MemberResponse> listMembers(String slug, Pageable pageable, String userId) {
|
||||
public PageResponse<MemberResponse> listMembers(String slug, Pageable pageable, String userId, Set<String> platformRoles) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.assertMember(namespace.getId(), userId);
|
||||
if (namespace.getType() == NamespaceType.GLOBAL) {
|
||||
Set<String> 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<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
|
||||
|
||||
List<String> memberUserIds = members.getContent().stream()
|
||||
|
|
|
|||
|
|
@ -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("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"的已发布技能,无法提交
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> entries = packageEntries("skillhub-hello", "1.0.0", "same");
|
||||
givenExtractedPackage(entries);
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
|
||||
|
||||
runInitializer();
|
||||
|
||||
ArgumentCaptor<List<PackageEntry>> 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<PackageEntry> 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<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
|
||||
verify(userAccountRepository).save(userCaptor.capture());
|
||||
assertThat(userCaptor.getValue().getId()).isEqualTo(PUBLISHER);
|
||||
assertThat(userCaptor.getValue().isSystemAccount()).isTrue();
|
||||
|
||||
ArgumentCaptor<NamespaceMember> 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<PackageEntry> 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<PackageEntry> 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<ManifestItem> 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<PackageEntry> 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<SkillFile> skillFilesFor(List<PackageEntry> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BuiltinSkillManifestLoader.ManifestItem> 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<BuiltinSkillManifestLoader.ManifestItem> 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
|
@ -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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<Optional<byte[]>> 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> cookieHandler() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Duration> connectTimeout() {
|
||||
return Optional.of(Duration.ofSeconds(5));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Redirect followRedirects() {
|
||||
return Redirect.NEVER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProxySelector> proxy() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SSLContext sslContext() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SSLParameters sslParameters() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Authenticator> authenticator() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Version version() {
|
||||
return Version.HTTP_1_1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Executor> executor() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
|
||||
throws IOException {
|
||||
lastRequest = request;
|
||||
sendCalls++;
|
||||
@SuppressWarnings("unchecked")
|
||||
T responseBody = (T) body;
|
||||
return new FakeResponse<>(request, statusCode, responseBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
|
||||
HttpRequest request,
|
||||
HttpResponse.BodyHandler<T> responseBodyHandler
|
||||
) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
|
||||
HttpRequest request,
|
||||
HttpResponse.BodyHandler<T> responseBodyHandler,
|
||||
HttpResponse.PushPromiseHandler<T> 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<T>(HttpRequest request, int statusCode, T body) implements HttpResponse<T> {
|
||||
@Override
|
||||
public Optional<HttpResponse<T>> 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> sslSession() {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<NotificationResponse> 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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> 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<String, byte[]> entries) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
|
||||
ZipEntry entry = new ZipEntry(e.getKey());
|
||||
zos.putNextEntry(entry);
|
||||
zos.write(e.getValue());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Path> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ServletResponse> 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<ServletResponse> 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<>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> result = resolver.resolvePlatformUserAdmins();
|
||||
|
||||
assertThat(result).containsExactly("user-admin", "super-admin");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1");
|
||||
PageResponse<MemberResponse> 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<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1");
|
||||
PageResponse<MemberResponse> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,9 @@ public class PasswordResetService {
|
|||
}
|
||||
|
||||
private boolean isEligibleForReset(UserAccount user) {
|
||||
if (user.isSystemAccount()) {
|
||||
return false;
|
||||
}
|
||||
if (user.getStatus() != UserStatus.ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List<String> fields) {
|
||||
|
||||
public ProfileReviewSubmittedEvent {
|
||||
fields = List.copyOf(fields);
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ public class ReviewPermissionChecker {
|
|||
String userId,
|
||||
Set<String> platformRoles) {
|
||||
if (request.getSubmittedBy().equals(userId)) {
|
||||
return false;
|
||||
return platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
return hasPlatformReviewRole(platformRoles);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Skill> 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))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue